feat: controller supports vocabulary bias, prompt-echo guard, corrector and result hook
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ public final class DictationController {
|
||||
}
|
||||
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
|
||||
@@ -14,16 +16,25 @@ public final class DictationController {
|
||||
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
|
||||
silenceThreshold: Float = 0.005,
|
||||
corrector: TextCorrecting? = nil,
|
||||
vocabularyProvider: @escaping () -> [String] = { [] },
|
||||
biasMinimumRMS: Float = 0.015
|
||||
) {
|
||||
self.recorder = recorder
|
||||
self.transcriber = transcriber
|
||||
@@ -31,6 +42,9 @@ public final class DictationController {
|
||||
self.inserter = inserter
|
||||
self.minimumSamples = minimumSamples
|
||||
self.silenceThreshold = silenceThreshold
|
||||
self.corrector = corrector
|
||||
self.vocabularyProvider = vocabularyProvider
|
||||
self.biasMinimumRMS = biasMinimumRMS
|
||||
}
|
||||
|
||||
public func handle(_ event: HotkeyEvent) async {
|
||||
@@ -49,24 +63,35 @@ public final class DictationController {
|
||||
return
|
||||
}
|
||||
let energy = samples.reduce(0) { $0 + $1 * $1 }
|
||||
guard (energy / Float(samples.count)).squareRoot() >= silenceThreshold else {
|
||||
let rms = (energy / Float(samples.count)).squareRoot()
|
||||
guard rms >= silenceThreshold else {
|
||||
state = .idle
|
||||
return
|
||||
}
|
||||
await process(samples)
|
||||
await process(samples, rms: rms)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func process(_ samples: [Float]) async {
|
||||
private func process(_ samples: [Float], rms: Float) async {
|
||||
defer { state = .idle }
|
||||
do {
|
||||
state = .transcribing
|
||||
let raw = try await transcriber.transcribe(samples)
|
||||
let vocabulary = rms >= biasMinimumRMS ? vocabularyProvider() : []
|
||||
let raw = try await transcriber.transcribe(samples, vocabulary: vocabulary)
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
let cleaned = try await cleaner.clean(text, context: CleaningContext())
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
/// Whisper sometimes answers a vocabulary prompt with the prompt itself when there is little speech.
|
||||
public enum PromptEcho {
|
||||
static func normalize(_ text: String) -> String {
|
||||
String(text.lowercased().map { $0.isLetter || $0.isNumber ? $0 : " " })
|
||||
.split(separator: " ")
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
/// True when `text` is nothing but the first k ≥ 2 terms of the prompt.
|
||||
/// A single term on its own is valid speech ("Anthropic").
|
||||
public static func isEcho(_ text: String, vocabulary: [String]) -> Bool {
|
||||
guard vocabulary.count >= 2 else { return false }
|
||||
let normalized = normalize(text)
|
||||
guard !normalized.isEmpty else { return false }
|
||||
return (2...vocabulary.count).contains { k in
|
||||
normalize(vocabulary.prefix(k).joined(separator: " ")) == normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user