/* * test_recv_noise_reduction — receive-side NR actually denoises a STEREO voice stream, and * never touches a screen-audio share. Regression for the bug where a stereo mic (shipped in * f72219d) silently bypassed listener-side NR because the decode loop gated on * `dec_channels == 1` as a proxy for "is voice" (docs/voice.md §10). * * White-box: drive AudioEngine in external-playback mode (the mixer-timer thread decodes+mixes * with no hardware device, same as test_external_playback) and tap the per-stream pcm_sink, which * fires AFTER the receive-side NR pass. We feed deterministic stereo white noise (paced at the * 20 ms engine cadence so the jitter buffer's catch-up never drops the backlog) and compare the * played-out noise floor across three runs: * • voice stream, NR off → baseline (decode only) * • voice stream, NR on → RNNoise folds stereo→mono and collapses the noise floor * • screen-audio (is_voice=false), NR on → NR is skipped; floor ≈ baseline * * Registered only under VOICECAT_USE_VCPKG_DEPS, where VOICECAT_HAS_NS is defined (a passthrough * build would correctly fail the reduction assertion). */ #include #include #include #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) namespace vca = voicecat::audio; namespace vcc = voicecat::codec; // pcm_sink accumulator (written on the mixer-timer thread, read by main after stop()). struct Sink { std::atomic frames{0}; std::atomic sumsq{0}; std::atomic count{0}; std::atomic last_ch{0}; int warmup = 0; // skip RNNoise's recurrent-state settle window }; static void sink_cb(void* user, uint32_t, uint32_t, const int16_t* pcm, size_t spc, uint32_t ch, uint32_t) { auto* s = static_cast(user); int f = s->frames.fetch_add(1, std::memory_order_relaxed); s->last_ch.store(ch, std::memory_order_relaxed); if (f < s->warmup) return; const size_t total = spc * ch; long long sq = 0; for (size_t i = 0; i < total; ++i) { long long v = pcm[i]; sq += v * v; } s->sumsq.fetch_add(sq, std::memory_order_relaxed); s->count.fetch_add(static_cast(total), std::memory_order_relaxed); } // Run one playout scenario and return the RMS of the played-out PCM, or -1 if nothing played. static double run_scenario(bool is_voice, bool nr_on, const std::vector>& frames, const vcc::OpusParams& op, int frame_samples) { vca::AudioEngine engine; engine.set_external_playback(true); Sink sink; sink.warmup = 20; engine.set_pcm_sink(&sink_cb, &sink); vca::AudioParams p; p.sample_rate = 48000; p.capture_channels = 1; p.playback_channels = 2; p.frame_ms = 20; if (!engine.start(p)) return -1; const uint32_t ssrc = 1; engine.init_recv_stream(ssrc, op, /*user_id=*/7, /*stream_id=*/3, is_voice); if (nr_on) engine.set_stream_noise_reduction(ssrc, true); uint32_t ts = 0; for (size_t i = 0; i < frames.size(); ++i) { vca::JitterBuffer::Frame f; f.seq = static_cast(i); f.timestamp = ts; f.fec_present = false; f.payload = frames[i]; engine.push_recv_frame(ssrc, std::move(f)); ts += static_cast(frame_samples); // Pace at the engine cadence so production ≈ consumption: the jitter buffer never builds a // backlog big enough to trigger catch-up frame-dropping, so every frame is decoded. std::this_thread::sleep_for(std::chrono::milliseconds(20)); } std::this_thread::sleep_for(std::chrono::milliseconds(120)); // drain engine.stop(); CHECK(sink.last_ch.load(std::memory_order_relaxed) == 2); // stereo throughout long long c = sink.count.load(std::memory_order_relaxed); if (c == 0) return -1; return std::sqrt(static_cast(sink.sumsq.load(std::memory_order_relaxed)) / static_cast(c)); } int main() { // Stereo Opus params (a stereo mic with send-side NR off arrives exactly like this). vcc::OpusParams op; op.sample_rate = 48000; op.frame_ms = 20; op.stereo = true; int frame_samples = vcc::opus_frame_samples(op); // 960 per channel // Encode a run of deterministic interleaved-stereo white noise (L and R independent so the // bitstream is genuinely stereo, not L==R). uint32_t rng = 0xC0FFEEu; auto next_noise = [&]() -> int16_t { rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5; return static_cast((static_cast(rng % 6001)) - 3000); // ~[-3000,3000] }; vcc::OpusEncoder enc; CHECK(enc.init(op)); const int kFrames = 80; // ~1.6 s; warmup 20 leaves ~60 measured std::vector> frames; frames.reserve(kFrames); std::vector interleaved(static_cast(frame_samples) * 2); uint8_t opus_buf[1500]; for (int f = 0; f < kFrames; ++f) { for (int i = 0; i < frame_samples * 2; ++i) interleaved[i] = next_noise(); int len = enc.encode(interleaved.data(), frame_samples, opus_buf, sizeof(opus_buf)); CHECK(len > 0); frames.emplace_back(opus_buf, opus_buf + len); } enc.destroy(); double voice_off = run_scenario(/*is_voice=*/true, /*nr_on=*/false, frames, op, frame_samples); double voice_on = run_scenario(/*is_voice=*/true, /*nr_on=*/true, frames, op, frame_samples); double screen_on = run_scenario(/*is_voice=*/false, /*nr_on=*/true, frames, op, frame_samples); std::printf("recv_nr: voice_off_rms=%.1f voice_on_rms=%.1f screen_on_rms=%.1f\n", voice_off, voice_on, screen_on); CHECK(voice_off > 0.0); CHECK(voice_on > 0.0); CHECK(screen_on > 0.0); // The fix: enabling NR on the stereo VOICE stream must collapse the noise floor. RNNoise drops // pure noise ~99%; require a clear, unambiguous reduction (the pre-fix bug left it unchanged). CHECK(voice_on < voice_off * 0.6); // A screen-audio share is not voice: NR must be a no-op there, so its floor stays ≈ the // undenoised baseline (same decode path, no RNNoise). Loose band absorbs run-to-run timing. CHECK(screen_on > voice_off * 0.7); if (g_failures == 0) { std::printf("recv_noise_reduction: OK\n"); return 0; } std::printf("recv_noise_reduction: %d failure(s)\n", g_failures); return 1; } #else int main() { std::printf("recv_noise_reduction: SKIP (VOICECAT_HAS_AUDIO or VOICECAT_HAS_OPUS not defined)\n"); return 0; } #endif