feat(voice): speech-interrupted latch in the TTS streaming core

mark_speech_interrupted() / take_speech_interrupted(): a one-shot,
TTL'd (120s) latch plus SPEECH_INTERRUPTED_NOTE. Barge-in paths mark
it when they cut live speech; the next turn's submit path pops it and
prepends the note to the model-bound message — API-call local, never
persisted, so history and prompt caching are untouched.
This commit is contained in:
Brooklyn Nicholson 2026-07-22 17:53:06 -05:00
parent 83456d0bc6
commit 393c100a92
2 changed files with 49 additions and 0 deletions

View file

@ -53,6 +53,26 @@ class TestSentenceChunker:
]
# ── Interruption latch ───────────────────────────────────────────────────
class TestSpeechInterruptedLatch:
def test_take_pops_and_reports_recent_barge(self):
ts.mark_speech_interrupted()
assert ts.take_speech_interrupted() is True
assert ts.take_speech_interrupted() is False # one-shot
def test_untouched_latch_is_false(self):
ts._interrupted_at = None
assert ts.take_speech_interrupted() is False
def test_stale_barge_expires(self, monkeypatch):
ts.mark_speech_interrupted()
at = ts._interrupted_at
monkeypatch.setattr(ts.time, "monotonic", lambda: at + ts._INTERRUPT_TTL_S + 1)
assert ts.take_speech_interrupted() is False
# ── Registry + resolver ──────────────────────────────────────────────────

View file

@ -23,6 +23,7 @@ from __future__ import annotations
import logging
import re
import time
from abc import ABC, abstractmethod
from typing import Callable, Dict, Iterator, List, Optional
@ -30,6 +31,34 @@ from tools.tts_tool import _get_provider, get_env_value
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Interruption latch — lets the model know it was cut off mid-speech
# ---------------------------------------------------------------------------
# When the user barges in on a spoken reply (talks over it, types, hits the
# record key), the surface marks the latch; the next turn's submit path takes
# it and prepends SPEECH_INTERRUPTED_NOTE to the model-bound message (API-call
# local — never persisted, same as the CLI's model-switch notes). The TTL
# keeps a stale barge from annotating an unrelated message minutes later.
SPEECH_INTERRUPTED_NOTE = (
"[Note: the user interrupted your previous spoken reply before it finished.]"
)
_INTERRUPT_TTL_S = 120.0
_interrupted_at: Optional[float] = None
def mark_speech_interrupted() -> None:
global _interrupted_at
_interrupted_at = time.monotonic()
def take_speech_interrupted() -> bool:
"""Pop the latch; True when a barge happened within the TTL."""
global _interrupted_at
at, _interrupted_at = _interrupted_at, None
return at is not None and time.monotonic() - at < _INTERRUPT_TTL_S
# Sentence boundary: after .!? followed by whitespace, or a blank line.
SENTENCE_BOUNDARY_RE = re.compile(r"(?<=[.!?])(?:\s|\n)|(?:\n\n)")
_THINK_BLOCK_RE = re.compile(r"<think[\s>].*?</think>", flags=re.DOTALL)