From 393c100a929ba77fee2b49b0db6d842f1ccb1c53 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 17:53:06 -0500 Subject: [PATCH] feat(voice): speech-interrupted latch in the TTS streaming core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/tools/test_tts_streaming.py | 20 ++++++++++++++++++++ tools/tts_streaming.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/tests/tools/test_tts_streaming.py b/tests/tools/test_tts_streaming.py index 77241ea485b7..dd5b921d1695 100644 --- a/tests/tools/test_tts_streaming.py +++ b/tests/tools/test_tts_streaming.py @@ -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 ────────────────────────────────────────────────── diff --git a/tools/tts_streaming.py b/tools/tts_streaming.py index 8d773d954f59..746886ccce3f 100644 --- a/tools/tts_streaming.py +++ b/tools/tts_streaming.py @@ -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"].*?", flags=re.DOTALL)