File size: 32,978 Bytes
3de49c7
 
2103c03
e9b447a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b48ae63
a2c51f3
b48ae63
 
 
 
 
 
 
a2c51f3
b48ae63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3de49c7
 
 
 
 
f84395e
3de49c7
b48ae63
3de49c7
 
 
 
e3d8805
 
3de49c7
 
 
b48ae63
2103c03
 
b48ae63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2103c03
 
3de49c7
b48ae63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2103c03
 
3de49c7
b48ae63
 
 
 
 
 
 
 
 
 
 
 
 
 
2103c03
b48ae63
 
a2c51f3
3de49c7
b48ae63
 
 
 
 
 
 
 
 
 
3de49c7
 
 
 
2103c03
 
3de49c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b48ae63
e3d8805
3de49c7
 
e3d8805
 
 
 
 
 
 
 
3de49c7
 
 
 
 
 
f84395e
3de49c7
 
 
b48ae63
3de49c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2103c03
 
 
 
 
 
 
 
 
 
a2c51f3
 
 
 
 
 
3de49c7
 
 
 
 
a2c51f3
b48ae63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3de49c7
f84395e
 
 
b48ae63
 
 
 
 
f84395e
 
 
 
b48ae63
 
 
 
 
 
 
 
 
 
 
 
 
a2c51f3
b48ae63
a2c51f3
 
 
 
b48ae63
a2c51f3
 
 
b48ae63
 
3de49c7
a2c51f3
3de49c7
 
 
2103c03
 
a2c51f3
3de49c7
b48ae63
3de49c7
 
 
 
 
b48ae63
 
 
e9b447a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2c51f3
b48ae63
 
 
 
 
e9b447a
 
 
 
 
 
b48ae63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2c51f3
b48ae63
e9b447a
 
 
 
 
 
 
 
 
 
b48ae63
 
 
 
 
 
 
 
 
e9b447a
 
b48ae63
 
 
 
 
e9b447a
 
 
 
b48ae63
 
 
 
 
 
 
 
 
 
 
 
e9b447a
 
b48ae63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2c51f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3de49c7
b48ae63
3de49c7
 
 
a2c51f3
3de49c7
a2c51f3
 
 
b48ae63
a2c51f3
3de49c7
 
 
 
 
e3d8805
 
 
 
 
 
3de49c7
 
a2c51f3
3de49c7
 
a2c51f3
3de49c7
e3d8805
 
 
 
 
 
 
 
 
a2c51f3
e3d8805
3de49c7
 
 
 
 
 
b48ae63
 
 
 
 
 
 
 
 
3de49c7
 
b48ae63
3de49c7
 
 
b48ae63
3de49c7
 
 
 
 
 
 
 
 
 
 
 
 
 
b48ae63
3de49c7
 
a2c51f3
3de49c7
 
 
 
 
 
 
 
 
 
b48ae63
3de49c7
 
 
 
 
 
 
 
 
 
 
 
e3d8805
 
3de49c7
e3d8805
 
 
 
 
 
b48ae63
 
e3d8805
 
b48ae63
 
 
e3d8805
 
 
 
 
 
 
 
 
 
 
 
 
 
b48ae63
e3d8805
 
 
 
 
 
 
 
b48ae63
e3d8805
 
 
 
2103c03
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
# voice_orchestrator.py
# Real-time state machine coordinating telephony socket framing, STT, LLM streaming, and TTS playout
#
# v5 β€” Issue 3 fix: instant audio stop on barge-in (was waiting 1.1-2.0s)
#
#   Previous (v4) behaviour: when a candidate interrupt was detected,
#   the orchestrator transitioned to INTERRUPT_PENDING but did NOT stop
#   audio playback β€” 'clear_audio' was only sent once the full 1.5s STT
#   confirmation window resolved as 'legitimate'. Combined with VAD
#   debounce + silence window + STT processing, this meant the agent
#   kept talking for 1.1-2.0 SECONDS after the caller started speaking
#   over them β€” defeating the entire purpose of barge-in.
#
#   Fix: 'clear_audio' is now sent IMMEDIATELY in _enter_interrupt_pending(),
#   the instant a candidate interrupt is detected (within the existing
#   0.2s VAD debounce). Confirmed via frontend scan (useVoiceWebSocket.ts)
#   that 'clear_audio' already does exactly what's needed β€” stops the
#   active Audio element and empties the playback queue β€” so NO frontend
#   changes were required. The STT confirmation window (Phase 2) still
#   runs underneath to decide whether to fully cancel the turn (resolved
#   legitimate) or resume playback (resolved accidental) β€” only the
#   AUDIO STOP MOMENT moved earlier, not the decision logic itself.
#
# v4 β€” Issue B correction handler + Issue C smart interrupt system
#
#   Issue B (same utterance answered twice):
#     STT now emits is_correction=True / corrects_pending=True instead of
#     a second is_final when Groq provides a same-WC spelling improvement.
#     The-brain handles this in run_stt_loop: if state==THINKING and a
#     correction arrives, the last user message in session history is
#     silently updated with the better text. No new turn is triggered.
#     This eliminates all double-reply occurrences from the last session.
#
#   Issue C (smart interrupt system):
#     Previous: any VAD blip passing 0.2s debounce β†’ hard cancel β†’ silence.
#     No recovery from accidental interrupts. No graceful handover.
#
#     New: three-phase interrupt system.
#
#     Phase 1 β€” Detection (unchanged):
#       VAD speech_start Γ— 2 within 0.2s β†’ candidate interrupt.
#       State transitions to INTERRUPT_PENDING (new sub-state of SPEAKING).
#       Current TTS sentence continues to completion β€” agent never cut off
#       mid-word. New TTS sentences are paused but queued in
#       _interrupt_pending_tts so they can be resumed if accidental.
#
#     Phase 2 β€” STT Confirmation Gate (1.5s window):
#       Wait for is_final from STT. Classify the confirmed speech:
#
#       LEGITIMATE (β‰₯2 words, or 1 meaningful word in INTERRUPT_MEANINGFUL):
#         β†’ Graceful stop after current sentence
#         β†’ Emit bridge phrase "جی، Ψ¨Ψͺائیں" / "جی ΩΨ±Ω…Ψ§Ψ¦ΫŒΪΊ" to fill silence
#         β†’ Start new LLM turn with the caller's text
#
#       SHORT_MEANINGFUL (1 word in INTERRUPT_SHORT_MEANINGFUL like ہاں/Ω†ΫΫŒΪΊ/جی/ٹھیک):
#         β†’ Same as LEGITIMATE but uses a shorter bridge "جی"
#         β†’ Caller signalled agreement/disagreement, handle as normal turn
#
#       ACCIDENTAL (filler/noise word, or timeout with no is_final):
#         β†’ Resume dispatching the paused TTS queue from where we stopped
#         β†’ Agent continues speaking as if nothing happened
#         β†’ Log "Interrupt resolved as accidental β€” resuming TTS"
#
#     Phase 3 β€” Graceful handover:
#       Bridge phrase is synthesised and emitted before the LLM turn fires.
#       This fills the ~1-2s gap while Groq generates the response.
#       Feels natural β€” like a human agent saying "yes, go ahead" before
#       answering.

import json
import logging
import asyncio
import threading
import time
from enum import Enum
from typing import Optional, List, Tuple

from conversation_manager import ConversationSession
from stt_client import SttClient
import voice_pipeline
import neon_client
import summary

logger = logging.getLogger("voice_orchestrator")

# ── Echo guard (time-window only, content-overlap removed in v3) ──────────────
ECHO_GUARD_SECONDS = 1.2

# ── Smart interrupt constants ─────────────────────────────────────────────────
# How long to wait for STT confirmation after a candidate interrupt
INTERRUPT_CONFIRM_TIMEOUT = 1.5   # seconds

# Single meaningful words that constitute a SHORT_MEANINGFUL interrupt
# (caller said "yes"/"no"/"okay" etc. β€” still a real turn, just short)
INTERRUPT_SHORT_MEANINGFUL = {
    "ہاں", "Ω†ΫΫŒΪΊ", "جی", "ٹھیک", "ہاں جی", "Ω†ΫΫŒΪΊ جی",
    "رکو", "Ψ¨Ψ³", "Ψ§Ϊ†ΪΎΨ§", "ٹھیک ہے", "Ψ³Ω…Ψ¬ΪΎ گیا", "Ψ³Ω…Ψ¬ΪΎ گئی",
    "han", "nahi", "ji", "theek", "ruko", "bas", "acha",
}

# Hard filler/noise words β€” if interrupt text is ONLY these, it's accidental
INTERRUPT_NOISE_WORDS = {
    "Ω…ΩˆΨ³ΫŒΩ‚ΫŒ", "Ω…ΫŒΩˆΨ²Ϊ©", "Ψ³Ψ§Ψ²", "واہ", "ہم", "Ω…Ψ±Ψ­Ψ¨Ψ§",
}

# Bridge phrases emitted after a legitimate interrupt to fill the LLM gap
BRIDGE_PHRASES_FULL = [
    "جی، Ψ¨ΨͺΨ§Ψ¦ΫŒΪΊΫ”",
    "جی ΩΨ±Ω…Ψ§Ψ¦ΫŒΪΊΫ”",
    "ہاں، Ψ¨ΨͺΨ§Ψ¦ΫŒΪΊΫ”",
]
BRIDGE_PHRASES_SHORT = [
    "Ψ¬ΫŒΫ”",
    "ہاں۔",
]

# ── DISABLED (Issue 2 v3) β€” content-overlap echo guard ───────────────────────
# Preserved as comments. See v3 header for rationale.


class VoiceState(Enum):
    LISTENING          = "listening"
    PROCESSING         = "processing"
    THINKING           = "thinking"
    SPEAKING           = "speaking"
    INTERRUPT_PENDING  = "interrupt_pending"  # Issue C: new state


def _classify_interrupt_text(text: str) -> str:
    """
    Issue C: Classify STT text during interrupt confirmation window.
    Returns: 'legitimate', 'short_meaningful', or 'accidental'
    """
    if not text or not text.strip():
        return "accidental"

    words = text.strip().split()
    word_count = len(words)

    # Pure noise/artifact single word
    if word_count == 1 and words[0] in INTERRUPT_NOISE_WORDS:
        return "accidental"

    # 1 meaningful word
    if word_count == 1:
        if words[0] in INTERRUPT_SHORT_MEANINGFUL:
            return "short_meaningful"
        # Single content word (not in noise, not in short_meaningful)
        # Could be a genuine short command β€” treat as legitimate
        return "legitimate"

    # 2+ words β†’ always legitimate
    return "legitimate"


def _pick_bridge_phrase(interrupt_class: str, turn_count: int) -> str:
    """Pick a natural bridge phrase based on interrupt type."""
    phrases = BRIDGE_PHRASES_SHORT if interrupt_class == "short_meaningful" else BRIDGE_PHRASES_FULL
    return phrases[turn_count % len(phrases)]


class VoiceOrchestratorSession:
    def __init__(self, websocket, session: ConversationSession, stt_client: SttClient):
        self.websocket   = websocket
        self.session     = session
        self.stt_client  = stt_client
        self.state       = VoiceState.LISTENING
        self.cancel_event            = threading.Event()
        self.active_pipeline_task    = None
        self.stt_loop_task           = None
        self.lock                    = asyncio.Lock()
        self.current_summary         = ""
        self.is_configured           = True
        self._pending_turns          = asyncio.Queue()
        self.keep_alive_task         = None
        self.last_speech_start_time  = 0.0
        self.last_listening_start_time = 0.0

        # Issue 6 (v3): finals queued during THINKING/SPEAKING
        self._pending_finals_during_turn: asyncio.Queue = asyncio.Queue()

        # Issue C: TTS sentences queued but not yet dispatched during
        # INTERRUPT_PENDING β€” resumed if interrupt is accidental
        self._interrupt_pending_tts: List[Tuple[str, bytes, str]] = []
        # asyncio.Event set when interrupt resolution is known
        self._interrupt_resolved_event: Optional[asyncio.Event] = None
        # Resolution: 'legitimate', 'short_meaningful', 'accidental', or None
        self._interrupt_resolution: Optional[str] = None
        # Text from the caller that caused the interrupt
        self._interrupt_caller_text: Optional[str] = None

    async def set_state(self, new_state: VoiceState):
        async with self.lock:
            self.state = new_state
            logger.info(f"[VoiceSession {self.session.session_id}] State changed to: {new_state.value}")
            if new_state == VoiceState.LISTENING:
                self.last_listening_start_time = time.time()
            await self.send_json({"type": "state", "state": new_state.value})

    async def send_json(self, data: dict):
        try:
            await self.websocket.send_json(data)
        except Exception as e:
            logger.warning(f"Error sending json to client: {e}")

    async def send_binary(self, data: bytes):
        try:
            await self.websocket.send_bytes(data)
        except Exception as e:
            logger.warning(f"Error sending binary to client: {e}")

    async def start(self):
        self.stt_client.start()
        self.stt_loop_task   = asyncio.create_task(self.run_stt_loop())
        self.keep_alive_task = asyncio.create_task(self.run_keep_alive_loop())
        await self.send_json({"type": "state", "state": self.state.value})

    async def run_keep_alive_loop(self):
        try:
            while True:
                await asyncio.sleep(30.0)
                await self.send_json({"type": "ping"})
        except asyncio.CancelledError:
            pass

    async def run_ingress_loop(self):
        try:
            while True:
                message = await self.websocket.receive()
                if "bytes" in message:
                    pcm_data = message["bytes"]
                    if self.is_configured and self.stt_client.connected:
                        self.stt_client.send_pcm(pcm_data)
                elif "text" in message:
                    try:
                        data    = json.loads(message["text"])
                        msg_type = data.get("type")
                        if msg_type == "session_end":
                            logger.info(f"Client requested session end: {self.session.session_id}")
                            break
                        elif msg_type == "text_message":
                            text = data.get("message", "")
                            if text.strip():
                                await self.trigger_assistant_turn(text)
                    except Exception as parse_err:
                        logger.warning(f"Failed to parse text message from client: {parse_err}")
                else:
                    break
        except Exception as e:
            logger.info(f"Ingress loop exception: {e}")
        finally:
            await self.cleanup()

    def _is_echo(self, text: str) -> bool:
        elapsed = time.time() - self.last_listening_start_time
        if self.last_listening_start_time > 0 and elapsed < ECHO_GUARD_SECONDS:
            logger.info(
                f"[VoiceSession {self.session.session_id}] Echo guard (time window, "
                f"{elapsed:.2f}s < {ECHO_GUARD_SECONDS}s) β€” discarding: '{text[:60]}'"
            )
            return True
        return False

    def _get_last_assistant_message(self) -> str:
        for msg in reversed(self.session.messages):
            if msg.get("role") == "assistant":
                return msg.get("content", "")
        return ""

    async def run_stt_loop(self):
        """Reads events from upstream STT space and drives state machine."""
        try:
            while True:
                event = await self.stt_client.get_event()

                is_vad_start  = (event.get("type") == "vad" and event.get("event") == "speech_start")
                is_partial    = (not event.get("is_final") and event.get("text") and not event.get("is_correction"))
                is_final      = event.get("is_final") and not event.get("is_correction")
                is_correction = event.get("is_correction") and event.get("corrects_pending")

                # ── Issue B: handle STT correction event ─────────────────────
                # Update the last user message text silently β€” no new turn.
                if is_correction:
                    corrected_text = event.get("text", "").strip()
                    if corrected_text and self.state == VoiceState.THINKING:
                        # Find and update the last user message in session history
                        for i in range(len(self.session.messages) - 1, -1, -1):
                            if self.session.messages[i].get("role") == "user":
                                old_text = self.session.messages[i]["content"]
                                self.session.messages[i]["content"] = corrected_text
                                logger.info(
                                    f"[VoiceSession {self.session.session_id}] "
                                    f"Correction applied: '{old_text}' β†’ '{corrected_text}'"
                                )
                                break
                    continue  # corrections never trigger new turns

                # ── INTERRUPT_PENDING state: waiting for STT confirmation ─────
                if self.state == VoiceState.INTERRUPT_PENDING:
                    if is_final:
                        text = event.get("text", "").strip()
                        if text:
                            resolution = _classify_interrupt_text(text)
                            logger.info(
                                f"[VoiceSession {self.session.session_id}] "
                                f"Interrupt confirmed as '{resolution}': '{text[:60]}'"
                            )
                            self._interrupt_caller_text = text
                            self._interrupt_resolution  = resolution
                            if self._interrupt_resolved_event:
                                self._interrupt_resolved_event.set()
                        # If text is empty keep waiting until timeout
                    continue  # don't fall through to THINKING/SPEAKING/LISTENING

                # ── THINKING / SPEAKING states ────────────────────────────────
                if self.state in (VoiceState.THINKING, VoiceState.SPEAKING):
                    if is_vad_start:
                        now = time.time()
                        if now - self.last_speech_start_time <= 0.2:
                            logger.info(
                                f"[VoiceSession {self.session.session_id}] "
                                "Barge-in candidate β€” entering INTERRUPT_PENDING"
                            )
                            await self._enter_interrupt_pending()
                        else:
                            logger.info("First VAD speech_start detected. Waiting for confirmation.")
                            self.last_speech_start_time = now
                    elif is_partial:
                        # Partial text during speaking is a strong signal.
                        # Treat directly as legitimate interrupt candidate β€”
                        # enter INTERRUPT_PENDING so we still gate it.
                        if self.state == VoiceState.SPEAKING:
                            logger.info(
                                f"[VoiceSession {self.session.session_id}] "
                                "Partial text during SPEAKING β€” entering INTERRUPT_PENDING"
                            )
                            await self._enter_interrupt_pending()
                        else:
                            # THINKING β€” old behaviour: immediate interrupt
                            logger.info("Barge-in text during THINKING β€” halting turn.")
                            await self.interrupt_turn()
                    elif is_final:
                        # Issue 6 (v3): queue instead of dropping
                        text = event.get("text", "").strip()
                        if text:
                            logger.info(
                                f"[VoiceSession {self.session.session_id}] Final received "
                                f"while {self.state.value} β€” queueing: '{text[:60]}'"
                            )
                            await self._pending_finals_during_turn.put(text)

                # ── LISTENING state ───────────────────────────────────────────
                elif self.state == VoiceState.LISTENING:
                    if is_partial:
                        await self.send_json({"type": "partial", "text": event["text"]})
                    elif is_final:
                        text = event.get("text", "").strip()
                        if text:
                            if self._is_echo(text):
                                continue
                            await self.send_json({"type": "user_final", "text": text})
                            await self.trigger_assistant_turn(text)

        except asyncio.CancelledError:
            pass
        except Exception as e:
            logger.error(f"STT loop error: {e}")

    async def _enter_interrupt_pending(self):
        """
        Issue C Phase 1: transition to INTERRUPT_PENDING.

        Issue 3 fix (v5): Previously, audio playback only stopped when
        the FULL interrupt was resolved as 'legitimate' β€” after the
        1.5s STT confirmation window (debounce + silence window + STT
        processing = 1.1s-2.0s of the agent continuing to talk over the
        caller). This contradicted the entire purpose of barge-in.

        Now: 'clear_audio' is sent IMMEDIATELY here, the instant a
        candidate interrupt is detected (VAD debounce, <250ms from the
        caller's first sound). The frontend's flushAudioQueue() stops
        the currently-playing chunk and empties the queue within
        milliseconds β€” true "instant" barge-in, matching user
        expectation. We do NOT send 'interrupt' yet (that signals a
        full pipeline cancel) β€” only 'clear_audio' (audio playback
        stop). The LLM pipeline keeps running underneath; on_audio()
        already checks `self.state == INTERRUPT_PENDING` and queues
        any further synthesised sentences into _interrupt_pending_tts
        instead of sending them, so nothing more reaches the frontend
        until resolution.

        If the interrupt resolves as ACCIDENTAL, _resolve_interrupt()
        simply re-sends the queued sentences as fresh audio_header +
        binary messages β€” the frontend's existing message handling
        naturally re-queues and plays them, no special "resume"
        protocol needed (confirmed via useVoiceWebSocket.ts: every
        audio_header+binary pair is queued via the same code path
        regardless of when it arrives).

        If resolved as LEGITIMATE/SHORT_MEANINGFUL, the full pipeline
        cancel + bridge-phrase flow proceeds exactly as before β€” the
        only change is that perceived audio silence now starts
        immediately instead of after the full confirmation window.
        """
        async with self.lock:
            self.state = VoiceState.INTERRUPT_PENDING
        logger.info(f"[VoiceSession {self.session.session_id}] State changed to: interrupt_pending")
        await self.send_json({"type": "state", "state": "interrupt_pending"})

        # Issue 3 fix: stop audio playback NOW, not after the 1.5s
        # confirmation window. This is the actual "barge-in" moment β€”
        # the caller should hear silence within milliseconds, not
        # seconds, regardless of how the interrupt eventually resolves.
        await self.send_json({"type": "clear_audio"})

        self._interrupt_resolved_event = asyncio.Event()
        self._interrupt_resolution     = None
        self._interrupt_caller_text    = None

        # Start timeout task β€” if no is_final arrives in time β†’ accidental
        asyncio.create_task(self._interrupt_confirmation_timeout())

    async def _interrupt_confirmation_timeout(self):
        """
        Issue C Phase 2: wait for STT confirmation.
        Times out after INTERRUPT_CONFIRM_TIMEOUT seconds β†’ accidental.
        """
        try:
            await asyncio.wait_for(
                self._interrupt_resolved_event.wait(),
                timeout=INTERRUPT_CONFIRM_TIMEOUT,
            )
        except asyncio.TimeoutError:
            logger.info(
                f"[VoiceSession {self.session.session_id}] "
                f"Interrupt timeout ({INTERRUPT_CONFIRM_TIMEOUT}s) β€” resolving as accidental"
            )
            self._interrupt_resolution = "accidental"
            if self._interrupt_resolved_event:
                self._interrupt_resolved_event.set()

        await self._resolve_interrupt()

    async def _resolve_interrupt(self):
        """
        Issue C Phase 3: act on the confirmed interrupt resolution.

        Issue 3 fix (v5): audio playback was ALREADY stopped immediately
        in _enter_interrupt_pending() (clear_audio sent there). This
        method now only decides what happens NEXT:
          - accidental: resume by re-sending the paused sentences
          - legitimate/short_meaningful: cancel pipeline + bridge phrase
        The 'interrupt'/'clear_audio' sends below for the legitimate
        path are now redundant-but-harmless (frontend queue is already
        empty) β€” kept for protocol clarity and to handle any sentence
        that slipped through between detection and this resolution.
        """
        resolution  = self._interrupt_resolution or "accidental"
        caller_text = self._interrupt_caller_text

        if resolution == "accidental":
            logger.info(
                f"[VoiceSession {self.session.session_id}] "
                "Interrupt resolved as accidental β€” resuming TTS"
            )
            # Audio was already stopped in _enter_interrupt_pending().
            # Resume by replaying the paused queue from where we stopped.
            async with self.lock:
                self.state = VoiceState.SPEAKING
            await self.send_json({"type": "state", "state": "speaking"})
            await self.send_json({"type": "interrupt_cancelled"})

            # Replay any TTS sentences that were queued during INTERRUPT_PENDING.
            # These re-enter the frontend's normal audio_header+binary handling
            # path (useVoiceWebSocket.ts) and queue/play exactly like any other
            # turn's audio β€” no special resume protocol required.
            for sentence, audio_bytes, tts_lang in self._interrupt_pending_tts:
                if not self.cancel_event.is_set():
                    await self.send_json({
                        "type": "audio_header",
                        "sentence": sentence,
                        "lang": tts_lang,
                    })
                    await self.send_binary(audio_bytes)
            self._interrupt_pending_tts.clear()

        else:
            # LEGITIMATE or SHORT_MEANINGFUL β€” graceful stop
            # (audio already stopped in _enter_interrupt_pending(); this
            # just cancels the underlying pipeline and starts the new turn)
            logger.info(
                f"[VoiceSession {self.session.session_id}] "
                f"Interrupt resolved as '{resolution}' β€” graceful handover"
            )
            self._interrupt_pending_tts.clear()

            # Hard cancel the pipeline
            self.cancel_event.set()
            if self.active_pipeline_task and not self.active_pipeline_task.done():
                self.active_pipeline_task.cancel()
            self.cancel_event = threading.Event()

            # Clear pending finals from the old turn
            while not self._pending_finals_during_turn.empty():
                self._pending_finals_during_turn.get_nowait()

            await self.send_json({"type": "interrupt"})
            await self.send_json({"type": "clear_audio"})

            # Emit bridge phrase to fill the LLM gap
            if caller_text:
                bridge = _pick_bridge_phrase(resolution, self.session.turn_count)
                logger.info(
                    f"[VoiceSession {self.session.session_id}] "
                    f"Emitting bridge phrase: '{bridge}'"
                )
                await self.send_json({"type": "state", "state": "speaking"})
                bridge_audio, bridge_lang = await voice_pipeline.fire_tts(
                    bridge, "ur", self.session.agent_gender
                )
                if bridge_audio:
                    await self.send_json({
                        "type": "audio_header",
                        "sentence": bridge,
                        "lang": bridge_lang,
                    })
                    await self.send_binary(bridge_audio)

                # Now start the real turn with caller's text
                await self.set_state(VoiceState.THINKING)
                self.cancel_event = threading.Event()
                self.active_pipeline_task = asyncio.create_task(
                    self._execute_turn(caller_text)
                )
            else:
                await self.set_state(VoiceState.LISTENING)

        # Clean up interrupt state
        self._interrupt_resolved_event = None
        self._interrupt_resolution     = None
        self._interrupt_caller_text    = None

    async def _maybe_process_pending_final(self):
        """Issue 6 (v3): drain STT finals queued during THINKING/SPEAKING."""
        if self._pending_finals_during_turn.empty():
            return

        text: Optional[str] = None
        while not self._pending_finals_during_turn.empty():
            text = await self._pending_finals_during_turn.get()

        if not text:
            return

        logger.info(
            f"[VoiceSession {self.session.session_id}] Processing queued final "
            f"(arrived during prior turn): '{text[:60]}'"
        )
        await self.send_json({"type": "user_final", "text": text})
        await self.trigger_assistant_turn(text)

    async def interrupt_turn(self):
        """Hard interrupt β€” used during THINKING state (pre-speech)."""
        self.cancel_event.set()
        if self.active_pipeline_task and not self.active_pipeline_task.done():
            self.active_pipeline_task.cancel()

        self.cancel_event = threading.Event()

        while not self._pending_finals_during_turn.empty():
            self._pending_finals_during_turn.get_nowait()
        self._interrupt_pending_tts.clear()

        await self.send_json({"type": "interrupt"})
        await self.send_json({"type": "clear_audio"})
        await self.set_state(VoiceState.LISTENING)

    async def trigger_assistant_turn(self, text: str):
        if not self.is_configured:
            logger.info(f"Session not yet configured. Queueing turn: {text}")
            await self._pending_turns.put(text)
            asyncio.create_task(self._drain_pending_turns())
            return

        if self.state in (VoiceState.THINKING, VoiceState.SPEAKING):
            await self.interrupt_turn()

        await self.set_state(VoiceState.THINKING)
        self.cancel_event = threading.Event()
        self.active_pipeline_task = asyncio.create_task(self._execute_turn(text))

    async def _drain_pending_turns(self):
        while not self.is_configured:
            await asyncio.sleep(0.05)
        while not self._pending_turns.empty():
            text = await self._pending_turns.get()
            if self.state in (VoiceState.THINKING, VoiceState.SPEAKING):
                await self.interrupt_turn()
            await self.set_state(VoiceState.THINKING)
            self.cancel_event = threading.Event()
            self.active_pipeline_task = asyncio.create_task(self._execute_turn(text))

    async def _execute_turn(self, text: str):
        try:
            async def on_token(token: str):
                await self.send_json({"type": "token", "text": token})

            async def on_audio(sentence: str, audio_bytes: bytes, tts_lang: str):
                # Issue C: if INTERRUPT_PENDING, queue audio instead of sending
                if self.state == VoiceState.INTERRUPT_PENDING:
                    logger.debug(
                        f"[VoiceSession {self.session.session_id}] "
                        f"TTS queued during INTERRUPT_PENDING: '{sentence[:40]}'"
                    )
                    self._interrupt_pending_tts.append((sentence, audio_bytes, tts_lang))
                    return

                if self.state == VoiceState.THINKING:
                    await self.set_state(VoiceState.SPEAKING)

                await self.send_json({
                    "type": "audio_header",
                    "sentence": sentence,
                    "lang": tts_lang,
                })
                await self.send_binary(audio_bytes)

            async def on_done(full_text: str, language: str, active_summary: str, elapsed_ms: int):
                self.current_summary = active_summary
                await self.send_json({
                    "type": "done",
                    "session_id": self.session.session_id,
                    "full_text": full_text,
                    "language": language,
                    "language_locked": self.session.language_detector.is_locked(),
                    "turn_count": self.session.turn_count,
                    "caller_info": self.session.caller_info.to_dict(),
                    "activeSummary": active_summary,
                    "elapsed_ms": elapsed_ms,
                })
                await self.set_state(VoiceState.LISTENING)
                await self._maybe_process_pending_final()

            await voice_pipeline.run_turn(
                session=self.session,
                message=text,
                cancel_event=self.cancel_event,
                on_token=on_token,
                on_audio=on_audio,
                on_done=on_done,
                agent_gender=self.session.agent_gender,
                current_summary=self.current_summary,
                session_start=int(self.session.created_at * 1000),
            )
        except Exception as err:
            logger.error(f"Error during assistant turn execution: {err}")
            await self.send_json({"type": "error", "detail": str(err)})
            await self.set_state(VoiceState.LISTENING)

    async def cleanup(self):
        self.cancel_event.set()
        if self.active_pipeline_task and not self.active_pipeline_task.done():
            self.active_pipeline_task.cancel()
        if self.stt_loop_task and not self.stt_loop_task.done():
            self.stt_loop_task.cancel()
        if self.keep_alive_task and not self.keep_alive_task.done():
            self.keep_alive_task.cancel()
        await self.stt_client.close()

        transcript_turns = [
            f"{'Caller' if m['role'] == 'user' else 'Agent'}: {m['content']}"
            for m in self.session.messages
        ]
        full_transcript = "\n".join(transcript_turns)
        fields_spec     = self.session.caller_collection_fields
        extracted_data  = self.session.caller_info.to_dict()

        if fields_spec and full_transcript.strip():
            llm_extracted = await summary.extract_caller_data_from_transcript(
                full_transcript, fields_spec
            )
            self.session.caller_info.update_from_llm_extraction(llm_extracted)
            extracted_data = self.session.caller_info.to_dict()

        import time
        duration = int(time.time() - self.session.created_at)

        if getattr(self.session, "company_id", None):
            await neon_client.insert_call_log(
                call_id=self.session.session_id,
                company_id=self.session.company_id,
                phone_line_id=getattr(self.session, "phone_line_id", None),
                full_transcript=full_transcript,
                extracted_caller_data=extracted_data,
                duration_seconds=duration,
                status="completed",
            )

        try:
            await self.send_json({
                "type": "call_summary",
                "session_id": self.session.session_id,
                "extracted_caller_data": extracted_data,
                "duration_seconds": duration,
                "transcript_turns": len(self.session.messages),
            })
        except Exception:
            pass

        logger.info(f"Orchestration session closed and cleaned up: {self.session.session_id}")