102 lines
3.9 KiB
Swift
102 lines
3.9 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)?
|
|
/// 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)
|
|
}
|
|
}
|
|
}
|