diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84f6603 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.build/ +build/ +.DS_Store +*.xcodeproj diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..85c3f8a --- /dev/null +++ b/Package.swift @@ -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] +) diff --git a/Sources/SstCore/DictationController.swift b/Sources/SstCore/DictationController.swift new file mode 100644 index 0000000..0577f6e --- /dev/null +++ b/Sources/SstCore/DictationController.swift @@ -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) + } + } +} diff --git a/Sources/SstCore/Models.swift b/Sources/SstCore/Models.swift new file mode 100644 index 0000000..36444a2 --- /dev/null +++ b/Sources/SstCore/Models.swift @@ -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 +} diff --git a/Tests/SstCoreTests/DictationControllerTests.swift b/Tests/SstCoreTests/DictationControllerTests.swift new file mode 100644 index 0000000..3ba6832 --- /dev/null +++ b/Tests/SstCoreTests/DictationControllerTests.swift @@ -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) + } +} diff --git a/Tests/SstCoreTests/Fakes.swift b/Tests/SstCoreTests/Fakes.swift new file mode 100644 index 0000000..f8b2cf7 --- /dev/null +++ b/Tests/SstCoreTests/Fakes.swift @@ -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 = .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) + } +}