diff --git a/doc/call_audio_batch.txt b/doc/call_audio_batch.txt index b492e9e7d..c457a3d90 100644 --- a/doc/call_audio_batch.txt +++ b/doc/call_audio_batch.txt @@ -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). diff --git a/doc/tasks.md b/doc/tasks.md index c42f6577f..10184040c 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -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 перед каждым кадром. Приём валидирует всю пачку до доставки; diff --git a/src/call/call_delay.cpp b/src/call/call_delay.cpp index bc3ea9387..401152205 100644 --- a/src/call/call_delay.cpp +++ b/src/call/call_delay.cpp @@ -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)); diff --git a/src/call/call_delay.h b/src/call/call_delay.h index dce047731..a006b4306 100644 --- a/src/call/call_delay.h +++ b/src/call/call_delay.h @@ -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. diff --git a/src/call/call_jitter.cpp b/src/call/call_jitter.cpp index 20d2ba4f2..a556fa103 100644 --- a/src/call/call_jitter.cpp +++ b/src/call/call_jitter.cpp @@ -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(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 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(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( @@ -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) { diff --git a/src/call/call_playout_policy.h b/src/call/call_playout_policy.h index 504d37925..f7fb9db01 100644 --- a/src/call/call_playout_policy.h +++ b/src/call/call_playout_policy.h @@ -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; } diff --git a/tools/chatgui/tests/test_call_jitter.cpp b/tools/chatgui/tests/test_call_jitter.cpp index 4df9962aa..b286bb696 100644 --- a/tools/chatgui/tests/test_call_jitter.cpp +++ b/tools/chatgui/tests/test_call_jitter.cpp @@ -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);