diff --git a/Sources/quill/Audio/MicRecorder.swift b/Sources/quill/Audio/MicRecorder.swift index 88485ed..7cba0ec 100644 --- a/Sources/quill/Audio/MicRecorder.swift +++ b/Sources/quill/Audio/MicRecorder.swift @@ -1,5 +1,6 @@ import AVFoundation import Foundation +import os.lock /// Records the default input device to a file via AVAudioEngine, encoding AAC /// mono. Buffers stream straight to disk — nothing is held in memory, so @@ -28,15 +29,32 @@ final class MicRecorder: @unchecked Sendable { } private var engine = AVAudioEngine() - private var file: AVAudioFile? private var url: URL? private(set) var isRecording = false + + // Thread-safe shared state: accessed from both the main thread and the + // audio-tap callback (background audio thread) without further sync. + private struct LockedState { + var file: AVAudioFile? + var firstBufferAt: Date? + } + private let state = OSAllocatedUnfairLock(initialState: LockedState()) + + private var file: AVAudioFile? { + get { state.withLock { $0.file } } + set { state.withLock { $0.file = newValue } } + } + /// Wall-clock time of the first captured buffer — the track's true start, /// used to offset-align the two tracks' transcript timestamps. - private(set) var firstBufferAt: Date? + private(set) var firstBufferAt: Date? { + get { state.withLock { $0.firstBufferAt } } + set { state.withLock { $0.firstBufferAt = newValue } } + } // Liveness check state (voice-processing path only). Written from the tap - // callback, read on main when deciding to fall back. + // callback, read on main when deciding to fall back. The dispatch to main + // in fallBackToRaw creates a happens-before, so these need no lock. private var livenessFrames = 0 private var livenessPeak: Float = 0 private var livenessSettled = false diff --git a/Sources/quill/Audio/SystemAudioRecorder.swift b/Sources/quill/Audio/SystemAudioRecorder.swift index 7e062ce..be3b437 100644 --- a/Sources/quill/Audio/SystemAudioRecorder.swift +++ b/Sources/quill/Audio/SystemAudioRecorder.swift @@ -1,6 +1,7 @@ import AVFoundation import CoreAudio import Foundation +import os.lock /// Records all system audio output to a file via a Core Audio process tap /// (macOS 14.2+). No virtual device, no kernel extension — the tap mixes every @@ -32,12 +33,28 @@ final class SystemAudioRecorder { private var tapID = AudioObjectID(kAudioObjectUnknown) private var aggregateID = AudioObjectID(kAudioObjectUnknown) private var procID: AudioDeviceIOProcID? - private var file: AVAudioFile? private let queue = DispatchQueue(label: "com.digimata.quill.system-tap") private(set) var isRecording = false + + // Thread-safe shared state: accessed from both the main thread and the + // IOProc callback (background serial queue) without further sync. + private struct LockedState { + var file: AVAudioFile? + var firstBufferAt: Date? + } + private let state = OSAllocatedUnfairLock(initialState: LockedState()) + + private var file: AVAudioFile? { + get { state.withLock { $0.file } } + set { state.withLock { $0.file = newValue } } + } + /// Wall-clock time of the first captured buffer — the track's true start, /// used to offset-align the two tracks' transcript timestamps. - private(set) var firstBufferAt: Date? + private(set) var firstBufferAt: Date? { + get { state.withLock { $0.firstBufferAt } } + set { state.withLock { $0.firstBufferAt = newValue } } + } /// Start capturing system audio, encoding AAC into `url` (use a .caf /// extension — CAF needs no finalization pass, so a crash mid-meeting diff --git a/Sources/quill/UI/MenuBarController.swift b/Sources/quill/UI/MenuBarController.swift index 30676b6..4f2dc9f 100644 --- a/Sources/quill/UI/MenuBarController.swift +++ b/Sources/quill/UI/MenuBarController.swift @@ -90,12 +90,12 @@ final class MenuBarController { // has no separate resource bundle to install alongside it — true // single-binary. private static let featherSVG = """ - \ - \ - \ - \ + + + + """