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

@@ -31,6 +31,7 @@
AAAA00000000000000000048 /* UserPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000047 /* UserPickerSheet.swift */; };
AAAA0000000000000000004A /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000049 /* SettingsWindowController.swift */; };
AAAA0000000000000000004C /* ScreenAudioCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000004B /* ScreenAudioCapture.swift */; };
AAAA00000000000000000051 /* InputDeviceCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000050 /* InputDeviceCapture.swift */; };
AAAA0000000000000000004E /* ScreenSharePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000004F /* ScreenSharePickerSheet.swift */; };
/* End PBXBuildFile section */
@@ -45,6 +46,7 @@
AAAA00000000000000000019 /* ConnectWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectWindowController.swift; sourceTree = "<group>"; };
AAAA0000000000000000001A /* MainWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowController.swift; sourceTree = "<group>"; };
AAAA0000000000000000004B /* ScreenAudioCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenAudioCapture.swift; sourceTree = "<group>"; };
AAAA00000000000000000050 /* InputDeviceCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputDeviceCapture.swift; sourceTree = "<group>"; };
AAAA0000000000000000001B /* AddServerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerSheet.swift; sourceTree = "<group>"; };
AAAA0000000000000000001C /* ServerIdentitySheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerIdentitySheet.swift; sourceTree = "<group>"; };
AAAA0000000000000000001D /* PasswordPromptSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptSheet.swift; sourceTree = "<group>"; };
@@ -104,6 +106,7 @@
isa = PBXGroup;
children = (
AAAA0000000000000000004B /* ScreenAudioCapture.swift */,
AAAA00000000000000000050 /* InputDeviceCapture.swift */,
);
path = Audio;
sourceTree = "<group>";
@@ -247,6 +250,7 @@
AAAA0000000000000000004E /* ScreenSharePickerSheet.swift in Sources */,
AAAA0000000000000000004A /* SettingsWindowController.swift in Sources */,
AAAA0000000000000000004C /* ScreenAudioCapture.swift in Sources */,
AAAA00000000000000000051 /* InputDeviceCapture.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -0,0 +1,229 @@
import AVFoundation
import CoreAudio
// InputDeviceCapture captures a single hardware INPUT device (mic / line-in / aux) on macOS and
// emits 20 ms (960 samples/channel @ 48 kHz, interleaved int16) frames for the aux outgoing stream.
//
// The input-device analogue of ScreenAudioCapture (which captures system audio via
// ScreenCaptureKit). The core already owns ONE capture device (the mic) and can't open a second
// arbitrary input, so for the aux stream the client captures the chosen device here and feeds PCM
// into the core via `vc_stream_feed_pcm` the same external-feed pipeline screen audio uses.
//
// Device selection: an AVAudioEngine's input node wraps an AUHAL audio unit; setting
// kAudioOutputUnitProperty_CurrentDevice on it pins capture to a specific Core Audio device. We
// pass the device's *UID* (stable across reboots/replugs, unlike AudioDeviceID) and resolve it at
// start. The tap delivers Float32; AVAudioConverter resamples/quantises to 48 kHz int16.
final class InputDeviceCapture {
/// Receives a full 20 ms frame: (interleaved int16 PCM, samplesPerChannel = 960, channels).
typealias PcmHandler = (UnsafePointer<Int16>, Int, UInt32) -> Void
enum CaptureError: Error { case deviceNotFound, engineStartFailed(OSStatus) }
private static let frameSamplesPerChannel = 960 // 20 ms @ 48 kHz
private let deviceUID: String? // nil = system default input device
private let onPcm: PcmHandler
private let engine = AVAudioEngine()
private var converter: AVAudioConverter?
private var outFormat: AVAudioFormat?
private var channels = 1
/// Interleaved int16 carry-over between tap callbacks (the tap buffer doesn't align to 20 ms),
/// drained in whole `frameSamplesPerChannel * channels` chunks. Only touched on the tap queue.
private var pending: [Int16] = []
init(deviceUID: String?, onPcm: @escaping PcmHandler) {
self.deviceUID = deviceUID
self.onPcm = onPcm
}
/// Begin capture. Throws if the device can't be resolved or the engine fails to start.
func start() throws {
let input = engine.inputNode
// Pin the engine's AUHAL to the chosen device (skip for default the engine already uses
// the system default input). Must happen before reading inputFormat, which changes with
// the selected device.
if let deviceUID, let devId = Self.deviceID(forUID: deviceUID) {
var dev = devId
let status = AudioUnitSetProperty(input.audioUnit!,
kAudioOutputUnitProperty_CurrentDevice,
kAudioUnitScope_Global, 0,
&dev, UInt32(MemoryLayout<AudioDeviceID>.size))
if status != noErr { throw CaptureError.engineStartFailed(status) }
} else if deviceUID != nil {
throw CaptureError.deviceNotFound
}
let inFormat = input.inputFormat(forBus: 0)
channels = max(1, min(2, Int(inFormat.channelCount)))
guard let out = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 48000,
channels: AVAudioChannelCount(channels), interleaved: true)
else { throw CaptureError.deviceNotFound }
outFormat = out
converter = AVAudioConverter(from: inFormat, to: out)
input.installTap(onBus: 0, bufferSize: 960, format: inFormat) { [weak self] buf, _ in
self?.process(buf)
}
engine.prepare()
do { try engine.start() }
catch { throw CaptureError.engineStartFailed(-1) }
}
/// Stop capture and tear down the engine. Safe to call multiple times.
func stop() {
engine.inputNode.removeTap(onBus: 0)
if engine.isRunning { engine.stop() }
converter = nil
pending.removeAll(keepingCapacity: false)
}
// MARK: - Conversion (called on the tap's realtime thread)
private func process(_ inBuf: AVAudioPCMBuffer) {
guard let converter, let outFormat else { return }
// Output capacity must cover up-sampling (e.g. 44.1 48 kHz) plus slack.
let ratio = outFormat.sampleRate / inBuf.format.sampleRate
let cap = AVAudioFrameCount(Double(inBuf.frameLength) * ratio + 32)
guard cap > 0, let outBuf = AVAudioPCMBuffer(pcmFormat: outFormat, frameCapacity: cap)
else { return }
var fed = false
var err: NSError?
let status = converter.convert(to: outBuf, error: &err) { _, outStatus in
if fed { outStatus.pointee = .noDataNow; return nil }
fed = true
outStatus.pointee = .haveData
return inBuf
}
guard status != .error, outBuf.frameLength > 0,
let ch = outBuf.int16ChannelData else { return }
// Interleaved int16: all channels live in the first buffer (ch[0]).
let n = Int(outBuf.frameLength) * channels
pending.append(contentsOf: UnsafeBufferPointer(start: ch[0], count: n))
emit()
}
/// Fire `onPcm` for every whole 20 ms frame accumulated.
private func emit() {
let full = Self.frameSamplesPerChannel * channels
while pending.count >= full {
pending.withUnsafeBufferPointer { buf in
onPcm(buf.baseAddress!, Self.frameSamplesPerChannel, UInt32(channels))
}
pending.removeFirst(full)
}
}
// MARK: - UID AudioDeviceID resolution
private static func deviceID(forUID uid: String) -> AudioDeviceID? {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyTranslateUIDToDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var deviceID = AudioDeviceID(0)
var cfUID = uid as CFString
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
let status = withUnsafeMutablePointer(to: &cfUID) { uidPtr -> OSStatus in
AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr,
UInt32(MemoryLayout<CFString>.size), uidPtr,
&size, &deviceID)
}
return (status == noErr && deviceID != 0) ? deviceID : nil
}
}
// InputDeviceInfo / InputDeviceEnumerator Core Audio input-device enumeration for the aux-stream
// picker. Separate from the core's vc_list_devices (whose ids are miniaudio-opaque and can't be
// passed to Core Audio); the aux device is client-captured, so the picker uses device UIDs.
struct InputDeviceInfo: Equatable {
let uid: String // stable across reboots/replugs what we persist
let name: String
let isDefault: Bool
}
enum InputDeviceEnumerator {
/// All Core Audio devices that expose at least one input channel.
static func list() -> [InputDeviceInfo] {
let defaultUID = defaultInputUID()
var result: [InputDeviceInfo] = []
for devID in allDeviceIDs() {
guard inputChannelCount(devID) > 0 else { continue }
guard let uid = stringProperty(devID, kAudioDevicePropertyDeviceUID) else { continue }
let name = stringProperty(devID, kAudioObjectPropertyName)
?? stringProperty(devID, kAudioDevicePropertyDeviceNameCFString)
?? "Unknown input device"
result.append(InputDeviceInfo(uid: uid, name: name, isDefault: uid == defaultUID))
}
return result.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
// MARK: - Core Audio helpers
private static func allDeviceIDs() -> [AudioDeviceID] {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size = UInt32(0)
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil,
&size) == noErr, size > 0 else { return [] }
let count = Int(size) / MemoryLayout<AudioDeviceID>.size
var ids = [AudioDeviceID](repeating: 0, count: count)
let status = AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0,
nil, &size, &ids)
return status == noErr ? ids : []
}
private static func inputChannelCount(_ devID: AudioDeviceID) -> Int {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioObjectPropertyScopeInput,
mElement: kAudioObjectPropertyElementMain)
var size = UInt32(0)
guard AudioObjectGetPropertyDataSize(devID, &addr, 0, nil, &size) == noErr, size > 0
else { return 0 }
let bufList = UnsafeMutableRawPointer.allocate(byteCount: Int(size),
alignment: MemoryLayout<AudioBufferList>.alignment)
defer { bufList.deallocate() }
guard AudioObjectGetPropertyData(devID, &addr, 0, nil, &size, bufList) == noErr
else { return 0 }
let abl = UnsafeMutableAudioBufferListPointer(
bufList.assumingMemoryBound(to: AudioBufferList.self))
return abl.reduce(0) { $0 + Int($1.mNumberChannels) }
}
private static func defaultInputUID() -> String? {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var devID = AudioDeviceID(0)
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil,
&size, &devID) == noErr, devID != 0 else { return nil }
return stringProperty(devID, kAudioDevicePropertyDeviceUID)
}
private static func stringProperty(_ devID: AudioDeviceID,
_ selector: AudioObjectPropertySelector) -> String? {
var addr = AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var cf: CFString? = nil
var size = UInt32(MemoryLayout<CFString?>.size)
let status = withUnsafeMutablePointer(to: &cf) { ptr in
AudioObjectGetPropertyData(devID, &addr, 0, nil, &size, ptr)
}
guard status == noErr else { return nil }
return cf as String?
}
}

View File

@@ -31,6 +31,8 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
internal var micStreamId: UInt32 = 0
private var screenStreamId: UInt32 = 0
private var screenCapture: ScreenAudioCapture?
private var auxStreamId: UInt32 = 0 // 0 = aux (second input device) stream not active
private var auxCapture: InputDeviceCapture? // client-side capture feeding the aux stream
// Last app/exclusion choice from the share picker; reused as the default next time.
private var screenAudioSelection: ScreenAudioSelection = .default
internal var pttKeyCode: UInt16 = 0x60 { // F8
@@ -60,6 +62,9 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
static let vadThreshold = "voice.vadThreshold"
static let inputGain = "voice.inputGain"
static let pttKeyCode = "voice.pttKeyCode"
static let auxEnabled = "voice.auxEnabled"
static let auxDeviceUID = "voice.auxDeviceUID"
static let auxGain = "voice.auxGain"
}
internal var selectedInputMode: VoiceCatInputMode = .voiceActivation {
@@ -73,6 +78,20 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
}
internal var selectedInputDeviceId: String?
// Aux outgoing stream: a second hardware input device the client captures itself and feeds to
// the core (kind = AUX_DEVICE, external_feed). Device + volume only aux is always-on (the
// core never gates AUX_DEVICE on VAD/PTT). auxDeviceUID is a Core Audio device UID (stable),
// NOT a core/miniaudio id. auxGain is read live by the capture feed, so the slider is instant.
internal var auxEnabled: Bool = false {
didSet { UserDefaults.standard.set(auxEnabled, forKey: AudioDefaults.auxEnabled) }
}
internal var auxDeviceUID: String? {
didSet { UserDefaults.standard.set(auxDeviceUID, forKey: AudioDefaults.auxDeviceUID) }
}
internal var auxGain: Float = 1.0 {
didSet { UserDefaults.standard.set(auxGain, forKey: AudioDefaults.auxGain) }
}
// MARK: - UI components
private let channelOutlineView = NSOutlineView()
private let userTableView = NSTableView()
@@ -140,6 +159,13 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
if d.object(forKey: AudioDefaults.pttKeyCode) != nil {
pttKeyCode = UInt16(d.integer(forKey: AudioDefaults.pttKeyCode))
}
auxEnabled = d.bool(forKey: AudioDefaults.auxEnabled)
if d.object(forKey: AudioDefaults.auxDeviceUID) != nil {
auxDeviceUID = d.string(forKey: AudioDefaults.auxDeviceUID)
}
if d.object(forKey: AudioDefaults.auxGain) != nil {
auxGain = d.float(forKey: AudioDefaults.auxGain)
}
}
required init?(coder: NSCoder) { fatalError() }
@@ -675,7 +701,8 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
displayedUsers = []; userTableView.reloadData()
users.removeAll(); talkingUsers.removeAll()
stopScreenCapture()
currentChannelId = 0; micStreamId = 0; screenStreamId = 0
auxCapture?.stop(); auxCapture = nil // connection gone drop capture, no stopStream
currentChannelId = 0; micStreamId = 0; screenStreamId = 0; auxStreamId = 0
composeField.isEnabled = false; sendButton.isEnabled = false
joinVoiceButton?.isEnabled = false
shareScreenButton?.isEnabled = false
@@ -757,10 +784,12 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
EventFeedback.shared.play(.voiceOn)
NSAccessibility.post(element: logTextView, notification: .announcementRequested,
userInfo: [.announcement: "Joined voice", .priority: NSAccessibilityPriorityLevel.medium])
startAuxStream() // no-op unless the aux stream is enabled in settings
} else {
addActivity("Failed to start microphone: \(result)")
}
} else {
stopAuxStream()
client.setPushToTalk(false)
client.stopStream(micStreamId)
micStreamId = 0
@@ -771,6 +800,84 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
}
}
// MARK: - Aux input stream (second hardware input device)
/// Called by SettingsWindowController when the user toggles the aux checkbox.
func applyAuxEnabled(_ on: Bool) {
auxEnabled = on
guard micStreamId != 0 else { return } // not in voice applied on next Join Voice
if on { startAuxStream() } else { stopAuxStream() }
}
/// Called by SettingsWindowController when the user picks a different aux device.
func applyAuxDevice(_ uid: String?) {
auxDeviceUID = uid
if auxStreamId != 0 { restartAuxCapture() }
}
private func startAuxStream() {
guard auxStreamId == 0, auxEnabled else { return }
let (result, streamId) = client.startStream(
StreamDescriptor(kind: .auxDevice, deviceId: nil, label: "Aux device", externalFeed: true))
guard result == .ok else {
addActivity("Failed to start aux stream: \(result)")
return
}
auxStreamId = streamId
startAuxCapture()
addActivity("Aux input stream active")
}
private func startAuxCapture() {
let capture = InputDeviceCapture(deviceUID: auxDeviceUID) { [weak self] ptr, spc, ch in
self?.feedAux(ptr, samplesPerChannel: spc, channels: ch)
}
auxCapture = capture
do { try capture.start() }
catch {
addActivity("Failed to open aux input device")
stopAuxStream()
}
}
// 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 func restartAuxCapture() {
guard auxStreamId != 0 else { return }
auxCapture?.stop()
auxCapture = nil
startAuxCapture()
}
private func stopAuxStream() {
auxCapture?.stop()
auxCapture = nil
if auxStreamId != 0 {
client.stopStream(auxStreamId)
auxStreamId = 0
}
}
// Fired on the capture's realtime thread. feedPcm is thread-safe. Gain is read live from
// auxGain each frame so the volume slider takes effect immediately.
private func feedAux(_ pcm: UnsafePointer<Int16>, samplesPerChannel: Int, channels: UInt32) {
guard auxStreamId != 0 else { return }
let gain = auxGain
if gain != 1.0 {
let n = samplesPerChannel * Int(channels)
var scaled = [Int16](repeating: 0, count: n)
for i in 0..<n {
let v = (Float(pcm[i]) * gain).rounded()
scaled[i] = Int16(max(-32768, min(32767, v)))
}
client.feedPcm(streamId: auxStreamId, pcm: scaled,
samplesPerChannel: samplesPerChannel, channels: channels)
} else {
client.feedPcm(streamId: auxStreamId, pcm: pcm,
samplesPerChannel: samplesPerChannel, channels: channels)
}
}
/// Update toolbar button labels/states to reflect whether voice is active. Mirrors the
/// Windows client's `SetVoiceJoinedState`.
private func setVoiceJoinedState(_ joined: Bool) {
@@ -1158,6 +1265,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
}
client.setPushToTalk(false)
stopScreenCapture()
if auxStreamId != 0 { stopAuxStream() }
if screenStreamId != 0 { client.stopStream(screenStreamId) }
if micStreamId != 0 { client.stopStream(micStreamId) }
client.onLevel = nil

View File

@@ -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) {