feat: show partial transcripts with subdued tint

main
Peter Steinberger 2025-12-08 16:44:00 +01:00
parent 7a0830de15
commit 367526f750
1 changed files with 47 additions and 13 deletions

View File

@ -22,6 +22,8 @@ actor VoiceWakeRuntime {
private var capturedTranscript: String = "" private var capturedTranscript: String = ""
private var isCapturing: Bool = false private var isCapturing: Bool = false
private var heardBeyondTrigger: Bool = false private var heardBeyondTrigger: Bool = false
private var committedTranscript: String = ""
private var volatileTranscript: String = ""
private var cooldownUntil: Date? private var cooldownUntil: Date?
private var currentConfig: RuntimeConfig? private var currentConfig: RuntimeConfig?
@ -97,7 +99,8 @@ actor VoiceWakeRuntime {
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
guard let self else { return } guard let self else { return }
let transcript = result?.bestTranscription.formattedString let transcript = result?.bestTranscription.formattedString
Task { await self.handleRecognition(transcript: transcript, error: error, config: config) } let isFinal = result?.isFinal ?? false
Task { await self.handleRecognition(transcript: transcript, isFinal: isFinal, error: error, config: config) }
} }
self.logger.info("voicewake runtime started") self.logger.info("voicewake runtime started")
@ -134,6 +137,7 @@ actor VoiceWakeRuntime {
private func handleRecognition( private func handleRecognition(
transcript: String?, transcript: String?,
isFinal: Bool,
error: Error?, error: Error?,
config: RuntimeConfig) async config: RuntimeConfig) async
{ {
@ -150,8 +154,18 @@ actor VoiceWakeRuntime {
let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers) let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers)
self.capturedTranscript = trimmed self.capturedTranscript = trimmed
self.updateHeardBeyondTrigger(withTrimmed: trimmed) self.updateHeardBeyondTrigger(withTrimmed: trimmed)
let snapshot = self.capturedTranscript if isFinal {
let attributed = Self.makeAttributed(transcript: snapshot, isFinal: false) self.committedTranscript = trimmed
self.volatileTranscript = ""
} else {
self.volatileTranscript = Self.delta(after: self.committedTranscript, current: trimmed)
}
let attributed = Self.makeAttributed(
committed: self.committedTranscript,
volatile: self.volatileTranscript,
isFinal: isFinal)
let snapshot = self.committedTranscript + self.volatileTranscript
await MainActor.run { await MainActor.run {
VoiceWakeOverlayController.shared.showPartial(transcript: snapshot, attributed: attributed) VoiceWakeOverlayController.shared.showPartial(transcript: snapshot, attributed: attributed)
} }
@ -183,12 +197,17 @@ actor VoiceWakeRuntime {
self.isCapturing = true self.isCapturing = true
let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers) let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers)
self.capturedTranscript = trimmed self.capturedTranscript = trimmed
self.committedTranscript = ""
self.volatileTranscript = trimmed
self.captureStartedAt = Date() self.captureStartedAt = Date()
self.cooldownUntil = nil self.cooldownUntil = nil
self.heardBeyondTrigger = !trimmed.isEmpty self.heardBeyondTrigger = !trimmed.isEmpty
let snapshot = self.capturedTranscript let snapshot = self.committedTranscript + self.volatileTranscript
let attributed = Self.makeAttributed(transcript: snapshot, isFinal: false) let attributed = Self.makeAttributed(
committed: self.committedTranscript,
volatile: self.volatileTranscript,
isFinal: false)
await MainActor.run { await MainActor.run {
VoiceWakeOverlayController.shared.showPartial(transcript: snapshot, attributed: attributed) VoiceWakeOverlayController.shared.showPartial(transcript: snapshot, attributed: attributed)
} }
@ -245,7 +264,10 @@ actor VoiceWakeRuntime {
let forwardConfig = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig } let forwardConfig = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
let delay: TimeInterval = (heardBeyondTrigger && !finalTranscript.isEmpty) ? 1.0 : 3.0 let delay: TimeInterval = (heardBeyondTrigger && !finalTranscript.isEmpty) ? 1.0 : 3.0
let finalAttributed = Self.makeAttributed(transcript: finalTranscript, isFinal: true) let finalAttributed = Self.makeAttributed(
committed: finalTranscript,
volatile: "",
isFinal: true)
await MainActor.run { await MainActor.run {
VoiceWakeOverlayController.shared.presentFinal( VoiceWakeOverlayController.shared.presentFinal(
transcript: finalTranscript, transcript: finalTranscript,
@ -258,14 +280,14 @@ actor VoiceWakeRuntime {
self.restartRecognizer() self.restartRecognizer()
} }
private func restartRecognizer() { private func restartRecognizer() {
// Restart the recognizer so we listen for the next trigger with a clean buffer. // Restart the recognizer so we listen for the next trigger with a clean buffer.
let current = self.currentConfig let current = self.currentConfig
self.stop() self.stop()
if let current { if let current {
Task { await self.start(with: current) } Task { await self.start(with: current) }
} }
} }
private func updateHeardBeyondTrigger(withTrimmed trimmed: String) { private func updateHeardBeyondTrigger(withTrimmed trimmed: String) {
if !self.heardBeyondTrigger, !trimmed.isEmpty { if !self.heardBeyondTrigger, !trimmed.isEmpty {
@ -295,7 +317,7 @@ private func restartRecognizer() {
} }
static func _testAttributedColor(isFinal: Bool) -> NSColor { static func _testAttributedColor(isFinal: Bool) -> NSColor {
self.makeAttributed(transcript: "sample", isFinal: isFinal) self.makeAttributed(committed: "sample", volatile: "", isFinal: isFinal)
.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear .attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear
} }
@ -304,9 +326,21 @@ private func restartRecognizer() {
} }
#endif #endif
private static func makeAttributed(transcript: String, isFinal: Bool) -> NSAttributedString { private static func delta(after committed: String, current: String) -> String {
let color: NSColor = isFinal ? .labelColor : .secondaryLabelColor if current.hasPrefix(committed) {
let attrs: [NSAttributedString.Key: Any] = [.foregroundColor: color] let start = current.index(current.startIndex, offsetBy: committed.count)
return NSAttributedString(string: transcript, attributes: attrs) return String(current[start...])
}
return current
}
private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString {
let full = NSMutableAttributedString()
let committedAttr: [NSAttributedString.Key: Any] = [.foregroundColor: NSColor.labelColor]
full.append(NSAttributedString(string: committed, attributes: committedAttr))
let volatileColor: NSColor = isFinal ? .labelColor : .secondaryLabelColor
let volatileAttr: [NSAttributedString.Key: Any] = [.foregroundColor: volatileColor]
full.append(NSAttributedString(string: volatile, attributes: volatileAttr))
return full
} }
} }