Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Sources/HeardCore/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ public final class AppModel: ObservableObject {
downloadManager.transcriptionModel = savedVersion
dictationManager.modelVersion = savedVersion

// Propagate the user's selected mic to both managers
let selectedMicUID = settingsStore.settings.selectedInputDeviceUID
dictationManager.inputDeviceUID = selectedMicUID
recordingManager.inputDeviceUID = selectedMicUID

let model = AppModel(
settingsStore: settingsStore,
speakerStore: speakerStore,
Expand Down Expand Up @@ -218,6 +223,7 @@ public final class AppModel: ObservableObject {
// Stop dictation before recording starts — mic should not transcribe
// remote participants' audio (which the mic picks up from speakers).
self.stopDictationIfActive()
self.recordingManager.inputDeviceUID = self.settingsStore.settings.selectedInputDeviceUID
do {
try self.recordingManager.startRecording(
title: snapshot.title,
Expand Down Expand Up @@ -319,6 +325,7 @@ public var filteredSpeakers: [SpeakerProfile] {
dictationManager.formattingCommands = settingsStore.settings.formattingCommands
dictationManager.modelVersion = settingsStore.settings.transcriptionModel
dictationManager.modelKeepAliveSeconds = TimeInterval(settingsStore.settings.modelKeepAlive * 60)
dictationManager.inputDeviceUID = settingsStore.settings.selectedInputDeviceUID
try await dictationManager.start()
// Push-to-talk race: if the key was released before loading finished,
// stop immediately rather than leaving dictation stuck on.
Expand Down Expand Up @@ -468,6 +475,17 @@ public var filteredSpeakers: [SpeakerProfile] {
objectWillChange.send()
}

/// Set the input device used for dictation and meeting mic capture.
/// Passing nil reverts to the system default input. The change takes
/// effect on the next dictation start / recording start — already-running
/// sessions keep their current device until restarted.
public func setInputDeviceUID(_ uid: String?) {
settingsStore.settings.selectedInputDeviceUID = uid
dictationManager.inputDeviceUID = uid
recordingManager.inputDeviceUID = uid
objectWillChange.send()
}

private func setupHotkeyManager() {
let pushToTalk = settingsStore.settings.pushToTalk
hotkeyManager = HotkeyManager(
Expand Down Expand Up @@ -555,6 +573,7 @@ public var filteredSpeakers: [SpeakerProfile] {
public func startManualRecording() {
guard recordingManager.activeSession == nil else { return }
stopDictationIfActive()
recordingManager.inputDeviceUID = settingsStore.settings.selectedInputDeviceUID
do {
try recordingManager.startRecording(title: "Manual Recording", meetingPID: nil, rosterNames: [])
phase = .recording
Expand Down
147 changes: 147 additions & 0 deletions Sources/HeardCore/AudioInputDevices.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import AudioToolbox
import AVFoundation
import CoreAudio
import Foundation

/// A microphone-capable CoreAudio device the user can select for dictation and
/// meeting recording.
public struct AudioInputDevice: Identifiable, Hashable, Sendable {
/// CoreAudio device UID — stable across launches and unplug/replug.
public let uid: String
/// Transient CoreAudio device ID, only valid for the current process lifetime.
public let deviceID: AudioDeviceID
public let name: String

public var id: String { uid }
}

/// Enumerates input devices via CoreAudio and applies a user-chosen device to
/// an `AVAudioEngine`. UID is persisted in settings; the transient device ID
/// is resolved at engine-start time, so devices that have been unplugged or
/// renumbered between launches are handled gracefully.
public enum AudioInputDevices {

/// All connected audio devices that expose at least one input stream.
public static func list() -> [AudioInputDevice] {
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)
guard
AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids
) == noErr
else { return [] }

return ids.compactMap { deviceID -> AudioInputDevice? in
guard hasInputStreams(deviceID) else { return nil }
guard
let uid = stringProperty(deviceID, selector: kAudioDevicePropertyDeviceUID),
let name = stringProperty(deviceID, selector: kAudioObjectPropertyName)
?? stringProperty(deviceID, selector: kAudioDevicePropertyDeviceNameCFString)
else { return nil }
return AudioInputDevice(uid: uid, deviceID: deviceID, name: name)
}
}

/// Resolve a saved UID to the current `AudioDeviceID`, or nil if the
/// device isn't connected.
public static func deviceID(forUID uid: String) -> AudioDeviceID? {
list().first { $0.uid == uid }?.deviceID
}

/// The current system default input device, if any.
public static func defaultInputDevice() -> AudioInputDevice? {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var deviceID: AudioDeviceID = 0
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
guard
AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &deviceID
) == noErr,
deviceID != 0,
let uid = stringProperty(deviceID, selector: kAudioDevicePropertyDeviceUID),
let name = stringProperty(deviceID, selector: kAudioObjectPropertyName)
?? stringProperty(deviceID, selector: kAudioDevicePropertyDeviceNameCFString)
else { return nil }
return AudioInputDevice(uid: uid, deviceID: deviceID, name: name)
}

/// Set the chosen device on the engine's input audio unit. Must be called
/// BEFORE `inputNode.outputFormat(forBus:)` or any tap install, since
/// changing the device changes the format. A nil UID, an unknown UID, or
/// an unplugged device leaves the system default in place.
@discardableResult
public static func apply(uid: String?, to engine: AVAudioEngine) -> Bool {
guard let uid, !uid.isEmpty, let deviceID = deviceID(forUID: uid) else {
return false
}
guard let audioUnit = engine.inputNode.audioUnit else {
NSLog("Heard: AudioInputDevices.apply — input node has no audio unit")
return false
}
var id = deviceID
let size = UInt32(MemoryLayout<AudioDeviceID>.size)
let status = AudioUnitSetProperty(
audioUnit,
kAudioOutputUnitProperty_CurrentDevice,
kAudioUnitScope_Global,
0,
&id,
size
)
if status != noErr {
NSLog("Heard: AudioInputDevices.apply failed (uid=%@): %d", uid, status)
return false
}
return true
}

// MARK: - Private

private static func hasInputStreams(_ deviceID: AudioDeviceID) -> Bool {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreams,
mScope: kAudioDevicePropertyScopeInput,
mElement: kAudioObjectPropertyElementMain
)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(deviceID, &addr, 0, nil, &size) == noErr else {
return false
}
return size > 0
}

private static func stringProperty(
_ deviceID: AudioDeviceID,
selector: AudioObjectPropertySelector
) -> String? {
var addr = AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var cfString: Unmanaged<CFString>?
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
let status = AudioObjectGetPropertyData(
deviceID, &addr, 0, nil, &size, &cfString
)
guard status == noErr, let value = cfString else { return nil }
return value.takeRetainedValue() as String
}
}
10 changes: 8 additions & 2 deletions Sources/HeardCore/CoreModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,9 @@ public struct AppSettings: Codable, Equatable {
/// How many days of inactivity before a speaker profile is automatically deleted.
/// 0 means never delete automatically.
public var speakerRetentionDays: Int
/// CoreAudio UID of the user-selected input device for dictation and meeting
/// recording. nil means follow the system default input device.
public var selectedInputDeviceUID: String?

public static let `default` = AppSettings(
userName: "",
Expand Down Expand Up @@ -389,7 +392,8 @@ public struct AppSettings: Codable, Equatable {
diarizationClusteringSimilarity: 0.65,
appearance: .system,
lowMemoryMode: false,
speakerRetentionDays: 90
speakerRetentionDays: 90,
selectedInputDeviceUID: nil
)

public init(
Expand All @@ -415,7 +419,8 @@ public struct AppSettings: Codable, Equatable {
diarizationClusteringSimilarity: Double = 0.65,
appearance: AppAppearance = .system,
lowMemoryMode: Bool = false,
speakerRetentionDays: Int = 90
speakerRetentionDays: Int = 90,
selectedInputDeviceUID: String? = nil
) {
self.userName = userName
self.launchAtLogin = launchAtLogin
Expand All @@ -440,6 +445,7 @@ public struct AppSettings: Codable, Equatable {
self.appearance = appearance
self.lowMemoryMode = lowMemoryMode
self.speakerRetentionDays = speakerRetentionDays
self.selectedInputDeviceUID = selectedInputDeviceUID
}
}

Expand Down
Loading
Loading