Add shared PCM delay processor with lossless mode boundaries
This commit is contained in:
1 parent
ff16adda17
commit
962067c2ac
2 files changed
+274
No files matched your search
@@ -0,0 +1,212 @@
|
||||
#include "call_delay.h"
|
||||
#include "../../lib/debug_config.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
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<CallDelay*>(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<uint64_t>(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<uint64_t>(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<uint64_t>(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<uint64_t>(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;
|
||||
}
|
||||
@@ -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 <array>
|
||||
#include <cstdint>
|
||||
|
||||
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<float, Capacity> raw{};
|
||||
struct Span { soundtouch::SourceSpan value{soundtouch::SourceKind::Copy}; };
|
||||
std::array<Span, 32> 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
|
||||
Reference in new issue
Block a user