refactor(tools): compact TTS command/streaming/normalizer docstrings, keep every invariant
This commit is contained in:
@@ -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 ``<section>.providers.<name>`` 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.<name>: {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 ``<section>.providers.<name>`` 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 ``<section>.providers.<name>`` config layer (TTS and STT share it)
|
||||
# ===========================================================================
|
||||
|
||||
# ---- Generic ``<section>.providers.<name>`` 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]:
|
||||
"""``<section>.providers.<name>`` (canonical), else ``<section>.<name>`` 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.
|
||||
"""
|
||||
"""``<section>.providers.<name>`` (canonical), else ``<section>.<name>`` 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)
|
||||
|
||||
@@ -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"<think[\s>].*?</think>", 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 ``<think>`` 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 ``<think>`` 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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user