66
77import Foundation
88import AVFoundation
9+ import AudioToolbox
10+ import CoreAudio
911import VocaMacObjC
1012
1113final 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
0 commit comments