diff --git a/tools/tts_command_provider.py b/tools/tts_command_provider.py index 284aceac74..5a636dc3bd 100644 --- a/tools/tts_command_provider.py +++ b/tools/tts_command_provider.py @@ -1,20 +1,12 @@ """Shared runner for user-configured shell ("command") TTS/STT providers. -``tools.tts_tool`` and ``tools.transcription_tools`` both let users declare a -provider as a shell command template with ``{placeholders}`` (``{{``/``}}`` stay -literal; values are shell-quoted for their surrounding quote context). This -module owns the quote-aware rendering, the idle-timeout process runner and the -generic ``
.providers.`` config readers; each origin module -re-imports them under its historical private names. TTS config shape:: - - tts: - provider: piper-en - providers: - piper-en: {type: command, command: "piper -f {output_path} < {input_path}", output_format: wav} - -TTS placeholders: ``{input_path}``/``{text_path}``, ``{output_path}``, ``{format}``, -``{voice}``, ``{model}``, ``{speed}``. Built-in provider names always win over a -same-named entry under ``providers``. +``tts.providers.: {type: command, command: "piper -f {output_path} < {input_path}"}`` +(and the ``stt.`` twin): ``{placeholders}`` are shell-quoted for their surrounding quote +context, ``{{``/``}}`` stay literal. Owns the quote-aware rendering, the idle-timeout +process runner and the generic ``
.providers.`` readers, re-imported by +``tts_tool``/``transcription_tools`` under their historical private names. TTS placeholders: +``{input_path}``/``{text_path}``, ``{output_path}``, ``{format}``, ``{voice}``, ``{model}``, +``{speed}``. Built-in provider names always win over a same-named ``providers`` entry. """ from __future__ import annotations @@ -143,14 +135,11 @@ def run_command_provider( command: str, timeout: float, env_passthrough: Optional[list] = None, ) -> subprocess.CompletedProcess: """Run a command-provider shell command with process-tree idle cleanup. - - ``timeout`` is an IDLE timeout, reset whenever the command emits output — a - slow-but-alive provider survives, a silently stalled one is killed. Child env - is scrubbed of Hermes secrets while propagating delegated-child lineage markers. - """ + ``timeout`` is an IDLE timeout, reset whenever the command emits output — a slow-but-alive + provider survives, a silently stalled one is killed. Child env is scrubbed of Hermes secrets + while propagating delegated-child lineage markers.""" from agent.delegation_context import delegated_child_subprocess_env from tools.environments.local import hermes_subprocess_env - scrubbed = hermes_subprocess_env(inherit_credentials=False) for key in env_passthrough or []: value = os.environ.get(key) @@ -199,13 +188,11 @@ def run_command_provider( continue chunks[name].append(chunk) deadline = time.monotonic() + timeout - if not timed_out: try: proc.wait(timeout=max(0.0, deadline - time.monotonic())) except subprocess.TimeoutExpired: timed_out = True - if timed_out: terminate_command_process_tree(proc) for reader in readers: @@ -223,10 +210,7 @@ def run_command_provider( return subprocess.CompletedProcess(command, proc.returncode, stdout, stderr) -# =========================================================================== -# Generic ``
.providers.`` config layer (TTS and STT share it) -# =========================================================================== - +# ---- Generic ``
.providers.`` config layer (TTS and STT share it) ---- def _get_provider_section(config: Dict[str, Any], name: str) -> Dict[str, Any]: """Return ``config[name]`` if it's a dict, else an empty dict.""" @@ -235,11 +219,8 @@ def _get_provider_section(config: Dict[str, Any], name: str) -> Dict[str, Any]: def _named_provider_config(config: Dict[str, Any], name: str, builtins: FrozenSet[str]) -> Dict[str, Any]: - """``
.providers.`` (canonical), else ``
.`` for non-built-in names only. - - The back-compat form is refused for built-ins so a user's ``openai:`` block - still means the OpenAI provider, not a custom command. - """ + """``
.providers.`` (canonical), else ``
.`` for non-built-in names only — + refused for built-ins so a user's ``openai:`` block still means the OpenAI provider, not a command.""" section = _get_provider_section(config, "providers").get(name) if isinstance(section, dict): return section @@ -329,14 +310,10 @@ def _generate_command_tts( text: str, output_path: str, provider_name: str, config: Dict[str, Any], tts_config: Dict[str, Any], ) -> str: """Generate speech by running a user-configured shell command; returns the audio path it wrote. - - Raises ``ValueError`` for invalid provider config and ``RuntimeError`` for - timeouts / non-zero exits / empty output. - """ + Raises ``ValueError`` for invalid provider config, ``RuntimeError`` for timeouts / non-zero exits / empty output.""" command_template = str(config.get("command") or "").strip() if not command_template: raise ValueError(f"tts.providers.{provider_name}.command is not configured") - output = Path(output_path).expanduser() output.parent.mkdir(parents=True, exist_ok=True) if output.exists(): @@ -361,7 +338,6 @@ def _generate_command_tts( raise RuntimeError( f"TTS provider '{provider_name}' exited with code {exc.returncode}: {command_failure_detail(exc)}" ) from exc - if not output.exists() or output.stat().st_size <= 0: raise RuntimeError(f"TTS provider '{provider_name}' produced no output at {output}") return str(output) diff --git a/tools/tts_streaming.py b/tools/tts_streaming.py index 66fbb9f146..f3f44029d2 100644 --- a/tools/tts_streaming.py +++ b/tools/tts_streaming.py @@ -27,9 +27,7 @@ _STREAM_SENTENCE_BYTE_CAP = 16 * 1024 * 1024 def _resolve_key(env_var: str, provider_id: str) -> str: """Provider secret lookup (config > env/.env > credential pool); seam over ``tts_tool._resolve_provider_key``. - - ALL streaming-provider key lookups go through here — never bare ``get_env_value``. - """ + ALL streaming-provider key lookups go through here — never bare ``get_env_value``.""" try: from tools.tts_tool import _resolve_provider_key return _resolve_provider_key(env_var, provider_id) or "" @@ -41,10 +39,9 @@ def _gemini_key() -> str: return _resolve_key("GEMINI_API_KEY", "gemini") or _resolve_key("GOOGLE_API_KEY", "gemini") -# Interruption latch: when the user barges in on a spoken reply, the surface marks -# it; the next turn's submit path takes it and prepends SPEECH_INTERRUPTED_NOTE to -# the model-bound message (API-call local, never persisted). The TTL keeps a stale -# barge from annotating an unrelated message minutes later. +# Interruption latch: a barge-in on a spoken reply marks it; the next turn's submit path takes it +# and prepends SPEECH_INTERRUPTED_NOTE to the model-bound message (API-call local, never +# persisted). 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 @@ -67,12 +64,9 @@ _THINK_BLOCK_RE = re.compile(r"].*?", flags=re.DOTALL) class SentenceChunker: - """Incremental sentence cutter for LLM token deltas. - - Shared by the speaker pipeline and the speak-stream WebSocket so every surface - cuts speech identically. Strips ```` blocks (even split across deltas) and - merges fragments shorter than *min_len* into the following sentence. - """ + """Incremental sentence cutter for LLM token deltas, shared by the speaker pipeline and the + speak-stream WebSocket so every surface cuts speech identically. Strips ```` blocks (even + split across deltas) and merges fragments shorter than *min_len* into the following sentence.""" def __init__(self, min_len: int = 20): self.min_len = min_len @@ -152,13 +146,11 @@ _PROVIDER_PRIORITY: List[str] = ["elevenlabs", "gemini", "openai", "xai"] def resolve_streaming_provider( tts_config: Dict, preferred: Optional[str] = None) -> Optional[StreamingTTSProvider]: """Return a ready streamer for the *configured* provider, else ``None``. - - ``tts.streaming.provider`` when set: a name pins that exact streamer (``None`` - if unusable); ``auto`` returns the first usable in ``_PROVIDER_PRIORITY``. - Otherwise the configured TTS provider (or ``preferred``): ``None`` means "no - chunked API" — the dispatcher speaks per-sentence via the sync path, preserving - the user's chosen voice. We never silently swap providers just to get streaming. - """ + ``tts.streaming.provider`` when set: a name pins that exact streamer (``None`` if unusable); + ``auto`` returns the first usable in ``_PROVIDER_PRIORITY``. Otherwise the configured TTS + provider (or ``preferred``): ``None`` means "no chunked API" — the dispatcher speaks + per-sentence via the sync path, preserving the user's chosen voice. We never silently swap + providers just to get streaming.""" pinned = str((tts_config.get("streaming") or {}).get("provider") or "").lower().strip() if pinned == "auto": return next((inst for name in _PROVIDER_PRIORITY if (inst := _try_instantiate(name, tts_config))), None) @@ -281,11 +273,8 @@ class GeminiStreamer(StreamingTTSProvider): @register("xai") class XAIStreamer(StreamingTTSProvider): """xAI WebSocket TTS (``wss://api.x.ai/v1/tts``) → binary PCM frames (24 kHz mono int16). - - Credentials route through ``resolve_xai_http_credentials`` (OAuth or XAI_API_KEY), - same as the sync path. ``_collect_async`` bridges the async WS loop to the sync - iterator contract — the seam unit tests patch. - """ + Credentials route through ``resolve_xai_http_credentials`` (OAuth or XAI_API_KEY), same as the + sync path. ``_collect_async`` bridges the async WS loop to the sync iterator contract (test seam).""" @staticmethod def available() -> bool: @@ -315,7 +304,6 @@ class XAIStreamer(StreamingTTSProvider): raise RuntimeError("No xAI credentials for streaming TTS") voice = str(self.section.get("voice_id", DEFAULT_XAI_VOICE_ID)).strip() or DEFAULT_XAI_VOICE_ID ws_url = str(self.section.get("streaming_url") or "wss://api.x.ai/v1/tts").strip() - async with websockets.connect(ws_url, extra_headers={"Authorization": f"Bearer {api_key}"}) as ws: await ws.send(_json.dumps({"text": text, "voice_id": voice, "response_format": "pcm"})) try: diff --git a/tools/tts_text_normalize.py b/tools/tts_text_normalize.py index 0a165d94b0..dccb01f088 100644 --- a/tools/tts_text_normalize.py +++ b/tools/tts_text_normalize.py @@ -124,11 +124,9 @@ def normalize_symbols_for_tts(text: str) -> str: def smooth_whitespace_for_tts(text: str) -> str: - """Collapse visual formatting into calm spoken paragraphs. - - A _HEAD-marked heading folds into the next content line as a lead-in ("Weather, - It will be sunny."); a heading with no content after it becomes its own sentence. - """ + """Collapse visual formatting into calm spoken paragraphs. A _HEAD-marked heading folds into + the next content line as a lead-in ("Weather, It will be sunny."); a heading with no content + after it becomes its own sentence.""" if not text: return "" raw_lines = text.splitlines() @@ -141,7 +139,6 @@ def smooth_whitespace_for_tts(text: str) -> str: if pending_heading is not None: lines.append(pending_heading.rstrip(".:;,") + ".") pending_heading = None - for raw_line in raw_lines: is_heading = raw_line.rstrip().endswith(_HEAD) line = raw_line.replace(_HEAD, "").strip() @@ -188,11 +185,9 @@ def strip_nonspoken_blocks(text: str) -> str: def flatten_newlines_for_payload(text: str) -> str: - """Collapse newlines into sentence breaks for single-line TTS payloads. - - Some OpenAI-compatible backends (e.g. Kokoro) truncate at the first newline; the - smoothing pass already terminates each line with punctuation, so this is safe. - """ + """Collapse newlines into sentence breaks for single-line TTS payloads: some OpenAI-compatible + backends (e.g. Kokoro) truncate at the first newline; smoothing already ends each line with + punctuation, so this is safe.""" if not text: return "" for pattern, repl in ((r"\n{2,}", ". "), (r"(?<=[.!?;:,])\n", " "), (r"\n", ". "), (r"\.\s*\.", "."), @@ -203,10 +198,8 @@ def flatten_newlines_for_payload(text: str) -> str: def prepare_spoken_text(text: str, max_chars: int | None = 4000) -> str: """Return a TTS-friendly script from assistant text (deterministic cleanup, not a rewrite). - - Pipeline: non-spoken blocks > Markdown > symbols/units > line formatting into - sentence pauses > single line (for newline-sensitive providers), then ``max_chars``. - """ + Pipeline: non-spoken blocks > Markdown > symbols/units > line formatting into sentence + pauses > single line (for newline-sensitive providers), then ``max_chars``.""" spoken = text for step in (strip_nonspoken_blocks, strip_markdown_for_tts, normalize_symbols_for_tts, smooth_whitespace_for_tts, flatten_newlines_for_payload):