feat: tint partial transcripts and stabilize delays
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user