Skip to content

Commit e807fc7

Browse files
authored
feat: allow fixed audio input device (#156)
1 parent 465989a commit e807fc7

7 files changed

Lines changed: 305 additions & 41 deletions

File tree

Sources/VocaMac/Models/AppState.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ final class AppState: ObservableObject {
9999
@AppStorage("vocamac.silenceThreshold") var silenceThreshold: Double = 0.01
100100
@AppStorage("vocamac.silenceDuration") var silenceDuration: Double = 2.0
101101
@AppStorage("vocamac.maxRecordingDuration") var maxRecordingDuration: Int = 60
102+
@AppStorage("vocamac.selectedAudioDeviceID") var selectedAudioDeviceID: String = ""
103+
@AppStorage("vocamac.selectedAudioDeviceName") var selectedAudioDeviceName: String = ""
102104
@AppStorage("vocamac.selectedModelSize") var selectedModelSize: String = ModelSize.tiny.rawValue
103105
@AppStorage("vocamac.selectedLanguage") var selectedLanguage: String = "auto"
104106
@AppStorage("vocamac.launchAtLogin") var launchAtLogin: Bool = false
@@ -486,7 +488,8 @@ final class AppState: ObservableObject {
486488
let didStartRecording = audioEngine.startRecording(
487489
silenceThreshold: Float(silenceThreshold),
488490
silenceDuration: silenceDuration,
489-
maxDuration: TimeInterval(maxRecordingDuration)
491+
maxDuration: TimeInterval(maxRecordingDuration),
492+
preferredInputDeviceID: selectedAudioDeviceID.isEmpty ? nil : selectedAudioDeviceID
490493
)
491494

492495
guard didStartRecording else {

Sources/VocaMac/Services/AudioEngine.swift

Lines changed: 182 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
import Foundation
88
import AVFoundation
9+
import AudioToolbox
10+
import CoreAudio
911
import VocaMacObjC
1012

1113
final class AudioEngine {
@@ -188,7 +190,8 @@ final class AudioEngine {
188190
func startRecording(
189191
silenceThreshold: Float = 0.01,
190192
silenceDuration: Double = 2.0,
191-
maxDuration: TimeInterval = 60.0
193+
maxDuration: TimeInterval = 60.0,
194+
preferredInputDeviceID: String? = nil
192195
) -> Bool {
193196
lifecycleQueue.sync {
194197
guard !self._isCurrentlyRecording else { return true }
@@ -202,6 +205,7 @@ final class AudioEngine {
202205

203206
let engine = acquireEngine()
204207
let inputNode = engine.inputNode
208+
configurePreferredInputDevice(preferredInputDeviceID, on: inputNode)
205209
let inputFormat = inputNode.outputFormat(forBus: 0)
206210

207211
guard isValidInputFormat(inputFormat) else {
@@ -490,23 +494,184 @@ final class AudioEngine {
490494

491495
// MARK: - Audio Device Enumeration
492496

493-
/// List available audio input devices
497+
/// List available audio input devices.
494498
static func availableInputDevices() -> [AudioDevice] {
495-
let devices = AVCaptureDevice.DiscoverySession(
496-
deviceTypes: [.builtInMicrophone, .externalUnknown],
497-
mediaType: .audio,
498-
position: .unspecified
499-
).devices
500-
501-
let defaultDevice = AVCaptureDevice.default(for: .audio)
502-
503-
return devices.map { device in
504-
AudioDevice(
505-
id: device.uniqueID,
506-
name: device.localizedName,
507-
isDefault: device.uniqueID == defaultDevice?.uniqueID
499+
let defaultDeviceID = defaultInputAudioDeviceID()
500+
501+
return inputAudioDeviceIDs().compactMap { deviceID in
502+
guard let uid = audioDeviceUID(for: deviceID),
503+
let name = audioDeviceName(for: deviceID) else {
504+
return nil
505+
}
506+
507+
return AudioDevice(
508+
id: uid,
509+
name: name,
510+
isDefault: deviceID == defaultDeviceID,
511+
sampleRate: audioDeviceSampleRate(for: deviceID),
512+
channelCount: inputChannelCount(for: deviceID)
508513
)
509514
}
515+
.sorted { lhs, rhs in
516+
if lhs.isDefault != rhs.isDefault { return lhs.isDefault }
517+
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
518+
}
519+
}
520+
521+
/// Configure this engine's input unit to use a specific Core Audio device.
522+
/// This is scoped to VocaMac's AudioUnit and does not change macOS' global default input.
523+
private func configurePreferredInputDevice(_ preferredInputDeviceID: String?, on inputNode: AVAudioInputNode) {
524+
guard let preferredInputDeviceID,
525+
!preferredInputDeviceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
526+
VocaLogger.debug(.audioEngine, "Using system default input device")
527+
return
528+
}
529+
530+
guard let deviceID = Self.inputAudioDeviceID(forUID: preferredInputDeviceID) else {
531+
VocaLogger.warning(.audioEngine, "Preferred input device unavailable, falling back to system default: \(preferredInputDeviceID)")
532+
return
533+
}
534+
535+
guard let audioUnit = inputNode.audioUnit else {
536+
VocaLogger.warning(.audioEngine, "Input node has no AudioUnit; falling back to system default input")
537+
return
538+
}
539+
540+
var mutableDeviceID = deviceID
541+
let status = AudioUnitSetProperty(
542+
audioUnit,
543+
kAudioOutputUnitProperty_CurrentDevice,
544+
kAudioUnitScope_Global,
545+
0,
546+
&mutableDeviceID,
547+
UInt32(MemoryLayout<AudioDeviceID>.size)
548+
)
549+
550+
guard status == noErr else {
551+
VocaLogger.warning(.audioEngine, "Failed to set preferred input device \(preferredInputDeviceID): OSStatus \(status)")
552+
return
553+
}
554+
555+
let deviceName = Self.audioDeviceName(for: deviceID) ?? preferredInputDeviceID
556+
VocaLogger.info(.audioEngine, "Using preferred input device: \(deviceName)")
557+
}
558+
559+
private static func inputAudioDeviceID(forUID uid: String) -> AudioDeviceID? {
560+
inputAudioDeviceIDs().first { audioDeviceUID(for: $0) == uid }
561+
}
562+
563+
private static func inputAudioDeviceIDs() -> [AudioDeviceID] {
564+
var address = AudioObjectPropertyAddress(
565+
mSelector: kAudioHardwarePropertyDevices,
566+
mScope: kAudioObjectPropertyScopeGlobal,
567+
mElement: kAudioObjectPropertyElementMain
568+
)
569+
var dataSize: UInt32 = 0
570+
let systemObjectID = AudioObjectID(kAudioObjectSystemObject)
571+
572+
guard AudioObjectGetPropertyDataSize(systemObjectID, &address, 0, nil, &dataSize) == noErr else {
573+
VocaLogger.warning(.audioEngine, "Failed to read Core Audio device list size")
574+
return []
575+
}
576+
577+
let deviceCount = Int(dataSize) / MemoryLayout<AudioDeviceID>.size
578+
guard deviceCount > 0 else { return [] }
579+
580+
var deviceIDs = [AudioDeviceID](repeating: AudioDeviceID(kAudioObjectUnknown), count: deviceCount)
581+
let status = AudioObjectGetPropertyData(systemObjectID, &address, 0, nil, &dataSize, &deviceIDs)
582+
guard status == noErr else {
583+
VocaLogger.warning(.audioEngine, "Failed to read Core Audio device list: OSStatus \(status)")
584+
return []
585+
}
586+
587+
return deviceIDs.filter { inputChannelCount(for: $0) > 0 }
588+
}
589+
590+
private static func defaultInputAudioDeviceID() -> AudioDeviceID? {
591+
var address = AudioObjectPropertyAddress(
592+
mSelector: kAudioHardwarePropertyDefaultInputDevice,
593+
mScope: kAudioObjectPropertyScopeGlobal,
594+
mElement: kAudioObjectPropertyElementMain
595+
)
596+
var deviceID = AudioDeviceID(kAudioObjectUnknown)
597+
var dataSize = UInt32(MemoryLayout<AudioDeviceID>.size)
598+
let status = AudioObjectGetPropertyData(
599+
AudioObjectID(kAudioObjectSystemObject),
600+
&address,
601+
0,
602+
nil,
603+
&dataSize,
604+
&deviceID
605+
)
606+
607+
guard status == noErr, deviceID != AudioDeviceID(kAudioObjectUnknown) else {
608+
return nil
609+
}
610+
return deviceID
611+
}
612+
613+
private static func audioDeviceUID(for deviceID: AudioDeviceID) -> String? {
614+
stringProperty(kAudioDevicePropertyDeviceUID, for: deviceID)
615+
}
616+
617+
private static func audioDeviceName(for deviceID: AudioDeviceID) -> String? {
618+
stringProperty(kAudioObjectPropertyName, for: deviceID)
619+
}
620+
621+
private static func stringProperty(_ selector: AudioObjectPropertySelector, for deviceID: AudioDeviceID) -> String? {
622+
var address = AudioObjectPropertyAddress(
623+
mSelector: selector,
624+
mScope: kAudioObjectPropertyScopeGlobal,
625+
mElement: kAudioObjectPropertyElementMain
626+
)
627+
var value: Unmanaged<CFString>?
628+
var dataSize = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
629+
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, &value)
630+
631+
guard status == noErr, let value else { return nil }
632+
return value.takeRetainedValue() as String
633+
}
634+
635+
private static func audioDeviceSampleRate(for deviceID: AudioDeviceID) -> Double {
636+
var address = AudioObjectPropertyAddress(
637+
mSelector: kAudioDevicePropertyNominalSampleRate,
638+
mScope: kAudioObjectPropertyScopeGlobal,
639+
mElement: kAudioObjectPropertyElementMain
640+
)
641+
var sampleRate = Float64(0)
642+
var dataSize = UInt32(MemoryLayout<Float64>.size)
643+
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, &sampleRate)
644+
645+
guard status == noErr else { return 0 }
646+
return sampleRate
647+
}
648+
649+
private static func inputChannelCount(for deviceID: AudioDeviceID) -> Int {
650+
var address = AudioObjectPropertyAddress(
651+
mSelector: kAudioDevicePropertyStreamConfiguration,
652+
mScope: kAudioObjectPropertyScopeInput,
653+
mElement: kAudioObjectPropertyElementMain
654+
)
655+
var dataSize: UInt32 = 0
656+
657+
guard AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &dataSize) == noErr,
658+
dataSize > 0 else {
659+
return 0
660+
}
661+
662+
let rawPointer = UnsafeMutableRawPointer.allocate(
663+
byteCount: Int(dataSize),
664+
alignment: MemoryLayout<AudioBufferList>.alignment
665+
)
666+
defer { rawPointer.deallocate() }
667+
668+
let bufferList = rawPointer.bindMemory(to: AudioBufferList.self, capacity: 1)
669+
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, bufferList)
670+
guard status == noErr else { return 0 }
671+
672+
return UnsafeMutableAudioBufferListPointer(bufferList).reduce(0) { total, buffer in
673+
total + Int(buffer.mNumberChannels)
674+
}
510675
}
511676
}
512677

@@ -517,6 +682,8 @@ struct AudioDevice: Identifiable, Hashable {
517682
let id: String
518683
let name: String
519684
let isDefault: Bool
685+
let sampleRate: Double
686+
let channelCount: Int
520687
}
521688

522689
// MARK: - AudioRecording Conformance

Sources/VocaMac/Services/ServiceProtocols.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ protocol AudioRecording: AnyObject {
1717
var onAudioDeviceChanged: (() -> Void)? { get set }
1818

1919
@discardableResult
20-
func startRecording(silenceThreshold: Float, silenceDuration: Double, maxDuration: TimeInterval) -> Bool
20+
func startRecording(
21+
silenceThreshold: Float,
22+
silenceDuration: Double,
23+
maxDuration: TimeInterval,
24+
preferredInputDeviceID: String?
25+
) -> Bool
2126
@discardableResult func stopRecording() -> [Float]
2227
func forceReset()
2328
func checkPermissionStatus() -> PermissionStatus

Sources/VocaMac/Views/SettingsView.swift

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -595,49 +595,98 @@ struct AudioSettingsTab: View {
595595
}
596596

597597
Section("Input Device") {
598+
Picker("Microphone", selection: $appState.selectedAudioDeviceID) {
599+
Text("System Default").tag("")
600+
if selectedAudioDeviceIsUnavailable {
601+
Text("\(selectedAudioDeviceDisplayName) (Unavailable)").tag(appState.selectedAudioDeviceID)
602+
}
603+
ForEach(audioDevices) { device in
604+
Text(audioDeviceLabel(for: device)).tag(device.id)
605+
}
606+
}
607+
.onChange(of: appState.selectedAudioDeviceID) { _ in
608+
syncSelectedAudioDeviceName()
609+
}
610+
598611
if audioDevices.isEmpty {
599612
HStack {
600613
Image(systemName: "exclamationmark.triangle")
601614
.foregroundStyle(.orange)
602615
Text("No audio input devices found")
603616
.foregroundStyle(.secondary)
604617
}
605-
} else {
606-
ForEach(audioDevices) { device in
607-
HStack {
608-
Image(systemName: device.isDefault ? "mic.circle.fill" : "mic.circle")
609-
.foregroundStyle(device.isDefault ? .blue : .secondary)
610-
VStack(alignment: .leading) {
611-
Text(device.name)
612-
.font(.callout)
613-
if device.isDefault {
614-
Text("System Default")
615-
.font(.caption2)
616-
.foregroundStyle(.blue)
617-
}
618-
}
619-
Spacer()
620-
if device.isDefault {
621-
Image(systemName: "checkmark")
622-
.foregroundStyle(.blue)
623-
}
624-
}
618+
} else if selectedAudioDeviceIsUnavailable {
619+
HStack(alignment: .top) {
620+
Image(systemName: "exclamationmark.triangle")
621+
.foregroundStyle(.orange)
622+
Text("\(selectedAudioDeviceDisplayName) is unavailable. VocaMac will use System Default until it reconnects.")
623+
.foregroundStyle(.secondary)
625624
}
625+
} else if let selectedAudioDevice {
626+
HStack {
627+
Image(systemName: "mic.circle.fill")
628+
.foregroundStyle(.blue)
629+
Text("VocaMac will record from \(selectedAudioDevice.name) without changing macOS' system default input.")
630+
.foregroundStyle(.secondary)
631+
}
632+
} else {
633+
Text(systemDefaultInputDescription)
634+
.foregroundStyle(.secondary)
626635
}
627636

628637
Button("Refresh Devices") {
629-
audioDevices = AudioEngine.availableInputDevices()
638+
refreshAudioDevices()
630639
}
631640
.controlSize(.small)
632641

633-
Text("VocaMac uses your system default input device. Change it in System Settings → Sound → Input.")
642+
Text("Choose System Default to follow macOS, or pin VocaMac to a specific microphone.")
634643
.font(.caption)
635644
.foregroundStyle(.secondary)
636645
}
637646
}
638647
.formStyle(.grouped)
639648
.onAppear {
640-
audioDevices = AudioEngine.availableInputDevices()
649+
refreshAudioDevices()
650+
}
651+
}
652+
653+
private var selectedAudioDevice: AudioDevice? {
654+
guard !appState.selectedAudioDeviceID.isEmpty else { return nil }
655+
return audioDevices.first { $0.id == appState.selectedAudioDeviceID }
656+
}
657+
658+
private var selectedAudioDeviceIsUnavailable: Bool {
659+
!appState.selectedAudioDeviceID.isEmpty && selectedAudioDevice == nil
660+
}
661+
662+
private var selectedAudioDeviceDisplayName: String {
663+
appState.selectedAudioDeviceName.isEmpty ? "Selected microphone" : appState.selectedAudioDeviceName
664+
}
665+
666+
private var systemDefaultInputDescription: String {
667+
if let defaultDevice = audioDevices.first(where: { $0.isDefault }) {
668+
return "VocaMac will follow macOS' system default input: \(defaultDevice.name)."
669+
}
670+
return "VocaMac will follow macOS' system default input."
671+
}
672+
673+
private func audioDeviceLabel(for device: AudioDevice) -> String {
674+
device.isDefault ? "\(device.name) (System Default)" : device.name
675+
}
676+
677+
private func refreshAudioDevices() {
678+
audioDevices = AudioEngine.availableInputDevices()
679+
syncSelectedAudioDeviceName()
680+
}
681+
682+
private func syncSelectedAudioDeviceName() {
683+
guard !appState.selectedAudioDeviceID.isEmpty else {
684+
appState.selectedAudioDeviceName = ""
685+
return
686+
}
687+
688+
if let selectedAudioDevice {
689+
appState.selectedAudioDeviceName = selectedAudioDevice.name
641690
}
642691
}
643692

0 commit comments

Comments
 (0)