From a3a54af0fe0f02caa1a975951a49a7ca5bb27d4a Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 30 Oct 2026 19:52:40 +0100 Subject: [PATCH] Define protected call startup reserve and explicit queued renderer readiness --- src/call/call_audio.c | 18 +++++++++++- src/call/call_audio.h | 10 +++++++ src/call/call_jitter.cpp | 45 ++++++++++++++++++++++-------- src/call/call_jitter.h | 9 +++--- src/call/call_playout_policy.h | 50 ++++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 src/call/call_playout_policy.h diff --git a/src/call/call_audio.c b/src/call/call_audio.c index 8843279c8..768914a0b 100644 --- a/src/call/call_audio.c +++ b/src/call/call_audio.c @@ -533,7 +533,8 @@ int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count, int64_t return call_audio_feed_impl(call_id, pcm, count, 0, NULL, 0, capture_pts_us); } -int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) { +static int call_audio_render_impl(uint64_t call_id, int16_t* out, int max_samples, struct call_audio_render* state) { + if (state) *state = (struct call_audio_render){CALL_AUDIO_WAITING, 0}; if (!out || max_samples <= 0) return 0; int active = 0, ending = 0; @@ -558,6 +559,7 @@ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) { * здесь (аудио-поток) — begin_end лишь выставляет флаг, чтобы не гонять * генератор тонов с GUI-потоком. */ if (ending) { + if (state) state->phase = CALL_AUDIO_ENDING; if (!g_ended_started) { call_tones_ended_start(tones); g_ended_started = 1; @@ -569,6 +571,11 @@ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) { n = max_samples; } else { n = call_jitter_pull(j, out, max_samples); + if (state) { + state->speech_samples = n; + state->phase = n ? CALL_AUDIO_SPEECH : g_playback_started ? CALL_AUDIO_RECOVERING : CALL_AUDIO_WAITING; + if (state->phase == CALL_AUDIO_WAITING) return 0; + } int previous_gap = g_gap_samples; if (n > 0) { if (!g_playback_started) events |= 1; @@ -612,6 +619,15 @@ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) { return n; } +int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) { + return call_audio_render_impl(call_id, out, max_samples, NULL); +} + +int call_audio_render_pcm(uint64_t call_id, int16_t* out, int max_samples, struct call_audio_render* state) { + if (!state) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call render: missing output state"); return 0; } + return call_audio_render_impl(call_id, out, max_samples, state); +} + void call_audio_playback_progress(uint64_t call_id, uint64_t epoch, uint64_t position, int64_t at_us, int64_t now_us, int measured) { pthread_mutex_lock(&g_mtx); diff --git a/src/call/call_audio.h b/src/call/call_audio.h index b83a82599..d98d49bbe 100644 --- a/src/call/call_audio.h +++ b/src/call/call_audio.h @@ -80,6 +80,16 @@ int call_audio_feed_pcm_muted(uint64_t call_id, const int16_t* pcm, int count, i * Активный движок всегда выдаёт полный блок; 0 означает отсутствие движка/ошибку аргументов. */ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples); +/* Очередной адаптер использует render_pcm: до первого речевого PCM возвращается 0, + * без продвижения PCM-шкалы. Адаптер готовит свою очередь перед разрешением вывода. + * После старта блок полный, с маскирующей тишиной/тонами при паузе; speech_samples + * отличает речь от заполнения. pull_pcm выше — непрерывный вывод (Android AudioTrack): + * начальная тишина занимает реальные позиции устройства и учитывается в шкале. + * Оба API принадлежат одному playback worker; смешивать их в одной сессии нельзя. */ +enum call_audio_render_phase { CALL_AUDIO_WAITING, CALL_AUDIO_SPEECH, CALL_AUDIO_RECOVERING, CALL_AUDIO_ENDING }; +struct call_audio_render { enum call_audio_render_phase phase; int speech_samples; }; +int call_audio_render_pcm(uint64_t call_id, int16_t* out, int max_samples, struct call_audio_render* state); + /* uasync-поток (из call.c): кадр текущего inst/активного DSP передаётся в call_jitter_push; * данные копируются, декодирование на pull. ts_ms — время пачки; packet_start=1 только на первом кадре. Чужой inst/ID или отсутствие DSP — no-op. */ void call_audio_on_media(struct UTUN_INSTANCE* inst, uint64_t call_id, diff --git a/src/call/call_jitter.cpp b/src/call/call_jitter.cpp index da7f5e41a..20d2ba4f2 100644 --- a/src/call/call_jitter.cpp +++ b/src/call/call_jitter.cpp @@ -3,6 +3,7 @@ #include "call_jitter_window.h" #include "call_timeline.h" #include "call_delay.h" +#include "call_playout_policy.h" #include #include "../../lib/debug_config.h" #include "../../lib/mem.h" @@ -32,7 +33,11 @@ struct call_jitter { call_jitter_diagnostics diagnostics{}; int processing_ms = 0, tempo_x100 = 100; /* снимок аудиопотока под mtx */ call_jitter_window depth_window, arrival_window; - call_jitter_range range = {0, 0, CALL_JITTER_MIN_RESERVE_MS}; /* опубликованный снимок под mtx */ + call_jitter_window packet_window; // Длительность пачек за то же окно, не второй сетевой запас. + int packet_frames = 0; + call_playout_policy policy; + bool stable_playback = false; // Снимок состояния worker для сетевой оценки, под mtx. + call_jitter_range range = {0, 0, CALL_JITTER_STARTUP_MS}; /* опубликованный снимок под mtx */ call_jitter_decode_fn decode; void* arg; CallDelay delay{jitter_decode, this, [](void* context, const soundtouch::SourceSpan& span) { @@ -122,8 +127,17 @@ void call_jitter_push(struct call_jitter* j, const uint8_t* enc, int len, uint32 (long long)j->arrival_window.last_received_ms, (long long)received_ms); return; } - if (packet_start) j->arrival_window.observe_arrival(received_ms, sent_ms); - j->range.reserve_ms = j->arrival_window.reserve; + if (packet_start) { + j->packet_frames = 0; + j->arrival_window.observe_arrival(received_ms, sent_ms); + int previous = j->policy.reserve_ms; + j->policy.observe(received_ms, j->arrival_window.reserve, j->arrival_window.max_reserve, j->stable_playback); + if (previous != j->policy.reserve_ms) DEBUG_INFO(DEBUG_CATEGORY_CALL, + "call_jitter: reserve=%d->%dms spread=%lldms playing=%d packets=%u", previous, j->policy.reserve_ms, + (long long)(j->arrival_window.high - j->arrival_window.low), j->stable_playback, j->policy.packets); + } + j->packet_window.observe(received_ms, ++j->packet_frames * CALL_JITTER_FRAME_MS); + j->range.reserve_ms = j->policy.reserve_ms; if (j->count == CALL_JITTER_MAX_FRAMES) { j->head = (j->head + 1) % CALL_JITTER_MAX_FRAMES; j->count--; j->dropped++; } @@ -134,8 +148,9 @@ void call_jitter_push(struct call_jitter* j, const uint8_t* enc, int len, uint32 int call_jitter_pull(struct call_jitter* j, int16_t* out, int max_samples) { if (!j || !out || max_samples <= 0) return 0; - int count, reserve; - { std::lock_guard lock(j->mtx); count = j->count; reserve = j->arrival_window.reserve; } + int count, reserve, packet_ms; + { std::lock_guard lock(j->mtx); + count = j->count; reserve = j->policy.reserve_ms; packet_ms = (int)j->packet_window.high; } double depth_samples = count * kFrameSamples + j->delay.stats().pending; double depth = depth_samples / 48; int64_t now_ms = std::chrono::duration_cast( @@ -148,8 +163,7 @@ int call_jitter_pull(struct call_jitter* j, int16_t* out, int max_samples) { } if (!j->primed) { // После первого полного блока остаётся сетевой запас, не скрытый запас DSP. - int required = reserve * 48 + max_samples; - if (!j->playback_started) required = std::max(required, CALL_JITTER_STARTUP_MS * 48); + int required = reserve * 48 + std::max(max_samples, packet_ms * 48); if (depth_samples < required) { std::lock_guard lock(j->mtx); j->diagnostics.depth_ms = (int)depth; j->diagnostics.reserve_ms = reserve; @@ -173,7 +187,14 @@ int call_jitter_pull(struct call_jitter* j, int16_t* out, int max_samples) { std::lock_guard lock(j->mtx); j->timeline.read((unsigned)n); j->processing_ms = (int)std::lround(state.pending / 48); j->tempo_x100 = (int)std::lround(state.tempo * 100); - if (n < max_samples) j->underruns++; + j->stable_playback = n == max_samples; + if (n < max_samples) { + j->underruns++; + j->policy.recover(j->arrival_window.last_received_ms, j->arrival_window.max_reserve); + j->range.reserve_ms = j->policy.reserve_ms; + DEBUG_WARN(DEBUG_CATEGORY_CALL, "call_jitter: rebuffer pcm=%d/%d depth=%.1fms reserve=%dms packet=%dms", + n, max_samples, depth, j->policy.reserve_ms, packet_ms); + } j->diagnostics.depth_ms = (int)depth; j->diagnostics.reserve_ms = reserve; j->diagnostics.tempo_x100 = j->tempo_x100; @@ -198,7 +219,7 @@ void call_jitter_get_diagnostics(struct call_jitter* j, struct call_jitter_diagn *out = j->diagnostics; out->encoded_ms = j->count * CALL_JITTER_FRAME_MS; out->processing_ms = j->processing_ms; - out->reserve_ms = j->arrival_window.reserve; + out->reserve_ms = j->policy.reserve_ms; out->arrival_spread_ms = j->arrival_window.high - j->arrival_window.low; out->underruns = j->underruns; if (reset_events) { @@ -242,7 +263,8 @@ int call_jitter_try_clock(struct call_jitter* j, int64_t now_us, struct call_clo void call_jitter_reset(struct call_jitter* j) { if (!j) return; std::lock_guard lock(j->mtx); - int keep = std::min(j->count, (j->arrival_window.reserve + CALL_JITTER_FRAME_MS - 1) / CALL_JITTER_FRAME_MS); + j->policy.recover(j->arrival_window.last_received_ms, j->arrival_window.max_reserve); + int keep = std::min(j->count, (j->policy.reserve_ms + (int)j->packet_window.high + CALL_JITTER_FRAME_MS - 1) / CALL_JITTER_FRAME_MS); int discard = j->count - keep; j->head = (j->head + discard) % CALL_JITTER_MAX_FRAMES; j->count = keep; @@ -251,12 +273,13 @@ void call_jitter_reset(struct call_jitter* j) { j->timeline.reset(++j->playback_epoch); j->source_epoch = 0; j->capture_end = INT64_MIN; j->primed = false; + j->stable_playback = false; j->processing_ms = 0; j->tempo_x100 = 100; j->depth_window = {}; j->diagnostics = {}; j->range.min_ms = j->range.max_ms = keep * CALL_JITTER_FRAME_MS; DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_jitter: device discontinuity discarded=%dms kept=%dms reserve=%dms", - discard * CALL_JITTER_FRAME_MS, keep * CALL_JITTER_FRAME_MS, j->arrival_window.reserve); + discard * CALL_JITTER_FRAME_MS, keep * CALL_JITTER_FRAME_MS, j->policy.reserve_ms); } void call_jitter_get_stats(struct call_jitter* j, int* depth_ms, int* tempo_x100, diff --git a/src/call/call_jitter.h b/src/call/call_jitter.h index c5c5368ad..aa18df77c 100644 --- a/src/call/call_jitter.h +++ b/src/call/call_jitter.h @@ -8,15 +8,16 @@ extern "C" { #endif -/* Звонок: FIFO Opus, запас 20мс..max_reserve_ms по изменению задержки доставки за 10 секунд и догон после паузы устройства. +/* Звонок: FIFO Opus и политика качества из call_playout_policy.h. * push — сетевой поток, pull/decode/CallDelay — единственный аудиопоток. - * Первый старт ждёт не менее200мс; восстановление — сетевой запас плюс блок; при выдаче PCM запас не резервируется повторно. + * Стартовый нижний запас 200мс сохраняется после первого pull и адаптируется по измерениям. + * Старт/восстановление ждут запас плюс длительность пачки (не меньше запроса PCM). * destroy — после остановки всех обращений. get_stats/diagnostics/clock — любой поток, * внутренний mutex защищает их снимки. Все операции playback (включая silence/reset) * сериализует владелец; mutex не делает допустимыми два одновременных pull. */ #define CALL_JITTER_FRAME_MS 20 #define CALL_JITTER_MIN_RESERVE_MS 20 -#define CALL_JITTER_STARTUP_MS 200 /* Минимальная накопленная речь перед первым декодированием/выводом. */ +#define CALL_JITTER_STARTUP_MS 200 /* Начальный защищённый запас, сверх периода пачки/запроса PCM. */ #define CALL_JITTER_MAX_FRAMES 750 /* 10с перебоя + 5с запаса на накопление и восстановление */ /* Заимствованный context, encoded/len и выход/capacity (сэмплы, до 960); вернуть 1..capacity или <=0 при ошибке. * Callback вызывается синхронно на pull без сетевого mutex; encoded действителен только до возврата. */ @@ -52,7 +53,7 @@ void call_jitter_push(struct call_jitter* j, const uint8_t* enc, int len, uint32 * оставить последние ceil(reserve/20) Opus-кадров. Оценка доставки и суммарные dropped/underruns сохраняются. */ void call_jitter_reset(struct call_jitter* j); /* out — max_samples mono int16 @48кГц; вернуть 0..max_samples, без дополнения тишиной. - * Первый старт ждёт max(200мс, reserve плюс размер запроса); восстановление — reserve плюс размер запроса. + * Старт/восстановление ждут reserve + max(длительность пачки, размер запроса). * Decode ограничен min(750, 8+max_samples/480) попытками. * NULL/неположительный размер дают 0. Вызывающий учитывает дополненную локальную тишину через silence. */ int call_jitter_pull(struct call_jitter* j, int16_t* out, int max_samples); diff --git a/src/call/call_playout_policy.h b/src/call/call_playout_policy.h new file mode 100644 index 000000000..504d37925 --- /dev/null +++ b/src/call/call_playout_policy.h @@ -0,0 +1,50 @@ +/* Политика запуска/восстановления звонка. Используется call_jitter под его mutex. + * + * Владение: сеть добавляет Opus и измерения; единственный playback worker декодирует, + * меняет состояние накопления и управляет CallDelay. Адаптер устройства независимо + * поддерживает готовый PCM под размер callback. Callback только потребляет очередь. + * TX-регулятор пачки независим от RX: отправка не ждёт готовности удалённого вывода. + * + * reserve_ms — защищённый нижний запас речи, без периода пачки и аппаратного FIFO. + * Перед стартом нужен reserve + длительность пачки (не меньше запроса PCM): после + * подготовки PCM остаётся запас. Период пачки входит в порог один раз; CallDelay + * сравнивает с reserve нижнюю огибающую очереди, а не её пик после получения пачки. + * Стартовые 200мс — исходная цель, не одноразовый порог и не максимальная задержка. + * Цель растёт сразу, снижается по 20мс после подтверждённой устойчивости. Недостаток + * измерений/остановленный выход не являются основанием снижать запас. + * + * PREBUFFER/RECOVER -> речевой PCM -> подготовленный FIFO адаптера -> реальный вывод. + * До первого PCM ожидание отличается от underrun; тишина не считается готовой речью. + * CallDelay исполняет reserve, не выбирает его. Задержки FIFO выводятся отдельно; + * сумма задержек диагностическая, не общий регулятор всех очередей. + * Константы ниже — параметры политики, проверяемые сценариями, не свойства протокола. */ +#pragma once +#include +#include + +struct call_playout_policy { + int reserve_ms = 200; // Нижний сетевой запас, уменьшается только при устойчивом воспроизведении. + int64_t stable_since_ms = -1, last_lower_ms = -1; + unsigned packets = 0; // Число новых пачек с последнего нарушения устойчивости. + static constexpr int StableMs = 5000, LowerIntervalMs = 1000, StepMs = 20; + static constexpr unsigned MinimumPackets = 20; + + // Одна оценка на пачку; observed_ms — размах изменения доставки, без постоянного RTT. + void observe(int64_t now_ms, int observed_ms, int maximum_ms, bool playing) { + int required = std::min(maximum_ms, std::max(20, observed_ms)); + if (stable_since_ms < 0 || !playing || required > reserve_ms) { + stable_since_ms = now_ms; packets = 0; + } + reserve_ms = std::max(reserve_ms, required); + if (packets < MinimumPackets) ++packets; + if (playing && packets >= MinimumPackets && now_ms - stable_since_ms >= StableMs + && (last_lower_ms < 0 || now_ms - last_lower_ms >= LowerIntervalMs) && required < reserve_ms) { + reserve_ms = std::max(required, reserve_ms - StepMs); last_lower_ms = now_ms; + } + } + // Реальная нехватка/смена устройства отменяет уверенность; повторный старт не расходует старый малый резерв. + void recover(int64_t now_ms, int maximum_ms) { + reserve_ms = std::min(maximum_ms, std::max(200, reserve_ms + StepMs)); + stable_since_ms = now_ms; packets = 0; last_lower_ms = now_ms; + } +};