Replenish playout reserve smoothly and account for packet cadence in delay control

This commit is contained in:
evgeny committed 2026-10-30 20:03:49 +01:00
1 parent 0c20766cfe
commit a7bb72a0b7
7 files changed
+75 -37

No files matched your search

+23 -12
View File
@@ -60,10 +60,11 @@ Router сохраняет исходное монотонное enqueued_us в s
До получения ACK сохраняется стартовое число кадров; при no_route,
receive_busy, minRTT probe или несвежих ACK target не растёт.
При D>100мс непрерывно200мс target увеличивается на1 до10. Между изменениями
не менее max(500мс, 2*delivery_rtt_us). Высокий обычный RTT без очереди тоже
может включить упаковку — это намеренное поведение.
При D<40мс и пустом Router send_q в течение2с target уменьшается на1.
При queued_age_us>100мс или inflight_age_us>delivery_rtt_us+100мс непрерывно200мс
target увеличивается на1 до10. Между изменениями clamp(delivery_rtt_us,250мс,750мс)
и хотя бы одно новое подтверждение. Постоянный RTT сам по себе не увеличивает пачку.
При пустом Router send_q и inflight_age_us<=delivery_rtt_us+40мс в течение5с
target уменьшается на1; тоже требуется новое подтверждение после изменения.
Промежуточные значения сбрасывают отсчёты high/low, сохраняя target.
Если ACK не продвигался дольше max(2с, 2*delivery_rtt_us, 500мс), рост
выключается; это состояние не считается свободной полосой.
@@ -111,13 +112,23 @@ sent_ms ставится перед подачей пачки Router. Приём
а собственное накопление пачки не создаёт N фиктивных измерений jitter.
После полной проверки кадры идут в прежние decoder/jitter/headless callbacks.
Первое воспроизведение ждёт накопления не менее200мс аудио в jitter buffer
(10 кадров по20мс), либо сетевого запаса плюс PCM-блок, если это больше.
Захват и отправка начинаются сразу. До первой выдачи PCM нет сигнала перебоя.
После старта действуют прежние адаптивный запас и плавное ускорение излишка;
200мс не закрепляются как постоянная глубина. Повторное накопление после
недостачи и сброс устройства после начала речи используют обычный запас.
Это порог начального заполнения, не ограничение полной сквозной задержки.
Архитектура и владение описаны рядом с кодом: src/call/call_playout_policy.h,
контракт непрерывного/очередного вывода — src/call/call_audio.h.
Начальные200мс — защищённый нижний запас, сохраняемый после запуска. Порог
старта/восстановления: запас + max(длительность пачки, запрос PCM). Период пачки
учитывается один раз: CallDelay сравнивает с запасом нижнюю огибающую, не пик FIFO.
Запас растёт сразу по оценке доставки, снижается на20мс в секунду после5с
устойчивого воспроизведения и минимум20 пачек. При underrun/смене устройства
уверенность сбрасывается, запас увеличивается на20мс и составляет не менее200мс.
Оценка доставки получает20мс запаса на границу кадра. Окно нижней огибающей
CallDelay покрывает период пачки и вариацию доставки. При недостатке запаса
SoundTouch временно замедляет речь до0.98x без изменения высоты голоса;
одного изменения целевой глубины недостаточно для реального пополнения FIFO.
Устройство отдельно готовит PCM под размер callback; desktop начинает потреблять
речь только после публикации не менее40мс PCM (или полного callback, если больше).
Непрерывный Android AudioTrack получает начальную тишину с учётом аппаратной шкалы.
Захват/передача не ждут готовности вывода. Начальное ожидание не является underrun.
Это политика качества, не ограничение полной сквозной задержки величиной200мс.
cleanup/ENDED/destroy отменяют регулятор50мс и Router-запрос до освобождения FIFO.
Доступ к ядру только в uasync; audio capture worker API не меняется.
@@ -132,7 +143,7 @@ wait=frames/handshake/no-route/peer-busy/window/transport/unregistered пока
etcp_route=debug: выдача/отмена слота с waited_us, подтверждение enqueue->ACK и
число попыток. Ошибки формата, выделения, регистрации и отправки логируются.
Проверки: test_call_audio_batch (формат, прежний выбор target), test_call (накопление
Проверки: test_call_audio_batch (формат, очереди/RTT/свежесть ACK), test_call (накопление
полного target на свободном транспорте, задержка при backpressure, доставка и
teardown), test_etcp_router_faults (handshake без payload, BUSY, окно, смена пути,
отмена ожидающего/отложенного запроса, reset и закрытие внутри callback).
+8
View File
@@ -4,6 +4,14 @@
## Текущие задачи
[ ] **Android lifecycle: отказ сохранения новых ключей нарушает ожидание сохранения работающего ядра** — 30.10.2026.
В `test_android_audio_lifecycle_restart` и `restart_core_stop` воспроизводится отказ проверки
`instance_lite_is_running() && started == 2 && attached == 2 && detached == 1 && current_node == first_node`
после `instance_lite_regenerate_keys`: тест намеренно создаёт каталог вместо `keys.conf`, получает
`file destination is not regular` / `identity save failed phase=publish`, затем падает на строке560.
Добавить снимки running/started/attached/detached/node в момент identity_failures и проверить rollback
и порядок публикации ошибки. Обнаружено при проверке аудиостарта; исправление жизненного цикла отдельно.
[+] **Рация: настраиваемые burst-пачки Opus 1..10, по умолчанию4** — 30.10.2026.
`radio_burst_frames` сохраняется desktop/Android и читается при следующем PTT/VAD BEGIN, общий для headless.
Новый wire: общий header23, count, length:u16 перед каждым кадром. Приём валидирует всю пачку до доставки;
+11 -8
View File
@@ -182,33 +182,36 @@ unsigned CallDelay::direct(int16_t* out, unsigned count, unsigned reserve, unsig
return done;
}
/* Нижняя огибающая за 150..200мс убирает пилу пакетизации без зависимости от размера pull. */
/* Окно покрывает период пачки и вариацию доставки; размер pull не влияет на его длительность. */
double CallDelay::lowDepth(double depth, double& inventory) {
uint64_t time = deviceSamples / (Rate / 20);
auto& b = buckets[time % 4];
auto& b = buckets[time % 48];
if (b.time != time) { b.time = time; b.low = depth; b.inventory = 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) {
for (const auto& item : buckets) if (item.time <= time && time - item.time < observationBuckets) {
depth = std::min(depth, item.low); inventory = std::min(inventory, item.inventory);
}
return depth;
}
unsigned CallDelay::pull(int16_t* out, unsigned count, uint64_t queued, unsigned reserve) {
unsigned CallDelay::pull(int16_t* out, unsigned count, uint64_t queued, unsigned reserve, unsigned observation_ms, bool protect_reserve) {
if (failed) return 0;
if (!out || !count) { fail("invalid pull"); return 0; }
queuedInput = queued;
observationBuckets = std::max(4u, std::min(48u, (observation_ms + 49) / 50 + 1)); // Один неполный bucket сверх периода.
double depth = stats().pending + queued;
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 low = lowDepth(std::max(0.0, depth - count), inventory);
double excess = std::max(0.0, low - reserve);
bool replenish = protect_reserve && low + Frame < reserve && depth >= count + 80 * 48;
bool stretch = excess >= 120 * 48 || replenish;
if (mode == Mode::Stretch && ((!replenish && excess <= 40 * 48) || depth < count + 60 * 48)) mode = Mode::Drain;
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
double target = replenish ? .98 : 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));
+8 -3
View File
@@ -8,7 +8,8 @@
* Вход в SoundTouch при избытке >=120мс, выход <=40мс; низкий запас дополнительно
* принуждает выход. Прямой режим ускоряет не более чем на 2%, без замедления.
* Его цель — целая разность входных/выходных кадров по 960 сэмплов. Нижняя огибающая
* запаса за 150..200мс подавляет пилу пакетов; удаление останавливается точно на цели.
* запаса подавляет пилу пакетов; её окно задаёт владелец по периоду доставки.
* При protect_reserve дефицит восполняется через SoundTouch до0.98x; Direct не замедляется.
* При нехватке входа ускорение не расходует reserve: фазу доводим после восстановления.
* Это выравнивание границ PCM, не синхронизация физических часов двух аудиоустройств.
* Внутренние очереди ограничены, размеры pull произвольные, heap в нашем hot path нет. */
@@ -37,7 +38,10 @@ public:
bool valid() const { return !failed; }
// Выдать 0..count PCM-сэмплов, без дополнения тишиной; queued не включает уже принятый этим объектом PCM.
// output/count должны быть валидны; нехватка input даёт короткую выдачу, ошибка делает valid()==false.
unsigned pull(int16_t* output, unsigned count, uint64_t queued, unsigned reserve);
// observation_ms — окно нижней огибающей; при пакетном входе должно покрывать период пачки и джиттер.
// protect_reserve включает мягкое замедление до0.98x при недостаточном запасе (без изменения высоты голоса).
unsigned pull(int16_t* output, unsigned count, uint64_t queued, unsigned reserve,
unsigned observation_ms = 200, bool protect_reserve = false);
// Удалить PCM/DSP-хвост, счётчики и фазу; внешнюю PTS-карту владелец сбрасывает отдельно.
void reset();
void silence(unsigned count); // Тишина устройства тоже сдвигает выходную фазу.
@@ -60,7 +64,8 @@ private:
uint64_t deviceSamples = 0; // Выданный PCM плюс объявленная локальная тишина.
int64_t phaseGoal = 0; // Целевая разность cursor-deviceSamples, кратная Frame.
double playedSource = 0, tempo = 1, cutPhase = 0; // Позиция источника на выходе, темп, дробный долг удаления Direct.
struct Bucket { uint64_t time = UINT64_MAX; double low = 0, inventory = 0; } buckets[4]; // Четыре интервала по 50мс.
struct Bucket { uint64_t time = UINT64_MAX; double low = 0, inventory = 0; } buckets[48]; // До 2.4с, шаг 50мс.
unsigned observationBuckets = 4; // Действующая часть окна, 4..48 интервалов.
unsigned trim = 0; // Уже выданный Direct-контекст, который нужно убрать с начала SoundTouch.
bool failed = false, firstSpan = false, lastCopy = false; // Необратимая ошибка; ожидаем первый DSP-span; хвост — Copy.
Mode mode = Mode::Direct; // Текущий способ выдачи PCM.
+11 -5
View File
@@ -37,6 +37,7 @@ struct call_jitter {
int packet_frames = 0;
call_playout_policy policy;
bool stable_playback = false; // Снимок состояния worker для сетевой оценки, под mtx.
int64_t last_pull_received_ms = -1; // До какого сетевого измерения worker действительно дошёл.
call_jitter_range range = {0, 0, CALL_JITTER_STARTUP_MS}; /* опубликованный снимок под mtx */
call_jitter_decode_fn decode;
void* arg;
@@ -131,10 +132,12 @@ void call_jitter_push(struct call_jitter* j, const uint8_t* enc, int len, uint32
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);
int spread = (int)std::min<int64_t>(j->arrival_window.max_reserve, j->arrival_window.high - j->arrival_window.low);
bool playing = j->stable_playback && j->last_pull_received_ms >= 0 && received_ms - j->last_pull_received_ms <= 1000;
j->policy.observe(received_ms, spread, j->arrival_window.max_reserve, playing);
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);
(long long)(j->arrival_window.high - j->arrival_window.low), playing, j->policy.packets);
}
j->packet_window.observe(received_ms, ++j->packet_frames * CALL_JITTER_FRAME_MS);
j->range.reserve_ms = j->policy.reserve_ms;
@@ -148,9 +151,12 @@ 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, packet_ms;
int count, reserve, packet_ms, observation_ms;
{ std::lock_guard<std::mutex> lock(j->mtx);
count = j->count; reserve = j->policy.reserve_ms; packet_ms = (int)j->packet_window.high; }
j->last_pull_received_ms = j->arrival_window.last_received_ms;
count = j->count; reserve = j->policy.reserve_ms; packet_ms = (int)j->packet_window.high;
observation_ms = packet_ms + (int)std::min<int64_t>(j->arrival_window.max_reserve,
j->arrival_window.high - j->arrival_window.low) + CALL_JITTER_FRAME_MS; }
double depth_samples = count * kFrameSamples + j->delay.stats().pending;
double depth = depth_samples / 48;
int64_t now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
@@ -175,7 +181,7 @@ int call_jitter_pull(struct call_jitter* j, int16_t* out, int max_samples) {
}
j->budget = std::min(CALL_JITTER_MAX_FRAMES, 8 + max_samples / 480);
j->attempts = 0; j->decode_errors = 0;
int n = (int)j->delay.pull(out, (unsigned)max_samples, count * kFrameSamples, reserve * 48);
int n = (int)j->delay.pull(out, (unsigned)max_samples, count * kFrameSamples, reserve * 48, observation_ms, true);
auto state = j->delay.stats();
j->primed = n == max_samples;
if (n > 0 && !j->playback_started) {
+6 -1
View File
@@ -12,6 +12,11 @@
* Стартовые 200мс — исходная цель, не одноразовый порог и не максимальная задержка.
* Цель растёт сразу, снижается по 20мс после подтверждённой устойчивости. Недостаток
* измерений/остановленный выход не являются основанием снижать запас.
* Измеренная вариация доставки получает один кадр (20мс) запаса на границу PCM.
* CallDelay наблюдает минимум за период пачки + вариацию доставки, иначе пик
* большой пачки ошибочно считался бы постоянным избытком. При дефиците цели
* SoundTouch замедляет речь до0.98x, пополняя запас без изменения высоты голоса;
* при избытке действует прежний догон. Изменение цели само по себе не создаёт PCM.
*
* PREBUFFER/RECOVER -> речевой PCM -> подготовленный FIFO адаптера -> реальный вывод.
* До первого PCM ожидание отличается от underrun; тишина не считается готовой речью.
@@ -31,7 +36,7 @@ struct call_playout_policy {
// Одна оценка на пачку; 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));
int required = std::min(maximum_ms, std::max(20, observed_ms + StepMs)); // Одна Opus-граница сверх измеренной вариации.
if (stable_since_ms < 0 || !playing || required > reserve_ms) {
stable_since_ms = now_ms; packets = 0;
}
+8 -8
View File
@@ -51,7 +51,7 @@ static void test_network_recovery() {
if (time < 3 * 48000) stable_underruns = state.underruns;
else CHECK(state.underruns == stable_underruns);
}
CHECK(largest_reserve >= 60 && state.reserve_ms == 20);
CHECK(largest_reserve >= 60 && state.reserve_ms >= 20 && state.reserve_ms <= 40);
CHECK(state.delay_mode == 0 && state.phase_samples == 0 && state.tempo_ppm == 1000000);
CHECK(state.underruns <= 2);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "recovery block=%d max_reserve=%dms final=%dms phase=%u underruns=%u",
@@ -80,7 +80,7 @@ static void test_audio_clock() {
for (int s = 0; s < 960; s++) voice[s] = (int16_t)(8000 * std::sin((sent * 960ll + s) * 0.09));
int len = opus_codec_encode(encoder, voice, 960, packet, sizeof(packet)); CHECK(len > 0);
call_capture_time timing{1000000 + sent * 20000ll, 9, (uint32_t)delay};
call_audio_on_media(&audio_owner, id, (uint16_t)sent, 0, packet, len, &timing, 1);
call_audio_on_media(&audio_owner, id, (uint16_t)sent, (uint32_t)(get_time_tb() / 10), packet, len, &timing, 1);
}
CHECK(call_audio_pull_pcm(id, output, 480) == 480); written += 480;
int64_t now = tick * 10000ll;
@@ -143,8 +143,8 @@ static void test_startup_policy() {
for (int t = 10000; t < 15000; t += 80) p.observe(t, 20, 1000, true);
CHECK(p.reserve_ms == 220);
p.observe(15100, 20, 1000, true); CHECK(p.reserve_ms == 200);
p.observe(15101, 350, 1000, true); CHECK(p.reserve_ms == 350);
p.observe(15102, 20, 1000, true); CHECK(p.reserve_ms == 350);
p.observe(15101, 350, 1000, true); CHECK(p.reserve_ms == 370);
p.observe(15102, 20, 1000, true); CHECK(p.reserve_ms == 370);
for (bool abrupt : {false, true}) {
decoder d; auto* j = call_jitter_create(decode, &d, 1000);
@@ -166,13 +166,12 @@ static void test_startup_policy() {
if (state.underruns != previous_underruns) {
printf("startup shortage: now=%d next=%d batch=%d due=%d depth=%d encoded=%d reserve=%d tempo=%d\n",
now, next, batch, due, state.depth_ms, state.encoded_ms, state.reserve_ms, state.tempo_x100);
// Резкий рост 4->10 добавляет 120мс к пачке плюс 120мс джиттера: больше исходных 200мс.
CHECK(abrupt && now < 6000 && state.underruns == 1); previous_underruns = state.underruns;
CHECK(state.underruns == 0); previous_underruns = state.underruns;
}
if (now < 5000) CHECK(state.reserve_ms >= 200);
}
CHECK(played);
CHECK(previous_underruns == (abrupt ? 1u : 0u));
CHECK(previous_underruns == 0);
call_jitter_destroy(j);
}
puts("startup policy: cold start, held reserve, packet size change and jitter PASS");
@@ -298,7 +297,8 @@ int main() {
refill, depth, range.reserve_ms, d.seen.size() - before);
}
CHECK(refill == 480);
CHECK(d.seen.size() == before + 1);
CHECK(d.seen.size() > before && d.seen.size() <= before + 12); // SoundTouch может подготовить несколько кадров.
for (size_t i = before; i < d.seen.size(); i++) CHECK(d.seen[i] == (int)i);
for (int i = 0; i < 50; i++) push(j, next++);
CHECK(call_jitter_pull(j, pcm, 480) > 0);
call_jitter_destroy(j);