feat(clients): add aux outgoing stream (mic + second input device) on Windows + macOS

Lets a user transmit a second hardware input device (e.g. line-in / aux)
alongside the mic, with its own device picker and volume, from Audio Settings.

No core/ABI/proto changes: the aux is a VC_STREAM_AUX_DEVICE stream started
with external_feed=1 and fed via vc_stream_feed_pcm (the same external-feed
pipeline screen-audio uses). Per-kind local_streams_ already allows mic +
screen + one aux to coexist; volume is a client-side gain multiply (the core's
vc_set_input_gain is mic-only/global). Aux is always-on (core never gates
AUX_DEVICE on VAD/PTT) and is tied to the voice session.

Windows: new Audio/InputDeviceCapture.cs (WASAPI shared-mode capture from a
real input endpoint + capture-endpoint enumeration); aux section in
AudioSettingsForm.cs; lifecycle in MainForm.cs; persistence in VoiceSettings.cs.

macOS: new Audio/InputDeviceCapture.swift (AVAudioEngine input-node tap pinned
to the chosen Core Audio device + device enumeration by stable UID); aux section
in SettingsWindowController.swift; lifecycle + UserDefaults persistence in
MainWindowController.swift; file registered in project.pbxproj.

Windows verified (C# solution builds clean; aux confirmed working). macOS build
+ E2E pending a Mac.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 12:56:09 +02:00
parent a48b47d4ca
commit 7249a8fd30
9 changed files with 1210 additions and 7 deletions

View File

@@ -1,4 +1,5 @@
using System.ComponentModel;
using VoiceCat.App.Audio;
using VoiceCat.App.Models;
using VoiceCat.Interop;
@@ -15,12 +16,21 @@ public sealed class AudioSettingsForm : Form
private readonly VoiceSettings _settings;
private readonly uint _micStreamId;
// Aux-stream live-apply callbacks, supplied by MainForm (which owns the aux capture). Null
// when not connected — the controls still edit settings, just without live effect.
private readonly Action<bool>? _applyAuxEnabled;
private readonly Action<string?>? _applyAuxDevice;
private readonly Action<float>? _applyAuxGain;
// Snapshot of original values so Cancel can restore them
private readonly string? _origDeviceId;
private readonly VcInputMode _origMode;
private readonly int _origVadSlider;
private readonly int _origMicGain;
private readonly Keys _origPttKey;
private readonly bool _origAuxEnabled;
private readonly string? _origAuxDeviceId;
private readonly int _origAuxGain;
private readonly ComboBox _cboDevice;
private readonly Button _btnRefresh;
@@ -32,14 +42,25 @@ public sealed class AudioSettingsForm : Form
private readonly Label _lblSensitivity;
private readonly TrackBar _trkVad;
private readonly TrackBar _trkGain;
private readonly CheckBox _chkAux;
private readonly Label _lblAuxDevice;
private readonly ComboBox _cboAuxDevice;
private readonly Button _btnAuxRefresh;
private readonly Label _lblAuxGain;
private readonly TrackBar _trkAuxGain;
private Keys _pttKey;
public AudioSettingsForm(VoiceCatClient client, VoiceSettings settings, uint micStreamId)
public AudioSettingsForm(VoiceCatClient client, VoiceSettings settings, uint micStreamId,
Action<bool>? applyAuxEnabled = null, Action<string?>? applyAuxDevice = null,
Action<float>? applyAuxGain = null)
{
_client = client;
_settings = settings;
_micStreamId = micStreamId;
_applyAuxEnabled = applyAuxEnabled;
_applyAuxDevice = applyAuxDevice;
_applyAuxGain = applyAuxGain;
_pttKey = (Keys)settings.PttKey;
_origDeviceId = settings.InputDeviceId;
@@ -47,6 +68,9 @@ public sealed class AudioSettingsForm : Form
_origVadSlider = settings.VadThresholdSlider;
_origMicGain = settings.MicGain;
_origPttKey = _pttKey;
_origAuxEnabled = settings.AuxEnabled;
_origAuxDeviceId = settings.AuxDeviceId;
_origAuxGain = settings.AuxGain;
Text = "Audio settings";
FormBorderStyle = FormBorderStyle.FixedDialog;
@@ -54,7 +78,7 @@ public sealed class AudioSettingsForm : Form
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(420, 370);
ClientSize = new Size(420, 555);
// ── Device row ────────────────────────────────────────────────────────
var lblDevice = new Label
@@ -175,19 +199,83 @@ public sealed class AudioSettingsForm : Form
"Boost a quiet microphone. 100 is unity gain; range 0300 percent.";
_trkGain.Scroll += TrkGain_Scroll;
// ── 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
// (the core never gates AUX_DEVICE on VAD/PTT).
_chkAux = new CheckBox
{
Text = "&Aux stream (second input device)",
Location = new Point(12, 348),
AutoSize = true,
Checked = settings.AuxEnabled,
AccessibleName = "Enable aux input stream",
AccessibleDescription =
"Transmit a second hardware input device alongside your microphone.",
};
_chkAux.CheckedChanged += ChkAux_CheckedChanged;
_lblAuxDevice = new Label
{
Text = "Aux d&evice:",
Location = new Point(12, 378),
AutoSize = true,
};
_cboAuxDevice = new ComboBox
{
Location = new Point(12, 398),
Width = 300,
DropDownStyle = ComboBoxStyle.DropDownList,
DisplayMember = "Name",
ValueMember = "Id",
AccessibleName = "Aux input device",
AccessibleDescription = "Select the second audio input device to transmit.",
};
_cboAuxDevice.SelectedIndexChanged += CboAuxDevice_SelectedIndexChanged;
_btnAuxRefresh = new Button
{
Text = "Re&fresh",
Location = new Point(320, 396),
Size = new Size(80, 26),
};
_btnAuxRefresh.Click += (_, _) => LoadAuxDevices();
_lblAuxGain = new Label
{
Text = "Aux vo&lume:",
Location = new Point(12, 434),
AutoSize = true,
};
_trkAuxGain = new TrackBar
{
Location = new Point(12, 454),
Size = new Size(200, 45),
Minimum = 0,
Maximum = 300,
TickFrequency = 25,
SmallChange = 5,
LargeChange = 25,
Value = Math.Clamp(settings.AuxGain, 0, 300),
};
_trkAuxGain.AccessibleName = "Aux volume";
_trkAuxGain.AccessibleDescription =
"Volume of the aux input stream. 100 is unity gain; range 0300 percent.";
_trkAuxGain.Scroll += TrkAuxGain_Scroll;
// ── OK / Cancel ───────────────────────────────────────────────────────
var btnOk = new Button
{
Text = "&OK",
DialogResult = DialogResult.OK,
Location = new Point(228, 334),
Location = new Point(228, 516),
Size = new Size(80, 27),
};
var btnCancel = new Button
{
Text = "&Cancel",
DialogResult = DialogResult.Cancel,
Location = new Point(316, 334),
Location = new Point(316, 516),
Size = new Size(80, 27),
};
@@ -205,6 +293,7 @@ public sealed class AudioSettingsForm : Form
lblMode, _radioVad, _lblSensitivity, _trkVad,
_radioPtt, _lblPttKey, _btnChangePtt, _radioAlwaysOn,
lblGain, _trkGain,
_chkAux, _lblAuxDevice, _cboAuxDevice, _btnAuxRefresh, _lblAuxGain, _trkAuxGain,
btnOk, btnCancel,
]);
@@ -217,6 +306,8 @@ public sealed class AudioSettingsForm : Form
}
LoadDevices();
LoadAuxDevices();
UpdateAuxControlsEnabled();
}
private void LoadDevices()
@@ -245,6 +336,65 @@ public sealed class AudioSettingsForm : Form
_client.SetInputDevice(_micStreamId, dev.IsDefault ? null : dev.Id);
}
// ── Aux input stream ──────────────────────────────────────────────────────
private void LoadAuxDevices()
{
string? currentId = (_cboAuxDevice.SelectedItem as InputDeviceInfo)?.Id
?? _settings.AuxDeviceId;
var devices = InputDeviceEnumerator.List().ToList();
// Set the data source AND restore the selection while unsubscribed so neither the initial
// populate nor a Refresh fires a spurious device-change (which would restart the capture).
_cboAuxDevice.SelectedIndexChanged -= CboAuxDevice_SelectedIndexChanged;
_cboAuxDevice.DataSource = new BindingList<InputDeviceInfo>(devices);
int idx = -1;
if (currentId is not null)
idx = devices.FindIndex(d => d.Id == currentId);
if (idx < 0)
idx = devices.FindIndex(d => d.IsDefault);
_cboAuxDevice.SelectedIndex = idx >= 0 ? idx : (devices.Count > 0 ? 0 : -1);
_cboAuxDevice.SelectedIndexChanged += CboAuxDevice_SelectedIndexChanged;
// Persist the resolved selection (null = default) so Join Voice opens the same device.
if (_cboAuxDevice.SelectedItem is InputDeviceInfo dev)
_settings.AuxDeviceId = dev.IsDefault ? null : dev.Id;
}
private void ChkAux_CheckedChanged(object? sender, EventArgs e)
{
_settings.AuxEnabled = _chkAux.Checked;
UpdateAuxControlsEnabled();
_applyAuxEnabled?.Invoke(_chkAux.Checked);
}
private void CboAuxDevice_SelectedIndexChanged(object? sender, EventArgs e)
{
if (_cboAuxDevice.SelectedItem is not InputDeviceInfo dev) return;
_settings.AuxDeviceId = dev.IsDefault ? null : dev.Id;
if (_chkAux.Checked)
_applyAuxDevice?.Invoke(dev.IsDefault ? null : dev.Id);
}
private void TrkAuxGain_Scroll(object? sender, EventArgs e)
{
_settings.AuxGain = _trkAuxGain.Value;
if (_chkAux.Checked)
_applyAuxGain?.Invoke(_trkAuxGain.Value / 100f);
}
private void UpdateAuxControlsEnabled()
{
bool on = _chkAux.Checked;
_lblAuxDevice.Enabled = on;
_cboAuxDevice.Enabled = on;
_btnAuxRefresh.Enabled = on;
_lblAuxGain.Enabled = on;
_trkAuxGain.Enabled = on;
}
private void RadioMode_CheckedChanged(object? sender, EventArgs e)
{
var mode = CurrentMode();
@@ -310,6 +460,15 @@ public sealed class AudioSettingsForm : Form
_client.SetVadThreshold(0.1f * (1f - (_origVadSlider - 1f) / 99f));
_client.SetInputGain(_origMicGain / 100f);
}
// Aux: restore originals to settings and re-apply live (order: device + gain first so a
// re-enable starts with the right configuration).
_settings.AuxEnabled = _origAuxEnabled;
_settings.AuxDeviceId = _origAuxDeviceId;
_settings.AuxGain = _origAuxGain;
_applyAuxDevice?.Invoke(_origAuxDeviceId);
_applyAuxGain?.Invoke(_origAuxGain / 100f);
_applyAuxEnabled?.Invoke(_origAuxEnabled);
}
private void UpdatePttKeyLabel() =>

View File

@@ -28,6 +28,8 @@ public partial class MainForm : Form
private uint _micStreamId; // 0 = not started
private uint _screenStreamId; // 0 = not sharing screen audio
private ProcessAudioMixer? _screenMixer; // non-null only in per-app capture mode
private uint _auxStreamId; // 0 = aux (second input device) stream not active
private InputDeviceCapture? _auxCapture; // client-side capture feeding the aux stream
private Keys _pttKey = Keys.F8;
private bool _pttEngaged; // guards the PTT cue against key-repeat
private bool _serverMuted;
@@ -169,7 +171,16 @@ public partial class MainForm : Form
var miAudio = new ToolStripMenuItem("&Audio...");
miAudio.Click += (_, _) =>
{
using var dlg = new AudioSettingsForm(_client, _voiceSettings, _micStreamId);
using var dlg = new AudioSettingsForm(_client, _voiceSettings, _micStreamId,
applyAuxEnabled: on =>
{
if (_micStreamId == 0) return; // not in voice — applied on next Join Voice
if (on) StartAuxStream(); else StopAuxStream();
},
applyAuxDevice: _ =>
{
if (_auxStreamId != 0) RestartAuxCapture(); // settings.AuxDeviceId already updated
});
dlg.ShowDialog(this);
_pttKey = (Keys)_voiceSettings.PttKey;
};
@@ -511,6 +522,7 @@ public partial class MainForm : Form
_micStreamId = 0;
_screenStreamId = 0;
_screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null;
DisposeAuxCapture(); _auxStreamId = 0; // connection gone — drop capture, no StopStream
txtCompose.Enabled = false;
btnSend.Enabled = false;
tsbJoinVoice.Enabled = false;
@@ -641,6 +653,7 @@ public partial class MainForm : Form
SetVoiceJoinedState(true);
AddActivity("Joined voice — microphone active");
_feedback.PlaySound(SoundEvent.VoiceOn);
StartAuxStream(); // no-op unless the aux stream is enabled in settings
}
else
{
@@ -649,6 +662,7 @@ public partial class MainForm : Form
}
else
{
StopAuxStream();
_client.SetPushToTalk(false);
_client.StopStream(_micStreamId);
_micStreamId = 0;
@@ -743,6 +757,82 @@ public partial class MainForm : Form
AddActivity("Stopped sharing screen audio");
}
// ── Aux input stream (second hardware input device) ─────────────────────────
// A second outgoing stream (kind = AUX_DEVICE, external_feed). The core can't open a second
// capture device, so we capture the chosen device here and feed PCM in — the same external-
// feed pipeline as per-app screen audio. Tied to the voice session: started on Join Voice
// (when enabled) and stopped on Leave Voice. The aux is always-on (the core never gates
// AUX_DEVICE on VAD/PTT); volume is applied client-side before feeding.
private void StartAuxStream()
{
if (_auxStreamId != 0 || !_voiceSettings.AuxEnabled) return;
var (result, streamId) = _client.StartStreamExternalFeed(VcStreamKind.AuxDevice, "Aux device");
if (result != VcResult.Ok)
{
AddActivity($"Failed to start aux stream: {result}");
return;
}
_auxStreamId = streamId;
_auxCapture = new InputDeviceCapture(_voiceSettings.AuxDeviceId);
_auxCapture.PcmFrameReady += OnAuxPcmFrame;
if (!_auxCapture.Start())
{
AddActivity("Failed to open aux input device");
StopAuxStream();
return;
}
AddActivity("Aux input stream active");
}
private void StopAuxStream()
{
DisposeAuxCapture();
if (_auxStreamId != 0)
{
_client.StopStream(_auxStreamId);
_auxStreamId = 0;
}
}
// Re-open the capture on a different device while the aux stream stays up (the core stream id
// is unchanged — only the client-side capture source changes).
private void RestartAuxCapture()
{
if (_auxStreamId == 0) return;
DisposeAuxCapture();
_auxCapture = new InputDeviceCapture(_voiceSettings.AuxDeviceId);
_auxCapture.PcmFrameReady += OnAuxPcmFrame;
if (!_auxCapture.Start())
AddActivity("Failed to open aux input device");
}
private void DisposeAuxCapture()
{
if (_auxCapture == null) return;
_auxCapture.PcmFrameReady -= OnAuxPcmFrame;
_auxCapture.Stop();
_auxCapture.Dispose();
_auxCapture = null;
}
// Fired on the capture thread. vc_stream_feed_pcm is thread-safe, so feed directly. Gain is
// read live from settings each frame (so the volume slider takes effect immediately).
private void OnAuxPcmFrame(short[] pcm, int samplesPerChannel, int channels)
{
if (_auxStreamId == 0) return;
float gain = _voiceSettings.AuxGain / 100f;
if (gain != 1f)
{
for (int i = 0; i < pcm.Length; i++)
pcm[i] = (short)Math.Clamp((int)MathF.Round(pcm[i] * gain),
short.MinValue, short.MaxValue);
}
_client.StreamFeedPcm(_auxStreamId, pcm, samplesPerChannel, (uint)channels);
}
private void TrkOutputVolume_Scroll(object? sender, EventArgs e) =>
_client.SetOutputVolume(trkOutputVolume.Value / 100f);
@@ -1057,6 +1147,7 @@ public partial class MainForm : Form
foreach (var win in _pmWindows.Values.ToList()) win.Close();
_pmWindows.Clear();
if (_screenStreamId != 0) { _screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null; _client.StopStream(_screenStreamId); }
if (_auxStreamId != 0) StopAuxStream();
if (_micStreamId != 0) _client.StopStream(_micStreamId);
_client.Disconnect();
_client.Dispose();