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:
@@ -58,6 +58,22 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
}()
|
||||
private let inputGainValueLabel = NSTextField(labelWithString: "100%")
|
||||
|
||||
// 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.
|
||||
private let auxCheckbox = NSButton(checkboxWithTitle: "Aux input stream (second device)",
|
||||
target: nil, action: nil)
|
||||
private let auxDevicePicker = NSPopUpButton()
|
||||
private let auxRefreshButton = NSButton()
|
||||
private let auxGainSlider: NSSlider = {
|
||||
let s = NSSlider(value: 100, minValue: 0, maxValue: 300, target: nil, action: nil)
|
||||
s.numberOfTickMarks = 0
|
||||
return s
|
||||
}()
|
||||
private let auxGainValueLabel = NSTextField(labelWithString: "100%")
|
||||
private let auxDeviceLabel = NSTextField(labelWithString: "Aux device:")
|
||||
private let auxGainLabel = NSTextField(labelWithString: "Aux volume:")
|
||||
|
||||
// Notification feedback controls. Read/write UserDefaults with the same keys VoiceCatCore's
|
||||
// FeedbackSettings reads, so EventFeedback honours these immediately.
|
||||
private let soundsCheckbox = NSButton(checkboxWithTitle: "Event sounds", target: nil, action: nil)
|
||||
@@ -94,6 +110,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
buildUI()
|
||||
syncFromMainController()
|
||||
loadInputDevices()
|
||||
loadAuxDevices()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
@@ -175,6 +192,41 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
inputGainRow.orientation = .horizontal
|
||||
inputGainRow.spacing = 8
|
||||
|
||||
// ── Aux input stream ──────────────────────────────────────────────────
|
||||
let auxHeader = NSTextField(labelWithString: "Aux input stream")
|
||||
auxHeader.font = .boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||
|
||||
auxCheckbox.target = self
|
||||
auxCheckbox.action = #selector(auxEnabledChanged)
|
||||
auxCheckbox.setAccessibilityLabel("Enable aux input stream")
|
||||
auxCheckbox.setAccessibilityHelp("Transmit a second hardware input device alongside your microphone.")
|
||||
|
||||
auxDeviceLabel.setAccessibilityLabel("Aux input device")
|
||||
auxDevicePicker.setAccessibilityLabel("Aux input device")
|
||||
auxDevicePicker.target = self
|
||||
auxDevicePicker.action = #selector(auxDeviceChanged)
|
||||
auxRefreshButton.title = "↺"
|
||||
auxRefreshButton.bezelStyle = .rounded
|
||||
auxRefreshButton.target = self
|
||||
auxRefreshButton.action = #selector(refreshAuxDevicesClicked)
|
||||
auxRefreshButton.setAccessibilityLabel("Refresh aux device list")
|
||||
auxRefreshButton.toolTip = "Refresh"
|
||||
|
||||
auxGainLabel.setAccessibilityLabel("Aux volume")
|
||||
auxGainSlider.target = self
|
||||
auxGainSlider.action = #selector(auxGainChanged)
|
||||
auxGainSlider.setAccessibilityLabel("Aux volume")
|
||||
auxGainSlider.setAccessibilityHelp("Volume of the aux input stream. 100% is unity.")
|
||||
auxGainValueLabel.setAccessibilityLabel("Aux volume value")
|
||||
|
||||
let auxDeviceRow = NSStackView(views: [auxDeviceLabel, auxDevicePicker, auxRefreshButton])
|
||||
auxDeviceRow.orientation = .horizontal
|
||||
auxDeviceRow.spacing = 8
|
||||
|
||||
let auxGainRow = NSStackView(views: [auxGainLabel, auxGainSlider, auxGainValueLabel])
|
||||
auxGainRow.orientation = .horizontal
|
||||
auxGainRow.spacing = 8
|
||||
|
||||
// Notifications
|
||||
let notificationsHeader = NSTextField(labelWithString: "Notifications")
|
||||
notificationsHeader.font = .boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||
@@ -198,7 +250,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
volumeRow.spacing = 8
|
||||
|
||||
let stack = NSStackView(views: [inputModeRow, vadRow, inputGainRow, pttRow, deviceRow,
|
||||
levelRow, notificationsHeader, soundsCheckbox, volumeRow,
|
||||
levelRow, auxHeader, auxCheckbox, auxDeviceRow, auxGainRow,
|
||||
notificationsHeader, soundsCheckbox, volumeRow,
|
||||
speechCheckbox, selfTalkCheckbox, pttSoundCheckbox])
|
||||
stack.orientation = .vertical
|
||||
stack.spacing = 12
|
||||
@@ -218,6 +271,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
levelMeter.widthAnchor.constraint(equalToConstant: 200),
|
||||
devicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||
soundsVolumeSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200),
|
||||
auxDevicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||
auxGainSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||
])
|
||||
|
||||
syncNotificationControls()
|
||||
@@ -265,6 +320,11 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
inputGainSlider.doubleValue = Double(mc.inputGain * 100)
|
||||
updateInputGainLabel()
|
||||
|
||||
auxCheckbox.state = mc.auxEnabled ? .on : .off
|
||||
auxGainSlider.doubleValue = Double(mc.auxGain * 100)
|
||||
updateAuxGainLabel()
|
||||
updateAuxControlsEnabled()
|
||||
|
||||
updateConditionalControls()
|
||||
}
|
||||
|
||||
@@ -338,6 +398,65 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Aux input stream actions
|
||||
|
||||
@objc private func auxEnabledChanged() {
|
||||
let on = auxCheckbox.state == .on
|
||||
updateAuxControlsEnabled()
|
||||
mainController?.applyAuxEnabled(on)
|
||||
}
|
||||
|
||||
@objc private func refreshAuxDevicesClicked() { loadAuxDevices() }
|
||||
|
||||
@objc private func auxDeviceChanged() {
|
||||
let uid = auxDevicePicker.selectedItem?.representedObject as? String
|
||||
mainController?.applyAuxDevice(uid)
|
||||
}
|
||||
|
||||
@objc private func auxGainChanged() {
|
||||
mainController?.auxGain = Float(auxGainSlider.doubleValue) / 100.0
|
||||
updateAuxGainLabel()
|
||||
}
|
||||
|
||||
private func updateAuxGainLabel() {
|
||||
let pct = Int(auxGainSlider.doubleValue.rounded())
|
||||
auxGainValueLabel.stringValue = "\(pct)%"
|
||||
auxGainSlider.setAccessibilityValue("\(pct) percent")
|
||||
}
|
||||
|
||||
private func updateAuxControlsEnabled() {
|
||||
let on = auxCheckbox.state == .on
|
||||
auxDeviceLabel.isEnabled = on
|
||||
auxDevicePicker.isEnabled = on
|
||||
auxRefreshButton.isEnabled = on
|
||||
auxGainLabel.isEnabled = on
|
||||
auxGainSlider.isEnabled = on
|
||||
auxGainValueLabel.isEnabled = on
|
||||
}
|
||||
|
||||
private func loadAuxDevices() {
|
||||
let devices = InputDeviceEnumerator.list()
|
||||
let prevSelected = (auxDevicePicker.selectedItem?.representedObject as? String)
|
||||
?? mainController?.auxDeviceUID
|
||||
auxDevicePicker.removeAllItems()
|
||||
for d in devices {
|
||||
let item = NSMenuItem(title: d.name, action: nil, keyEquivalent: "")
|
||||
item.representedObject = d.uid
|
||||
auxDevicePicker.menu?.addItem(item)
|
||||
}
|
||||
if let prev = prevSelected,
|
||||
let item = auxDevicePicker.itemArray.first(where: { ($0.representedObject as? String) == prev }) {
|
||||
auxDevicePicker.select(item)
|
||||
} else if let def = devices.first(where: { $0.isDefault }),
|
||||
let item = auxDevicePicker.itemArray.first(where: { ($0.representedObject as? String) == def.uid }) {
|
||||
auxDevicePicker.select(item)
|
||||
} else if auxDevicePicker.numberOfItems > 0 {
|
||||
auxDevicePicker.selectItem(at: 0)
|
||||
}
|
||||
// Persist the resolved selection so a relaunch (or Join Voice) opens the same device.
|
||||
mainController?.auxDeviceUID = auxDevicePicker.selectedItem?.representedObject as? String
|
||||
}
|
||||
|
||||
// MARK: - Level meter (called by MainWindowController)
|
||||
|
||||
func updateLevel(rms: Float) {
|
||||
|
||||
Reference in New Issue
Block a user