Replenish playout reserve smoothly and account for packet cadence in delay control
This commit is contained in:
1 parent
0c20766cfe
commit
a7bb72a0b7
7 files changed
+75
-37
No files matched your search
+23
-12
@@ -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).
|
||||
|
||||
@@ -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
@@ -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,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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user