Files
hermes-agent/hermes_cli/web_routers/audio.py
Austin Pickett ca38caeea0 fix(dashboard+auth): cap HTTP JSON response body reads
Dashboard-side urllib readers (gateway health probe, ElevenLabs voices)
and self-hosted OIDC httpx readers (discovery, token exchange, revocation)
buffered remote response bodies with no size bound — a hostile or
compromised upstream could exhaust memory. Both halves now read with a
1 MiB hard cap: a shared _read_dashboard_json_response for urllib JSON
reads and a streaming _request_limited_response for IdP endpoints that
prechecks Content-Length and caps the chunked body regardless.

Adapted from #54809 and #55122 (credit @ooiuuii) to current main, where
the token exchange lives in plugins/dashboard_auth/_shared.py and the
health probe in hermes_cli/web_server_gateway.py.

Fixes #54808, Fixes #55121
2026-10-06 12:28:55 -04:00

694 lines
29 KiB
Python

"""Audio dashboard routes: transcription upload, voice config, ElevenLabs voices, TTS speak/lease and the speak-stream WebSocket.
Extracted from ``hermes_cli.web_server``; helpers/state that tests monkeypatch on
``web_server`` stay there and are late-bound (cycle-safe).
"""
import base64
import binascii
import contextlib
import logging
import queue
import tempfile
import threading
import asyncio
import json
import os
import urllib.parse
import urllib.request
from fastapi import APIRouter
from hermes_cli.web_routers._common import http_failure
from hermes_cli.web_deps import late
from hermes_cli.web_server_chat import _ws_auth_ok, _ws_request_is_allowed
from hermes_cli.web_server_gateway import _read_dashboard_json_response, _split_text_for_speak_stream
from fastapi import HTTPException, WebSocket, WebSocketDisconnect
from hermes_cli.web_models import (
AudioTranscriptionRequest,
STTLeaseRequest,
TTSSpeakRequest,
TTSLeaseRequest,
VoiceLiveSessionRequest,
)
from typing import Any, Dict, Optional
_log = logging.getLogger("hermes_cli.web_server")
router = APIRouter()
# Late-bound so a test's monkeypatch on the owning module wins at call time.
_config_profile_scope = late("_config_profile_scope", "hermes_cli.web_server_profiles")
_voice_list_error_logged_once = late("_voice_list_error_logged_once")
load_env = late("load_env", "hermes_cli.config")
_AUDIO_MIME_EXTENSIONS: Dict[str, str] = {
"audio/aac": ".aac", "audio/flac": ".flac", "audio/m4a": ".m4a", "audio/mp3": ".mp3",
"audio/mp4": ".mp4", "audio/mpeg": ".mp3", "audio/ogg": ".ogg", "audio/wav": ".wav",
"audio/wave": ".wav", "audio/webm": ".webm", "audio/x-m4a": ".m4a", "audio/x-wav": ".wav",
"video/webm": ".webm",
}
_MAX_TRANSCRIPTION_UPLOAD_BYTES = 25 * 1024 * 1024
_SPEAK_MIME_BY_EXT = {
".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".opus": "audio/ogg", ".wav": "audio/wav",
".flac": "audio/flac",
}
def _unlink_quietly(path: str) -> None:
try:
os.unlink(path)
except OSError:
pass
async def _run_config_scoped(profile: Optional[str], fn):
"""Run ``fn()`` on a worker thread under the config-only profile scope.
Home-only contextvar scope, NOT ``_profile_scope``: these calls block for a
provider round-trip and only need config/.env resolution, while
``_profile_scope`` holds a process-global skills lock for its entire body.
"""
def _scoped():
with _config_profile_scope(profile):
return fn()
return await asyncio.get_running_loop().run_in_executor(None, _scoped)
def _audio_extension_for_mime(mime_type: str) -> str:
normalized = (mime_type or "").split(";", 1)[0].strip().lower()
return _AUDIO_MIME_EXTENSIONS.get(normalized, ".webm")
@router.post("/api/audio/transcribe")
async def transcribe_audio_upload(
payload: AudioTranscriptionRequest, profile: Optional[str] = None
):
data_url = (payload.data_url or "").strip()
if not data_url.startswith("data:") or "," not in data_url:
raise HTTPException(status_code=400, detail="Invalid audio payload")
header, encoded = data_url.split(",", 1)
if ";base64" not in header:
raise HTTPException(status_code=400, detail="Audio payload must be base64 encoded")
mime_type = (payload.mime_type or header[5:].split(";", 1)[0] or "audio/webm").strip()
normalized_mime_type = mime_type.split(";", 1)[0].lower()
if not (normalized_mime_type.startswith("audio/") or normalized_mime_type == "video/webm"):
raise HTTPException(status_code=400, detail="Payload must be an audio recording")
try:
audio_bytes = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError):
raise HTTPException(status_code=400, detail="Audio payload is not valid base64")
if not audio_bytes:
raise HTTPException(status_code=400, detail="Audio recording is empty")
if len(audio_bytes) > _MAX_TRANSCRIPTION_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Audio recording is too large")
temp_path = ""
try:
with http_failure("Desktop voice transcription failed", 500, "Transcription failed"):
with tempfile.NamedTemporaryFile(
prefix="hermes-desktop-voice-", suffix=_audio_extension_for_mime(mime_type),
delete=False,
) as tmp:
tmp.write(audio_bytes)
temp_path = tmp.name
# transcribe_recording (not raw transcribe_audio): filters Whisper
# hallucinations and maps provider "empty transcript" errors to a
# successful empty result — the live voice loop treats "" as silence
# and re-listens instead of surfacing a 400 on every quiet turn.
from tools.voice_mode import transcribe_recording
result = await _run_config_scoped(profile, lambda: transcribe_recording(temp_path))
finally:
if temp_path:
_unlink_quietly(temp_path)
if not result.get("success"):
err = result.get("error") or "Transcription failed"
# No speech detected is a normal outcome for VAD/continuous voice loops
# (re-listening on silence), not an error: return an empty transcript so
# the client quietly re-listens instead of showing a failure toast.
if "empty transcript" in err.lower():
return {"ok": True, "transcript": "", "provider": result.get("provider")}
raise HTTPException(status_code=400, detail=err)
return {
"ok": True, "transcript": str(result.get("transcript") or "").strip(),
"provider": result.get("provider"),
}
@router.get("/api/audio/voice-config")
async def get_client_voice_config(profile: Optional[str] = None):
"""The active profile's STT/TTS config for CLIENT-DIRECT voice.
Lets the desktop cut the audio relay hop: mic audio goes straight to the
profile's STT provider and reply text is synthesized on the client with
the profile's TTS provider — the desktop↔gateway link carries only text.
Providers that can only run on this host (local whisper, edge-tts,
command/plugin providers) resolve to ``{"mode": "relay"}`` and the
desktop keeps using the /api/audio/* relay endpoints.
Same trust boundary as every profile-scoped route: the caller is an
authenticated client that can already drive the agent. Keys in the
response are held in client memory only, never persisted client-side.
Gate: ``voice.client_direct`` in config.yaml (default true).
"""
from tools.voice_client_config import resolve_client_voice_config
try:
result = await _run_config_scoped(profile, resolve_client_voice_config)
except HTTPException:
raise # an unknown ?profile= is the scope's 404, not a reason to fall back to relay
except Exception:
_log.exception("Client voice-config resolution failed")
fallback = {"mode": "relay", "reason": "resolution error"}
return {"ok": True, "stt": fallback, "tts": dict(fallback)}
return {"ok": True, **result}
@router.get("/api/audio/voice-live/status")
async def get_voice_live_status(profile: Optional[str] = None):
"""Which voice chat mode the profile selected (``chained`` | ``gpt-live``) and whether GPT-Live
can start. Non-secret: the desktop decides which conversation engine to mount from this."""
from tools.voice_live import resolve_gpt_live_status
with http_failure("GPT-Live status resolution failed", 500, "GPT-Live status failed"):
result = await _run_config_scoped(profile, resolve_gpt_live_status)
return {"ok": True, **result}
@router.post("/api/audio/voice-live/session")
async def create_voice_live_session(payload: VoiceLiveSessionRequest, profile: Optional[str] = None):
"""Exchange the renderer's WebRTC SDP offer for a GPT-Live session answer.
The project API key stays on this host; the renderer only receives the session id and the
SDP answer. Client delegation is fixed at creation: every ``session.delegation.created`` the
renderer receives becomes a Hermes turn on the session it belongs to.
"""
from tools.voice_live import create_webrtc_session
# Validate emptiness only: the vendor's SDP parser needs the offer byte-exact, including the
# trailing CRLF (a stripped offer answers 400 "failed to unmarshal SDP: EOF").
sdp = payload.sdp or ""
if not sdp.strip():
raise HTTPException(status_code=400, detail="An SDP offer is required")
try:
result = await _run_config_scoped(profile, lambda: create_webrtc_session(sdp, payload.history))
except ValueError as exc:
raise HTTPException(status_code=503, detail=str(exc))
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc))
return {"ok": True, **result}
def _elevenlabs_voice_label(voice: Dict[str, Any]) -> str:
name = str(voice.get("name") or voice.get("voice_id") or "Voice").strip()
category = str(voice.get("category") or "").strip()
return f"{name} ({category})" if category else name
@router.get("/api/audio/elevenlabs/voices")
async def get_elevenlabs_voices(profile: Optional[str] = None):
"""Return ElevenLabs voices when an API key is configured.
The desktop UI uses this for the ``tts.elevenlabs.voice_id`` dropdown.
Only non-secret voice metadata is returned; the API key stays server-side.
"""
# Config-only scope (await-safe): the key lookup reads the requested
# profile's .env, matching the profile the settings UI writes to.
with _config_profile_scope(profile):
api_key = (load_env().get("ELEVENLABS_API_KEY") or "").strip()
if not api_key:
# Fallback for env-only deployments — scope-aware: under multiplex
# os.environ may hold another profile's key, so honor the installed
# scope's verdict. Only the unscoped default-profile path
# (UnscopedSecretError) reads the env; any other failure stays empty.
try:
from agent.secret_scope import UnscopedSecretError, get_secret
try:
api_key = (get_secret("ELEVENLABS_API_KEY") or "").strip()
except UnscopedSecretError:
api_key = (os.environ.get("ELEVENLABS_API_KEY") or "").strip()
except Exception:
pass
if not api_key:
return {"available": False, "voices": []}
request = urllib.request.Request(
"https://api.elevenlabs.io/v1/voices",
headers={"Accept": "application/json", "xi-api-key": api_key},
)
try:
loop = asyncio.get_running_loop()
def _fetch() -> Dict[str, Any]:
with urllib.request.urlopen(request, timeout=10) as response:
return _read_dashboard_json_response(response)
payload = await loop.run_in_executor(None, _fetch)
except urllib.error.HTTPError as exc:
# An auth failure (bad/expired/scoped key) is a persistent, user-fixable
# state and the desktop polls this on every settings open/focus, so
# treat 401/403 as "integration unavailable": 200 to the UI and log at
# most once until the error signature changes.
if exc.code in (401, 403):
if _voice_list_error_logged_once(f"http-{exc.code}"):
_log.info("ElevenLabs voices unavailable: %s — check ELEVENLABS_API_KEY", exc)
return {"available": False, "voices": [], "error": "unauthorized"}
if _voice_list_error_logged_once(f"http-{exc.code}"):
_log.warning("ElevenLabs voice list failed: %s", exc)
raise HTTPException(status_code=502, detail="Could not load ElevenLabs voices")
except Exception as exc:
if _voice_list_error_logged_once(str(exc)):
_log.warning("ElevenLabs voice list failed: %s", exc)
raise HTTPException(status_code=502, detail="Could not load ElevenLabs voices")
_voice_list_error_logged_once(None) # success — re-arm logging for next failure
voices = []
for voice in payload.get("voices") or []:
if not isinstance(voice, dict):
continue
voice_id = str(voice.get("voice_id") or "").strip()
if not voice_id:
continue
voices.append({
"voice_id": voice_id, "name": str(voice.get("name") or voice_id),
"label": _elevenlabs_voice_label(voice),
})
voices.sort(key=lambda item: str(item.get("label") or "").lower())
return {"available": True, "voices": voices}
@router.post("/api/audio/speak")
async def speak_text(payload: TTSSpeakRequest, profile: Optional[str] = None):
"""Synthesize speech and return audio as base64 data URL.
Used by the desktop voice-conversation mode to play back assistant
responses without exposing the on-disk file path; reuses the TTS provider
chain configured under ``tts.`` in config.yaml.
"""
text = (payload.text or "").strip()
if not text:
raise HTTPException(status_code=400, detail="Text is required")
# _config_profile_scope raises 400/404 for a bad profile — pass it
# through instead of masking it as a 500 synthesis failure.
with http_failure("Desktop voice TTS failed", 500, "Speech synthesis failed"):
from tools.tts_tool import text_to_speech_tool
result_json = await _run_config_scoped(profile, lambda: text_to_speech_tool(text))
try:
result = json.loads(result_json) if isinstance(result_json, str) else result_json
except Exception:
raise HTTPException(status_code=500, detail="Invalid TTS response")
if not result.get("success"):
raise HTTPException(
status_code=400, detail=result.get("error") or "Speech synthesis failed",
)
file_path = result.get("file_path")
if not file_path or not os.path.isfile(file_path):
raise HTTPException(status_code=500, detail="Audio file missing")
mime_type = _SPEAK_MIME_BY_EXT.get(os.path.splitext(file_path)[1].lower(), "audio/mpeg")
def _read_and_unlink() -> bytes:
# Off-loop: synthesized audio can be several MB; reading it inline
# blocks the uvicorn event loop. Unlink rides the same thread hop so
# the temp file cannot outlive an early return.
try:
with open(file_path, "rb") as fh:
return fh.read()
finally:
_unlink_quietly(file_path)
try:
audio_bytes = await asyncio.to_thread(_read_and_unlink)
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not read audio: {exc}")
encoded = base64.b64encode(audio_bytes).decode("ascii")
return {
"ok": True, "data_url": f"data:{mime_type};base64,{encoded}", "mime_type": mime_type,
"provider": result.get("provider"),
}
@router.post("/api/audio/tts-lease")
async def tts_lease(payload: TTSLeaseRequest, profile: Optional[str] = None):
"""Desktop TTS-output toggles as warm-up / release signals.
``active: true`` registers a lease on the TTS engine and pre-loads the
configured provider (local model, lazily-installed SDK) so the first spoken
reply doesn't pay the load as dead air; ``active: false`` drops the lease
and, once no surface holds one, unloads resident local models. Blocking
work runs off the event loop. Warm-up failures are reported in the body,
never as an HTTP error — the toggle must succeed even when preload fails.
"""
lease = (payload.lease or "").strip()
if not lease:
raise HTTPException(status_code=400, detail="lease is required")
def _apply():
from tools.tts_tool_lifecycle import acquire_tts_lease, release_tts_lease
if payload.active:
with _config_profile_scope(profile):
return acquire_tts_lease(lease)
# Release reads the requester's keep_warm_seconds, but must drop the lease even when
# that profile is gone — a stuck lease pins the local model in memory.
try:
with _config_profile_scope(profile):
return release_tts_lease(lease)
except HTTPException:
return release_tts_lease(lease)
try:
result = await asyncio.get_running_loop().run_in_executor(None, _apply)
except HTTPException:
raise
except Exception as exc:
_log.warning("TTS lease %s (%s) failed: %s", lease, payload.active, exc)
result = {"leases": None, "action": "error", "error": str(exc)}
return {"ok": True, "lease": lease, "active": payload.active, **result}
class _SyncSentencePCMStreamer:
"""Speak one sentence through the sync TTS tool and yield int16 mono PCM.
Not a second provider: ``text_to_speech_tool`` is the same stack the CLI
speaker uses for edge and every other non-chunked provider. The desktop
socket only plays PCM, so the written file is decoded before it is sent.
``sample_rate`` is updated before the first yield, matching the chunked
streamers whose rate is only final once synthesis has answered.
"""
sample_rate = 24000
channels = 1
def stream(self, text: str):
pcm, rate = _sync_sentence_to_pcm(text)
if rate:
self.sample_rate = rate
if pcm:
yield pcm
def _sync_sentence_to_pcm(text: str) -> tuple:
"""Synthesize *text* with the configured sync provider and return PCM."""
from tools.tts_tool import text_to_speech_tool
fd, tmp_path = tempfile.mkstemp(suffix=".mp3")
os.close(fd)
extra = None
try:
raw = text_to_speech_tool(text=text, output_path=tmp_path)
try:
payload = json.loads(raw) if isinstance(raw, str) else raw
except (TypeError, ValueError) as exc:
raise RuntimeError("Invalid TTS response") from exc
if not isinstance(payload, dict) or not payload.get("success"):
detail = str(payload.get("error") or "") if isinstance(payload, dict) else ""
raise RuntimeError(detail or "Speech synthesis failed")
written = payload.get("file_path") or tmp_path
if not isinstance(written, str) or not os.path.isfile(written) or os.path.getsize(written) <= 0:
raise RuntimeError("Audio file missing")
if os.path.abspath(written) != os.path.abspath(tmp_path):
extra = written
pcm, rate = _audio_file_to_pcm(written)
if not pcm:
raise RuntimeError("TTS audio decoded to silence")
return pcm, rate
finally:
_unlink_quietly(tmp_path)
if extra:
_unlink_quietly(extra)
def _audio_file_to_pcm(path: str) -> tuple:
"""Decode *path* to int16 mono PCM. WAV via the stdlib; anything else via ffmpeg."""
with open(path, "rb") as fh:
head = fh.read(12)
if len(head) >= 12 and head.startswith(b"RIFF") and head[8:12] == b"WAVE":
pcm, rate = _wav_s16le_mono(path)
if pcm:
return pcm, rate
return _ffmpeg_s16le_mono(path)
def _wav_s16le_mono(path: str) -> tuple:
import wave
try:
with wave.open(path, "rb") as wf:
if wf.getsampwidth() != 2 or wf.getnchannels() != 1 or wf.getframerate() <= 0:
return b"", 0
return wf.readframes(wf.getnframes()), int(wf.getframerate())
except (wave.Error, EOFError, OSError):
return b"", 0
def _ffmpeg_s16le_mono(path: str) -> tuple:
import shutil
from tools.tts_tool_delivery import _ffmpeg_run
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
raise RuntimeError("ffmpeg is required to decode TTS audio for speak-stream")
result = _ffmpeg_run(
ffmpeg,
["-i", path, "-f", "s16le", "-ac", "1", "-ar", "24000", "-loglevel", "error", "pipe:1"],
timeout=60,
)
if result.returncode != 0 or not result.stdout:
stderr = (result.stderr or b"").decode("utf-8", "replace")[:200]
raise RuntimeError(f"TTS audio decode failed: {stderr}")
return result.stdout, 24000
@router.post("/api/audio/stt-lease")
async def stt_lease(payload: STTLeaseRequest, profile: Optional[str] = None):
"""Desktop voice-input sessions as STT warm-up / release signals.
``active: true`` registers a lease and pre-loads the configured local STT
model (first-use download + load) so the transcription request doesn't pay
the cold cost inside its timeout; ``active: false`` drops the lease. The
model stays resident after the last release — it is shared with the
gateway/CLI surfaces in this process, and ``stt.local.unload_after_idle_seconds``
still governs eviction. Blocking work runs off the event loop. Warm-up
failures are reported in the body, never as an HTTP error — recording must
start even when preload fails.
"""
lease = (payload.lease or "").strip()
if not lease:
raise HTTPException(status_code=400, detail="lease is required")
def _apply():
from tools.stt_lease import acquire_stt_lease, release_stt_lease
if payload.active:
with _config_profile_scope(profile):
return acquire_stt_lease(lease)
return release_stt_lease(lease)
try:
result = await asyncio.get_running_loop().run_in_executor(None, _apply)
except HTTPException:
raise
except Exception as exc:
_log.warning("STT lease %s (%s) failed: %s", lease, payload.active, exc)
result = {"leases": None, "action": "error", "error": str(exc)}
return {"ok": True, "lease": lease, "active": payload.active, **result}
@router.websocket("/api/audio/speak-stream")
async def speak_stream_ws(ws: "WebSocket") -> None:
"""Streaming TTS for the desktop: text in, raw int16 PCM frames out.
The socket is a per-reply speech *session*: the client feeds text
incrementally as LLM deltas arrive, the server cuts sentences
(``SentenceChunker`` — same cutter as the CLI/TUI speaker pipeline) and
streams each one's PCM the moment it's ready, so speech overlaps generation.
Protocol:
client → ``{"text": "..."}`` frames (incremental; may combine with done),
``{"done": true}`` when the reply is complete,
``{"stop": true}`` or disconnect = barge-in
server → ``{"type": "start", "sample_rate": N, "channels": 1}`` (sent
with the first PCM frame, once the provider's rate is final),
binary PCM frames, then ``{"type": "end"}``
server → ``{"type": "fallback"}`` only when sentence synthesis produced
no audio. Providers with no chunked API (edge, the default)
still speak per sentence via ``text_to_speech_tool`` and stream
that PCM — the client POST is the last resort, not the Edge path.
"""
if not _ws_auth_ok(ws):
await ws.close(code=4401)
return
if not _ws_request_is_allowed(ws):
await ws.close(code=4403)
return
await ws.accept()
# Profile via query param, like /api/pty and /api/console: the provider
# chain + API keys must resolve from the requesting profile's config, not
# the dashboard's own — at resolve time AND in the synthesis thread.
profile = (ws.query_params.get("profile") or "").strip() or None
loop = asyncio.get_running_loop()
def _resolve():
from tools.tts_streaming import resolve_streaming_provider
from tools.tts_tool import _get_provider, _load_tts_config, _resolve_max_text_length
with _config_profile_scope(profile):
cfg = _load_tts_config()
streamer = resolve_streaming_provider(cfg)
cap = _resolve_max_text_length(_get_provider(cfg), cfg)
return streamer, cap, cfg
try:
streamer, cap, cfg = await loop.run_in_executor(None, _resolve)
except Exception:
_log.exception("speak-stream provider resolution failed")
streamer, cap, cfg = None, 0, {}
if streamer is None:
# Edge (the default) and every other non-chunked provider still have a
# documented per-sentence path. type=fallback here is what makes Desktop
# wait for the whole reply and POST it to /api/audio/speak.
streamer = _SyncSentencePCMStreamer()
# The start frame is deferred until the first PCM chunk (or end-of-speech):
# the OpenAI-compatible streamer only learns the endpoint's real rate from
# the response headers inside stream(), and the client opens its
# AudioContext at whatever rate the start frame carries.
start_sent = False
async def _send_start():
nonlocal start_sent
if start_sent:
return
start_sent = True
await ws.send_json(
{"type": "start", "sample_rate": streamer.sample_rate, "channels": streamer.channels}
)
stop = threading.Event()
produced_audio = False
synthesis_failed = False
text_q: queue.Queue = queue.Queue() # str deltas; None = end-of-text
chunks: asyncio.Queue = asyncio.Queue() # PCM out; None = synthesis done
def _produce():
# Every streamer re-resolves its API key on each stream() call (tts_streaming ->
# resolve_provider_secret), so the whole synthesis body runs under the requesting
# profile's scope, not only the resolve step above (else the launch profile's key).
with _config_profile_scope(profile):
_synthesize()
def _synthesize():
nonlocal produced_audio, synthesis_failed
from tools.tts_streaming import SentenceChunker
from tools.tts_text_normalize import _strip_markdown_for_tts
chunker = SentenceChunker.from_config(cfg) # the requesting profile's tts.streaming.min_len
# The session stays open for a whole agent turn and no text arrives
# during tool execution, so without an idle flush a narration line with
# no trailing whitespace ("Let me check.") sits in the chunker until
# end-of-turn. Mirror the CLI speaker pipeline: poll with a timeout and
# flush when the producer goes idle — immediately when the buffer ends
# on sentence punctuation, after a longer quiet spell otherwise.
idle_poll_seconds = 0.5
idle_polls_before_force_flush = 4 # ~2s of silence
def _sentences():
idle_polls = 0
while not stop.is_set():
try:
delta = text_q.get(timeout=idle_poll_seconds)
except queue.Empty:
idle_polls += 1
buffered = chunker.buf.strip()
if not buffered or ("<think" in chunker.buf and "</think>" not in chunker.buf):
continue
if buffered.endswith((".", "!", "?", "…", ":")) or idle_polls >= idle_polls_before_force_flush:
yield from chunker.flush()
continue
idle_polls = 0
if delta is None:
yield from chunker.flush()
return
yield from chunker.feed(delta)
try:
for sentence in _sentences():
cleaned = _strip_markdown_for_tts(sentence)
if not cleaned:
continue
for piece in _split_text_for_speak_stream(cleaned, cap):
for chunk in streamer.stream(piece):
if stop.is_set():
return
produced_audio = True
loop.call_soon_threadsafe(chunks.put_nowait, chunk)
except Exception as exc:
_log.warning("speak-stream synthesis failed: %s", exc)
synthesis_failed = True
finally:
loop.call_soon_threadsafe(chunks.put_nowait, None)
threading.Thread(target=_produce, daemon=True).start()
async def _pump_client():
# Text frames feed synthesis; done ends the text; stop/disconnect
# (or any unparseable frame) is barge-in.
try:
while True:
frame = json.loads(await ws.receive_text())
if frame.get("text"):
text_q.put(str(frame["text"]))
if frame.get("stop"):
break
if frame.get("done"):
text_q.put(None)
except Exception:
pass
stop.set()
text_q.put(None) # unblock the producer
pump = asyncio.ensure_future(_pump_client())
try:
while True:
chunk = await chunks.get()
if chunk is None:
break
await _send_start()
await ws.send_bytes(chunk)
if not stop.is_set():
# Fallback is the last resort: sentence synthesis was asked for and
# produced nothing. A normal edge reply has already streamed PCM.
if synthesis_failed and not produced_audio:
await ws.send_json({"type": "fallback"})
else:
await _send_start()
await ws.send_json({"type": "end"})
except (WebSocketDisconnect, RuntimeError):
pass
finally:
stop.set()
text_q.put(None)
pump.cancel()
with contextlib.suppress(Exception):
await ws.close()