Files
sst/Sources/SstCore/DictationController.swift
T
2026-10-01 09:58:09 +02:00

77 lines
2.5 KiB
Swift

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
private let silenceThreshold: Float
/// - Parameter minimumSamples: 16 kHz samples; default is 0.3 s.
/// - Parameter silenceThreshold: RMS below which a recording counts as silence and is skipped.
public init(
recorder: AudioRecording,
transcriber: Transcriber,
cleaner: Cleaner,
inserter: TextInserting,
minimumSamples: Int = 4_800,
silenceThreshold: Float = 0.005
) {
self.recorder = recorder
self.transcriber = transcriber
self.cleaner = cleaner
self.inserter = inserter
self.minimumSamples = minimumSamples
self.silenceThreshold = silenceThreshold
}
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
}
let energy = samples.reduce(0) { $0 + $1 * $1 }
guard (energy / Float(samples.count)).squareRoot() >= silenceThreshold 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)
}
}
}