feat: add package scaffold and DictationController state machine
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public final class DictationController {
|
||||
public private(set) var state: DictationState = .idle {
|
||||
didSet { onStateChange?(state) }
|
||||
}
|
||||
public var onStateChange: ((DictationState) -> Void)?
|
||||
public var onError: ((String) -> Void)?
|
||||
|
||||
private let recorder: AudioRecording
|
||||
private let transcriber: Transcriber
|
||||
private let cleaner: Cleaner
|
||||
private let inserter: TextInserting
|
||||
private let minimumSamples: Int
|
||||
|
||||
/// - Parameter minimumSamples: 16 kHz samples; default is 0.3 s.
|
||||
public init(
|
||||
recorder: AudioRecording,
|
||||
transcriber: Transcriber,
|
||||
cleaner: Cleaner,
|
||||
inserter: TextInserting,
|
||||
minimumSamples: Int = 4_800
|
||||
) {
|
||||
self.recorder = recorder
|
||||
self.transcriber = transcriber
|
||||
self.cleaner = cleaner
|
||||
self.inserter = inserter
|
||||
self.minimumSamples = minimumSamples
|
||||
}
|
||||
|
||||
public func handle(_ event: HotkeyEvent) async {
|
||||
switch (event, state) {
|
||||
case (.pressed, .idle):
|
||||
do {
|
||||
try recorder.start()
|
||||
state = .recording
|
||||
} catch {
|
||||
onError?(error.localizedDescription)
|
||||
}
|
||||
case (.released, .recording):
|
||||
let samples = recorder.stop()
|
||||
guard samples.count >= minimumSamples else {
|
||||
state = .idle
|
||||
return
|
||||
}
|
||||
await process(samples)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func process(_ samples: [Float]) async {
|
||||
defer { state = .idle }
|
||||
do {
|
||||
state = .transcribing
|
||||
let raw = try await transcriber.transcribe(samples)
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
let cleaned = try await cleaner.clean(text, context: CleaningContext())
|
||||
state = .inserting
|
||||
try await inserter.insert(cleaned)
|
||||
} catch {
|
||||
onError?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
public enum HotkeyEvent: Equatable, Sendable {
|
||||
case pressed
|
||||
case released
|
||||
}
|
||||
|
||||
public enum DictationState: Equatable, Sendable {
|
||||
case idle
|
||||
case recording
|
||||
case transcribing
|
||||
case inserting
|
||||
}
|
||||
|
||||
public struct CleaningContext: Sendable {
|
||||
public init() {}
|
||||
}
|
||||
|
||||
public protocol AudioRecording {
|
||||
func start() throws
|
||||
/// Stops recording and returns all captured 16 kHz mono Float samples.
|
||||
func stop() -> [Float]
|
||||
}
|
||||
|
||||
public protocol Transcriber {
|
||||
func transcribe(_ samples: [Float]) async throws -> String
|
||||
}
|
||||
|
||||
public protocol Cleaner {
|
||||
func clean(_ text: String, context: CleaningContext) async throws -> String
|
||||
}
|
||||
|
||||
public protocol TextInserting {
|
||||
func insert(_ text: String) async throws
|
||||
}
|
||||
Reference in New Issue
Block a user