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
25 changes: 22 additions & 3 deletions Sources/OpenWisprLib/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ public class AppDelegate: NSObject, NSApplicationDelegate {
private func setupInner() throws {
config = Config.load()
inserter = TextInserter()
recorder.preferredDeviceID = config.audioInputDeviceID
migrateAudioDeviceUIDIfNeeded()
recorder.preferredDeviceID = AudioDeviceManager.resolveConfiguredDeviceID(
uid: config.audioInputDeviceUID,
legacyID: config.audioInputDeviceID
)
if Config.effectiveMaxRecordings(config.maxRecordings) == 0 {
RecordingStore.deleteAllRecordings()
}
Expand Down Expand Up @@ -153,13 +157,28 @@ public class AppDelegate: NSObject, NSApplicationDelegate {
applyConfigChange(newConfig)
}

/// Configs written by older versions store only the numeric AudioDeviceID,
/// which is not stable across reboots or device replugs. If that ID still
/// refers to a device, persist its UID so the selection survives.
private func migrateAudioDeviceUIDIfNeeded() {
guard config.audioInputDeviceUID == nil,
let legacyID = config.audioInputDeviceID,
let uid = AudioDeviceManager.getDeviceUID(deviceID: legacyID) else { return }
config.audioInputDeviceUID = uid
try? config.save()
}

func applyConfigChange(_ newConfig: Config) {
guard isReady else { return }
let wasDownloading: Bool
if case .downloading = statusBar.state { wasDownloading = true } else { wasDownloading = false }
let deviceChanged = recorder.preferredDeviceID != newConfig.audioInputDeviceID
let newDeviceID = AudioDeviceManager.resolveConfiguredDeviceID(
uid: newConfig.audioInputDeviceUID,
legacyID: newConfig.audioInputDeviceID
)
let deviceChanged = recorder.preferredDeviceID != newDeviceID
config = newConfig
recorder.preferredDeviceID = config.audioInputDeviceID
recorder.preferredDeviceID = newDeviceID
if deviceChanged {
recorder.reload()
}
Expand Down
25 changes: 25 additions & 0 deletions Sources/OpenWisprLib/AudioDeviceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Foundation

struct AudioInputDevice {
let id: AudioDeviceID
let uid: String?
let name: String
let isDefault: Bool
}
Expand Down Expand Up @@ -44,6 +45,7 @@ class AudioDeviceManager {
let name = getDeviceName(deviceID: deviceID) else { continue }
result.append(AudioInputDevice(
id: deviceID,
uid: getDeviceUID(deviceID: deviceID),
name: name,
isDefault: deviceID == defaultID
))
Expand All @@ -69,6 +71,29 @@ class AudioDeviceManager {
return deviceID
}

/// Resolve the configured input device to a current AudioDeviceID.
/// A stored UID wins over the numeric ID, because AudioDeviceIDs are not
/// stable across reboots or device replugs while UIDs are. If a UID is
/// set but no longer present, returns nil (system default) rather than
/// trusting the possibly-reassigned numeric ID.
static func resolveConfiguredDeviceID(uid: String?, legacyID: AudioDeviceID?) -> AudioDeviceID? {
guard let uid = uid else { return legacyID }
return listInputDevices().first(where: { $0.uid == uid })?.id
}

static func getDeviceUID(deviceID: AudioDeviceID) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceUID,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var uid: Unmanaged<CFString>?
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &uid)
guard status == noErr, let cfUID = uid?.takeRetainedValue() else { return nil }
return cfUID as String
}

private static func isVirtualDevice(deviceID: AudioDeviceID) -> Bool {
var transportType: UInt32 = 0
var size = UInt32(MemoryLayout<UInt32>.size)
Expand Down
8 changes: 7 additions & 1 deletion Sources/OpenWisprLib/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public struct Config: Codable {
public var maxRecordings: Int?
public var toggleMode: FlexBool?
public var audioInputDeviceID: UInt32?
public var audioInputDeviceUID: String?

public var hotkey: HotkeyConfig {
get { hotkeys[0] }
Expand Down Expand Up @@ -44,6 +45,7 @@ public struct Config: Codable {
case maxRecordings
case toggleMode
case audioInputDeviceID
case audioInputDeviceUID
}

public init(from decoder: Decoder) throws {
Expand All @@ -64,6 +66,7 @@ public struct Config: Codable {
self.maxRecordings = try c.decodeIfPresent(Int.self, forKey: .maxRecordings)
self.toggleMode = try c.decodeIfPresent(FlexBool.self, forKey: .toggleMode)
self.audioInputDeviceID = try c.decodeIfPresent(UInt32.self, forKey: .audioInputDeviceID)
self.audioInputDeviceUID = try c.decodeIfPresent(String.self, forKey: .audioInputDeviceUID)
}

public func encode(to encoder: Encoder) throws {
Expand All @@ -77,6 +80,7 @@ public struct Config: Codable {
try c.encodeIfPresent(maxRecordings, forKey: .maxRecordings)
try c.encodeIfPresent(toggleMode, forKey: .toggleMode)
try c.encodeIfPresent(audioInputDeviceID, forKey: .audioInputDeviceID)
try c.encodeIfPresent(audioInputDeviceUID, forKey: .audioInputDeviceUID)
}

public init(
Expand All @@ -87,7 +91,8 @@ public struct Config: Codable {
spokenPunctuation: FlexBool?,
maxRecordings: Int?,
toggleMode: FlexBool?,
audioInputDeviceID: UInt32? = nil
audioInputDeviceID: UInt32? = nil,
audioInputDeviceUID: String? = nil
) {
self.hotkeys = hotkeys.isEmpty
? [HotkeyConfig(keyCode: 63, modifiers: [])]
Expand All @@ -99,6 +104,7 @@ public struct Config: Codable {
self.maxRecordings = maxRecordings
self.toggleMode = toggleMode
self.audioInputDeviceID = audioInputDeviceID
self.audioInputDeviceUID = audioInputDeviceUID
}

public static let supportedLanguages: [LanguageOption] = [
Expand Down
22 changes: 12 additions & 10 deletions Sources/OpenWisprLib/StatusBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,28 +221,29 @@ class StatusBarController: NSObject {
menu.addItem(modelItem)

let devices = AudioDeviceManager.listInputDevices()
let selectedDeviceID = config.audioInputDeviceID
let currentDeviceName: String
if let selectedID = selectedDeviceID,
let device = devices.first(where: { $0.id == selectedID }) {
currentDeviceName = device.name
} else {
currentDeviceName = "System Default"
}
let selectedDevice = devices.first(where: { device in
if let uid = config.audioInputDeviceUID { return device.uid == uid }
if let id = config.audioInputDeviceID { return device.id == id }
return false
})
let currentDeviceName = selectedDevice?.name ?? "System Default"
let audioItem = NSMenuItem(title: "Audio Input: \(currentDeviceName)", action: nil, keyEquivalent: "")
let audioSubmenu = NSMenu()
audioSubmenu.autoenablesItems = false

let defaultTarget = MenuItemTarget { [weak self] in
var cfg = Config.load()
cfg.audioInputDeviceID = nil
cfg.audioInputDeviceUID = nil
try? cfg.save()
self?.onConfigChange?(cfg)
}
menuItemTargets.append(defaultTarget)
let defaultItem = NSMenuItem(title: "System Default", action: #selector(MenuItemTarget.invoke), keyEquivalent: "")
defaultItem.target = defaultTarget
if selectedDeviceID == nil { defaultItem.state = .on }
if config.audioInputDeviceID == nil && config.audioInputDeviceUID == nil {
defaultItem.state = .on
}
audioSubmenu.addItem(defaultItem)

if !devices.isEmpty {
Expand All @@ -253,13 +254,14 @@ class StatusBarController: NSObject {
let target = MenuItemTarget { [weak self] in
var cfg = Config.load()
cfg.audioInputDeviceID = device.id
cfg.audioInputDeviceUID = device.uid
try? cfg.save()
self?.onConfigChange?(cfg)
}
menuItemTargets.append(target)
let item = NSMenuItem(title: device.name, action: #selector(MenuItemTarget.invoke), keyEquivalent: "")
item.target = target
if selectedDeviceID == device.id { item.state = .on }
if selectedDevice?.id == device.id { item.state = .on }
audioSubmenu.addItem(item)
}

Expand Down
27 changes: 27 additions & 0 deletions Tests/OpenWisprTests/AudioDeviceManagerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import XCTest
@testable import OpenWisprLib

final class AudioDeviceManagerTests: XCTestCase {

func testResolveWithoutUIDFallsBackToLegacyID() {
XCTAssertEqual(
AudioDeviceManager.resolveConfiguredDeviceID(uid: nil, legacyID: 42),
42
)
}

func testResolveWithoutUIDAndWithoutLegacyIDReturnsNil() {
XCTAssertNil(AudioDeviceManager.resolveConfiguredDeviceID(uid: nil, legacyID: nil))
}

func testResolveWithUnknownUIDReturnsNilInsteadOfStaleID() {
// The stored numeric ID must not be trusted when the UID it was
// saved with no longer matches any present device.
XCTAssertNil(
AudioDeviceManager.resolveConfiguredDeviceID(
uid: "OpenWisprTests:NoSuchDevice:UID",
legacyID: 42
)
)
}
}
47 changes: 47 additions & 0 deletions Tests/OpenWisprTests/ConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,53 @@ final class ConfigTests: XCTestCase {
XCTAssertEqual(config.toggleMode?.value, false)
}

// MARK: - audioInputDevice decoding

func testConfigDecodesAudioInputDeviceUID() throws {
let json = """
{
"hotkey": {"keyCode": 63, "modifiers": []},
"modelSize": "base.en",
"language": "en",
"audioInputDeviceID": 82,
"audioInputDeviceUID": "AppleUSBAudioEngine:Vendor:Headset:1234:1"
}
""".data(using: .utf8)!
let config = try Config.decode(from: json)
XCTAssertEqual(config.audioInputDeviceID, 82)
XCTAssertEqual(config.audioInputDeviceUID, "AppleUSBAudioEngine:Vendor:Headset:1234:1")
}

func testConfigDecodesLegacyAudioInputDeviceIDWithoutUID() throws {
let json = """
{
"hotkey": {"keyCode": 63, "modifiers": []},
"modelSize": "base.en",
"language": "en",
"audioInputDeviceID": 82
}
""".data(using: .utf8)!
let config = try Config.decode(from: json)
XCTAssertEqual(config.audioInputDeviceID, 82)
XCTAssertNil(config.audioInputDeviceUID)
}

func testConfigEncodesAudioInputDeviceUIDRoundTrip() throws {
var config = Config.defaultConfig
config.audioInputDeviceID = 82
config.audioInputDeviceUID = "BuiltInMicrophoneDevice"
let data = try JSONEncoder().encode(config)
let decoded = try Config.decode(from: data)
XCTAssertEqual(decoded.audioInputDeviceID, 82)
XCTAssertEqual(decoded.audioInputDeviceUID, "BuiltInMicrophoneDevice")
}

func testConfigOmitsAudioInputDeviceUIDWhenNil() throws {
let data = try JSONEncoder().encode(Config.defaultConfig)
let json = String(data: data, encoding: .utf8)!
XCTAssertFalse(json.contains("audioInputDeviceUID"))
}

// MARK: - Language and model constants

func testSupportedLanguagesContainsEnglish() {
Expand Down
Loading