From 3f13860eac88dcd1f93feb7886636d7a46a3e6bd Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 15 Oct 2026 13:14:30 +0200 Subject: [PATCH] Align direct playback to source frames using bounded acceleration --- src/call/call_delay.cpp | 46 +++++++++++++++++++++++++++++------------ src/call/call_delay.h | 8 +++++-- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/call/call_delay.cpp b/src/call/call_delay.cpp index 8872b6c5..803f4447 100644 --- a/src/call/call_delay.cpp +++ b/src/call/call_delay.cpp @@ -35,7 +35,8 @@ void CallDelay::fail(const char* reason) { void CallDelay::reset() { st.clear(); st.setTempo(1); spanHead = spanCount = trim = 0; - written = cursor = generated = consumed = stBase = stFed = entry = queuedInput = elapsed = 0; + written = cursor = generated = consumed = stBase = stFed = entry = queuedInput = elapsed = deviceSamples = 0; + phaseGoal = 0; playedSource = cutPhase = 0; tempo = 1; for (auto& b : buckets) b = {}; firstSpan = lastCopy = false; mode = Mode::Direct; @@ -44,9 +45,12 @@ void CallDelay::reset() { } CallDelay::Stats CallDelay::stats() const { - return {mode, tempo, std::max(0.0, written - playedSource), cuts, entered, exited}; + unsigned phase = (unsigned)((cursor % Frame + Frame - deviceSamples % Frame) % Frame); + return {mode, tempo, std::max(0.0, written - playedSource), cuts, entered, exited, phase}; } +void CallDelay::silence(unsigned count) { deviceSamples += count; } + /* Сохраняем контекст и ещё не выданный исходный хвост даже после передачи в DSP. */ bool CallDelay::fill() { uint64_t keep = cursor > Context ? cursor - Context : 0; @@ -119,6 +123,8 @@ void CallDelay::endStretch() { if (st.numSamples() || spanCount || (!firstSpan && !lastCopy)) { fail("unsafe stretch exit"); return; } st.clear(); mode = Mode::Direct; cutPhase = 0; tempo = std::min(tempo, 1.02); ++exited; + int64_t offset = (int64_t)cursor - (int64_t)deviceSamples; + phaseGoal = (int64_t)std::ceil((double)offset / Frame) * Frame; DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call_delay: stretch->direct source=%llu recovered=%.2fms tempo=%.4f", (unsigned long long)cursor, (written - cursor) / 48.0, tempo); } @@ -152,25 +158,30 @@ unsigned CallDelay::direct(int16_t* out, unsigned count, unsigned reserve, unsig if (cutPhase >= 1) { cutPhase -= std::floor(cutPhase); uint64_t available = written - cursor + queuedInput; - if (available > (uint64_t)reserve + remaining - done && (written - cursor > 1 || fill())) { + int64_t offset = (int64_t)cursor - (int64_t)deviceSamples; + if (offset < phaseGoal && available > (uint64_t)reserve + remaining - done + && (written - cursor > 1 || fill())) { finish(); ++cursor; ++cuts; } } if (!run) first = cursor; - out[done++] = to_pcm(raw[cursor++ % Capacity]); ++run; - cutPhase += tempo - 1; + out[done++] = to_pcm(raw[cursor++ % Capacity]); ++run; ++deviceSamples; + if ((int64_t)cursor - (int64_t)deviceSamples < phaseGoal) cutPhase += std::min(.02, tempo - 1); + else cutPhase = 0; } finish(); return done; } /* Нижняя огибающая за 150..200мс убирает пилу пакетизации без зависимости от размера pull. */ -double CallDelay::lowDepth(double depth) { +double CallDelay::lowDepth(double depth, double& inventory) { uint64_t time = elapsed / (Rate / 20); auto& b = buckets[time % 4]; - if (b.time != time) b = {time, depth}; - else b.low = std::min(b.low, depth); - for (const auto& item : buckets) if (item.time <= time && time - item.time < 4) depth = std::min(depth, item.low); + if (b.time != time) b = {time, depth, inventory}; + else { b.low = std::min(b.low, depth); b.inventory = std::min(b.inventory, inventory); } + for (const auto& item : buckets) if (item.time <= time && time - item.time < 4) { + depth = std::min(depth, item.low); inventory = std::min(inventory, item.inventory); + } return depth; } @@ -179,10 +190,16 @@ unsigned CallDelay::pull(int16_t* out, unsigned count, uint64_t queued, unsigned if (!out || !count) { fail("invalid pull"); return 0; } queuedInput = queued; double depth = stats().pending + queued; - double excess = std::max(0.0, lowDepth(std::max(0.0, depth - count)) - reserve); + double inventory = (double)written + queued - deviceSamples - count; + double excess = std::max(0.0, lowDepth(std::max(0.0, depth - count), inventory) - reserve); bool stretch = excess >= 120 * 48; if (mode == Mode::Stretch && (excess <= 40 * 48 || depth < count + 60 * 48)) mode = Mode::Drain; - double target = mode == Mode::Direct && !stretch ? 1 + std::min(.02, excess / (2 * Rate)) + int64_t offset = (int64_t)cursor - (int64_t)deviceSamples; + // Цель кратна кадру: прекращаем ускорение точно на границе, а не при произвольной глубине. + phaseGoal = (int64_t)std::max(std::ceil((double)offset / Frame), std::floor((inventory - reserve) / Frame)) * Frame; + double correction = (double)(phaseGoal - offset); + double directTempo = correction > 0 ? 1 + std::min(.02, std::max(.001, correction / (2 * Rate))) : 1; + double target = mode == Mode::Direct && !stretch ? directTempo : 1 + .6 * std::max(0.0, std::min(1.0, (excess / 48 - 15) / 925)); double step = .6 * count / (Rate * .5); tempo += std::max(-step, std::min(step, target - tempo)); @@ -194,7 +211,10 @@ unsigned CallDelay::pull(int16_t* out, unsigned count, uint64_t queued, unsigned // Начало звонка сначала выдаёт контекст: ни один начальный сэмпл не теряется. if (stretch && cursor >= Context) { beginStretch(); continue; } unsigned take = std::min(count - done, Frame); - if (stretch) take = (unsigned)std::min(take, Context - cursor); + if (stretch) { + take = (unsigned)std::min(take, Context - cursor); + phaseGoal = (int64_t)cursor - (int64_t)deviceSamples; + } unsigned n = direct(out + done, take, reserve, count - done); done += n; if (n < take) break; @@ -204,7 +224,7 @@ unsigned CallDelay::pull(int16_t* out, unsigned count, uint64_t queued, unsigned float pcm[Frame]; unsigned n = st.receiveSamples(pcm, std::min(count - done, Frame)); for (unsigned i = 0; i < n; ++i) out[done + i] = to_pcm(pcm[i]); - consume(n); done += n; + consume(n); done += n; deviceSamples += n; } } elapsed += count; diff --git a/src/call/call_delay.h b/src/call/call_delay.h index 7d950f01..9cd6f1c6 100644 --- a/src/call/call_delay.h +++ b/src/call/call_delay.h @@ -21,11 +21,13 @@ public: Mode mode = Mode::Direct; double tempo = 1, pending = 0; uint64_t cuts = 0, entered = 0, exited = 0; + unsigned phase = 0; // Ошибка относительно 20мс выхода; 0 — границы совпадают. }; CallDelay(Input input, void* input_context, soundtouch::SourceCallback source, void* source_context); bool valid() const { return !failed; } unsigned pull(int16_t* output, unsigned count, uint64_t queued, unsigned reserve); void reset(); + void silence(unsigned count); // Тишина устройства тоже сдвигает выходную фазу. Stats stats() const; private: @@ -41,8 +43,10 @@ private: unsigned spanHead = 0, spanCount = 0; uint64_t written = 0, cursor = 0, generated = 0, consumed = 0; uint64_t stBase = 0, stFed = 0, entry = 0, queuedInput = 0, elapsed = 0; + uint64_t deviceSamples = 0; + int64_t phaseGoal = 0; double playedSource = 0, tempo = 1, cutPhase = 0; - struct Bucket { uint64_t time = UINT64_MAX; double low = 0; } buckets[4]; + struct Bucket { uint64_t time = UINT64_MAX; double low = 0, inventory = 0; } buckets[4]; unsigned trim = 0; bool failed = false, firstSpan = false, lastCopy = false; Mode mode = Mode::Direct; @@ -56,7 +60,7 @@ private: void onSource(const soundtouch::SourceSpan& span); void consume(unsigned count); unsigned direct(int16_t* output, unsigned count, unsigned reserve, unsigned remaining); - double lowDepth(double depth); + double lowDepth(double depth, double& inventory); void fail(const char* reason); }; #endif