diff --git a/PROGRESS.md b/PROGRESS.md
index 66f5c1c..54462ec 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -26,6 +26,24 @@ up instantly. Newest status at the top.
send cushion could be reduced or removed. Shared-core change → add a test and re-verify
desktop↔desktop stays low-latency (steady sender ⇒ ~0 arrival jitter ⇒ no regression).
+- **Done (2026-06-23):** **Stereo mic capture on Windows & macOS desktop clients.** Both
+ desktop mics were hard-mono: `ensure_audio_running()` defaults `capture_channels = 1` and
+ neither client ever called `vc_set_capture_channels` (only iOS did). Added a **"Stereo
+ microphone" toggle** to each client's Audio settings (off by default, persisted —
+ `VoiceSettings.StereoMic` on Windows, `MainWindowController.stereoMic` /
+ `voice.stereoMic` UserDefaults on macOS). It's applied to the core when the mic stream
+ starts (stored on the stream before the announce round-trip, so the first device open picks
+ it up) and live in settings via `vc_set_capture_channels` + `vc_audio_restart`. Exposed both
+ ABI calls in the Windows interop (`NativeMethods`/`VoiceCatClient`); the macOS wrapper already
+ had them. **Core fix:** `encode_and_send_frame` (`core/src/core/client.cpp`) now folds a
+ stereo mic frame to mono when the channel is mono — previously the `channels == 2` branch
+ encoded interleaved L/R directly even on a mono channel, feeding a mono `opus_encode` 2× its
+ samples (wrong pitch / garbage). Real stereo still only reaches the wire on a **stereo
+ channel** (encoder channel count = channel's Opus mode); on a mono channel the mic is cleanly
+ downmixed. Test: `test_stereo_mic_mono_channel` in `tests/test_vad_ptt_devices.cpp`. Full
+ `ctest --preset dev` green — 28/28. macOS Xcode build not compiled here (Windows host); the
+ Swift changes follow existing `nrChanged`/`setInputDevice` patterns. Docs: voice.md §8.
+
- **Done (2026-06-23):** **Fixed iOS dual-stream / crackly mic — core opened a second
(miniaudio) capture device alongside the AVAudioEngine tap.** Symptom: with two clients in
a channel, the remote end heard the iOS mic **twice** and crackly. With Voice Chat + a BT
diff --git a/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift b/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift
index 87a0eab..c69b85c 100644
--- a/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift
+++ b/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift
@@ -62,6 +62,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
static let vadThreshold = "voice.vadThreshold"
static let inputGain = "voice.inputGain"
static let inputNoiseReduction = "voice.inputNoiseReduction"
+ static let stereoMic = "voice.stereoMic"
static let pttKeyCode = "voice.pttKeyCode"
static let auxEnabled = "voice.auxEnabled"
static let auxDeviceUID = "voice.auxDeviceUID"
@@ -80,6 +81,12 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
internal var inputNoiseReduction: Bool = false {
didSet { UserDefaults.standard.set(inputNoiseReduction, forKey: AudioDefaults.inputNoiseReduction) }
}
+ // Capture the mic in stereo (interleaved L/R) instead of mono. Real stereo only reaches the
+ // wire on a stereo channel; the core folds a stereo mic to mono on a mono channel. Applied to
+ // the core when the mic stream starts (micToggleClicked) and live via SettingsWindowController.
+ internal var stereoMic: Bool = false {
+ didSet { UserDefaults.standard.set(stereoMic, forKey: AudioDefaults.stereoMic) }
+ }
internal var selectedInputDeviceId: String?
// Aux outgoing stream: a second hardware input device the client captures itself and feeds to
@@ -163,6 +170,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
if d.object(forKey: AudioDefaults.inputNoiseReduction) != nil {
inputNoiseReduction = d.bool(forKey: AudioDefaults.inputNoiseReduction)
}
+ stereoMic = d.bool(forKey: AudioDefaults.stereoMic)
if d.object(forKey: AudioDefaults.pttKeyCode) != nil {
pttKeyCode = UInt16(d.integer(forKey: AudioDefaults.pttKeyCode))
}
@@ -781,6 +789,9 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
if let devId = selectedInputDeviceId {
client.setInputDevice(streamId: streamId, deviceId: devId)
}
+ // Stored on the stream before the announce round-trip completes, so the core's
+ // first capture-device open (ensure_audio_running) picks up the channel count.
+ client.setCaptureChannels(streamId: streamId, channels: stereoMic ? 2 : 1)
client.setInputMode(selectedInputMode)
if selectedInputMode == .voiceActivation {
client.setVadThreshold(vadThresholdValue)
diff --git a/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift b/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift
index a962033..47f4240 100644
--- a/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift
+++ b/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift
@@ -63,6 +63,12 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private let nrCheckbox = NSButton(checkboxWithTitle: "Noise reduction (RNNoise)",
target: nil, action: nil)
+ // Capture the mic in stereo (interleaved L/R) instead of mono. Real stereo only reaches the
+ // wire on a stereo channel; the core folds a stereo mic to mono on a mono channel. Persisted
+ // via MainWindowController.stereoMic.
+ private let stereoMicCheckbox = NSButton(checkboxWithTitle: "Stereo microphone",
+ target: nil, action: nil)
+
// Aux input stream: a second outgoing stream from another hardware input device (e.g. line-in
// / aux), captured client-side. Device + volume only — aux is always-on. Persisted via
// MainWindowController.auxEnabled / auxDeviceUID / auxGain.
@@ -178,6 +184,11 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
nrCheckbox.setAccessibilityLabel("Microphone noise reduction")
nrCheckbox.setAccessibilityHelp("RNNoise denoising of your microphone. Cleans your signal for everyone.")
+ stereoMicCheckbox.target = self
+ stereoMicCheckbox.action = #selector(stereoMicChanged)
+ stereoMicCheckbox.setAccessibilityLabel("Stereo microphone")
+ stereoMicCheckbox.setAccessibilityHelp("Capture your microphone in stereo. Only transmitted in stereo on a stereo channel.")
+
let inputModeRow = NSStackView(views: [inputModeLabel, inputModeControl])
inputModeRow.orientation = .horizontal
inputModeRow.spacing = 8
@@ -259,7 +270,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
volumeRow.orientation = .horizontal
volumeRow.spacing = 8
- let stack = NSStackView(views: [inputModeRow, vadRow, inputGainRow, nrCheckbox, pttRow, deviceRow,
+ let stack = NSStackView(views: [inputModeRow, vadRow, inputGainRow, nrCheckbox, stereoMicCheckbox, pttRow, deviceRow,
levelRow, auxHeader, auxCheckbox, auxDeviceRow, auxGainRow,
notificationsHeader, soundsCheckbox, volumeRow,
speechCheckbox, selfTalkCheckbox, pttSoundCheckbox])
@@ -330,6 +341,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
inputGainSlider.doubleValue = Double(mc.inputGain * 100)
updateInputGainLabel()
nrCheckbox.state = mc.inputNoiseReduction ? .on : .off
+ stereoMicCheckbox.state = mc.stereoMic ? .on : .off
auxCheckbox.state = mc.auxEnabled ? .on : .off
auxGainSlider.doubleValue = Double(mc.auxGain * 100)
@@ -390,6 +402,16 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
}
}
+ @objc private func stereoMicChanged() {
+ let on = stereoMicCheckbox.state == .on
+ mainController?.stereoMic = on
+ // Channel count only takes effect when the capture device (re)starts, so restart it live.
+ if let mc = mainController, mc.micStreamId != 0 {
+ client.setCaptureChannels(streamId: mc.micStreamId, channels: on ? 2 : 1)
+ client.audioRestart()
+ }
+ }
+
private func updateInputGainLabel() {
let pct = Int(inputGainSlider.doubleValue.rounded())
inputGainValueLabel.stringValue = "\(pct)%"
diff --git a/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs b/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs
index 691d098..6b51cb0 100644
--- a/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs
+++ b/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs
@@ -28,6 +28,7 @@ public sealed class AudioSettingsForm : Form
private readonly int _origVadSlider;
private readonly int _origMicGain;
private readonly bool _origMicNoiseReduction;
+ private readonly bool _origStereoMic;
private readonly Keys _origPttKey;
private readonly bool _origAuxEnabled;
private readonly string? _origAuxDeviceId;
@@ -44,6 +45,7 @@ public sealed class AudioSettingsForm : Form
private readonly TrackBar _trkVad;
private readonly TrackBar _trkGain;
private readonly CheckBox _chkNoiseReduction;
+ private readonly CheckBox _chkStereoMic;
private readonly CheckBox _chkAux;
private readonly Label _lblAuxDevice;
private readonly ComboBox _cboAuxDevice;
@@ -70,6 +72,7 @@ public sealed class AudioSettingsForm : Form
_origVadSlider = settings.VadThresholdSlider;
_origMicGain = settings.MicGain;
_origMicNoiseReduction = settings.MicNoiseReduction;
+ _origStereoMic = settings.StereoMic;
_origPttKey = _pttKey;
_origAuxEnabled = settings.AuxEnabled;
_origAuxDeviceId = settings.AuxDeviceId;
@@ -81,7 +84,7 @@ public sealed class AudioSettingsForm : Form
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
AutoScaleMode = AutoScaleMode.Font;
- ClientSize = new Size(420, 583);
+ ClientSize = new Size(420, 611);
// ── Device row ────────────────────────────────────────────────────────
var lblDevice = new Label
@@ -217,6 +220,23 @@ public sealed class AudioSettingsForm : Form
};
_chkNoiseReduction.CheckedChanged += ChkNoiseReduction_CheckedChanged;
+ // ── Stereo microphone ─────────────────────────────────────────────────
+ // Opens the mic capture device in stereo (interleaved L/R) instead of mono. Real stereo
+ // only reaches the wire on a stereo channel; the core folds a stereo mic to mono on a mono
+ // channel. Toggling while connected restarts the capture device (vc_audio_restart) so the
+ // new channel count takes effect immediately.
+ _chkStereoMic = new CheckBox
+ {
+ Text = "&Stereo microphone",
+ Location = new Point(12, 364),
+ AutoSize = true,
+ Checked = settings.StereoMic,
+ AccessibleName = "Stereo microphone",
+ AccessibleDescription =
+ "Capture your microphone in stereo. Only transmitted in stereo on a stereo channel.",
+ };
+ _chkStereoMic.CheckedChanged += ChkStereoMic_CheckedChanged;
+
// ── Aux input stream ──────────────────────────────────────────────────
// A second outgoing stream from another hardware input device (e.g. line-in / aux),
// captured client-side and fed to the core. Device + volume only — aux is always-on
@@ -224,7 +244,7 @@ public sealed class AudioSettingsForm : Form
_chkAux = new CheckBox
{
Text = "&Aux stream (second input device)",
- Location = new Point(12, 376),
+ Location = new Point(12, 404),
AutoSize = true,
Checked = settings.AuxEnabled,
AccessibleName = "Enable aux input stream",
@@ -236,12 +256,12 @@ public sealed class AudioSettingsForm : Form
_lblAuxDevice = new Label
{
Text = "Aux d&evice:",
- Location = new Point(12, 406),
+ Location = new Point(12, 434),
AutoSize = true,
};
_cboAuxDevice = new ComboBox
{
- Location = new Point(12, 426),
+ Location = new Point(12, 454),
Width = 300,
DropDownStyle = ComboBoxStyle.DropDownList,
DisplayMember = "Name",
@@ -254,7 +274,7 @@ public sealed class AudioSettingsForm : Form
_btnAuxRefresh = new Button
{
Text = "Re&fresh",
- Location = new Point(320, 424),
+ Location = new Point(320, 452),
Size = new Size(80, 26),
};
_btnAuxRefresh.Click += (_, _) => LoadAuxDevices();
@@ -262,12 +282,12 @@ public sealed class AudioSettingsForm : Form
_lblAuxGain = new Label
{
Text = "Aux vo&lume:",
- Location = new Point(12, 462),
+ Location = new Point(12, 490),
AutoSize = true,
};
_trkAuxGain = new TrackBar
{
- Location = new Point(12, 482),
+ Location = new Point(12, 510),
Size = new Size(200, 45),
Minimum = 0,
Maximum = 300,
@@ -286,14 +306,14 @@ public sealed class AudioSettingsForm : Form
{
Text = "&OK",
DialogResult = DialogResult.OK,
- Location = new Point(228, 544),
+ Location = new Point(228, 572),
Size = new Size(80, 27),
};
var btnCancel = new Button
{
Text = "&Cancel",
DialogResult = DialogResult.Cancel,
- Location = new Point(316, 544),
+ Location = new Point(316, 572),
Size = new Size(80, 27),
};
@@ -310,7 +330,7 @@ public sealed class AudioSettingsForm : Form
lblDevice, _cboDevice, _btnRefresh,
lblMode, _radioVad, _lblSensitivity, _trkVad,
_radioPtt, _lblPttKey, _btnChangePtt, _radioAlwaysOn,
- lblGain, _trkGain, _chkNoiseReduction,
+ lblGain, _trkGain, _chkNoiseReduction, _chkStereoMic,
_chkAux, _lblAuxDevice, _cboAuxDevice, _btnAuxRefresh, _lblAuxGain, _trkAuxGain,
btnOk, btnCancel,
]);
@@ -455,6 +475,17 @@ public sealed class AudioSettingsForm : Form
_client.SetInputNoiseReduction(_chkNoiseReduction.Checked);
}
+ private void ChkStereoMic_CheckedChanged(object? sender, EventArgs e)
+ {
+ _settings.StereoMic = _chkStereoMic.Checked;
+ // Channel count only takes effect when the capture device (re)starts, so restart it live.
+ if (_micStreamId != 0)
+ {
+ _client.SetCaptureChannels(_micStreamId, _chkStereoMic.Checked ? 2u : 1u);
+ _client.AudioRestart();
+ }
+ }
+
private void BtnChangePtt_Click(object? sender, EventArgs e)
{
using var dlg = new PttKeyCaptureDialog(_pttKey);
@@ -476,6 +507,7 @@ public sealed class AudioSettingsForm : Form
_settings.VadThresholdSlider = _origVadSlider;
_settings.MicGain = _origMicGain;
_settings.MicNoiseReduction = _origMicNoiseReduction;
+ _settings.StereoMic = _origStereoMic;
_settings.PttKey = (int)_origPttKey;
if (_micStreamId != 0)
@@ -486,6 +518,12 @@ public sealed class AudioSettingsForm : Form
_client.SetVadThreshold(0.1f * (1f - (_origVadSlider - 1f) / 99f));
_client.SetInputGain(_origMicGain / 100f);
_client.SetInputNoiseReduction(_origMicNoiseReduction);
+ // Restore capture channel count; restart the device only if it actually changed.
+ if (_origStereoMic != _chkStereoMic.Checked)
+ {
+ _client.SetCaptureChannels(_micStreamId, _origStereoMic ? 2u : 1u);
+ _client.AudioRestart();
+ }
}
// Aux: restore originals to settings and re-apply live (order: device + gain first so a
diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.cs b/clients/windows/VoiceCat.App/Forms/MainForm.cs
index 4c3de16..19d7f0c 100644
--- a/clients/windows/VoiceCat.App/Forms/MainForm.cs
+++ b/clients/windows/VoiceCat.App/Forms/MainForm.cs
@@ -646,6 +646,7 @@ public partial class MainForm : Form
var mode = (VcInputMode)_voiceSettings.InputMode;
if (_voiceSettings.InputDeviceId is string devId)
_client.SetInputDevice(streamId, devId);
+ _client.SetCaptureChannels(streamId, _voiceSettings.StereoMic ? 2u : 1u);
_client.SetInputMode(mode);
if (mode == VcInputMode.VoiceActivation)
_client.SetVadThreshold(VadThresholdFromSettings());
diff --git a/clients/windows/VoiceCat.App/Models/VoiceSettings.cs b/clients/windows/VoiceCat.App/Models/VoiceSettings.cs
index 7dbdc94..05dbe52 100644
--- a/clients/windows/VoiceCat.App/Models/VoiceSettings.cs
+++ b/clients/windows/VoiceCat.App/Models/VoiceSettings.cs
@@ -26,6 +26,11 @@ public sealed class VoiceSettings
/// cleaned signal. Independent of the per-listener receive-side NR.
public bool MicNoiseReduction { get; set; } = false;
+ /// Capture the mic in stereo (interleaved L/R) instead of mono. Off by default. Real
+ /// stereo only reaches the wire on a stereo channel; on a mono channel the core folds the mic
+ /// to mono. Applied when the capture device next starts (Join Voice or an audio restart).
+ public bool StereoMic { get; set; } = false;
+
/// Push-to-talk key, stored as the integer value of System.Windows.Forms.Keys.
public int PttKey { get; set; } = (int)Keys.F8;
diff --git a/clients/windows/VoiceCat.Interop/NativeMethods.cs b/clients/windows/VoiceCat.Interop/NativeMethods.cs
index 35f776b..8021b6f 100644
--- a/clients/windows/VoiceCat.Interop/NativeMethods.cs
+++ b/clients/windows/VoiceCat.Interop/NativeMethods.cs
@@ -65,6 +65,12 @@ internal static partial class NativeMethods
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_set_input_device(nint c, uint streamId, string? deviceId);
+ [LibraryImport(LibName)]
+ internal static partial VcResult vc_set_capture_channels(nint c, uint streamId, uint channels);
+
+ [LibraryImport(LibName)]
+ internal static partial VcResult vc_audio_restart(nint c);
+
[LibraryImport(LibName)]
internal static partial VcResult vc_set_input_mode(nint c, VcInputMode mode);
diff --git a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs
index 501a14e..cc35210 100644
--- a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs
+++ b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs
@@ -212,6 +212,19 @@ public sealed class VoiceCatClient : IDisposable
public VcResult SetInputDevice(uint streamId, string? deviceId) =>
NativeMethods.vc_set_input_device(_handle.DangerousGetHandle(), streamId, deviceId);
+ /// Sets the mic capture channel count (1 = mono, 2 = stereo) for the given stream.
+ /// Applied when the capture device next (re)starts — call before Join Voice, or pair with an
+ /// audio restart to take effect live. Real stereo only reaches the wire on a stereo channel;
+ /// the core folds a stereo mic to mono on a mono channel.
+ public VcResult SetCaptureChannels(uint streamId, uint channels) =>
+ NativeMethods.vc_set_capture_channels(_handle.DangerousGetHandle(), streamId, channels);
+
+ /// Uninitializes and re-initializes the capture and playback devices on a running
+ /// engine, applying pending changes (e.g. capture channel count) that only take effect on a
+ /// device restart. No-op if audio isn't running.
+ public VcResult AudioRestart() =>
+ NativeMethods.vc_audio_restart(_handle.DangerousGetHandle());
+
public VcResult SetInputMode(VcInputMode mode) =>
NativeMethods.vc_set_input_mode(_handle.DangerousGetHandle(), mode);
diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp
index 4fc4dbe..d46fc21 100644
--- a/core/src/core/client.cpp
+++ b/core/src/core/client.cpp
@@ -1106,15 +1106,26 @@ void vc_client::encode_and_send_frame(LocalStream& ls, const int16_t* pcm, int s
int channels, int fd) {
uint8_t opus_buf[1500];
int opus_len;
- if (channels == 2) {
- // Real interleaved stereo PCM (SCREEN_AUDIO loopback on a stereo channel) — encode
- // directly, no upmix. `samples` is samples-per-channel, as OpusEncoder::encode expects.
+ if (channels == 2 && ls.effective_params.stereo) {
+ // Real interleaved stereo PCM (a stereo mic via vc_set_capture_channels, or SCREEN_AUDIO
+ // loopback) on a channel configured for stereo — encode directly, true L/R, no fold.
+ // `samples` is samples-per-channel, as OpusEncoder::encode expects.
opus_len = ls.encoder.encode(pcm, samples, opus_buf, sizeof(opus_buf));
+ } else if (channels == 2) {
+ // Stereo capture (stereo mic / line-in) on a MONO channel: the encoder is mono, so fold
+ // L/R to mono first. Feeding interleaved pairs straight to a mono opus_encode would make
+ // it read 2× the samples it should (wrong pitch / garbage). upmix_scratch is pre-sized at
+ // announce and easily holds `samples` mono values. This keeps a stereo-mic toggle safe on
+ // every channel: real L/R when the channel is stereo, a clean downmix when it isn't.
+ int16_t* mono = ls.upmix_scratch.data();
+ for (int i = 0; i < samples; ++i)
+ mono[i] = static_cast(
+ (static_cast(pcm[2 * i]) + static_cast(pcm[2 * i + 1])) / 2);
+ opus_len = ls.encoder.encode(mono, samples, opus_buf, sizeof(opus_buf));
} else if (ls.effective_params.stereo) {
- // Mono capture (mic, or loopback on a mono channel, or test injection) on a channel
+ // Mono capture (mono mic, or loopback on a mono channel, or test injection) on a channel
// configured for stereo — upmix L=R so the stream is still a spec-correct stereo Opus
- // bitstream. (Mic stays mono in v1 — no stereo capture device — but a stereo channel
- // requires a stereo bitstream, hence the upmix.) upmix_scratch is pre-sized at announce.
+ // bitstream (a stereo channel requires a stereo bitstream). upmix_scratch is pre-sized at announce.
int16_t* st = ls.upmix_scratch.data();
for (int i = 0; i < samples; ++i) {
st[i * 2] = pcm[i];
diff --git a/docs/voice.md b/docs/voice.md
index fc79dd4..4e9c6fb 100644
--- a/docs/voice.md
+++ b/docs/voice.md
@@ -224,9 +224,20 @@ Each receiver keeps an **adaptive jitter buffer per ssrc** with **bounded-depth
- Capture and playback run on miniaudio's real-time callbacks (WASAPI / CoreAudio / ALSA).
Playback is genuinely stereo end-to-end. **Mic capture** is mono by default; **stereo mic
capture** is supported via `vc_set_capture_channels(stream_id, 2)` — when enabled, the
- capture device opens in stereo (interleaved L/R) and the encoder receives real stereo PCM
- (no upmix). A mono mic frame on a stereo channel is upmixed L=R before encoding so the Opus
- bitstream is still spec-correct stereo. **Screen-audio (`SCREEN_AUDIO`) loopback** captures
+ capture device opens in stereo (interleaved L/R). All native clients expose this as a
+ per-user toggle (iOS in Settings; the Windows and macOS desktop clients via a "Stereo
+ microphone" checkbox in Audio settings — a live toggle there restarts the capture device via
+ `vc_audio_restart` so it takes effect immediately). Whether stereo actually reaches the wire
+ depends on the **channel's** Opus mode, which decides the encoder's channel count — the mic's
+ channel count and the channel's mode are independent knobs:
+ - **stereo mic + stereo channel** → real interleaved L/R is encoded directly (no upmix).
+ - **mono mic + stereo channel** → the mono frame is upmixed L=R so the Opus bitstream is
+ still spec-correct stereo.
+ - **stereo mic + mono channel** → the interleaved L/R is folded to mono before the mono
+ encoder. (Handing interleaved pairs straight to a mono `opus_encode` would make it read 2×
+ the samples it should — wrong pitch / garbage — so the fold keeps the toggle safe on any
+ channel.)
+ **Screen-audio (`SCREEN_AUDIO`) loopback** captures
in the channel's mode — stereo when the channel is stereo (real interleaved L/R, no
downmix), mono when the channel is mono — so a stereo music/screen-share channel gets
genuine stereo end-to-end. See §9 for the platform-specific loopback mechanism.
diff --git a/tests/test_vad_ptt_devices.cpp b/tests/test_vad_ptt_devices.cpp
index f0491cf..2a3a0aa 100644
--- a/tests/test_vad_ptt_devices.cpp
+++ b/tests/test_vad_ptt_devices.cpp
@@ -662,6 +662,59 @@ static void test_stereo_mic_capture() {
}
#endif
+// ── 6. Stereo mic capture on a MONO channel (downmix safety) ──────────────────
+// A stereo mic (vc_set_capture_channels=2) can be enabled while on a mono channel. The mic
+// then delivers interleaved L/R, but the channel's Opus encoder is mono. encode_and_send_frame
+// must fold L/R to mono before encoding — handing interleaved pairs straight to a mono
+// opus_encode makes it read 2× the samples it should (wrong pitch / garbage). This mirrors that
+// fold and proves the result is a valid mono bitstream that decodes to the expected averaged
+// signal, rather than half-length junk.
+#if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS)
+static void test_stereo_mic_mono_channel() {
+ voicecat::codec::OpusParams mono_params;
+ mono_params.stereo = false; // mono channel — encoder is mono
+ mono_params.application = voicecat::codec::OpusApplication::Voip;
+ mono_params.bitrate_bps = 64000;
+ const int frame_samples = voicecat::codec::opus_frame_samples(mono_params);
+
+ voicecat::codec::OpusEncoder enc;
+ CHECK(enc.init(mono_params));
+
+ // Loud left, silent right — folding (L+R)/2 yields a half-amplitude tone on every sample.
+ std::vector interleaved(static_cast(frame_samples) * 2);
+ for (int i = 0; i < frame_samples; ++i) {
+ float t = static_cast(i) / 48000.0f;
+ interleaved[i * 2] = static_cast(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
+ interleaved[i * 2 + 1] = 0;
+ }
+
+ // Fold exactly as encode_and_send_frame does for a stereo frame on a mono channel.
+ std::vector folded(frame_samples);
+ for (int i = 0; i < frame_samples; ++i)
+ folded[i] = static_cast(
+ (static_cast(interleaved[i * 2]) + static_cast(interleaved[i * 2 + 1])) / 2);
+
+ uint8_t opus_buf[1500];
+ int opus_len = enc.encode(folded.data(), frame_samples, opus_buf, sizeof(opus_buf));
+ CHECK(opus_len > 0);
+
+ // Decode mono and verify a full-length frame with real energy survived (a garbage half-read
+ // would either fail to decode the full frame_samples or come back near-silent / wrong length).
+ voicecat::codec::OpusDecoder dec;
+ CHECK(dec.init(mono_params));
+ std::vector decoded(frame_samples, 0);
+ int dec_samples = dec.decode(opus_buf, opus_len, decoded.data(), frame_samples);
+ CHECK(dec_samples == frame_samples);
+
+ int64_t energy = 0;
+ for (int i = 0; i < frame_samples; ++i) energy += std::abs(static_cast(decoded[i]));
+ CHECK(energy > static_cast(frame_samples) * 500); // clearly audible, not silence
+
+ std::printf("test_stereo_mic_mono_channel: ok (opus_len=%d, energy=%lld)\n",
+ opus_len, static_cast(energy));
+}
+#endif
+
int main() {
test_device_enumeration();
#if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS)
@@ -670,6 +723,7 @@ int main() {
test_loopback_stereo_capture();
#endif
test_stereo_mic_capture();
+ test_stereo_mic_mono_channel();
test_playout_resync();
#endif
#ifdef VOICECAT_HAS_AUDIO