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,4 @@
|
|||||||
|
.build/
|
||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
|
*.xcodeproj
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "Sst",
|
||||||
|
platforms: [.macOS(.v14)],
|
||||||
|
targets: [
|
||||||
|
.target(name: "SstCore"),
|
||||||
|
.testTarget(name: "SstCoreTests", dependencies: ["SstCore"]),
|
||||||
|
],
|
||||||
|
swiftLanguageModes: [.v5]
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SstCore
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct DictationControllerTests {
|
||||||
|
let recorder = FakeRecorder()
|
||||||
|
let transcriber = FakeTranscriber()
|
||||||
|
let cleaner = FakeCleaner()
|
||||||
|
let inserter = FakeInserter()
|
||||||
|
static let oneSecond = [Float](repeating: 0.1, count: 16_000)
|
||||||
|
|
||||||
|
func makeController() -> DictationController {
|
||||||
|
DictationController(
|
||||||
|
recorder: recorder,
|
||||||
|
transcriber: transcriber,
|
||||||
|
cleaner: cleaner,
|
||||||
|
inserter: inserter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pressAndReleaseTranscribesCleansAndInserts() async {
|
||||||
|
recorder.samples = Self.oneSecond
|
||||||
|
let controller = makeController()
|
||||||
|
var states: [DictationState] = []
|
||||||
|
controller.onStateChange = { states.append($0) }
|
||||||
|
|
||||||
|
await controller.handle(.pressed)
|
||||||
|
await controller.handle(.released)
|
||||||
|
|
||||||
|
#expect(states == [.recording, .transcribing, .inserting, .idle])
|
||||||
|
#expect(inserter.inserted == ["[hallo wereld]"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func shortRecordingIsDiscarded() async {
|
||||||
|
recorder.samples = [Float](repeating: 0.1, count: 1_000)
|
||||||
|
let controller = makeController()
|
||||||
|
var states: [DictationState] = []
|
||||||
|
controller.onStateChange = { states.append($0) }
|
||||||
|
|
||||||
|
await controller.handle(.pressed)
|
||||||
|
await controller.handle(.released)
|
||||||
|
|
||||||
|
#expect(states == [.recording, .idle])
|
||||||
|
#expect(transcriber.callCount == 0)
|
||||||
|
#expect(inserter.inserted.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyTranscriptionIsNotInserted() async {
|
||||||
|
recorder.samples = Self.oneSecond
|
||||||
|
transcriber.result = .success(" \n")
|
||||||
|
let controller = makeController()
|
||||||
|
var errors: [String] = []
|
||||||
|
controller.onError = { errors.append($0) }
|
||||||
|
|
||||||
|
await controller.handle(.pressed)
|
||||||
|
await controller.handle(.released)
|
||||||
|
|
||||||
|
#expect(inserter.inserted.isEmpty)
|
||||||
|
#expect(errors.isEmpty)
|
||||||
|
#expect(controller.state == .idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func transcriberErrorReportsAndReturnsToIdle() async {
|
||||||
|
recorder.samples = Self.oneSecond
|
||||||
|
transcriber.result = .failure(TestError())
|
||||||
|
let controller = makeController()
|
||||||
|
var errors: [String] = []
|
||||||
|
controller.onError = { errors.append($0) }
|
||||||
|
|
||||||
|
await controller.handle(.pressed)
|
||||||
|
await controller.handle(.released)
|
||||||
|
|
||||||
|
#expect(errors.count == 1)
|
||||||
|
#expect(inserter.inserted.isEmpty)
|
||||||
|
#expect(controller.state == .idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inserterErrorReportsAndReturnsToIdle() async {
|
||||||
|
recorder.samples = Self.oneSecond
|
||||||
|
inserter.error = TestError()
|
||||||
|
let controller = makeController()
|
||||||
|
var errors: [String] = []
|
||||||
|
controller.onError = { errors.append($0) }
|
||||||
|
|
||||||
|
await controller.handle(.pressed)
|
||||||
|
await controller.handle(.released)
|
||||||
|
|
||||||
|
#expect(errors.count == 1)
|
||||||
|
#expect(controller.state == .idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func recorderStartFailureStaysIdle() async {
|
||||||
|
recorder.startError = TestError()
|
||||||
|
let controller = makeController()
|
||||||
|
var errors: [String] = []
|
||||||
|
controller.onError = { errors.append($0) }
|
||||||
|
|
||||||
|
await controller.handle(.pressed)
|
||||||
|
|
||||||
|
#expect(errors.count == 1)
|
||||||
|
#expect(controller.state == .idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func secondPressWhileRecordingIsIgnored() async {
|
||||||
|
let controller = makeController()
|
||||||
|
await controller.handle(.pressed)
|
||||||
|
await controller.handle(.pressed)
|
||||||
|
#expect(recorder.startCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func releaseWithoutPressIsIgnored() async {
|
||||||
|
let controller = makeController()
|
||||||
|
var states: [DictationState] = []
|
||||||
|
controller.onStateChange = { states.append($0) }
|
||||||
|
|
||||||
|
await controller.handle(.released)
|
||||||
|
|
||||||
|
#expect(states.isEmpty)
|
||||||
|
#expect(transcriber.callCount == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
@testable import SstCore
|
||||||
|
|
||||||
|
struct TestError: Error {}
|
||||||
|
|
||||||
|
final class FakeRecorder: AudioRecording {
|
||||||
|
var samples: [Float] = []
|
||||||
|
var startError: Error?
|
||||||
|
private(set) var startCount = 0
|
||||||
|
|
||||||
|
func start() throws {
|
||||||
|
startCount += 1
|
||||||
|
if let startError { throw startError }
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() -> [Float] { samples }
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FakeTranscriber: Transcriber {
|
||||||
|
var result: Result<String, Error> = .success("hallo wereld")
|
||||||
|
private(set) var callCount = 0
|
||||||
|
|
||||||
|
func transcribe(_ samples: [Float]) async throws -> String {
|
||||||
|
callCount += 1
|
||||||
|
return try result.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FakeCleaner: Cleaner {
|
||||||
|
func clean(_ text: String, context: CleaningContext) async throws -> String {
|
||||||
|
"[\(text)]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FakeInserter: TextInserting {
|
||||||
|
var error: Error?
|
||||||
|
private(set) var inserted: [String] = []
|
||||||
|
|
||||||
|
func insert(_ text: String) async throws {
|
||||||
|
if let error { throw error }
|
||||||
|
inserted.append(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user