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
95 changes: 78 additions & 17 deletions Sources/OpenWisprLib/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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(&currentRecordingURL)
statusBar.state = .idle
return
}

currentRecordingURL = nil
statusBar.state = .transcribing

DispatchQueue.global(qos: .userInitiated).async { [weak self] in
Expand Down Expand Up @@ -326,6 +333,60 @@ public class AppDelegate: NSObject, NSApplicationDelegate {
}
}

func handleSystemWillSleep() {
guard recordingLifecycle.systemWillSleep() == .cancelRecording else { return }

recorder.teardown()
RecordingCancellation.discardTrackedPartialRecording(&currentRecordingURL)
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 }

Expand Down
60 changes: 60 additions & 0 deletions Sources/OpenWisprLib/RecordingLifecycle.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
83 changes: 83 additions & 0 deletions Tests/OpenWisprTests/RecordingLifecycleTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading