feat: add package scaffold and DictationController state machine

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 09:39:56 +02:00
co-authored by Claude Sonnet 5.5
parent aa20a5941f
commit 6ab94cfdd0
6 changed files with 282 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.build/
build/
.DS_Store
*.xcodeproj
+12
View File
@@ -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]
)
+67
View File
@@ -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)
}
}
}
+35
View File
@@ -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)
}
}
+43
View File
@@ -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)
}
}