fix(voice): unify overlay send flow
parent
cf2b659491
commit
657450c40c
|
|
@ -288,7 +288,9 @@ actor VoicePushToTalk {
|
||||||
VoiceWakeChimePlayer.play(chime, reason: "ptt.fallback_send")
|
VoiceWakeChimePlayer.play(chime, reason: "ptt.fallback_send")
|
||||||
}
|
}
|
||||||
Task.detached {
|
Task.detached {
|
||||||
await VoiceWakeForwarder.forward(transcript: finalText, config: forward)
|
await VoiceWakeForwarder.forward(
|
||||||
|
transcript: VoiceWakeForwarder.prefixedTranscript(finalText),
|
||||||
|
config: forward)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ final class VoiceSessionCoordinator: ObservableObject {
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.coordinator")
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.coordinator")
|
||||||
private var session: Session?
|
private var session: Session?
|
||||||
private var autoSendTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
// MARK: - API
|
// MARK: - API
|
||||||
|
|
||||||
|
|
@ -40,7 +39,7 @@ final class VoiceSessionCoordinator: ObservableObject {
|
||||||
let token = UUID()
|
let token = UUID()
|
||||||
self.logger.info("coordinator start token=\(token.uuidString) source=\(source.rawValue) len=\(text.count)")
|
self.logger.info("coordinator start token=\(token.uuidString) source=\(source.rawValue) len=\(text.count)")
|
||||||
let attributedText = attributed ?? VoiceWakeOverlayController.shared.makeAttributed(from: text)
|
let attributedText = attributed ?? VoiceWakeOverlayController.shared.makeAttributed(from: text)
|
||||||
self.session = Session(
|
let session = Session(
|
||||||
token: token,
|
token: token,
|
||||||
source: source,
|
source: source,
|
||||||
text: text,
|
text: text,
|
||||||
|
|
@ -49,7 +48,9 @@ final class VoiceSessionCoordinator: ObservableObject {
|
||||||
forwardConfig: forwardEnabled ? AppStateStore.shared.voiceWakeForwardConfig : nil,
|
forwardConfig: forwardEnabled ? AppStateStore.shared.voiceWakeForwardConfig : nil,
|
||||||
sendChime: .none,
|
sendChime: .none,
|
||||||
autoSendDelay: nil)
|
autoSendDelay: nil)
|
||||||
|
self.session = session
|
||||||
VoiceWakeOverlayController.shared.startSession(
|
VoiceWakeOverlayController.shared.startSession(
|
||||||
|
token: token,
|
||||||
source: VoiceWakeOverlayController.Source(rawValue: source.rawValue) ?? .wakeWord,
|
source: VoiceWakeOverlayController.Source(rawValue: source.rawValue) ?? .wakeWord,
|
||||||
transcript: text,
|
transcript: text,
|
||||||
attributed: attributedText,
|
attributed: attributedText,
|
||||||
|
|
@ -76,7 +77,6 @@ final class VoiceSessionCoordinator: ObservableObject {
|
||||||
self.logger
|
self.logger
|
||||||
.info(
|
.info(
|
||||||
"coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)")
|
"coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)")
|
||||||
self.autoSendTask?.cancel(); self.autoSendTask = nil
|
|
||||||
self.session?.text = text
|
self.session?.text = text
|
||||||
self.session?.isFinal = true
|
self.session?.isFinal = true
|
||||||
self.session?.forwardConfig = forwardConfig
|
self.session?.forwardConfig = forwardConfig
|
||||||
|
|
@ -108,7 +108,7 @@ final class VoiceSessionCoordinator: ObservableObject {
|
||||||
self.clearSession()
|
self.clearSession()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
VoiceWakeOverlayController.shared.sendNow(token: token, sendChime: session.sendChime)
|
VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: session.sendChime)
|
||||||
Task.detached {
|
Task.detached {
|
||||||
_ = await VoiceWakeForwarder.forward(
|
_ = await VoiceWakeForwarder.forward(
|
||||||
transcript: VoiceWakeForwarder.prefixedTranscript(text),
|
transcript: VoiceWakeForwarder.prefixedTranscript(text),
|
||||||
|
|
@ -139,7 +139,5 @@ final class VoiceSessionCoordinator: ObservableObject {
|
||||||
|
|
||||||
private func clearSession() {
|
private func clearSession() {
|
||||||
self.session = nil
|
self.session = nil
|
||||||
self.autoSendTask?.cancel()
|
|
||||||
self.autoSendTask = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func startSession(
|
func startSession(
|
||||||
|
token: UUID = UUID(),
|
||||||
source: Source,
|
source: Source,
|
||||||
transcript: String,
|
transcript: String,
|
||||||
attributed: NSAttributedString? = nil,
|
attributed: NSAttributedString? = nil,
|
||||||
|
|
@ -56,7 +57,6 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
self.logger.log(level: .info, "overlay drop session_start while sending")
|
self.logger.log(level: .info, "overlay drop session_start while sending")
|
||||||
return self.activeToken ?? UUID()
|
return self.activeToken ?? UUID()
|
||||||
}
|
}
|
||||||
let token = UUID()
|
|
||||||
let message = """
|
let message = """
|
||||||
overlay session_start source=\(source.rawValue) \
|
overlay session_start source=\(source.rawValue) \
|
||||||
len=\(transcript.count)
|
len=\(transcript.count)
|
||||||
|
|
@ -133,9 +133,9 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
if let delay {
|
if let delay {
|
||||||
if delay <= 0 {
|
if delay <= 0 {
|
||||||
self.logger.log(level: .info, "overlay autoSend immediate token=\(token.uuidString)")
|
self.logger.log(level: .info, "overlay autoSend immediate token=\(token.uuidString)")
|
||||||
self.sendNow(token: token, sendChime: sendChime)
|
VoiceSessionCoordinator.shared.sendNow(token: token, reason: "autoSendImmediate")
|
||||||
} else {
|
} else {
|
||||||
self.scheduleAutoSend(token: token, after: delay, sendChime: sendChime)
|
self.scheduleAutoSend(token: token, after: delay)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -164,50 +164,41 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
self.updateWindowFrame(animate: true)
|
self.updateWindowFrame(animate: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendNow(token: UUID? = nil, sendChime: VoiceWakeChime = .none) {
|
/// UI-only path: show sending state and dismiss; actual forwarding is handled by the coordinator.
|
||||||
guard self.guardToken(token, context: "send") else { return }
|
func beginSendUI(token: UUID, sendChime: VoiceWakeChime = .none) {
|
||||||
|
guard self.guardToken(token, context: "beginSendUI") else { return }
|
||||||
|
self.autoSendTask?.cancel(); self.autoSendToken = nil
|
||||||
let message = """
|
let message = """
|
||||||
overlay sendNow called token=\(self.activeToken?.uuidString ?? "nil") \
|
overlay beginSendUI token=\(token.uuidString) \
|
||||||
isSending=\(self.model.isSending) \
|
isSending=\(self.model.isSending) \
|
||||||
forwardEnabled=\(self.model.forwardEnabled) \
|
forwardEnabled=\(self.model.forwardEnabled) \
|
||||||
textLen=\(self.model.text.count)
|
textLen=\(self.model.text.count)
|
||||||
"""
|
"""
|
||||||
self.logger.log(level: .info, "\(message)")
|
self.logger.log(level: .info, "\(message)")
|
||||||
self.autoSendTask?.cancel(); self.autoSendToken = nil
|
|
||||||
if self.model.isSending { return }
|
if self.model.isSending { return }
|
||||||
self.model.isEditing = false
|
self.model.isEditing = false
|
||||||
guard let forwardConfig, forwardConfig.enabled else {
|
|
||||||
self.logger.log(level: .info, "overlay sendNow disabled -> dismiss")
|
|
||||||
self.dismiss(reason: .explicit)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let text = self.model.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !text.isEmpty else {
|
|
||||||
self.logger.log(level: .info, "overlay sendNow empty -> dismiss")
|
|
||||||
self.dismiss(reason: .empty)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if sendChime != .none {
|
if sendChime != .none {
|
||||||
let message = "overlay sendNow playing sendChime=\(String(describing: sendChime))"
|
let message = "overlay beginSendUI playing sendChime=\(String(describing: sendChime))"
|
||||||
self.logger.log(level: .info, "\(message)")
|
self.logger.log(level: .info, "\(message)")
|
||||||
VoiceWakeChimePlayer.play(sendChime, reason: "overlay.send")
|
VoiceWakeChimePlayer.play(sendChime, reason: "overlay.send")
|
||||||
}
|
}
|
||||||
|
|
||||||
self.model.isSending = true
|
self.model.isSending = true
|
||||||
let payload = VoiceWakeForwarder.prefixedTranscript(text)
|
|
||||||
self.logger.log(level: .info, "overlay sendNow forwarding len=\(payload.count, privacy: .public)")
|
|
||||||
Task.detached {
|
|
||||||
await VoiceWakeForwarder.forward(transcript: payload, config: forwardConfig)
|
|
||||||
}
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) {
|
||||||
self.logger.log(
|
self.logger.log(
|
||||||
level: .info,
|
level: .info,
|
||||||
"overlay sendNow dismiss ticking token=\(self.activeToken?.uuidString ?? "nil")")
|
"overlay beginSendUI dismiss ticking token=\(self.activeToken?.uuidString ?? "nil")")
|
||||||
self.dismiss(token: token, reason: .explicit, outcome: .sent)
|
self.dismiss(token: token, reason: .explicit, outcome: .sent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requestSend(token: UUID? = nil, reason: String = "overlay_request") {
|
||||||
|
guard self.guardToken(token, context: "requestSend") else { return }
|
||||||
|
guard let active = token ?? self.activeToken else { return }
|
||||||
|
VoiceSessionCoordinator.shared.sendNow(token: active, reason: reason)
|
||||||
|
}
|
||||||
|
|
||||||
func dismiss(token: UUID? = nil, reason: DismissReason = .explicit, outcome: SendOutcome = .empty) {
|
func dismiss(token: UUID? = nil, reason: DismissReason = .explicit, outcome: SendOutcome = .empty) {
|
||||||
guard self.guardToken(token, context: "dismiss") else { return }
|
guard self.guardToken(token, context: "dismiss") else { return }
|
||||||
let message = """
|
let message = """
|
||||||
|
|
@ -408,17 +399,16 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleAutoSend(token: UUID, after delay: TimeInterval, sendChime: VoiceWakeChime) {
|
private func scheduleAutoSend(token: UUID, after delay: TimeInterval) {
|
||||||
self.logger.log(
|
self.logger.log(
|
||||||
level: .info,
|
level: .info,
|
||||||
"""
|
"""
|
||||||
overlay scheduleAutoSend token=\(token.uuidString) \
|
overlay scheduleAutoSend token=\(token.uuidString) \
|
||||||
after=\(delay) \
|
after=\(delay)
|
||||||
sendChime=\(String(describing: sendChime))
|
|
||||||
""")
|
""")
|
||||||
self.autoSendTask?.cancel()
|
self.autoSendTask?.cancel()
|
||||||
self.autoSendToken = token
|
self.autoSendToken = token
|
||||||
self.autoSendTask = Task<Void, Never> { [weak self, sendChime, token] in
|
self.autoSendTask = Task<Void, Never> { [weak self, token] in
|
||||||
let nanos = UInt64(max(0, delay) * 1_000_000_000)
|
let nanos = UInt64(max(0, delay) * 1_000_000_000)
|
||||||
try? await Task.sleep(nanoseconds: nanos)
|
try? await Task.sleep(nanoseconds: nanos)
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|
@ -428,7 +418,7 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
self.logger.log(
|
self.logger.log(
|
||||||
level: .info,
|
level: .info,
|
||||||
"overlay autoSend firing token=\(token.uuidString, privacy: .public)")
|
"overlay autoSend firing token=\(token.uuidString, privacy: .public)")
|
||||||
self.sendNow(token: token, sendChime: sendChime)
|
VoiceSessionCoordinator.shared.sendNow(token: token, reason: "autoSendDelay")
|
||||||
self.autoSendTask = nil
|
self.autoSendTask = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -471,7 +461,7 @@ private struct VoiceWakeOverlayView: View {
|
||||||
self.controller.endEditing()
|
self.controller.endEditing()
|
||||||
},
|
},
|
||||||
onSend: {
|
onSend: {
|
||||||
self.controller.sendNow()
|
self.controller.requestSend()
|
||||||
})
|
})
|
||||||
.focused(self.$textFocused)
|
.focused(self.$textFocused)
|
||||||
.frame(maxWidth: .infinity, minHeight: 32, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, minHeight: 32, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
|
@ -488,7 +478,7 @@ private struct VoiceWakeOverlayView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
self.controller.sendNow()
|
self.controller.requestSend()
|
||||||
} label: {
|
} label: {
|
||||||
let sending = self.controller.model.isSending
|
let sending = self.controller.model.isSending
|
||||||
let level = self.controller.model.level
|
let level = self.controller.model.level
|
||||||
|
|
|
||||||
|
|
@ -351,7 +351,9 @@ actor VoiceWakeRuntime {
|
||||||
await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "voicewake.send") }
|
await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "voicewake.send") }
|
||||||
}
|
}
|
||||||
Task.detached {
|
Task.detached {
|
||||||
await VoiceWakeForwarder.forward(transcript: finalTranscript, config: forwardConfig)
|
await VoiceWakeForwarder.forward(
|
||||||
|
transcript: VoiceWakeForwarder.prefixedTranscript(finalTranscript),
|
||||||
|
config: forwardConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.overlayToken = nil
|
self.overlayToken = nil
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue