feat: tint partial transcripts and stabilize delays

main
Peter Steinberger 2025-12-08 16:41:33 +01:00
parent a5fbfa3748
commit 7a0830de15
2 changed files with 38 additions and 12 deletions

View File

@ -15,6 +15,7 @@ final class VoiceWakeOverlayController: ObservableObject {
var isVisible: Bool = false var isVisible: Bool = false
var forwardEnabled: Bool = false var forwardEnabled: Bool = false
var isSending: Bool = false var isSending: Bool = false
var attributed: NSAttributedString = NSAttributedString(string: "")
} }
private var window: NSPanel? private var window: NSPanel?
@ -25,23 +26,25 @@ final class VoiceWakeOverlayController: ObservableObject {
private let width: CGFloat = 360 private let width: CGFloat = 360
private let padding: CGFloat = 10 private let padding: CGFloat = 10
func showPartial(transcript: String) { func showPartial(transcript: String, attributed: NSAttributedString? = nil) {
self.autoSendTask?.cancel() self.autoSendTask?.cancel()
self.forwardConfig = nil self.forwardConfig = nil
self.model.text = transcript self.model.text = transcript
self.model.isFinal = false self.model.isFinal = false
self.model.forwardEnabled = false self.model.forwardEnabled = false
self.model.isSending = false self.model.isSending = false
self.model.attributed = attributed ?? NSAttributedString(string: transcript)
self.present() self.present()
} }
func presentFinal(transcript: String, forwardConfig: VoiceWakeForwardConfig, delay: TimeInterval) { func presentFinal(transcript: String, forwardConfig: VoiceWakeForwardConfig, delay: TimeInterval, attributed: NSAttributedString? = nil) {
self.autoSendTask?.cancel() self.autoSendTask?.cancel()
self.forwardConfig = forwardConfig self.forwardConfig = forwardConfig
self.model.text = transcript self.model.text = transcript
self.model.isFinal = true self.model.isFinal = true
self.model.forwardEnabled = forwardConfig.enabled self.model.forwardEnabled = forwardConfig.enabled
self.model.isSending = false self.model.isSending = false
self.model.attributed = attributed ?? NSAttributedString(string: transcript)
self.present() self.present()
self.scheduleAutoSend(after: delay) self.scheduleAutoSend(after: delay)
} }
@ -54,6 +57,7 @@ final class VoiceWakeOverlayController: ObservableObject {
func updateText(_ text: String) { func updateText(_ text: String) {
self.model.text = text self.model.text = text
self.model.isSending = false self.model.isSending = false
self.model.attributed = NSAttributedString(string: text)
self.updateWindowFrame(animate: true) self.updateWindowFrame(animate: true)
} }
@ -198,6 +202,7 @@ private struct VoiceWakeOverlayView: View {
text: Binding( text: Binding(
get: { self.controller.model.text }, get: { self.controller.model.text },
set: { self.controller.updateText($0) }), set: { self.controller.updateText($0) }),
attributed: self.controller.model.attributed,
isFinal: self.controller.model.isFinal, isFinal: self.controller.model.isFinal,
onBeginEditing: { onBeginEditing: {
self.controller.userBeganEditing() self.controller.userBeganEditing()
@ -249,6 +254,7 @@ private struct VoiceWakeOverlayView: View {
private struct TranscriptTextView: NSViewRepresentable { private struct TranscriptTextView: NSViewRepresentable {
@Binding var text: String @Binding var text: String
var attributed: NSAttributedString
var isFinal: Bool var isFinal: Bool
var onBeginEditing: () -> Void var onBeginEditing: () -> Void
var onSend: () -> Void var onSend: () -> Void
@ -286,10 +292,14 @@ private struct TranscriptTextView: NSViewRepresentable {
func updateNSView(_ scrollView: NSScrollView, context: Context) { func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? TranscriptNSTextView else { return } guard let textView = scrollView.documentView as? TranscriptNSTextView else { return }
if textView.string != self.text { let isEditing = scrollView.window?.firstResponder == textView
textView.string = self.text if isEditing {
if textView.string != self.text {
textView.string = self.text
}
} else {
textView.textStorage?.setAttributedString(self.attributed)
} }
textView.textColor = self.isFinal ? .labelColor : .secondaryLabelColor
} }
final class Coordinator: NSObject, NSTextViewDelegate { final class Coordinator: NSObject, NSTextViewDelegate {

View File

@ -2,6 +2,9 @@ import AVFoundation
import Foundation import Foundation
import OSLog import OSLog
import Speech import Speech
#if canImport(AppKit)
import AppKit
#endif
/// Background listener that keeps the voice-wake pipeline alive outside the settings test view. /// Background listener that keeps the voice-wake pipeline alive outside the settings test view.
actor VoiceWakeRuntime { actor VoiceWakeRuntime {
@ -148,8 +151,9 @@ actor VoiceWakeRuntime {
self.capturedTranscript = trimmed self.capturedTranscript = trimmed
self.updateHeardBeyondTrigger(withTrimmed: trimmed) self.updateHeardBeyondTrigger(withTrimmed: trimmed)
let snapshot = self.capturedTranscript let snapshot = self.capturedTranscript
let attributed = Self.makeAttributed(transcript: snapshot, isFinal: false)
await MainActor.run { await MainActor.run {
VoiceWakeOverlayController.shared.showPartial(transcript: snapshot) VoiceWakeOverlayController.shared.showPartial(transcript: snapshot, attributed: attributed)
} }
} }
} }
@ -184,8 +188,9 @@ actor VoiceWakeRuntime {
self.heardBeyondTrigger = !trimmed.isEmpty self.heardBeyondTrigger = !trimmed.isEmpty
let snapshot = self.capturedTranscript let snapshot = self.capturedTranscript
let attributed = Self.makeAttributed(transcript: snapshot, isFinal: false)
await MainActor.run { await MainActor.run {
VoiceWakeOverlayController.shared.showPartial(transcript: snapshot) VoiceWakeOverlayController.shared.showPartial(transcript: snapshot, attributed: attributed)
} }
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
@ -240,25 +245,27 @@ 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)
await MainActor.run { await MainActor.run {
VoiceWakeOverlayController.shared.presentFinal( VoiceWakeOverlayController.shared.presentFinal(
transcript: finalTranscript, transcript: finalTranscript,
forwardConfig: forwardConfig, forwardConfig: forwardConfig,
delay: delay) delay: delay,
attributed: finalAttributed)
} }
self.cooldownUntil = Date().addingTimeInterval(self.debounceAfterSend) self.cooldownUntil = Date().addingTimeInterval(self.debounceAfterSend)
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 {
@ -286,11 +293,20 @@ actor VoiceWakeRuntime {
static func _testHasContentAfterTrigger(_ text: String, triggers: [String]) -> Bool { static func _testHasContentAfterTrigger(_ text: String, triggers: [String]) -> Bool {
!self.trimmedAfterTrigger(text, triggers: triggers).isEmpty !self.trimmedAfterTrigger(text, triggers: triggers).isEmpty
} }
#endif
#if DEBUG static func _testAttributedColor(isFinal: Bool) -> NSColor {
self.makeAttributed(transcript: "sample", isFinal: isFinal)
.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear
}
static func _testMatches(text: String, triggers: [String]) -> Bool { static func _testMatches(text: String, triggers: [String]) -> Bool {
self.matches(text: text, triggers: triggers) self.matches(text: text, triggers: triggers)
} }
#endif #endif
private static func makeAttributed(transcript: String, isFinal: Bool) -> NSAttributedString {
let color: NSColor = isFinal ? .labelColor : .secondaryLabelColor
let attrs: [NSAttributedString.Key: Any] = [.foregroundColor: color]
return NSAttributedString(string: transcript, attributes: attrs)
}
} }