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:
@@ -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 0–300 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 0–300 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() =>
|
||||
|
||||
Reference in New Issue
Block a user