From 80f6997e1b068649ca4ee6693231153b563395cf Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 15 Oct 2026 03:35:59 +0200 Subject: [PATCH] Keep capture PCM timestamps continuous across backend timing updates --- tools/chatgui/src/voice_audio_io.cpp | 16 ++++++- tools/chatgui/src/voice_capture_clock.h | 31 ++++++++++++++ tools/chatgui/tests/test_voice_audio_io.cpp | 47 ++++++++++++++++++++- 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 tools/chatgui/src/voice_capture_clock.h diff --git a/tools/chatgui/src/voice_audio_io.cpp b/tools/chatgui/src/voice_audio_io.cpp index b362b751..799c35d6 100644 --- a/tools/chatgui/src/voice_audio_io.cpp +++ b/tools/chatgui/src/voice_audio_io.cpp @@ -1,4 +1,5 @@ #include "voice_audio_io.h" +#include "voice_capture_clock.h" extern "C" { #include "call/call_audio.h" #include "radio/radio_audio.h" @@ -229,6 +230,8 @@ void VoiceAudioIo::captureLoop() { int64_t frameTime = 0, lastReport = voice_time_us(), lastCapture = lastReport, maxQueueAge = 0; uint64_t expectedSequence = 0, missingReference = 0, reportedReferenceDrops = referenceDrops, captureRoute = 0; int64_t expectedCaptureTime = 0; + VoiceCaptureClock captureClock; + int64_t maxClockError = 0, maxClockAdjustment = 0; bool referenceAvailable = false; const char* referenceReason = "empty_history"; double referenceGapUs = 0; @@ -318,6 +321,13 @@ void VoiceAudioIo::captureLoop() { DEBUG_WARN(DEBUG_CATEGORY_CALL, "voice-io: capture stalled session=%llu no_pcm=%.1fms queue=%zu", (unsigned long long)m_session, (now - lastCapture) / 1000.0, m_captureEvents.size()); maxQueueAge = 0; + if (captureClock.initialized) { + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "voice-io: capture clock session=%llu sequence=%llu phase_us=%.1f " + "error_max_us=%lld adjustment_max_us=%lld", (unsigned long long)m_session, + (unsigned long long)expectedSequence, captureClock.phaseUs, + (long long)maxClockError, (long long)maxClockAdjustment); + maxClockError = maxClockAdjustment = 0; + } if (signalFrames) { auto db = [](double power) { return 10 * std::log10(std::max(power, 1e-12)); }; DEBUG_DEBUG(DEBUG_CATEGORY_AEC, "voice-io: signal session=%llu frames=%u aec_frames=%u raw=%.1fdBFS clean=%.1fdBFS " @@ -369,6 +379,7 @@ void VoiceAudioIo::captureLoop() { if (clockJump) DEBUG_WARN(DEBUG_CATEGORY_AEC, "voice-io: capture clock jump=%lldus", (long long)(event.time - expectedCaptureTime)); for (size_t i = 0; i < markerCount; ++i) applyMarker(markers[i].kind, markers[i].value); filled = 0; markerCount = 0; + captureClock.reset(); if (aec) speex_aec_reset(aec); if (m_kind == Kind::Radio) radio_audio_capture_discontinuity(m_session); else call_audio_reset_io(m_session, 0); @@ -376,8 +387,11 @@ void VoiceAudioIo::captureLoop() { expectedSequence = event.sequence + event.frames; captureRoute = event.route; expectedCaptureTime = event.time + (int64_t)event.frames * 1000000 / 48000; + const int64_t pcmTime = captureClock.map(event.sequence, event.time); + maxClockError = std::max(maxClockError, std::abs(captureClock.errorUs)); + maxClockAdjustment = std::max(maxClockAdjustment, std::abs(captureClock.adjustmentUs)); for (unsigned offset = 0; offset < event.frames;) { - if (!filled) frameTime = event.time + (int64_t)offset * 1000000 / 48000; + if (!filled) frameTime = pcmTime + (int64_t)offset * 1000000 / 48000; unsigned take = std::min(960 - filled, event.frames - offset); memcpy(raw + filled * m_channels, event.pcm + offset * m_channels, take * m_channels * sizeof(int16_t)); filled += take; offset += take; diff --git a/tools/chatgui/src/voice_capture_clock.h b/tools/chatgui/src/voice_capture_clock.h new file mode 100644 index 00000000..2408cb5e --- /dev/null +++ b/tools/chatgui/src/voice_capture_clock.h @@ -0,0 +1,31 @@ +#pragma once +#include +#include +#include + +// Позиция PCM задаёт непрерывное время; backend уточняет привязку без скачков на каждом обновлении latency. +// Единственный владелец — capture worker. Настоящий разрыв sequence/route/time вызывает reset у владельца. +struct VoiceCaptureClock { + bool initialized = false; + uint64_t originSequence = 0, previousSequence = 0; + int64_t originUs = 0, errorUs = 0, adjustmentUs = 0; + double phaseUs = 0; + + void reset() { *this = {}; } + + int64_t map(uint64_t sequence, int64_t observedUs) { + if (!initialized) { + initialized = true; originSequence = previousSequence = sequence; originUs = observedUs; + return observedUs; + } + const double nominal = (sequence - originSequence) * (1000000.0 / 48000.0); + const double elapsed = (sequence - previousSequence) * (1000000.0 / 48000.0); + const double error = double(observedUs - originUs) - nominal - phaseUs; + // Постоянная времени 2с; предел 0.5% (не более 100мкс коррекции на 20мс PCM). + const double adjustment = std::clamp(error * (1.0 - std::exp(-elapsed / 2000000.0)), -elapsed / 200, elapsed / 200); + phaseUs += adjustment; + previousSequence = sequence; + errorUs = std::llround(error); adjustmentUs = std::llround(adjustment); + return originUs + std::llround(nominal + phaseUs); + } +}; diff --git a/tools/chatgui/tests/test_voice_audio_io.cpp b/tools/chatgui/tests/test_voice_audio_io.cpp index 8e3ceef5..d140ade7 100644 --- a/tools/chatgui/tests/test_voice_audio_io.cpp +++ b/tools/chatgui/tests/test_voice_audio_io.cpp @@ -1,5 +1,6 @@ /* Настоящие workers/AEC; граница сервисного PCM заменена наблюдателями без сети/устройства. */ #include "../src/voice_audio_io.h" +#include "../src/voice_capture_clock.h" extern "C" { #include "call/call_audio.h" #include "radio/radio_audio.h" @@ -19,6 +20,8 @@ extern "C" { static std::mutex mutex; static std::vector capture, transmitted; static std::vector masks; +static std::vector captureTimes; +static std::atomic captureResets{0}; static bool talking; static std::atomic pulls{0}, blocks{0}; static std::atomic stall{false}, stalled{false}; @@ -34,7 +37,7 @@ extern "C" int call_audio_pull_pcm(uint64_t, int16_t* out, int count) { ++pulls; extern "C" int radio_audio_pull_pcm(uint64_t, int16_t* out, int count) { ++pulls; for (int i = 0; i < count; ++i) out[i] = i % 2 ? 2000 : 1000; return count; } -extern "C" void call_audio_reset_io(uint64_t, int) {} +extern "C" void call_audio_reset_io(uint64_t, int playback) { if (!playback) ++captureResets; } extern "C" int call_audio_get_clock(uint64_t, int64_t, call_clock_snapshot* out) { *out = {}; out->epoch = 1; return 0; } extern "C" void call_audio_playback_progress(uint64_t, uint64_t, uint64_t position, int64_t at, int64_t now, int) { output_position = position; output_delay = at > now ? (at - now) / 100 : 0; @@ -54,6 +57,7 @@ static int64_t capture_time; extern "C" int call_audio_feed_prepared_pcm(uint64_t, const int16_t* pcm, const uint8_t* mask, int64_t pts) { std::lock_guard lock(mutex); capture_time = pts; + captureTimes.push_back(pts); capture.insert(capture.end(), pcm, pcm + 960); masks.insert(masks.end(), mask, mask + 960); ++blocks; return 0; } @@ -64,9 +68,25 @@ template static void await(F ready) { for (int i = 0; i < 2000 && !ready(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(1)); REQUIRE(ready()); } -static void reset() { std::lock_guard lock(mutex); capture.clear(); transmitted.clear(); masks.clear(); talking = false; blocks = 0; } +static void reset() { + std::lock_guard lock(mutex); + capture.clear(); transmitted.clear(); masks.clear(); captureTimes.clear(); captureResets = 0; talking = false; blocks = 0; +} int main() { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); + // Десять минут +/-300ppm дрейфа: связь с физическим временем сохраняется, шага >2мс нет. + for (double drift : {-0.0003, 0.0003}) { + VoiceCaptureClock clock; + int64_t previous = 0; + for (int frame = 0; frame < 30000; ++frame) { + int64_t actual = 1000000 + std::llround(frame * 20000.0 * (1 + drift)); + int64_t mapped = clock.map(uint64_t(frame) * 960, actual + (frame % 75 == 0 ? -14000 : 0)); + if (frame) REQUIRE(std::abs(mapped - previous - 20000) <= 101); + if (frame > 2000) REQUIRE(std::abs(mapped - actual) < 1500); + previous = mapped; + } + clock.reset(); REQUIRE(clock.map(123, 900000) == 900000); + } VoiceAudioIo voice; std::array pcm{}; pcm.fill(1234); // Отсутствие PCM должно попадать в лог без необходимости ждать следующего кадра. @@ -126,6 +146,29 @@ int main() { voice.capture(pcm.data(), 960, capturePts); // Backend latency не является возрастом worker-очереди. await([] { return blocks.load() == 1; }); voice.stopCapture(); voice.stop(); REQUIRE(capture.size() == 960 && capture_time == capturePts); reset(); + // Чанки backend 25мс пересекают границы Opus 20мс. Джиттер меток не меняет число/порядок PCM. + voice.start(VoiceAudioIo::Kind::Call, 44); REQUIRE(voice.startCapture(1, false)); + std::array chunk{}; chunk.fill(2345); + const int64_t base = now_us() - 50000; + for (int i = 0; i < 120; ++i) { + voice.capture(chunk.data(), 1200, base + i * 25000 + (i % 2 ? 7000 : -7000), 1); + await([&] { return blocks.load() == (i + 1) * 1200 / 960; }); + } + REQUIRE(captureResets == 0); + voice.capture(chunk.data(), 1200, base + 120 * 25000, 1, true); + voice.capture(chunk.data(), 1200, base + 121 * 25000, 1); + await([] { return blocks.load() == 151; }); + REQUIRE(captureResets == 1); // Явная потеря PCM по-прежнему разрывает эпоху. + voice.capture(chunk.data(), 1200, base + 122 * 25000, 2); + await([] { return blocks.load() == 152; }); + REQUIRE(captureResets == 2); // Смена устройства не скрывается фильтром. + voice.capture(chunk.data(), 1200, base + 123 * 25000 + 100000, 2); + await([] { return blocks.load() == 153; }); + REQUIRE(captureResets == 3); // Большой разрыв исходных меток также остаётся разрывом. + voice.stop(); + REQUIRE(capture.size() == 153 * 960 && std::all_of(capture.begin(), capture.end(), [](int16_t v) { return v == 2345; })); + for (size_t i = 1; i < 150; ++i) REQUIRE(std::abs(captureTimes[i] - captureTimes[i-1] - 20000) <= 201); + reset(); // Референс противофазный: mono downmix дал бы ноль и не смог подавить эхо. voice.start(VoiceAudioIo::Kind::Call, 43); REQUIRE(voice.startCapture(1, true)); uint32_t rng = 1234567;