From 962067c2acaee7654aaa04c90c245b7a36478166 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 15 Oct 2026 13:10:45 +0200 Subject: [PATCH] Add shared PCM delay processor with lossless mode boundaries --- src/call/call_delay.cpp | 212 ++++++++++++++++++++++++++++++++++++++++ src/call/call_delay.h | 62 ++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 src/call/call_delay.cpp create mode 100644 src/call/call_delay.h diff --git a/src/call/call_delay.cpp b/src/call/call_delay.cpp new file mode 100644 index 00000000..8872b6c5 --- /dev/null +++ b/src/call/call_delay.cpp @@ -0,0 +1,212 @@ +#include "call_delay.h" +#include "../../lib/debug_config.h" +#include +#include + +using soundtouch::SourceKind; +using soundtouch::SourceError; +using soundtouch::SourceSpan; + +static int16_t to_pcm(float value) { + return (int16_t)std::lrintf(std::max(-32768.0f, std::min(32767.0f, value * 32768.0f))); +} + +CallDelay::CallDelay(Input reader, void* readerContext, soundtouch::SourceCallback sink, void* sinkContext) + : input(reader), inputContext(readerContext), source(sink), sourceContext(sinkContext) { + st.setSampleRate(Rate); st.setChannels(1); st.setTempo(1); + st.setSetting(SETTING_USE_QUICKSEEK, 1); + st.setSetting(SETTING_SEQUENCE_MS, 20); st.setSetting(SETTING_SEEKWINDOW_MS, 10); + st.setSetting(SETTING_OVERLAP_MS, 4); st.setSetting(SETTING_USE_AA_FILTER, 0); + if (!input || !source || !st.setSourceCallback([](void* p, const SourceSpan& s) { + static_cast(p)->onSource(s); + }, this)) fail("initialization"); + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call_delay: initialized direct_max=1.02 stretch_max=1.6 enter=120ms exit=40ms"); +} + +void CallDelay::fail(const char* reason) { + if (failed) return; + failed = true; + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_delay: %s mode=%d input=%llu cursor=%llu generated=%llu consumed=%llu spans=%u", + reason, (int)mode, (unsigned long long)written, (unsigned long long)cursor, + (unsigned long long)generated, (unsigned long long)consumed, spanCount); +} + +/* Reset принадлежит аудиосессии, а не переключению алгоритма. */ +void CallDelay::reset() { + st.clear(); st.setTempo(1); + spanHead = spanCount = trim = 0; + written = cursor = generated = consumed = stBase = stFed = entry = queuedInput = elapsed = 0; + playedSource = cutPhase = 0; tempo = 1; + for (auto& b : buckets) b = {}; + firstSpan = lastCopy = false; mode = Mode::Direct; + cuts = entered = exited = 0; + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call_delay: reset failed=%d", failed); +} + +CallDelay::Stats CallDelay::stats() const { + return {mode, tempo, std::max(0.0, written - playedSource), cuts, entered, exited}; +} + +/* Сохраняем контекст и ещё не выданный исходный хвост даже после передачи в DSP. */ +bool CallDelay::fill() { + uint64_t keep = cursor > Context ? cursor - Context : 0; + if (written - keep + Frame > Capacity) { fail("raw history overflow"); return false; } + int16_t pcm[Frame]; + unsigned count = input(inputContext, pcm, Frame); + if (!count) return false; + if (count > Frame) { fail("invalid input count"); return false; } + queuedInput -= std::min(queuedInput, count); + for (unsigned i = 0; i < count; ++i) raw[(written + i) % Capacity] = pcm[i] / 32768.0f; + written += count; + return true; +} + +/* Общая выходная нумерация сохраняется при любых переходах. */ +void CallDelay::emit(SourceSpan s) { + if (failed) return; + if (!s.count || spanCount == spans.size()) { fail("source queue overflow"); return; } + s.output = generated; + spans[(spanHead + spanCount++) % spans.size()].value = s; + generated += s.count; + source(sourceContext, s); +} + +/* Начальный Copy обрезается только по уже выданному контексту, не по новому звуку. */ +void CallDelay::onSource(const SourceSpan& local) { + if (local.kind == SourceKind::Reset) return; + if (local.kind == SourceKind::Invalid) { fail("SoundTouch source invalid"); return; } + SourceSpan s = local; + s.input0 += stBase; s.input1 += stBase; + if (firstSpan) { + if (s.kind != SourceKind::Copy || s.input0 > entry || s.input0 + s.count <= entry) { + fail("SoundTouch entry does not cover boundary"); return; + } + trim = (unsigned)(entry - s.input0); + s.input0 += trim; s.count -= trim; + firstSpan = false; + } + lastCopy = s.kind == SourceKind::Copy; + if (lastCopy) cursor = s.input0 + s.count; + emit(s); +} + +/* Глубина учитывает фактическое происхождение прочитанного PCM, а не текущий tempo. */ +void CallDelay::consume(unsigned count) { + while (count && !failed) { + if (!spanCount) { fail("source queue underflow"); return; } + const auto& s = spans[spanHead].value; + unsigned take = (unsigned)std::min(count, s.output + s.count - consumed); + consumed += take; count -= take; + double offset = (double)(consumed - s.output); + playedSource = s.input0 + offset; + if (s.kind == SourceKind::Mix) playedSource += ((double)s.input1 - s.input0) * offset / s.count; + if (consumed == s.output + s.count) { spanHead = (spanHead + 1) % spans.size(); --spanCount; } + } +} + +/* История перед границей компенсирует стартовый сдвиг интерполятора; величина берётся из SourceSpan. */ +void CallDelay::beginStretch() { + st.clear(); st.setTempo(tempo); + entry = cursor; stBase = cursor - Context; stFed = stBase; + firstSpan = true; lastCopy = false; trim = 0; mode = Mode::Stretch; + ++entered; + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call_delay: direct->stretch source=%llu pending=%.2fms tempo=%.4f", + (unsigned long long)cursor, stats().pending / 48, tempo); +} + +/* Готовый выход обязательно заканчивается Copy. Хвост raw начинается с его точного конца. */ +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; + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call_delay: stretch->direct source=%llu recovered=%.2fms tempo=%.4f", + (unsigned long long)cursor, (written - cursor) / 48.0, tempo); +} + +/* Подаём небольшие порции, чтобы готовый выход и история оставались ограниченными. */ +bool CallDelay::pump() { + while (!st.numSamples() && !failed) { + if (stFed == written && !fill()) return false; + float pcm[Frame]; + unsigned count = (unsigned)std::min(Frame, written - stFed); + for (unsigned i = 0; i < count; ++i) pcm[i] = raw[(stFed + i) % Capacity]; + stFed += count; + st.putSamples(pcm, count); + if (trim) { + if (st.receiveSamples(trim) != trim) { fail("missing entry context"); return false; } + trim = 0; + } + } + return !failed; +} + +/* Сэмплы удаляются по одному; после ограничения запасом долг удаления не накапливается. */ +unsigned CallDelay::direct(int16_t* out, unsigned count, unsigned reserve, unsigned remaining) { + unsigned done = 0, run = 0; + uint64_t first = cursor; + auto finish = [&] { + if (run) { emit({SourceKind::Copy, SourceError::None, 0, first, 0, run}); consume(run); run = 0; } + }; + while (done < count && !failed) { + if (cursor == written && !fill()) break; + if (cutPhase >= 1) { + cutPhase -= std::floor(cutPhase); + uint64_t available = written - cursor + queuedInput; + if (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; + } + finish(); + return done; +} + +/* Нижняя огибающая за 150..200мс убирает пилу пакетизации без зависимости от размера pull. */ +double CallDelay::lowDepth(double depth) { + 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); + return depth; +} + +unsigned CallDelay::pull(int16_t* out, unsigned count, uint64_t queued, unsigned reserve) { + if (failed) return 0; + 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); + 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)) + : 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)); + if (mode == Mode::Direct && !stretch) tempo = std::min(tempo, 1.02); + st.setTempo(tempo); + unsigned done = 0; + while (done < count && !failed) { + if (mode == Mode::Direct) { + // Начало звонка сначала выдаёт контекст: ни один начальный сэмпл не теряется. + if (stretch && cursor >= Context) { beginStretch(); continue; } + unsigned take = std::min(count - done, Frame); + if (stretch) take = (unsigned)std::min(take, Context - cursor); + unsigned n = direct(out + done, take, reserve, count - done); + done += n; + if (n < take) break; + } else { + if (!st.numSamples() && mode == Mode::Stretch && !pump()) mode = Mode::Drain; + if (!st.numSamples()) { endStretch(); stretch = false; continue; } + 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; + } + } + elapsed += count; + return done; +} diff --git a/src/call/call_delay.h b/src/call/call_delay.h new file mode 100644 index 00000000..7d950f01 --- /dev/null +++ b/src/call/call_delay.h @@ -0,0 +1,62 @@ +/* Обработка задержки PCM 48кГц mono: прямое ускорение и SoundTouch с общей шкалой. + * Один playback worker. input возвращает до capacity сэмплов, 0 — вход исчерпан; + * до возврата input владелец регистрирует PTS этих сэмплов в своей карте времени. + * source сообщает происхождение выхода в абсолютных индексах от reset(). Локальные + * Reset SoundTouch наружу не выходят. Владелец сам сбрасывает карту при reset(). + * queued — ещё не декодированный вход, reserve — сетевой запас, оба в сэмплах. + * Переключение сохраняет хвост исходного PCM; ускорение учитывается отдельно. + * Внутренние очереди ограничены, размеры pull произвольные, heap в нашем hot path нет. */ +#ifndef CALL_DELAY_H +#define CALL_DELAY_H + +#include "soundtouch/SoundTouch.h" +#include +#include + +class CallDelay { +public: + using Input = unsigned (*)(void*, int16_t*, unsigned); + enum class Mode { Direct, Stretch, Drain }; + struct Stats { + Mode mode = Mode::Direct; + double tempo = 1, pending = 0; + uint64_t cuts = 0, entered = 0, exited = 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(); + Stats stats() const; + +private: + static constexpr unsigned Rate = 48000, Frame = 960, Capacity = 8192, Context = 16; + Input input; + void* inputContext; + soundtouch::SourceCallback source; + void* sourceContext; + soundtouch::SoundTouch st; + std::array raw{}; + struct Span { soundtouch::SourceSpan value{soundtouch::SourceKind::Copy}; }; + std::array spans{}; + 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; + double playedSource = 0, tempo = 1, cutPhase = 0; + struct Bucket { uint64_t time = UINT64_MAX; double low = 0; } buckets[4]; + unsigned trim = 0; + bool failed = false, firstSpan = false, lastCopy = false; + Mode mode = Mode::Direct; + uint64_t cuts = 0, entered = 0, exited = 0; + + bool fill(); + bool pump(); + void beginStretch(); + void endStretch(); + void emit(soundtouch::SourceSpan span); + 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); + void fail(const char* reason); +}; +#endif