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)? /// Called with every finished transcription, just before it is inserted. public var onResult: ((DictationResult) -> 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 private let corrector: TextCorrecting? private let vocabularyProvider: () -> [String] private let biasMinimumRMS: Float /// - Parameter minimumSamples: 16 kHz samples; default is 0.3 s. /// - Parameter silenceThreshold: RMS below which a recording counts as silence and is skipped. /// - Parameter corrector: optional dictionary correction applied to the transcript. /// - Parameter vocabularyProvider: words to bias the transcriber towards. /// - Parameter biasMinimumRMS: below this RMS no vocabulary is passed (guards against invented text on quiet audio). public init( recorder: AudioRecording, transcriber: Transcriber, cleaner: Cleaner, inserter: TextInserting, minimumSamples: Int = 4_800, silenceThreshold: Float = 0.005, corrector: TextCorrecting? = nil, vocabularyProvider: @escaping () -> [String] = { [] }, biasMinimumRMS: Float = 0.015 ) { self.recorder = recorder self.transcriber = transcriber self.cleaner = cleaner self.inserter = inserter self.minimumSamples = minimumSamples self.silenceThreshold = silenceThreshold self.corrector = corrector self.vocabularyProvider = vocabularyProvider self.biasMinimumRMS = biasMinimumRMS } 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 } let rms = (energy / Float(samples.count)).squareRoot() guard rms >= silenceThreshold else { state = .idle return } await process(samples, rms: rms) default: break } } private func process(_ samples: [Float], rms: Float) async { defer { state = .idle } do { state = .transcribing let vocabulary = rms >= biasMinimumRMS ? vocabularyProvider() : [] let raw = try await transcriber.transcribe(samples, vocabulary: vocabulary) let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, !PromptEcho.isEcho(text, vocabulary: vocabulary) else { return } let corrected = corrector?.correct(text) ?? CorrectionResult(text: text, corrections: []) let cleaned = try await cleaner.clean(corrected.text, context: CleaningContext()) onResult?(DictationResult( date: Date(), duration: Double(samples.count) / 16_000, rawText: text, finalText: cleaned, // Offsets only stay valid if the cleaner left the corrected text alone. corrections: cleaned == corrected.text ? corrected.corrections : [] )) state = .inserting try await inserter.insert(cleaned) } catch { onError?(error.localizedDescription) } } }