/* * test_plc_cap — verifies the PLC cap in AudioEngine::on_playback. * * After ~2s of pure packet-loss concealment (no real packets decoded), the mixer stops * calling opus_decode(nullptr,0,...) and emits digital silence instead. This bounds the * Opus comfort-noise hiss so a stale stream left in the mixer can never hiss forever — * defense-in-depth for the server's UserEvent::LEFT broadcast (the primary fix that * triggers remove_stream on disconnect). Also verifies that a fresh real packet resets * the PLC streak and audio resumes. * * White-box: drives AudioEngine::mix_for_test directly (no audio hardware needed). */ #include #include #include #include #if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS) #include "audio/audio_engine.h" #include "codec/opus_codec.h" static int g_failures = 0; #define CHECK(cond) \ do { \ if (!(cond)) { \ std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \ ++g_failures; \ } \ } while (0) static double rms(const int16_t* pcm, int n) { double sum = 0.0; for (int i = 0; i < n; ++i) sum += static_cast(pcm[i]) * pcm[i]; return std::sqrt(sum / n); } static int64_t abs_energy(const int16_t* pcm, int n) { int64_t e = 0; for (int i = 0; i < n; ++i) e += static_cast(std::abs(static_cast(pcm[i]))); return e; } int main() { voicecat::audio::AudioEngine engine; voicecat::audio::AudioParams p; p.sample_rate = 48000; p.capture_channels = 1; p.playback_channels = 2; p.frame_ms = 20; CHECK(engine.start(p)); // no capture_cb — headless safe (devices may fail to init; ok) voicecat::codec::OpusParams op; op.sample_rate = 48000; op.frame_ms = 20; op.stereo = false; int frame_samples = voicecat::codec::opus_frame_samples(op); // 960 // Encode a loud sine wave to seed the decoder's PLC state. voicecat::codec::OpusEncoder enc; CHECK(enc.init(op)); std::vector sine(static_cast(frame_samples)); for (int i = 0; i < frame_samples; ++i) { float t = static_cast(i) / 48000.0f; sine[i] = static_cast(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f); } uint8_t opus_buf[1500]; int opus_len = enc.encode(sine.data(), frame_samples, opus_buf, sizeof(opus_buf)); CHECK(opus_len > 0); const uint32_t ssrc = 1; engine.init_recv_stream(ssrc, op, /*user_id=*/0, /*stream_id=*/0); // Push one real frame to seed the decoder. voicecat::audio::JitterBuffer::Frame f; f.seq = 0; f.timestamp = 0; f.fec_present = false; f.payload.assign(opus_buf, opus_buf + opus_len); engine.push_recv_frame(ssrc, std::move(f)); // mix_for_test period — 480 frames @ 48kHz = 10ms (typical WASAPI shared period). const uint32_t pb_frames = 480; const int out_n = static_cast(pb_frames) * 2; // stereo interleaved std::vector out(static_cast(out_n), 0); // 1) Decode the real frame (first mix call) — seeds PLC state. engine.mix_for_test(out.data(), pb_frames); // 2) Drive ~50ms of pure PLC — should produce comfort noise (non-zero). double early_rms = 0.0; for (int i = 0; i < 5; ++i) { engine.mix_for_test(out.data(), pb_frames); early_rms = std::max(early_rms, rms(out.data(), out_n)); } CHECK(early_rms > 1.0); // PLC of a loud sine is audible, not digital silence // 3) Drive well past the 2s PLC cap (250 callbacks = 2.5s of output). // After the cap, on_playback emits silence (memset 0) instead of PLC noise. for (int i = 0; i < 250; ++i) { engine.mix_for_test(out.data(), pb_frames); } // 4) The output must now be digital silence (all zeros), not comfort noise. // Drain a couple more callbacks to flush any ring residue, then assert. int64_t energy = 0; for (int i = 0; i < 3; ++i) { engine.mix_for_test(out.data(), pb_frames); energy = std::max(energy, abs_energy(out.data(), out_n)); } CHECK(energy == 0); // capped PLC = silence // 5) Resumption: push a fresh real frame — PLC streak resets, audio returns. marker=true is // what the real sender stamps on the first frame after a silence (talkspurt restart); it // makes the playout clock reseed to this leading edge immediately (no prebuffer delay). voicecat::audio::JitterBuffer::Frame f2; f2.seq = 1; f2.timestamp = 200000; // far ahead — playout-clock reseeds to it f2.fec_present = false; f2.marker = true; f2.payload.assign(opus_buf, opus_buf + opus_len); engine.push_recv_frame(ssrc, std::move(f2)); double resume_rms = 0.0; for (int i = 0; i < 5; ++i) { // a few calls to flush silence residue + decode real engine.mix_for_test(out.data(), pb_frames); resume_rms = std::max(resume_rms, rms(out.data(), out_n)); } CHECK(resume_rms > 1.0); // real audio is back engine.remove_stream(ssrc); engine.stop(); enc.destroy(); if (g_failures == 0) { std::printf("plc_cap: all checks passed (early_rms=%.1f resume_rms=%.1f)\n", early_rms, resume_rms); return 0; } std::printf("plc_cap: %d failure(s)\n", g_failures); return 1; } #else int main() { std::printf("plc_cap: SKIP (VOICECAT_HAS_AUDIO or VOICECAT_HAS_OPUS not defined)\n"); return 0; } #endif