fix(audio): stereo screen-audio loopback capture on Windows

start_loopback_capture hardcoded channels=1, forcing miniaudio to downmix the system's stereo mix to mono before the encoder saw it -- on_capture_frame then upmixed L=R to produce fake stereo. Now the loopback device opens in the channel's mode (stereo when the channel is stereo), CaptureCallback carries an explicit channels param so the encoder gets real interleaved L/R, and a mono fallback covers unusual render endpoints. New test_loopback_stereo_capture asserts L!=R end-to-end; 18/18 ctest green.
This commit is contained in:
2026-06-17 23:27:59 +02:00
parent a88656f2fa
commit cccf085a87
7 changed files with 258 additions and 39 deletions

View File

@@ -105,10 +105,14 @@ class AudioEngine {
public:
// Callback type for encoded capture frames ready to be sent. `kind` identifies which
// local stream this PCM belongs to (a vc_stream_kind value; 0 = MIC for the real capture
// device, which is always the "primary" tap). M3: multiple concurrent local streams are
// possible (e.g. MIC + SCREEN_AUDIO), each fed via its own injection tap (see
// inject_capture) since there is only one real hardware capture device.
using CaptureCallback = std::function<void(int kind, const int16_t* pcm, int samples)>;
// device, which is always the "primary" tap). `channels` is the channel count of the PCM
// buffer (1 = mono, 2 = stereo interleaved) — the mic capture device is mono in v1, but
// the WASAPI loopback path (SCREEN_AUDIO) captures in the channel's mode when stereo, so
// the encoder sees real interleaved L/R PCM rather than a mono upmix. M3: multiple
// concurrent local streams are possible (e.g. MIC + SCREEN_AUDIO), each fed via its own
// injection tap (see inject_capture) since there is only one real hardware capture device.
using CaptureCallback = std::function<void(int kind, const int16_t* pcm, int samples,
int channels)>;
AudioEngine();
~AudioEngine();
@@ -131,8 +135,11 @@ class AudioEngine {
// Real desktop-audio loopback capture (Windows/WASAPI only, VOICECAT_HAS_LOOPBACK). Feeds
// `kind`'s capture_cb_ directly, same pattern as the real mic capture device — NOT routed
// through inject_capture()'s test-only ring. No-op (returns false) when unsupported.
bool start_loopback_capture(int kind);
// through inject_capture()'s test-only ring. `channels` is the channel count to open the
// loopback device with (1 = mono downmix of the system mix, 2 = stereo capture when the
// channel is configured stereo); the accumulator and capture_cb_ invocation are shaped to
// match. No-op (returns false) when unsupported.
bool start_loopback_capture(int kind, int channels);
void stop_loopback_capture();
// Inject synthetic PCM directly into the capture pipeline (bypasses real device).
@@ -193,13 +200,52 @@ class AudioEngine {
src += copy;
remaining -= copy;
if (capture_accum_.count == frame_samples_) {
cb(0, capture_accum_.buf.data(), frame_samples_);
cb(0, capture_accum_.buf.data(), frame_samples_, 1);
capture_accum_.count = 0;
}
}
}
#endif
#ifdef VOICECAT_HAS_LOOPBACK
// TEST-ONLY — drives the loopback accumulator directly with an explicit callback, the
// loopback analogue of feed_capture_for_test. Self-contained: sizes the accumulator and
// sets loopback_channels_ itself, so it works on headless CI where start_loopback_capture
// can't init a real WASAPI device. `channels` selects mono (1) or interleaved stereo (2).
// PCM is interleaved L/R when channels==2. Invokes `cb` once per full frame_samples_
// per-channel chunk, with the channel count passed through so the encoder branch in
// on_capture_frame sees real stereo (channels==2) rather than a mono upmix.
void feed_loopback_for_test(const int16_t* pcm, int frames_per_channel, int channels,
const CaptureCallback& cb) {
if (frame_samples_ <= 0 || !cb) return;
const int ch = std::max(1, channels);
const int full = frame_samples_ * ch;
// Size the accumulator for the requested channel count (off the RT thread; this is a
// test-only path). start_loopback_capture() does the same sizing when it opens a real
// device, but on headless CI that init fails — so do it here too.
if (static_cast<int>(loopback_accum_.buf.size()) != full) {
loopback_accum_.buf.assign(static_cast<size_t>(full), 0);
loopback_accum_.count = 0;
}
loopback_channels_ = ch;
const int16_t* src = pcm;
auto remaining = frames_per_channel * ch;
while (remaining > 0) {
int space = full - loopback_accum_.count;
int copy = std::min(remaining, space);
std::memcpy(loopback_accum_.buf.data() + loopback_accum_.count, src,
static_cast<size_t>(copy) * sizeof(int16_t));
loopback_accum_.count += copy;
src += copy;
remaining -= copy;
if (loopback_accum_.count == full) {
cb(loopback_kind_, loopback_accum_.buf.data(), frame_samples_, ch);
loopback_accum_.count = 0;
}
}
}
#endif
private:
#ifdef VOICECAT_HAS_AUDIO
static void capture_data_cb(ma_device*, void*, const void*, ma_uint32);
@@ -222,6 +268,7 @@ class AudioEngine {
ma_device loopback_device_{};
bool loopback_started_ = false;
int loopback_kind_ = 0;
int loopback_channels_ = 1; // channel count the loopback device was opened with
#endif
#endif