feat: tint partial transcripts and stabilize delays
parent
a5fbfa3748
commit
7a0830de15
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue