feat(audio): stereo mic capture on Windows & macOS desktop clients
Both desktop mics were hard-mono: the core defaults capture_channels=1 and neither client ever called vc_set_capture_channels (only iOS did). Add a persisted "Stereo microphone" toggle to each client's Audio settings, applied when the mic stream starts and live via vc_set_capture_channels + vc_audio_restart. Expose both ABI calls in the Windows interop; the macOS wrapper already had them. Core fix: encode_and_send_frame now folds a stereo mic frame to mono on a mono channel - previously the channels==2 branch encoded interleaved L/R directly even on a mono channel, feeding a mono opus_encode 2x its samples (wrong pitch/garbage). Real stereo still only reaches the wire on a stereo channel; on a mono channel the mic is cleanly downmixed. Test: test_stereo_mic_mono_channel. ctest --preset dev green (28/28). Docs: voice.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)%"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -26,6 +26,11 @@ public sealed class VoiceSettings
|
||||
/// cleaned signal. Independent of the per-listener receive-side NR.</summary>
|
||||
public bool MicNoiseReduction { get; set; } = false;
|
||||
|
||||
/// <summary>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).</summary>
|
||||
public bool StereoMic { get; set; } = false;
|
||||
|
||||
/// <summary>Push-to-talk key, stored as the integer value of System.Windows.Forms.Keys.</summary>
|
||||
public int PttKey { get; set; } = (int)Keys.F8;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public VcResult SetCaptureChannels(uint streamId, uint channels) =>
|
||||
NativeMethods.vc_set_capture_channels(_handle.DangerousGetHandle(), streamId, channels);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public VcResult AudioRestart() =>
|
||||
NativeMethods.vc_audio_restart(_handle.DangerousGetHandle());
|
||||
|
||||
public VcResult SetInputMode(VcInputMode mode) =>
|
||||
NativeMethods.vc_set_input_mode(_handle.DangerousGetHandle(), mode);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user