Files
hermes-agent/tools/tts_tool_speaker.py
teknium1 77fd944c57 fix(tts): drop the empty mkstemp placeholder when the provider wrote elsewhere
With the reported artifact now played, the requested .mp3 placeholder stays
behind as a zero-byte file whenever a command provider rewrote the suffix or
voice_compatible converted to .ogg; unlink it right after synthesis so nothing
leaks into TMPDIR. Same cleanup as #115078 (@Finn763).

Co-authored-by: finn763 <165816600+finn763@users.noreply.github.com>
2026-09-20 10:25:30 -07:00

407 lines
19 KiB
Python

"""Speaker-side streaming pipeline: ``stream_tts_to_speaker``.
Turns a queue of LLM text deltas into audio the moment each sentence is complete. Two paths
share the sentence cutter (``tools.tts_streaming``): :class:`_StreamerPlayback` for a registered
chunked streamer (prefetch thread per sentence, one FIFO playback worker through a sounddevice
OutputStream or temp WAV + system player) and :class:`_SyncSentencePipeline` for every other
provider (per-sentence ``text_to_speech_tool`` on a single-thread executor, overlapped with
playback). Origin seams are resolved through :func:`_origin` at call time.
"""
from __future__ import annotations
import contextlib
import itertools
import json
import logging
import os
import platform
import queue
import tempfile
import threading
from concurrent.futures import Future, ThreadPoolExecutor
from typing import Callable, Iterable, Iterator, List, Optional
from tools.tts_text_normalize import _strip_markdown_for_tts
from tools.tts_tool_delivery import _origin, _remove_quietly as _unlink_quietly
logger = logging.getLogger("tools.tts_tool")
def _align_int16_chunks(chunks: Iterable[bytes], stop_evt: threading.Event, *, pad_tail: bool = True) -> Iterator[bytes]:
"""Yield int16-aligned byte chunks; a dangling odd byte is padded at the end (or dropped)."""
leftover = b""
for chunk in chunks:
if stop_evt.is_set():
break
buf = leftover + chunk
aligned_len = len(buf) - (len(buf) % 2)
if aligned_len >= 2:
yield buf[:aligned_len]
leftover = buf[aligned_len:]
if leftover and pad_tail:
yield b"\x00"
def _play_via_tempfile(audio_iter: Iterable[bytes], stop_evt: threading.Event, sample_rate: int = 24000) -> None:
"""Write PCM chunks to a temp WAV file and play it with the system player."""
tmp = tmp_path = None
try:
import wave
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp_path = tmp.name
with wave.open(tmp, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2) # 16-bit
wf.setframerate(sample_rate)
for aligned in _align_int16_chunks(audio_iter, stop_evt):
wf.writeframes(aligned)
# wave.open() on a file object does NOT close it; on Windows the open write
# handle blocks the player and the unlink below (WinError 32).
tmp.close()
from tools.voice_mode import play_audio_file
play_audio_file(tmp_path)
except Exception as exc:
logger.warning("Temp-file TTS fallback failed: %s", exc)
finally:
if tmp is not None:
with contextlib.suppress(Exception):
tmp.close() # idempotent; ensures close on early error
_unlink_quietly(tmp_path)
def _drain_chunks(chunk_queue: "queue.Queue[Optional[bytes]]") -> List[bytes]:
"""Collect one sentence's PCM chunks up to the ``None`` sentinel."""
return list(iter(chunk_queue.get, None))
def _first_written_artifact(raw: object, requested: str) -> str:
"""The audio path the TTS tool actually wrote, falling back to the requested one.
``text_to_speech_tool`` reports its artifacts (``file_path``/``file_paths``) in a JSON
envelope, and they often land beside — not at — the requested path: a command provider's
declared ``format`` rewrites the suffix, and voice-compatible delivery ffmpeg-converts to
``.ogg``. Gating playback on the requested path alone drops every such sentence silently,
so prefer the first reported artifact that actually exists and is non-empty."""
candidates: List[str] = []
try:
payload = json.loads(raw) if isinstance(raw, str) else (raw or {})
if isinstance(payload, dict):
reported = payload.get("file_paths") or ([payload["file_path"]] if payload.get("file_path") else [])
if isinstance(reported, list):
candidates = [p for p in reported if isinstance(p, str)]
except (ValueError, TypeError):
pass
for path in candidates:
if os.path.isfile(path) and os.path.getsize(path) > 0:
return path
return requested
class _SyncSentencePipeline:
"""Overlap per-sentence synthesis with playback for non-streaming providers.
One single-thread synthesis executor (FIFO; providers never see concurrent calls) feeds one
playback worker through a small bounded queue, so sentence n+1 synthesizes while n plays;
the bound keeps lookahead/temp files small and gives the caller backpressure.
``text_to_speech_tool`` / ``play_audio_file`` are resolved late so test patches apply."""
def __init__(self, stop_event: threading.Event, *, lookahead: int = 2):
self._stop = stop_event
self._queue: "queue.Queue[Optional[tuple[str, Future]]]" = queue.Queue(maxsize=max(1, lookahead))
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="tts-sync-synth")
self._player = threading.Thread(target=self._drain, name="tts-sync-play", daemon=True)
self._player.start()
def speak(self, cleaned: str) -> None:
"""Queue one sentence. Blocks only when the lookahead bound is full."""
if not self._stop.is_set():
self._queue.put((cleaned, self._executor.submit(self._synthesize_to_tmp, cleaned)))
def close(self) -> None:
"""Flush queued sentences in order (skipped if stopped), then join."""
self._queue.put(None)
self._player.join()
self._executor.shutdown(wait=True)
def _synthesize_to_tmp(self, cleaned: str) -> Optional[str]:
if self._stop.is_set():
return None
tmp_path = None
try:
fd, tmp_path = tempfile.mkstemp(suffix=".mp3")
os.close(fd)
raw = _origin().text_to_speech_tool(text=cleaned, output_path=tmp_path)
written = _first_written_artifact(raw, tmp_path)
if os.path.abspath(written) != os.path.abspath(tmp_path):
_unlink_quietly(tmp_path) # provider wrote elsewhere: the placeholder is empty
return written
except Exception as exc:
logger.warning("Sync per-sentence TTS synthesis failed: %s", exc)
_unlink_quietly(tmp_path)
return None
def _drain(self) -> None:
for _sentence, future in iter(self._queue.get, None):
tmp_path = None
try:
tmp_path = future.result()
if tmp_path and not self._stop.is_set() and os.path.isfile(tmp_path) and os.path.getsize(tmp_path) > 0:
from tools.voice_mode import play_audio_file
play_audio_file(tmp_path)
except Exception as exc:
logger.warning("Sync per-sentence TTS failed: %s", exc)
finally:
_unlink_quietly(tmp_path)
class _StreamerPlayback:
"""Prefetch + FIFO playback for a chunked :class:`StreamingTTSProvider`.
``speak(text)`` starts ``streamer.stream()`` immediately on a prefetch thread (at most 3 in
flight) buffering into a bounded per-sentence queue; one playback worker drains those in order,
so sentence N+1 arrives while N plays. Output is a PortAudio stream when one opened, else temp
WAV files; a failing write is retried on a reinitialized stream up to ``_MAX_REINIT`` times."""
_MAX_REINIT = 3
_CHUNK_QUEUE_MAX = 64
def __init__(self, streamer, stop_event: threading.Event):
self.streamer, self.stop_event = streamer, stop_event
# The device is opened lazily, once the first sentence's first chunk has arrived: an
# OpenAI-compatible endpoint reports its real PCM rate in the response headers, so
# ``streamer.sample_rate`` is only trustworthy after the request answered (#76466).
self.output_stream = None
self._use_device = self._device_usable()
self._audio_queue: "queue.Queue[Optional[queue.Queue[Optional[bytes]]]]" = queue.Queue()
self._prefetch_threads: List[threading.Thread] = []
self._prefetch_sem = threading.Semaphore(3)
self._worker = threading.Thread(target=self._playback_worker, daemon=True)
self._worker.start()
def _create_output_stream(self):
sd = _origin()._import_sounddevice()
stream = sd.OutputStream(
samplerate=self.streamer.sample_rate, channels=self.streamer.channels, dtype="int16")
stream.start()
return stream
def _device_usable(self) -> bool:
# macOS skips sounddevice entirely: PortAudio/CoreAudio init triggers a
# kTCCServiceMediaLibrary prompt though output needs no media-library access.
# False routes every sentence through tempfile -> afplay. See PR #62601 / #13291.
if platform.system() == "Darwin":
return False
try:
_origin()._import_sounddevice()
except (ImportError, OSError) as exc:
logger.debug("sounddevice not available, streamer→tempfile: %s", exc)
return False
return True
def _ensure_output_stream(self) -> bool:
"""Open PortAudio at the streamer's *current* rate, reopening it when the rate changed
(a different endpoint answered); False routes the sentence through a temp WAV."""
rate = int(self.streamer.sample_rate)
if self._current_stream is not None and self._current_rate == rate:
return True
if self._current_stream is None and self._reinit_count >= self._MAX_REINIT:
return False
self.close_output_stream()
try:
self.output_stream = self._create_output_stream()
except Exception as exc:
logger.warning("sounddevice OutputStream failed: %s", exc)
self.output_stream, self._reinit_count = None, self._MAX_REINIT # don't retry per sentence
self._current_stream, self._current_rate = self.output_stream, rate
return self._current_stream is not None
def close_output_stream(self) -> None:
"""Always release the device so a later stream can open it."""
if self.output_stream is not None:
with contextlib.suppress(Exception):
self.output_stream.stop()
self.output_stream.close()
def speak(self, text: str) -> None:
"""Start ``streamer.stream(text)`` and prefetch its chunks immediately."""
try:
audio_iter = self.streamer.stream(text)
except Exception as exc:
logger.warning("Streaming TTS synthesis failed: %s", exc)
return
self._prefetch_sem.acquire()
chunk_queue: "queue.Queue[Optional[bytes]]" = queue.Queue(maxsize=self._CHUNK_QUEUE_MAX)
self._audio_queue.put(chunk_queue)
self._prefetch_threads.append(threading.Thread(
target=self._consume_to_queue, args=(audio_iter, chunk_queue), daemon=True))
self._prefetch_threads[-1].start()
def _consume_to_queue(self, audio_iter: Iterator[bytes], chunk_queue: "queue.Queue[Optional[bytes]]") -> None:
try:
for chunk in audio_iter:
if self.stop_event.is_set():
logger.info("TTS CUT: prefetch cancelled (stop_event set mid-sentence) — partial audio only")
break
chunk_queue.put(chunk, timeout=30.0)
except Exception as exc:
logger.warning("TTS CUT: streaming TTS prefetch failed mid-sentence (partial audio only): %s", exc)
finally:
chunk_queue.put(None) # sentinel: no more chunks
self._prefetch_sem.release()
def _play_sentence_via_tempfile(self, chunk_queue) -> None:
chunks = _drain_chunks(chunk_queue) # drained first: the rate is final once chunks exist
_play_via_tempfile(chunks, self.stop_event, self.streamer.sample_rate)
def _for_each_sentence(self, play: Callable[[queue.Queue], None]) -> None:
"""Feed queued sentences to *play* in order until the end sentinel; stopped sentences are skipped."""
for chunk_queue in iter(self._audio_queue.get, None):
if not self.stop_event.is_set():
play(chunk_queue)
def _write_pcm(self, buf: bytes) -> None:
self._current_stream.write(self._np.frombuffer(buf, dtype="<i2").reshape(-1, 1))
def _recover_stream(self) -> bool:
"""Close the broken PortAudio stream and open a fresh one after a failed write; False once
``_MAX_REINIT`` is exhausted (remaining sentences go through temp files)."""
if self._reinit_count >= self._MAX_REINIT:
logger.warning(
"TTS: PortAudio reinit exhausted after %d attempts, falling back to tempfile for remaining sentences",
self._MAX_REINIT)
self._current_stream = None
return False
self._reinit_count += 1
self.close_output_stream()
try:
self.output_stream = self._create_output_stream()
logger.info("TTS: PortAudio output stream reinitialized after error")
except Exception as exc:
logger.warning("TTS: PortAudio stream reinit failed: %s", exc)
self.output_stream = None
self._current_stream = self.output_stream
return self._current_stream is not None
def _play_sentence_via_stream(self, chunk_queue) -> None:
"""Write one sentence's PCM to PortAudio; after an unrecoverable write failure the rest is dropped."""
chunks = iter(chunk_queue.get, None)
first = next(chunks, None) # blocks until the endpoint answered: the rate is final now
if first is None:
return
chunks = itertools.chain([first], chunks)
if not self._ensure_output_stream():
_play_via_tempfile(list(chunks), self.stop_event, self.streamer.sample_rate)
return
for aligned in _align_int16_chunks(chunks, self.stop_event, pad_tail=False):
try:
self._write_pcm(aligned)
except Exception as write_exc:
logger.warning("PortAudio write failed, attempting stream reinit: %s", write_exc)
if not self._recover_stream():
return
with contextlib.suppress(Exception):
self._write_pcm(aligned)
def _playback_worker(self) -> None:
"""Single consumer: play audio segments from the queue in order."""
if not self._use_device:
self._for_each_sentence(self._play_sentence_via_tempfile)
return
import numpy as _np
try:
from tools.voice_mode import mark_audio_output_active
except Exception:
mark_audio_output_active = lambda _active: None # noqa: E731
self._np, self._reinit_count, self._current_stream, self._current_rate = _np, 0, None, None
mark_audio_output_active(True)
try:
self._for_each_sentence(self._play_sentence_via_stream)
finally:
mark_audio_output_active(False)
def finish(self) -> None:
"""Send the end sentinel, then wait for playback and prefetch threads."""
self._audio_queue.put(None)
self._worker.join(timeout=300.0)
for t in self._prefetch_threads:
t.join(timeout=10.0)
self.close_output_stream()
def stream_tts_to_speaker(
text_queue: queue.Queue, stop_event: threading.Event, tts_done_event: threading.Event,
display_callback: Optional[Callable[[str], None]] = None, provider: Optional[str] = None):
"""Consume text deltas from *text_queue*, cut into sentences, speak each the moment it's ready.
A registered streaming provider plays chunked PCM; every other provider is spoken
per-sentence via ``text_to_speech_tool``, so audio still starts on sentence one. Protocol:
``str`` deltas, a ``None`` sentinel = end-of-text (flush), *stop_event* aborts (barge-in),
*tts_done_event* is **set** in ``finally`` so continuous voice mode knows playback finished."""
tts_done_event.clear()
origin = _origin()
sync_pipeline: Optional[_SyncSentencePipeline] = None
playback: Optional[_StreamerPlayback] = None
try:
tts_config = origin._load_tts_config()
# Prefer a chunked streamer for low time-to-first-audio; otherwise per-sentence sync
# synthesis (universal — edge + every non-streamer).
from tools.tts_streaming import SentenceChunker, resolve_streaming_provider
streamer = resolve_streaming_provider(tts_config, preferred=provider)
stream_max_len = 0
if streamer is None:
sync_pipeline = _SyncSentencePipeline(stop_event)
else:
with contextlib.suppress(Exception):
stream_max_len = origin._resolve_max_text_length(
provider or origin._get_provider(tts_config), tts_config)
playback = _StreamerPlayback(streamer, stop_event)
chunker = SentenceChunker.from_config(tts_config)
spoken_sentences: list[str] = [] # skip duplicate/near-duplicate sentences (LLM repetition)
def _speak_sentence(sentence: str) -> None:
if stop_event.is_set():
return
cleaned = _strip_markdown_for_tts(sentence).strip()
if not cleaned:
return
cleaned_lower = cleaned.lower().rstrip(".!,")
if any(prev.lower().rstrip(".!,") == cleaned_lower for prev in spoken_sentences):
return
spoken_sentences.append(cleaned)
if display_callback is not None:
display_callback(sentence) # raw sentence on screen before TTS processing
if sync_pipeline is not None:
sync_pipeline.speak(cleaned)
return
if stream_max_len and len(cleaned) > stream_max_len:
cleaned = cleaned[:stream_max_len]
playback.speak(cleaned)
while not stop_event.is_set():
try:
delta = text_queue.get(timeout=0.5)
except queue.Empty:
delta = "" # idle producer: flush a long buffer instead of sitting on it
sentences = chunker.flush() if len(chunker.buf) > 100 else ()
else:
sentences = chunker.flush() if delta is None else chunker.feed(delta)
for sentence in sentences:
_speak_sentence(sentence)
if delta is None:
break
with contextlib.suppress(queue.Empty):
while True:
text_queue.get_nowait()
except Exception as exc:
logger.warning("Streaming TTS pipeline error: %s", exc)
finally:
# Flush the sync pipeline first: queued sentences finish playing (or are skipped when
# stop_event is set) BEFORE tts_done_event fires, so continuous voice mode never reopens
# the mic over its own voice. The end sentinel lives in finally: so an exception in the
# text pump still lets the playback worker exit.
if sync_pipeline is not None:
with contextlib.suppress(Exception):
sync_pipeline.close()
if playback is not None:
playback.finish()
tts_done_event.set()