Files
voice-cat/tests/test_recv_noise_reduction.cpp
Talon b44a200b95
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
fix(audio): apply receive-side NR to stereo mic streams
The per-listener noise-reduction toggle (vc_set_remote_stream) did nothing
on Windows/macOS/iOS. The decode loop gated the RNNoise pass on
dec_channels == 1 as a proxy for "this stream is voice" (assuming
stereo => screen-share). The stereo-mic capture commit broke that: a stereo
mic with send-side NR off transmits stereo Opus, so the receiver decoded
two channels and skipped NR entirely. gain/mute have no channel guard, which
is why only NR appeared broken.

Thread the stream kind through init_recv_stream into RemoteStream::is_voice
(set from si.kind() == STREAM_MIC), gate receive NR on is_voice instead of
channel count, and fold a stereo voice frame to mono -> denoise -> duplicate
back across both channels in place (symmetric with the send-side downmix;
RNNoise is mono-only). Screen-audio shares are never denoised.

New test test_recv_noise_reduction drives AudioEngine and asserts a stereo
voice stream's noise floor collapses with NR on (RMS 1046 -> 0.1) while a
screen-audio share stays unchanged. ctest --preset dev green 29/29.
Docs: voice.md section 10. Shared-core fix; clients need only a rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:11:03 +02:00

181 lines
7.3 KiB
C++

/*
* 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 <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <thread>
#include <vector>
#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<int> frames{0};
std::atomic<long long> sumsq{0};
std::atomic<long long> count{0};
std::atomic<uint32_t> 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<Sink*>(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<long long>(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<std::vector<uint8_t>>& 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<uint64_t>(i);
f.timestamp = ts;
f.fec_present = false;
f.payload = frames[i];
engine.push_recv_frame(ssrc, std::move(f));
ts += static_cast<uint32_t>(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<double>(sink.sumsq.load(std::memory_order_relaxed)) /
static_cast<double>(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<int16_t>((static_cast<int32_t>(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<std::vector<uint8_t>> frames;
frames.reserve(kFrames);
std::vector<int16_t> interleaved(static_cast<size_t>(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