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>
181 lines
7.3 KiB
C++
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
|