diff --git a/Sources/OpenWisprLib/AppDelegate.swift b/Sources/OpenWisprLib/AppDelegate.swift index 62d889b..b01f5e1 100644 --- a/Sources/OpenWisprLib/AppDelegate.swift +++ b/Sources/OpenWisprLib/AppDelegate.swift @@ -7,19 +7,26 @@ public class AppDelegate: NSObject, NSApplicationDelegate { var transcriber: Transcriber! var inserter: TextInserter! var config: Config! - var isPressed = false + var recordingLifecycle = RecordingLifecycle() + var currentRecordingURL: URL? + private var sleepWakeObservers: [NSObjectProtocol] = [] var isReady = false public var lastTranscription: String? public func applicationDidFinishLaunching(_ notification: Notification) { statusBar = StatusBarController() recorder = AudioRecorder() + registerSleepWakeObservers() DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.setup() } } + public func applicationWillTerminate(_ notification: Notification) { + unregisterSleepWakeObservers() + } + private func setup() { do { try setupInner() @@ -236,28 +243,27 @@ public class AppDelegate: NSObject, NSApplicationDelegate { let isToggle = config.toggleMode?.value ?? false - if isToggle { - if isPressed { - handleRecordingStop() - } else { - handleRecordingStart() - } - } else { - guard !isPressed else { return } + switch recordingLifecycle.keyDown(toggleMode: isToggle) { + case .startRecording: handleRecordingStart() + case .stopRecording: + handleRecordingStop() + case .none, .cancelRecording, .prepareRecorder: + break } } private func handleKeyUp() { + guard isReady else { return } + let isToggle = config.toggleMode?.value ?? false - if isToggle { return } - handleRecordingStop() + if recordingLifecycle.keyUp(toggleMode: isToggle) == .stopRecording { + handleRecordingStop() + } } private func handleRecordingStart() { - guard !isPressed else { return } - isPressed = true statusBar.state = .recording do { let outputURL: URL @@ -267,22 +273,23 @@ public class AppDelegate: NSObject, NSApplicationDelegate { outputURL = RecordingStore.newRecordingURL() } try recorder.startRecording(to: outputURL) + currentRecordingURL = outputURL } catch { print("Error: \(error.localizedDescription)") - isPressed = false + recordingLifecycle.recordingStartFailed() + currentRecordingURL = nil statusBar.state = .idle } } private func handleRecordingStop() { - guard isPressed else { return } - isPressed = false - guard let audioURL = recorder.stopRecording() else { + RecordingCancellation.discardTrackedPartialRecording(¤tRecordingURL) statusBar.state = .idle return } + currentRecordingURL = nil statusBar.state = .transcribing DispatchQueue.global(qos: .userInitiated).async { [weak self] in @@ -326,6 +333,60 @@ public class AppDelegate: NSObject, NSApplicationDelegate { } } + func handleSystemWillSleep() { + guard recordingLifecycle.systemWillSleep() == .cancelRecording else { return } + + recorder.teardown() + RecordingCancellation.discardTrackedPartialRecording(¤tRecordingURL) + resetRecordingStatusToIdleIfNeeded() + } + + func handleSystemDidWake() { + guard recordingLifecycle.systemDidWake(isReady: isReady) == .prepareRecorder else { return } + + recorder.preferredDeviceID = AudioDeviceManager.resolveConfiguredDeviceID( + uid: config.audioInputDeviceUID, + legacyID: config.audioInputDeviceID + ) + recorder.reload() + } + + private func registerSleepWakeObservers() { + guard sleepWakeObservers.isEmpty else { return } + + let center = NSWorkspace.shared.notificationCenter + sleepWakeObservers = [ + center.addObserver( + forName: NSWorkspace.willSleepNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleSystemWillSleep() + }, + center.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleSystemDidWake() + }, + ] + } + + private func unregisterSleepWakeObservers() { + let center = NSWorkspace.shared.notificationCenter + for observer in sleepWakeObservers { + center.removeObserver(observer) + } + sleepWakeObservers = [] + } + + private func resetRecordingStatusToIdleIfNeeded() { + guard case .recording = statusBar.state else { return } + statusBar.state = .idle + statusBar.buildMenu() + } + public func reprocess(audioURL: URL) { guard case .idle = statusBar.state else { return } diff --git a/Sources/OpenWisprLib/RecordingLifecycle.swift b/Sources/OpenWisprLib/RecordingLifecycle.swift new file mode 100644 index 0000000..ec12f1d --- /dev/null +++ b/Sources/OpenWisprLib/RecordingLifecycle.swift @@ -0,0 +1,60 @@ +import Foundation + +struct RecordingLifecycle { + enum Action: Equatable { + case none + case startRecording + case stopRecording + case cancelRecording + case prepareRecorder + } + + private(set) var isRecording = false + + mutating func keyDown(toggleMode: Bool) -> Action { + if toggleMode { + if isRecording { + isRecording = false + return .stopRecording + } + isRecording = true + return .startRecording + } + + guard !isRecording else { return .none } + isRecording = true + return .startRecording + } + + mutating func keyUp(toggleMode: Bool) -> Action { + guard !toggleMode, isRecording else { return .none } + isRecording = false + return .stopRecording + } + + mutating func systemWillSleep() -> Action { + guard isRecording else { return .none } + isRecording = false + return .cancelRecording + } + + func systemDidWake(isReady: Bool) -> Action { + isReady ? .prepareRecorder : .none + } + + mutating func recordingStartFailed() { + isRecording = false + } +} + +enum RecordingCancellation { + static func discardPartialRecording(at url: URL?, fileManager: FileManager = .default) { + guard let url else { return } + try? fileManager.removeItem(at: url) + } + + static func discardTrackedPartialRecording(_ url: inout URL?, fileManager: FileManager = .default) { + discardPartialRecording(at: url, fileManager: fileManager) + url = nil + } +} diff --git a/Tests/OpenWisprTests/RecordingLifecycleTests.swift b/Tests/OpenWisprTests/RecordingLifecycleTests.swift new file mode 100644 index 0000000..686e354 --- /dev/null +++ b/Tests/OpenWisprTests/RecordingLifecycleTests.swift @@ -0,0 +1,83 @@ +import XCTest +@testable import OpenWisprLib + +final class RecordingLifecycleTests: XCTestCase { + func testHoldToTalkStartsOnceAndStopsOnKeyUp() { + var lifecycle = RecordingLifecycle() + + XCTAssertEqual(lifecycle.keyDown(toggleMode: false), .startRecording) + XCTAssertEqual(lifecycle.keyDown(toggleMode: false), .none) + XCTAssertEqual(lifecycle.keyUp(toggleMode: false), .stopRecording) + XCTAssertFalse(lifecycle.isRecording) + } + + func testToggleModeStartsAndStopsOnKeyDown() { + var lifecycle = RecordingLifecycle() + + XCTAssertEqual(lifecycle.keyDown(toggleMode: true), .startRecording) + XCTAssertEqual(lifecycle.keyUp(toggleMode: true), .none) + XCTAssertEqual(lifecycle.keyDown(toggleMode: true), .stopRecording) + XCTAssertFalse(lifecycle.isRecording) + } + + func testSleepWhileIdleDoesNotRequestTranscription() { + var lifecycle = RecordingLifecycle() + + XCTAssertEqual(lifecycle.systemWillSleep(), .none) + XCTAssertFalse(lifecycle.isRecording) + } + + func testSleepWhileRecordingCancelsInsteadOfStopping() { + var lifecycle = RecordingLifecycle() + + XCTAssertEqual(lifecycle.keyDown(toggleMode: false), .startRecording) + XCTAssertEqual(lifecycle.systemWillSleep(), .cancelRecording) + XCTAssertFalse(lifecycle.isRecording) + XCTAssertEqual(lifecycle.keyDown(toggleMode: false), .startRecording) + XCTAssertEqual(lifecycle.keyUp(toggleMode: false), .stopRecording) + } + + func testWakePreparesOnlyWhenReady() { + let lifecycle = RecordingLifecycle() + + XCTAssertEqual(lifecycle.systemDidWake(isReady: false), .none) + XCTAssertEqual(lifecycle.systemDidWake(isReady: true), .prepareRecorder) + } + + func testStartFailureReturnsLifecycleToIdle() { + var lifecycle = RecordingLifecycle() + + XCTAssertEqual(lifecycle.keyDown(toggleMode: false), .startRecording) + lifecycle.recordingStartFailed() + XCTAssertFalse(lifecycle.isRecording) + XCTAssertEqual(lifecycle.keyDown(toggleMode: false), .startRecording) + } + + func testDiscardCancelledRecordingRemovesPartialFile() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("open-wispr-cancel-\(UUID().uuidString).wav") + try Data("partial".utf8).write(to: url) + + RecordingCancellation.discardPartialRecording(at: url) + + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) + } + + func testDiscardCancelledRecordingAllowsMissingFile() { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("open-wispr-cancel-\(UUID().uuidString).wav") + + RecordingCancellation.discardPartialRecording(at: url) + + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) + } + + func testDiscardTrackedPartialRecordingRemovesFileBeforeClearingURL() throws { + var trackedURL: URL? = FileManager.default.temporaryDirectory.appendingPathComponent("open-wispr-cancel-\(UUID().uuidString).wav") + let path = try XCTUnwrap(trackedURL?.path) + try Data("partial".utf8).write(to: XCTUnwrap(trackedURL)) + + RecordingCancellation.discardTrackedPartialRecording(&trackedURL) + + XCTAssertNil(trackedURL) + XCTAssertFalse(FileManager.default.fileExists(atPath: path)) + } +}