feat: tint partial transcripts and stabilize delays

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

View File

@@ -2,6 +2,9 @@ import AVFoundation
import Foundation
import OSLog
import Speech
#if canImport(AppKit)
import AppKit
#endif
/// Background listener that keeps the voice-wake pipeline alive outside the settings test view.
actor VoiceWakeRuntime {
@@ -148,8 +151,9 @@ actor VoiceWakeRuntime {
self.capturedTranscript = trimmed
self.updateHeardBeyondTrigger(withTrimmed: trimmed)
let snapshot = self.capturedTranscript
let attributed = Self.makeAttributed(transcript: snapshot, isFinal: false)
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
let snapshot = self.capturedTranscript
let attributed = Self.makeAttributed(transcript: snapshot, isFinal: false)
await MainActor.run {
VoiceWakeOverlayController.shared.showPartial(transcript: snapshot)
VoiceWakeOverlayController.shared.showPartial(transcript: snapshot, attributed: attributed)
}
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
@@ -240,25 +245,27 @@ actor VoiceWakeRuntime {
let forwardConfig = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
let delay: TimeInterval = (heardBeyondTrigger && !finalTranscript.isEmpty) ? 1.0 : 3.0
let finalAttributed = Self.makeAttributed(transcript: finalTranscript, isFinal: true)
await MainActor.run {
VoiceWakeOverlayController.shared.presentFinal(
transcript: finalTranscript,
forwardConfig: forwardConfig,
delay: delay)
delay: delay,
attributed: finalAttributed)
}
self.cooldownUntil = Date().addingTimeInterval(self.debounceAfterSend)
self.restartRecognizer()
}
private func restartRecognizer() {
private func restartRecognizer() {
// Restart the recognizer so we listen for the next trigger with a clean buffer.
let current = self.currentConfig
self.stop()
if let current {
Task { await self.start(with: current) }
}
}
}
private func updateHeardBeyondTrigger(withTrimmed trimmed: String) {
if !self.heardBeyondTrigger, !trimmed.isEmpty {
@@ -286,11 +293,20 @@ actor VoiceWakeRuntime {
static func _testHasContentAfterTrigger(_ text: String, triggers: [String]) -> Bool {
!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 {
self.matches(text: text, triggers: triggers)
}
#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)
}
}