Skip to content

Commit 56fd90a

Browse files
NeoNeo
authored andcommitted
feat(voice V5): turn-taking + barge-in + .record(STT)<->.playback(TTS) session handoff
The integration-risk piece. STT forces the shared AVAudioSession to .record; TTS needs .playback. Handled by MUTUAL OVERRIDE — each side stops the other and re-asserts its own category before its operation, so they never fight or deadlock: - startVoiceInput() stops TTS first (trinityVoice.stop()) → SpeechTranscriber sets .record+active. This is also BARGE-IN: tapping the mic while Trinity speaks cuts the reply and starts listening, and the mic never captures Trinity's own voice. - speakIfEnabled() stops STT first (if isListening) → TrinityVoice.speak() now re-asserts .playback via configureAudioSession() before speaking, flipping the session back from .record. isSpeaking is published for UI. The shared stop()/CarPlay path is untouched (the re-assert lives in speak()). Money-isolated: 0 signing calls. App build green. Held. DEVICE-TEST gate applies — session transitions (mic-after-TTS, TTS-after-mic, barge-in) only fully verify on a real device.
1 parent 80e292c commit 56fd90a

2 files changed

Lines changed: 18 additions & 1 deletion

File tree

Core/Trinity/TrinityVoice.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ final class TrinityVoice: NSObject, @unchecked Sendable {
9999
/// Speak the given text using Trinity's voice profile.
100100
/// - Parameter text: The text to speak.
101101
func speak(_ text: String) async {
102+
// V5 — re-assert the .playback session before speaking. The mic (STT) switches the shared
103+
// AVAudioSession to .record; this flips it back to .playback so the reply plays on the right
104+
// route. Idempotent for back-to-back replies; the mic side stops TTS before it records, so
105+
// the two never fight over the session.
106+
configureAudioSession()
102107
// Stop any current speech
103108
if synthesizer.isSpeaking {
104109
synthesizer.stopSpeaking(at: .word)

UI/Views/Agent/AgentConversationViewModel.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ final class AgentConversationViewModel: ObservableObject {
5454

5555
func startVoiceInput() {
5656
voiceError = nil
57+
// V5 — barge-in + clean handoff: stop any in-progress speech before recording, so the
58+
// session moves .playback → .record and the mic never captures Trinity's own voice.
59+
trinityVoice.stop()
60+
isSpeaking = false
5761
transcriber.requestAuthorization { [weak self] granted in
5862
guard let self else { return }
5963
guard granted else {
@@ -93,6 +97,7 @@ final class AgentConversationViewModel: ObservableObject {
9397

9498
// MARK: - Voice output (V4 — on-device TTS, Tier 1)
9599
@Published var voiceOutputEnabled = false
100+
@Published var isSpeaking = false
96101
private let trinityVoice = TrinityVoice()
97102

98103
func toggleVoiceOutput() {
@@ -110,7 +115,14 @@ final class AgentConversationViewModel: ObservableObject {
110115
guard !spoken.isEmpty else { return }
111116
let lang = NaturalLanguageProcessor.shared.languageProfile(for: spoken)
112117
guard lang.tier1Supported else { return }
113-
Task { await trinityVoice.speak(spoken, languageCode: lang.code) }
118+
// V5 — clean handoff: stop listening before speaking so the session moves .record → .playback
119+
// without a clash, and we never transcribe Trinity's own voice back into the input.
120+
if isListening { stopVoiceInput() }
121+
Task {
122+
isSpeaking = true
123+
await trinityVoice.speak(spoken, languageCode: lang.code)
124+
isSpeaking = false
125+
}
114126
}
115127

116128
/// Strip markdown so TTS doesn't read "asterisk asterisk" etc.

0 commit comments

Comments
 (0)