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
81 changes: 72 additions & 9 deletions App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,14 @@ final class AppState: ObservableObject {
/// Which engine writes notes, answers questions and explains highlights.
@Published var llmBackend: LLMBackend = .openAICompatible
@Published var interruptedSessions: [Session] = []
@Published var microphoneDevices: [MicrophoneInputDevice] = []
/// Starts as just "System default"; the real list loads in the background.
@Published var microphoneDevices: [MicrophoneInputDevice] = [.systemDefault]
/// True while a device query has gone unanswered far longer than a healthy
/// Core Audio ever takes, which means coreaudiod itself is stuck. Starting
/// a recording then would freeze the app, so it is refused with an
/// explanation instead. Cleared as soon as the query is answered.
@Published private(set) var isAudioServiceUnresponsive = false
private var microphoneQuery: Task<Void, Never>?
@AppStorage("preferredMicrophoneDeviceID", store: AppEnvironment.defaults) var preferredMicrophoneDeviceID: String = MicrophoneInputDevice.systemDefaultID
@Published private var importOrchestrators: [String: SessionOrchestrator] = [:]
/// Task Center ids for the running imports/re-transcriptions, keyed the same
Expand Down Expand Up @@ -184,21 +191,66 @@ final class AppState: ObservableObject {
}
}

func refreshMicrophoneDevices() {
microphoneDevices = MicrophoneDeviceCatalog.availableInputDevices()
if selectedMicrophoneUniqueID != nil,
!microphoneDevices.contains(where: { $0.id == preferredMicrophoneDeviceID }) {
preferredMicrophoneDeviceID = MicrophoneInputDevice.systemDefaultID
/// Lists the microphones without blocking the main thread.
///
/// The list comes from coreaudiod, and when that daemon deadlocks (a macOS
/// bug: seen on 27.2 when an audio device plug-in changed while a system
/// audio tap was running) the query never returns. Asked on the main
/// thread at launch, it left the app spinning before its window had drawn.
/// `stallAfter` and `query` are for tests.
func refreshMicrophoneDevices(
stallAfter: Duration = .seconds(5),
query: @escaping @Sendable () -> [MicrophoneInputDevice] = { MicrophoneDeviceCatalog.availableInputDevices() }
) {
// One query at a time: a stuck one holds its thread until coreaudiod
// recovers, and Settings asks again every time it appears.
guard microphoneQuery == nil else { return }
microphoneQuery = Task {
let watchdog = Task {
try? await Task.sleep(for: stallAfter)
guard !Task.isCancelled else { return }
self.isAudioServiceUnresponsive = true
NSLog("[ClassNote] Core Audio has not answered a device query in %@; coreaudiod looks stuck",
String(describing: stallAfter))
}
let devices = await Self.runOffMainThread(query)
watchdog.cancel()
let answeredLate = isAudioServiceUnresponsive
microphoneQuery = nil
isAudioServiceUnresponsive = false
microphoneDevices = devices
// A late answer comes from a daemon that was stuck or has just been
// restarted, and can be missing devices; it is not a reason to
// forget the user's microphone.
if !answeredLate, selectedMicrophoneUniqueID != nil,
!devices.contains(where: { $0.id == preferredMicrophoneDeviceID }) {
preferredMicrophoneDeviceID = MicrophoneInputDevice.systemDefaultID
}
}
}

/// On a GCD thread rather than the Swift concurrency pool: the call can
/// block for good, and GCD makes another thread when one is stuck.
private nonisolated static func runOffMainThread(
_ query: @escaping @Sendable () -> [MicrophoneInputDevice]
) async -> [MicrophoneInputDevice] {
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(returning: query())
}
}
}

var selectedMicrophoneUniqueID: String? {
preferredMicrophoneDeviceID == MicrophoneInputDevice.systemDefaultID ? nil : preferredMicrophoneDeviceID
}

/// From the loaded list only. The menu bar reads this on every redraw, so
/// it must never ask Core Audio itself.
var selectedMicrophoneName: String {
microphoneDevices.first { $0.id == preferredMicrophoneDeviceID }?.name
?? MicrophoneDeviceCatalog.name(for: selectedMicrophoneUniqueID)
?? selectedMicrophoneUniqueID
?? MicrophoneInputDevice.systemDefault.name
}

func refreshInterruptedSessions() async {
Expand Down Expand Up @@ -280,6 +332,10 @@ final class AppState: ObservableObject {
source: AudioSourceKind,
translationEnabled: Bool? = nil) async -> String? {
guard !isStartingRecording, !isRecording else { return nil }
guard !isAudioServiceUnresponsive else {
setError(L10n.t("record.error.audioServiceUnresponsive"))
return nil
}
isStartingRecording = true
defer { isStartingRecording = false }
do {
Expand All @@ -301,6 +357,10 @@ final class AppState: ObservableObject {

func startEphemeralTranslation(source: AudioSourceKind = .microphone) {
guard !isStartingRecording, !isRecording else { return }
guard !isAudioServiceUnresponsive else {
setError(L10n.t("record.error.audioServiceUnresponsive"))
return
}
isStartingRecording = true
Task { @MainActor in
defer { self.isStartingRecording = false }
Expand Down Expand Up @@ -648,8 +708,11 @@ final class AppState: ObservableObject {
status: Database.shared.dbPool == nil ? .failed : .ok,
detail: Database.shared.dbPool == nil ? L10n.t("diagnostics.database.failed") : L10n.t("diagnostics.ok")))
checks.append(.init(name: L10n.t("diagnostics.microphone"),
status: AVCaptureDevice.authorizationStatus(for: .audio) == .authorized ? .ok : .warning,
detail: "\(L10n.t("diagnostics.microphone.detail")) \(selectedMicrophoneName)"))
status: AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
&& !isAudioServiceUnresponsive ? .ok : .warning,
detail: isAudioServiceUnresponsive
? L10n.t("record.error.audioServiceUnresponsive")
: "\(L10n.t("diagnostics.microphone.detail")) \(selectedMicrophoneName)"))
checks.append(.init(name: L10n.t("diagnostics.screen"),
status: CGPreflightScreenCaptureAccess() ? .ok : .warning,
detail: L10n.t("diagnostics.screen.detail")))
Expand Down
3 changes: 3 additions & 0 deletions Core/Capture/AudioSourceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ struct MicrophoneInputDevice: Identifiable, Equatable, Hashable {
}

enum MicrophoneDeviceCatalog {
/// Asks coreaudiod, and waits for as long as it takes to answer: forever if
/// it is deadlocked. Keep it off the main thread; the app goes through
/// `AppState.refreshMicrophoneDevices()`.
static func availableInputDevices() -> [MicrophoneInputDevice] {
let devices = audioDevices().map {
MicrophoneInputDevice(id: $0.uniqueID,
Expand Down
2 changes: 2 additions & 0 deletions Core/Utils/L10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@ enum L10n {
"sync.error.noICloudDrive": "iCloud Drive is off on this Mac. Turn it on in System Settings → Apple Account → iCloud.",
"player.downloading": "Downloading from iCloud…",
"settings.audio.refresh": "Refresh devices",
"record.error.audioServiceUnresponsive": "macOS's audio service (coreaudiod) is not responding, so ClassNote can't use the microphone or system audio right now. Your library still works. Restart the Mac, or run “sudo killall coreaudiod” in Terminal, then try again.",
"settings.shortcuts.global": "Global shortcuts",
"settings.shortcuts.toggleRecording": "Toggle recording",
"settings.shortcuts.markHighlight": "Mark highlight",
Expand Down Expand Up @@ -1282,6 +1283,7 @@ enum L10n {
"sync.error.noICloudDrive": "这台 Mac 没有打开 iCloud 云盘。请在“系统设置 → Apple 账户 → iCloud”中打开。",
"player.downloading": "正在从 iCloud 下载…",
"settings.audio.refresh": "刷新设备",
"record.error.audioServiceUnresponsive": "macOS 的系统音频服务(coreaudiod)没有响应,ClassNote 暂时无法使用麦克风或系统音频。课程库仍可正常使用。请重启 Mac,或在终端运行“sudo killall coreaudiod”后再试。",
"settings.shortcuts.global": "全局快捷键",
"settings.shortcuts.toggleRecording": "开始/停止录制",
"settings.shortcuts.markHighlight": "标记重点",
Expand Down
5 changes: 4 additions & 1 deletion Features/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,10 @@ struct GeneralSettingsView: View {
Toggle(L10n.t("settings.engines.liveTranslationToggle"), isOn: $appState.translationEnabled)
}

SettingsSection(title: L10n.t("settings.audio.input"), footer: L10n.t("settings.audio.inputNote")) {
SettingsSection(title: L10n.t("settings.audio.input"),
footer: appState.isAudioServiceUnresponsive
? L10n.t("record.error.audioServiceUnresponsive")
: L10n.t("settings.audio.inputNote")) {
HStack {
Picker(L10n.t("settings.audio.microphone"), selection: $appState.preferredMicrophoneDeviceID) {
ForEach(appState.microphoneDevices) { device in
Expand Down
78 changes: 78 additions & 0 deletions Tests/ClassNoteTests/AudioServiceStallTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Foundation
import XCTest
@testable import ClassNote

/// coreaudiod can deadlock (a macOS bug), and then every Core Audio query
/// waits forever. The app used to list microphones on the main thread at
/// launch, so it spun before its window had drawn.
@MainActor
final class AudioServiceStallTests: XCTestCase {
private let usbMic = MicrophoneInputDevice(id: "usb-mic", name: "USB Mic", uniqueID: "usb-mic")

override func tearDown() async throws {
AppState.shared.preferredMicrophoneDeviceID = MicrophoneInputDevice.systemDefaultID
AppState.shared.lastError = nil
try await super.tearDown()
}

func testAStuckDeviceQueryLeavesTheAppResponsiveAndRefusesToRecord() async throws {
let appState = AppState.shared
let stuck = DispatchSemaphore(value: 0)
appState.refreshMicrophoneDevices(stallAfter: .milliseconds(50)) {
stuck.wait()
return [.systemDefault]
}
// Getting here at all means the main actor was not blocked.
try await waitUntil { appState.isAudioServiceUnresponsive }

let sessionId = await appState.startNewSession(courseId: nil, source: .microphone)
XCTAssertNil(sessionId)
XCTAssertEqual(appState.lastError, L10n.t("record.error.audioServiceUnresponsive"))
XCTAssertFalse(appState.isRecording)

stuck.signal()
try await waitUntil { !appState.isAudioServiceUnresponsive }
}

func testOnlyAPromptAnswerForgetsAMissingMicrophone() async throws {
let appState = AppState.shared
appState.preferredMicrophoneDeviceID = usbMic.id

// Late, as from a daemon that was just restarted: the USB mic is
// missing from the answer, but the preference stays.
let late = DispatchSemaphore(value: 0)
appState.refreshMicrophoneDevices(stallAfter: .milliseconds(50)) {
late.wait()
return [.systemDefault]
}
try await waitUntil { appState.isAudioServiceUnresponsive }
late.signal()
// Cleared in the same main-actor turn that applies the answer.
try await waitUntil { !appState.isAudioServiceUnresponsive }
XCTAssertEqual(appState.preferredMicrophoneDeviceID, usbMic.id)
XCTAssertEqual(appState.selectedMicrophoneName, usbMic.id)

// Prompt and still without it: the mic really is gone.
appState.refreshMicrophoneDevices { [.systemDefault] }
try await waitUntil { appState.preferredMicrophoneDeviceID == MicrophoneInputDevice.systemDefaultID }
}

func testSelectedNameComesFromTheLoadedList() async throws {
let appState = AppState.shared
let mic = usbMic
appState.refreshMicrophoneDevices { [.systemDefault, mic] }
try await waitUntil { appState.microphoneDevices.contains(mic) }
appState.preferredMicrophoneDeviceID = mic.id
XCTAssertEqual(appState.selectedMicrophoneName, "USB Mic")
}

private func waitUntil(_ condition: () -> Bool,
file: StaticString = #filePath, line: UInt = #line) async throws {
var waited = 0
while !condition(), waited < 300 {
try await Task.sleep(nanoseconds: 10_000_000)
waited += 1
}
XCTAssertTrue(condition(), "condition never became true", file: file, line: line)
}
}
4 changes: 2 additions & 2 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ settings:
SWIFT_STRICT_CONCURRENCY: complete
SWIFT_TREAT_WARNINGS_AS_ERRORS: "NO"
ENABLE_USER_SCRIPT_SANDBOXING: NO
MARKETING_VERSION: "0.16.9"
CURRENT_PROJECT_VERSION: "27"
MARKETING_VERSION: "0.16.10"
CURRENT_PROJECT_VERSION: "28"
# Apple Silicon only, in every configuration: a Release build would
# otherwise also carry an Intel slice nobody can use.
ARCHS: arm64
Expand Down
Loading