Browse Source

Keep capture PCM timestamps continuous across backend timing updates

master
evgeny 6 days ago
parent
commit
80f6997e1b
  1. 16
      tools/chatgui/src/voice_audio_io.cpp
  2. 31
      tools/chatgui/src/voice_capture_clock.h
  3. 47
      tools/chatgui/tests/test_voice_audio_io.cpp

16
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;

31
tools/chatgui/src/voice_capture_clock.h

@ -0,0 +1,31 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
// Позиция 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);
}
};

47
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<int16_t> capture, transmitted;
static std::vector<uint8_t> masks;
static std::vector<int64_t> captureTimes;
static std::atomic<unsigned> captureResets{0};
static bool talking;
static std::atomic<int> pulls{0}, blocks{0};
static std::atomic<bool> 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<class F> 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<int16_t, 960> 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<int16_t, 1200> 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;

Loading…
Cancel
Save