refactor(tools): collapse tts_tool/delivery/local/lifecycle/plugins guards and single-use locals; tighten docstrings
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Text-to-speech tool: config resolution, built-in provider dispatch, output policy, registration.
|
||||
|
||||
Built-in providers: Edge (free default), ElevenLabs, OpenAI, DeepInfra, MiniMax, Mistral,
|
||||
Gemini, xAI, and the local NeuTTS / KittenTTS / Piper engines; plus any ``type: command``
|
||||
provider under ``tts.providers.<name>`` and plugin-registered providers. Output is Opus
|
||||
(.ogg) for voice-bubble platforms, MP3 elsewhere. Sibling ``tts_tool_*`` modules hold the
|
||||
backends/delivery/lifecycle; their names are re-imported here so ``tools.tts_tool.<name>``
|
||||
keeps resolving and tests patching ``tools.tts_tool.<seam>`` still take effect (siblings
|
||||
resolve those seams through ``_origin()`` at call time).
|
||||
Built-ins: Edge (free default), ElevenLabs, OpenAI, DeepInfra, MiniMax, Mistral, Gemini, xAI,
|
||||
local NeuTTS / KittenTTS / Piper; plus ``type: command`` providers under ``tts.providers.<name>``
|
||||
and plugin-registered ones. Output is Opus (.ogg) on voice-bubble platforms, MP3 elsewhere.
|
||||
Sibling ``tts_tool_*`` modules hold backends/delivery/lifecycle; their names are re-imported
|
||||
here so ``tools.tts_tool.<name>`` resolves and test patches on this module still apply
|
||||
(siblings read those seams through ``_origin()`` at call time).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import datetime
|
||||
import importlib.util
|
||||
import json
|
||||
@@ -92,16 +92,14 @@ from tools.tts_tool_lifecycle import ( # noqa: F401 — historical names re-exp
|
||||
def _sdk_importer(module: str, attr: Optional[str] = None, feature: Optional[str] = None) -> Callable[[], Any]:
|
||||
"""Lazy SDK importer: returns ``module`` (or ``module.attr``), raising ImportError when absent.
|
||||
|
||||
``feature`` names a ``tools.lazy_deps`` feature to best-effort install first (users who
|
||||
enabled a provider in config.yaml never ran the post-setup hook); any failure there falls
|
||||
through so the raw import still raises cleanly. sounddevice also raises OSError without PortAudio."""
|
||||
``feature`` names a ``tools.lazy_deps`` feature to best-effort install first (users who enabled
|
||||
a provider in config.yaml never ran the post-setup hook); any failure there falls through so
|
||||
the raw import still raises cleanly. sounddevice also raises OSError without PortAudio."""
|
||||
def _import():
|
||||
if feature:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
from tools.lazy_deps import ensure
|
||||
ensure(feature, prompt=False)
|
||||
except Exception:
|
||||
pass
|
||||
mod = importlib.import_module(module)
|
||||
return getattr(mod, attr) if attr else mod
|
||||
_import.__name__ = f"_import_{module.split('.')[0]}"
|
||||
@@ -146,8 +144,7 @@ def _get_default_output_dir() -> str:
|
||||
return str(get_hermes_dir("cache/audio", "audio_cache"))
|
||||
|
||||
|
||||
DEFAULT_OUTPUT_DIR = _get_default_output_dir()
|
||||
_DEFAULT_OUTPUT_DIR_AT_IMPORT = DEFAULT_OUTPUT_DIR
|
||||
DEFAULT_OUTPUT_DIR = _DEFAULT_OUTPUT_DIR_AT_IMPORT = _get_default_output_dir()
|
||||
|
||||
|
||||
def _default_output_dir() -> str:
|
||||
@@ -172,8 +169,8 @@ def _load_tts_config() -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _get_provider(tts_config: Dict[str, Any]) -> str:
|
||||
"""The configured TTS provider, or the free default — inference credentials never imply consent
|
||||
to paid speech. ``nous`` is serviced by the OpenAI path via the managed openai-audio gateway."""
|
||||
"""Configured provider or the free default (inference credentials never imply consent to paid
|
||||
speech); ``nous`` is serviced by the OpenAI path through the managed openai-audio gateway."""
|
||||
provider = (tts_config.get("provider") or DEFAULT_PROVIDER).lower().strip()
|
||||
return "openai" if provider == NOUS_MANAGED_PROVIDER else provider
|
||||
|
||||
@@ -187,8 +184,7 @@ _FFMPEG_OPUS_PROVIDERS = frozenset({"edge", "neutts", "minimax", "xai", "kittent
|
||||
|
||||
# --- Built-in provider dispatch ---
|
||||
# provider -> (availability predicate or None, log label, generator name, "package missing" error).
|
||||
# Predicates and generator names resolve module globals at call time so tests that monkeypatch
|
||||
# ``tools.tts_tool._import_x`` / ``_check_x`` / ``_generate_x`` apply.
|
||||
# Predicates/generator names resolve module globals at call time so test monkeypatches apply.
|
||||
_BUILTIN_DISPATCH: Dict[str, tuple] = {
|
||||
"elevenlabs": (lambda: _importable(_import_elevenlabs), "ElevenLabs", "_generate_elevenlabs",
|
||||
"ElevenLabs provider selected but 'elevenlabs' package not installed. Run: pip install elevenlabs"),
|
||||
@@ -221,17 +217,18 @@ def _error_json(message: str) -> str:
|
||||
|
||||
def _run_edge_tts(text: str, file_str: str, tts_config: Dict[str, Any]) -> None:
|
||||
"""Run the async Edge generator from sync code (worker thread; direct run if that fails)."""
|
||||
run = lambda: asyncio.run(_generate_edge_tts(text, file_str, tts_config)) # noqa: E731
|
||||
try:
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
pool.submit(lambda: asyncio.run(_generate_edge_tts(text, file_str, tts_config))).result(timeout=60)
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
pool.submit(run).result(timeout=60)
|
||||
except RuntimeError:
|
||||
asyncio.run(_generate_edge_tts(text, file_str, tts_config))
|
||||
run()
|
||||
|
||||
|
||||
def _select_builtin_engine(provider: str) -> tuple:
|
||||
"""Check a built-in provider's SDK -> ``(engine, None)`` or ``(provider, error_json)``. Unknown
|
||||
names take the Edge default; without edge-tts NeuTTS is the fallback (engine != provider)."""
|
||||
"""SDK check -> ``(engine, None)`` or ``(provider, error_json)``. Unknown names take the Edge
|
||||
default; without edge-tts NeuTTS is the fallback (engine != provider)."""
|
||||
entry = _BUILTIN_DISPATCH.get(provider)
|
||||
if entry is not None:
|
||||
available, _label, _generator, missing_error = entry
|
||||
@@ -261,7 +258,7 @@ def _synthesize_builtin(engine: str, text: str, file_str: str, tts_config: Dict[
|
||||
def _finalize_voice_delivery(
|
||||
file_str: str, provider: str, command_provider_config: Optional[Dict[str, Any]], want_opus: bool,
|
||||
) -> tuple:
|
||||
"""Decide voice-bubble eligibility, Opus-converting when needed -> ``(path, voice_compatible)``.
|
||||
"""Voice-bubble eligibility (Opus-converting when needed) -> ``(path, voice_compatible)``.
|
||||
|
||||
Command/plugin providers are documents unless they opt in via ``voice_compatible``; native-Opus
|
||||
built-ins qualify when the platform wants Opus and they wrote .ogg; MP3/WAV built-ins are
|
||||
@@ -316,8 +313,7 @@ def _resolve_output_base(
|
||||
if has_traversal_component(output_path):
|
||||
return None, _error_json(
|
||||
f"output_path contains '..' traversal component: {output_path}. "
|
||||
"Use an absolute path or one relative to the current directory "
|
||||
"without '..'.")
|
||||
"Use an absolute path or one relative to the current directory without '..'.")
|
||||
file_path = Path(output_path).expanduser()
|
||||
if command_provider_config is not None:
|
||||
file_path = _configured_command_tts_output_path(file_path, command_provider_config)
|
||||
@@ -327,14 +323,12 @@ def _resolve_output_base(
|
||||
f"output_path targets a protected credential or system path: "
|
||||
f"{file_path}. Choose a normal audio output location.")
|
||||
else:
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
out_dir = Path(_default_output_dir())
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
if command_provider_config is not None:
|
||||
ext = _get_command_tts_output_format(command_provider_config)
|
||||
else:
|
||||
ext = "ogg" if want_opus and provider in _NATIVE_OPUS_PROVIDERS else "mp3"
|
||||
file_path = out_dir / f"tts_{timestamp}.{ext}"
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
file_path = Path(_default_output_dir()) / f"tts_{timestamp}.{ext}"
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return file_path, None
|
||||
|
||||
@@ -356,7 +350,7 @@ def _text_to_speech_single(
|
||||
text: str, file_str: str, *, provider: str, tts_config: Dict[str, Any],
|
||||
command_provider_config: Optional[Dict[str, Any]], want_opus: bool, instructions: Optional[str],
|
||||
) -> str:
|
||||
"""Synthesize one normalized, provider-safe chunk into *file_str*; returns the result envelope.
|
||||
"""Synthesize one provider-safe chunk into *file_str*; returns the result envelope.
|
||||
|
||||
Command providers resolve BEFORE built-in dispatch, but built-in names short-circuit so
|
||||
``tts.providers.openai.command`` can't shadow OpenAI. Plugins fire only for names that are
|
||||
@@ -438,9 +432,8 @@ def text_to_speech_tool(
|
||||
separate valid files and no over-limit artifact is ever returned."""
|
||||
if not text or not text.strip():
|
||||
return tool_error("Text is required", success=False)
|
||||
try: # shared cleaner: markdown, emoji, think blocks, verifier footer, units, newlines
|
||||
|
||||
# Shared cleaner: markdown, emoji, think blocks, verifier footer, units, newlines.
|
||||
try:
|
||||
from tools.tts_text_normalize import prepare_spoken_text
|
||||
text = prepare_spoken_text(text, max_chars=None)
|
||||
except Exception:
|
||||
@@ -513,7 +506,6 @@ def _minimax_requirements() -> bool:
|
||||
def _xai_requirements() -> bool:
|
||||
try:
|
||||
from tools.xai_http import resolve_xai_http_credentials
|
||||
|
||||
return bool(resolve_xai_http_credentials().get("api_key"))
|
||||
except Exception:
|
||||
return False
|
||||
@@ -542,9 +534,7 @@ def check_tts_requirements() -> bool:
|
||||
if _resolve_command_provider_config(provider, tts_config) is not None:
|
||||
return True
|
||||
check = _BUILTIN_REQUIREMENTS.get(provider)
|
||||
if check is not None:
|
||||
return check()
|
||||
return _plugin_provider_is_available(provider)
|
||||
return check() if check is not None else _plugin_provider_is_available(provider)
|
||||
|
||||
|
||||
# --- Registry ---
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Long-form chunking, ffmpeg encoding, container repair and delivery packing.
|
||||
"""Long-form chunking, ffmpeg encoding, container repair and delivery packing (``tools.tts_tool``).
|
||||
|
||||
Provider-agnostic post-processing for ``tools.tts_tool``: split text under a
|
||||
per-request cap, wrap raw PCM as WAV, convert WAV/MP3 to the target container,
|
||||
sniff/repair mislabelled ``.ogg`` files, and combine final-encoded chunks under a
|
||||
destination platform's upload limit. Origin module re-imports every name under
|
||||
its historical spelling.
|
||||
Provider-agnostic post-processing: split text under a per-request cap, wrap raw PCM as WAV,
|
||||
convert WAV/MP3 to the target container, sniff/repair mislabelled ``.ogg`` files, and combine
|
||||
final-encoded chunks under a destination platform's upload limit. Also home of the sibling
|
||||
helpers ``_origin`` / ``_section`` / ``_remove_quietly``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -43,11 +42,9 @@ def _section(tts_config: Any, key: str) -> Dict[str, Any]:
|
||||
return section if isinstance(section, dict) else {}
|
||||
|
||||
|
||||
# Final fallback when provider isn't recognised at all.
|
||||
FALLBACK_MAX_TEXT_LENGTH = 4000
|
||||
FALLBACK_MAX_TEXT_LENGTH = 4000 # provider not recognised at all
|
||||
|
||||
# Per-provider input-character caps (from official provider docs); override
|
||||
# via ``tts.<provider>.max_text_length``.
|
||||
# Per-provider input-character caps (official docs); override: ``tts.<provider>.max_text_length``.
|
||||
PROVIDER_MAX_TEXT_LENGTH: Dict[str, int] = {
|
||||
"edge": 5000, # edge-tts practical sync limit
|
||||
"openai": 4096, # https://platform.openai.com/docs/guides/text-to-speech
|
||||
@@ -75,9 +72,7 @@ ELEVENLABS_MODEL_MAX_TEXT_LENGTH: Dict[str, int] = {
|
||||
|
||||
def _positive_int(value: Any) -> Optional[int]:
|
||||
"""*value* when it is a positive non-bool int, else None."""
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
return None
|
||||
return value
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None
|
||||
|
||||
|
||||
def _resolve_max_text_length(provider: Optional[str], tts_config: Optional[Dict[str, Any]] = None) -> int:
|
||||
@@ -93,10 +88,8 @@ def _resolve_max_text_length(provider: Optional[str], tts_config: Optional[Dict[
|
||||
override = _positive_int(prov_cfg.get("max_text_length"))
|
||||
if override:
|
||||
return override
|
||||
|
||||
if key == "elevenlabs":
|
||||
from tools.tts_tool_providers import DEFAULT_ELEVENLABS_MODEL_ID # providers imports this module
|
||||
|
||||
model_id = prov_cfg.get("model_id") or DEFAULT_ELEVENLABS_MODEL_ID
|
||||
mapped = ELEVENLABS_MODEL_MAX_TEXT_LENGTH.get(str(model_id).strip())
|
||||
if mapped:
|
||||
@@ -161,11 +154,9 @@ def _resolve_audio_delivery_profile(
|
||||
|
||||
|
||||
def _pack_under_cap(pieces: List[str], max_chars: int, *, slice_oversized: bool = False) -> List[str]:
|
||||
"""Greedily join *pieces* with single spaces, starting a new chunk past *max_chars*.
|
||||
|
||||
With ``slice_oversized`` an over-long piece flushes the running chunk and emits its hard
|
||||
slices as their own chunks (the tail slice is not merged with following pieces).
|
||||
"""
|
||||
"""Greedily join *pieces* with single spaces, starting a new chunk past *max_chars*. With
|
||||
``slice_oversized`` an over-long piece flushes the running chunk and emits its hard slices as
|
||||
their own chunks (the tail slice is not merged with following pieces)."""
|
||||
chunks: List[str] = []
|
||||
current = ""
|
||||
for piece in pieces:
|
||||
@@ -256,9 +247,7 @@ def _remove_quietly(path: Optional[str]) -> None:
|
||||
|
||||
def _wav_sidecar_path(output_path: str) -> str:
|
||||
"""Path a WAV-native engine writes to before conversion to *output_path*'s format."""
|
||||
if output_path.endswith(".wav"):
|
||||
return output_path
|
||||
return output_path.rsplit(".", 1)[0] + ".wav"
|
||||
return output_path if output_path.endswith(".wav") else output_path.rsplit(".", 1)[0] + ".wav"
|
||||
|
||||
|
||||
def _finalize_wav_output(wav_path: str, output_path: str) -> str:
|
||||
@@ -279,29 +268,24 @@ def _wrap_pcm_as_wav(
|
||||
pcm_bytes: bytes, sample_rate: int = GEMINI_TTS_SAMPLE_RATE,
|
||||
channels: int = GEMINI_TTS_CHANNELS, sample_width: int = GEMINI_TTS_SAMPLE_WIDTH) -> bytes:
|
||||
"""Wrap raw signed-little-endian PCM (e.g. Gemini's L16) with a minimal WAV RIFF header."""
|
||||
byte_rate = sample_rate * channels * sample_width
|
||||
block_align = channels * sample_width
|
||||
data_size = len(pcm_bytes)
|
||||
fmt_chunk = struct.pack(
|
||||
"<4sIHHIIHH", b"fmt ", 16, 1, channels, sample_rate, byte_rate, block_align, sample_width * 8,
|
||||
)
|
||||
data_chunk_header = struct.pack("<4sI", b"data", data_size)
|
||||
riff_size = 4 + len(fmt_chunk) + len(data_chunk_header) + data_size
|
||||
fmt_chunk = struct.pack("<4sIHHIIHH", b"fmt ", 16, 1, channels, sample_rate,
|
||||
sample_rate * block_align, block_align, sample_width * 8)
|
||||
data_chunk_header = struct.pack("<4sI", b"data", len(pcm_bytes))
|
||||
riff_size = 4 + len(fmt_chunk) + len(data_chunk_header) + len(pcm_bytes)
|
||||
riff_header = struct.pack("<4sI4s", b"RIFF", riff_size, b"WAVE")
|
||||
return riff_header + fmt_chunk + data_chunk_header + pcm_bytes
|
||||
|
||||
|
||||
def _write_wav_bytes_as(wav_bytes: bytes, output_path: str) -> str:
|
||||
"""Write in-memory WAV to *output_path*, ffmpeg-converting to its container.
|
||||
|
||||
``.ogg`` is forced to Opus (ffmpeg's .ogg default is Vorbis, which voice bubbles reject).
|
||||
A failed conversion raises RuntimeError; without ffmpeg the raw WAV is written under
|
||||
the requested name (misleading extension, but the audio still plays)."""
|
||||
"""Write in-memory WAV to *output_path*, ffmpeg-converting to its container. ``.ogg`` is forced
|
||||
to Opus (ffmpeg's .ogg default is Vorbis, which voice bubbles reject). A failed conversion
|
||||
raises RuntimeError; without ffmpeg the raw WAV is written under the requested name
|
||||
(misleading extension, but the audio still plays)."""
|
||||
if output_path.lower().endswith(".wav"):
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(wav_bytes)
|
||||
return output_path
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
tmp.write(wav_bytes)
|
||||
wav_path = tmp.name
|
||||
@@ -330,7 +314,6 @@ def _ffmpeg_transcode_to_opus(input_path: str, ogg_path: str) -> Optional[str]:
|
||||
"""Transcode *input_path* to real Ogg/Opus at *ogg_path* (in-place safe via temp file); None on failure."""
|
||||
if shutil.which("ffmpeg") is None:
|
||||
return None
|
||||
|
||||
in_place = os.path.abspath(input_path) == os.path.abspath(ogg_path)
|
||||
work_path = ogg_path + ".tmp.ogg" if in_place else ogg_path
|
||||
try:
|
||||
@@ -356,15 +339,13 @@ def _ffmpeg_transcode_to_opus(input_path: str, ogg_path: str) -> Optional[str]:
|
||||
|
||||
|
||||
# --- Container sniffing / repair ---
|
||||
# Several backends silently ignore the requested opus format (Edge only emits MP3, Piper
|
||||
# writes WAV, xAI writes MP3, some OpenAI-compatible servers ignore response_format="opus"),
|
||||
# which breaks native voice bubbles. Sniff the magic bytes once after synthesis and repair
|
||||
# when they don't match the extension.
|
||||
# Several backends ignore the requested opus format (Edge/xAI emit MP3, Piper WAV, some
|
||||
# OpenAI-compatible servers ignore response_format="opus"), which breaks native voice bubbles:
|
||||
# sniff the magic bytes once after synthesis and repair when they don't match the extension.
|
||||
|
||||
def _sniff_audio_container(path: str) -> str:
|
||||
"""Return a container id ('ogg', 'wav', 'mp3', 'flac', ...) or 'unknown'."""
|
||||
from tools.audio_container import sniff_container
|
||||
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
head = fh.read(12)
|
||||
@@ -381,51 +362,42 @@ def _repair_ogg_container(file_str: str) -> str:
|
||||
container = _sniff_audio_container(file_str)
|
||||
if container in ("ogg", "unknown"):
|
||||
return file_str
|
||||
|
||||
logger.info("TTS wrote %s bytes into a .ogg path (%s) — transcoding to real Ogg/Opus", container, file_str)
|
||||
repaired = _ffmpeg_transcode_to_opus(file_str, file_str)
|
||||
if repaired:
|
||||
return repaired
|
||||
|
||||
honest = file_str[:-4] + "." + container
|
||||
try:
|
||||
os.replace(file_str, honest)
|
||||
logger.warning(
|
||||
"Could not transcode %s to Ogg/Opus — renamed to %s so the "
|
||||
"file is delivered with its real format", file_str, honest)
|
||||
return honest
|
||||
except OSError:
|
||||
return file_str
|
||||
logger.warning("Could not transcode %s to Ogg/Opus — renamed to %s so the "
|
||||
"file is delivered with its real format", file_str, honest)
|
||||
return honest
|
||||
|
||||
|
||||
# --- Long-form audio combination and delivery packing ---
|
||||
|
||||
def _concat_audio_files(audio_paths: List[str], output_path: str, *, voice_compatible: bool = False) -> Optional[str]:
|
||||
"""Combine independently encoded chunks with ffmpeg (never byte-joined).
|
||||
|
||||
OGG/Opus is always re-encoded (even without voice opt-in); matching MP3 chunks keep their
|
||||
frames (``-c:a copy``). None when ffmpeg is missing/fails so callers keep the valid parts."""
|
||||
"""Combine independently encoded chunks with ffmpeg (never byte-joined). OGG/Opus is always
|
||||
re-encoded (even without voice opt-in); matching MP3 chunks keep their frames (``-c:a copy``).
|
||||
None when ffmpeg is missing/fails so callers keep the valid parts."""
|
||||
if not audio_paths:
|
||||
raise ValueError("No audio chunks to combine")
|
||||
if len(audio_paths) == 1:
|
||||
source = audio_paths[0]
|
||||
if os.path.abspath(source) != os.path.abspath(output_path):
|
||||
shutil.copyfile(source, output_path)
|
||||
if os.path.abspath(audio_paths[0]) != os.path.abspath(output_path):
|
||||
shutil.copyfile(audio_paths[0], output_path)
|
||||
return output_path
|
||||
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if not ffmpeg:
|
||||
return None
|
||||
|
||||
destination = Path(output_path)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
concat_path = destination.with_name(f".{destination.name}.{uuid.uuid4().hex}.concat.txt")
|
||||
temp_output = destination.with_name(f".{destination.stem}.{uuid.uuid4().hex}.combining{destination.suffix}")
|
||||
try:
|
||||
with concat_path.open("w", encoding="utf-8") as concat_file:
|
||||
for path in audio_paths:
|
||||
concat_file.write(f"file {shlex.quote(os.path.abspath(path))}\n")
|
||||
|
||||
entries = "".join(f"file {shlex.quote(os.path.abspath(p))}\n" for p in audio_paths)
|
||||
concat_path.write_text(entries, encoding="utf-8")
|
||||
args = ["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", str(concat_path), "-vn"]
|
||||
suffix = destination.suffix.lower()
|
||||
if voice_compatible or suffix in {".ogg", ".opus"}:
|
||||
@@ -433,7 +405,6 @@ def _concat_audio_files(audio_paths: List[str], output_path: str, *, voice_compa
|
||||
elif suffix == ".mp3" and all(Path(path).suffix.lower() == ".mp3" for path in audio_paths):
|
||||
args += ["-c:a", "copy"]
|
||||
args.append(str(temp_output))
|
||||
|
||||
result = _ffmpeg_run(ffmpeg, args, timeout=120)
|
||||
if result.returncode == 0 and temp_output.exists() and temp_output.stat().st_size > 0:
|
||||
os.replace(temp_output, destination)
|
||||
@@ -450,7 +421,7 @@ def _concat_audio_files(audio_paths: List[str], output_path: str, *, voice_compa
|
||||
def _build_audio_delivery_files(
|
||||
audio_paths: List[str], output_path: str, profile: AudioDeliveryProfile, *, voice_compatible: bool = False,
|
||||
) -> Tuple[List[str], bool]:
|
||||
"""Pack final-encoded chunks and enforce the hard upload limit; returns ``(final_paths, combined_any)``.
|
||||
"""Pack final-encoded chunks under the hard upload limit -> ``(final_paths, combined_any)``.
|
||||
|
||||
Groups are packed against the conservative target, then each combined artifact is checked
|
||||
at its real size; an over-limit group is split in half and retried. A failed combine
|
||||
@@ -463,7 +434,6 @@ def _build_audio_delivery_files(
|
||||
raise ValueError(
|
||||
f"Final-encoded TTS chunk exceeds {profile.platform} delivery "
|
||||
f"limit ({size} > {profile.max_file_bytes} bytes): {path}")
|
||||
|
||||
base = Path(output_path)
|
||||
scratch_outputs: List[str] = []
|
||||
combined_any = False
|
||||
@@ -486,15 +456,12 @@ def _build_audio_delivery_files(
|
||||
midpoint = max(1, len(group) // 2)
|
||||
return emit(group[:midpoint]) + emit(group[midpoint:])
|
||||
|
||||
packed: List[str] = []
|
||||
for group in _pack_audio_files_for_delivery(audio_paths, profile):
|
||||
packed.extend(emit(group))
|
||||
|
||||
groups = _pack_audio_files_for_delivery(audio_paths, profile)
|
||||
packed = [path for group in groups for path in emit(group)]
|
||||
final_paths: List[str] = []
|
||||
for index, source in enumerate(packed, start=1):
|
||||
if len(packed) == 1:
|
||||
destination = base
|
||||
else:
|
||||
destination = base
|
||||
if len(packed) > 1:
|
||||
destination = base.with_name(f"{base.stem}.part{index:02d}{Path(source).suffix or base.suffix}")
|
||||
if os.path.abspath(source) != os.path.abspath(destination):
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -502,7 +469,6 @@ def _build_audio_delivery_files(
|
||||
if destination.stat().st_size > profile.max_file_bytes:
|
||||
raise ValueError(f"Final TTS deliverable exceeds {profile.platform} delivery limit: {destination}")
|
||||
final_paths.append(str(destination))
|
||||
|
||||
try:
|
||||
return final_paths, combined_any
|
||||
finally:
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Local-engine lifecycle for ``tools.tts_tool``: warm-up / release leases.
|
||||
|
||||
Local engines load their model lazily on first synthesis (dead air on the first spoken
|
||||
reply) and then stay resident forever. Every surface that flips speech output on holds a
|
||||
*lease* here (warming the configured engine); when the last lease is released the local
|
||||
model caches are dropped, so one surface's "off" can't unload a model another surface
|
||||
still needs. Cloud providers have nothing resident; warming only ensures the SDK imports.
|
||||
Seams tests monkeypatch on the origin are resolved through :func:`_origin` at call time.
|
||||
Local engines load lazily on first synthesis (dead air on the first spoken reply) and then stay
|
||||
resident. Every surface that flips speech output on holds a *lease* here (warming the configured
|
||||
engine); when the last lease is released the local model caches are dropped, so one surface's
|
||||
"off" can't unload a model another surface still needs. Cloud providers have nothing resident;
|
||||
warming only ensures the SDK imports. Origin seams are resolved through :func:`_origin` per call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -43,7 +42,6 @@ _LAZY_SDK_FEATURES = {"edge": "tts.edge", "elevenlabs": "tts.elevenlabs", "mistr
|
||||
|
||||
def _signal_user_tts_provider(name: str, tts_config: Dict[str, Any], hook: str) -> Optional[str]:
|
||||
"""Forward a lease ``hook`` (``"warm"``/``"release"``) to a user-declared provider; returns the action.
|
||||
|
||||
Command providers run their optional ``<hook>_command`` (same template/env/timeout rules as
|
||||
``command``) on a background thread so a toggle never waits on a model server; plugins get
|
||||
:meth:`TTSProvider.warm`/``release``. Best-effort: failures are logged at debug."""
|
||||
@@ -62,8 +60,9 @@ def _signal_user_tts_provider(name: str, tts_config: Dict[str, Any], hook: str)
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
_origin()._run_command_tts(command, _get_command_tts_timeout(cfg),
|
||||
env_passthrough=_command_provider_env_passthrough(cfg))
|
||||
_origin()._run_command_tts(
|
||||
command, _get_command_tts_timeout(cfg),
|
||||
env_passthrough=_command_provider_env_passthrough(cfg))
|
||||
except Exception as exc: # noqa: BLE001 — best-effort hook
|
||||
logger.debug("[TTS] %s_command for %s failed: %s", hook, name, exc)
|
||||
|
||||
@@ -81,7 +80,6 @@ def _signal_user_tts_provider(name: str, tts_config: Dict[str, Any], hook: str)
|
||||
|
||||
def warm_tts_provider(tts_config: Optional[Dict[str, Any]] = None, provider: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Pre-load the configured TTS provider so the next synthesis starts hot (blocking; never raises).
|
||||
|
||||
Local engines fill the same LRU slot synthesis reads (including first-use download); lazily
|
||||
installed cloud SDKs are made importable; user-declared providers get their warm hook;
|
||||
everything else is ``action: "noop"``. The result carries ``warmed`` / ``action`` / ``error``."""
|
||||
@@ -92,8 +90,8 @@ def warm_tts_provider(tts_config: Optional[Dict[str, Any]] = None, provider: Opt
|
||||
|
||||
warmer = _local_tts_warmers().get(name)
|
||||
if warmer is not None:
|
||||
cache = _LOCAL_TTS_MODEL_CACHES.get(name)
|
||||
before = len(cache) if cache is not None else 0
|
||||
cache = _LOCAL_TTS_MODEL_CACHES.get(name, {})
|
||||
before = len(cache)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
warmer(tts_config)
|
||||
@@ -101,9 +99,8 @@ def warm_tts_provider(tts_config: Optional[Dict[str, Any]] = None, provider: Opt
|
||||
logger.warning("[TTS] warm-up for %s failed: %s", name, exc)
|
||||
result.update(action="error", error=str(exc))
|
||||
return result
|
||||
after = len(cache) if cache is not None else 0
|
||||
result.update(
|
||||
warmed=True, action="loaded" if after > before else "cached",
|
||||
warmed=True, action="loaded" if len(cache) > before else "cached",
|
||||
elapsed_ms=int((time.monotonic() - started) * 1000))
|
||||
logger.info("[TTS] warm-up %s: %s in %dms", name, result["action"], result["elapsed_ms"])
|
||||
return result
|
||||
@@ -118,7 +115,6 @@ def warm_tts_provider(tts_config: Optional[Dict[str, Any]] = None, provider: Opt
|
||||
if feature is not None:
|
||||
try:
|
||||
from tools.lazy_deps import ensure, is_available
|
||||
|
||||
if is_available(feature):
|
||||
result.update(warmed=True, action="cached")
|
||||
else:
|
||||
@@ -139,10 +135,9 @@ def release_tts_provider(provider: Optional[str] = None) -> Dict[str, Any]:
|
||||
_signal_user_tts_provider(_origin()._get_provider(tts_config), tts_config, "release")
|
||||
released = 0
|
||||
for cache_name, cache in _LOCAL_TTS_MODEL_CACHES.items():
|
||||
if name and cache_name != name:
|
||||
continue
|
||||
released += len(cache)
|
||||
cache.clear()
|
||||
if not name or cache_name == name:
|
||||
released += len(cache)
|
||||
cache.clear()
|
||||
if released:
|
||||
logger.info("[TTS] released %d resident local model(s)", released)
|
||||
return {"released": released}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Local on-device TTS engines for ``tools.tts_tool``: NeuTTS, Piper, KittenTTS.
|
||||
|
||||
All three synthesize WAV natively; :func:`_finalize_wav_output` then converts/renames to
|
||||
the caller's requested container. Piper and KittenTTS keep loaded models in small LRU
|
||||
caches registered in ``_LOCAL_TTS_MODEL_CACHES`` so the warm/release lifecycle can
|
||||
pre-load or drop them. ``_import_piper`` / ``_import_kittentts`` are resolved through the
|
||||
origin module at call time so test monkeypatches there apply.
|
||||
All three synthesize WAV natively; :func:`_finalize_wav_output` converts/renames to the requested
|
||||
container. Piper and KittenTTS keep loaded models in small LRU caches registered in
|
||||
``_LOCAL_TTS_MODEL_CACHES`` so warm/release can pre-load or drop them. ``_import_piper`` /
|
||||
``_import_kittentts`` are resolved through the origin module at call time (test monkeypatches).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,14 +24,13 @@ DEFAULT_PIPER_VOICE = "en_US-lessac-medium" # balanced size/quality
|
||||
_NEUTTS_SAMPLES = Path(__file__).parent / "neutts_samples"
|
||||
|
||||
# --- Bounded model caches ---
|
||||
# Each cached entry is a whole loaded model (tens of MB); an unbounded dict would pin one
|
||||
# per distinct voice for the process lifetime. Most sessions use one or two voices and a
|
||||
# cold reload is cheap.
|
||||
# Each entry is a whole loaded model (tens of MB); unbounded, one would be pinned per distinct
|
||||
# voice for the process lifetime. Most sessions use one or two voices; a cold reload is cheap.
|
||||
_TTS_MODEL_CACHE_MAX = 3
|
||||
|
||||
# Provider name → the model cache it populates (consulted by warm/release in
|
||||
# tts_tool_lifecycle; a new local engine adds a row here plus a loader in _local_tts_warmers()).
|
||||
# Piper voices keyed on absolute .onnx path (+cuda flag); KittenTTS on model name.
|
||||
# Provider name -> the cache it populates (warm/release in tts_tool_lifecycle; a new local engine
|
||||
# adds a row here plus a loader in _local_tts_warmers()). Piper keyed on absolute .onnx path
|
||||
# (+cuda flag); KittenTTS on model name.
|
||||
_piper_voice_cache: Dict[str, Any] = {}
|
||||
_kittentts_model_cache: Dict[str, Any] = {}
|
||||
_LOCAL_TTS_MODEL_CACHES: Dict[str, Dict[str, Any]] = {"piper": _piper_voice_cache, "kittentts": _kittentts_model_cache}
|
||||
@@ -71,8 +69,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
|
||||
"--model", neutts_config.get("model", "neuphonic/neutts-air-q4-gguf"),
|
||||
"--device", neutts_config.get("device", "cpu")]
|
||||
result = _run_helper(cmd, 120)
|
||||
if result.returncode != 0:
|
||||
# The synth script reports success lines as "OK:" on stderr too.
|
||||
if result.returncode != 0: # the synth script reports success lines as "OK:" on stderr too
|
||||
error_lines = [l for l in result.stderr.strip().splitlines() if not l.startswith("OK:")]
|
||||
raise RuntimeError(f"NeuTTS synthesis failed: {chr(10).join(error_lines) or 'unknown error'}")
|
||||
return _finalize_wav_output(wav_path, output_path)
|
||||
@@ -95,11 +92,9 @@ def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str:
|
||||
candidate = Path(voice).expanduser()
|
||||
if candidate.suffix.lower() == ".onnx" and candidate.exists():
|
||||
return str(candidate)
|
||||
|
||||
cached = download_dir / f"{voice}.onnx"
|
||||
if cached.exists() and (download_dir / f"{voice}.onnx.json").exists():
|
||||
return str(cached)
|
||||
|
||||
logger.info("[Piper] Downloading voice '%s' to %s (first use)", voice, download_dir)
|
||||
try:
|
||||
result = _run_helper(
|
||||
@@ -127,11 +122,7 @@ def _load_piper_voice_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, Dict[
|
||||
download_dir = Path(piper_config.get("voices_dir") or _get_piper_voices_dir()).expanduser()
|
||||
download_dir.mkdir(parents=True, exist_ok=True)
|
||||
use_cuda = bool(piper_config.get("use_cuda", False))
|
||||
|
||||
model_path = _resolve_piper_voice_path(voice_name, download_dir)
|
||||
# speaker_id is applied per call via syn_config, so one PiperVoice instance serves
|
||||
# every speaker and stays out of the cache key.
|
||||
cache_key = f"{model_path}::cuda={use_cuda}"
|
||||
|
||||
def _load_piper_voice():
|
||||
logger.info("[Piper] Loading voice: %s", model_path)
|
||||
@@ -139,6 +130,8 @@ def _load_piper_voice_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, Dict[
|
||||
logger.info("[Piper] Voice loaded")
|
||||
return v
|
||||
|
||||
# speaker_id is applied per call via syn_config, so one instance serves every speaker.
|
||||
cache_key = f"{model_path}::cuda={use_cuda}"
|
||||
return _tts_cache_get_or_load(_piper_voice_cache, cache_key, _load_piper_voice), piper_config
|
||||
|
||||
|
||||
@@ -149,14 +142,11 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
|
||||
import wave
|
||||
|
||||
voice, piper_config = _load_piper_voice_for_config(tts_config)
|
||||
|
||||
# Bad speaker_id input drops to 0 (Piper's default); booleans are rejected outright
|
||||
# since True/False would silently coerce to 1/0.
|
||||
# Bad speaker_id drops to 0 (Piper's default); bools are rejected (they'd coerce to 1/0).
|
||||
_raw_speaker = piper_config.get("speaker_id", 0)
|
||||
speaker_id = 0 if isinstance(_raw_speaker, bool) or not isinstance(_raw_speaker, int) else _raw_speaker
|
||||
|
||||
# Only build a SynthesisConfig when an advanced knob is configured, so we don't
|
||||
# depend on a newer piper-tts than the user's unless we must.
|
||||
speaker_id = _raw_speaker if type(_raw_speaker) is int else 0
|
||||
# Only build a SynthesisConfig when an advanced knob is configured, so we don't depend on a
|
||||
# newer piper-tts than the user's unless we must.
|
||||
syn_config = None
|
||||
if any(k in piper_config for k in _PIPER_ADVANCED_KNOBS):
|
||||
try:
|
||||
@@ -199,12 +189,9 @@ def _load_kittentts_model_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, D
|
||||
|
||||
def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
|
||||
model, kt_config = _load_kittentts_model_for_config(tts_config)
|
||||
audio = model.generate(
|
||||
text,
|
||||
voice=kt_config.get("voice", DEFAULT_KITTENTTS_VOICE),
|
||||
speed=kt_config.get("speed", 1.0),
|
||||
clean_text=kt_config.get("clean_text", True),
|
||||
) # numpy array at 24kHz
|
||||
audio = model.generate( # numpy array at 24kHz
|
||||
text, voice=kt_config.get("voice", DEFAULT_KITTENTTS_VOICE),
|
||||
speed=kt_config.get("speed", 1.0), clean_text=kt_config.get("clean_text", True))
|
||||
|
||||
import soundfile as sf
|
||||
wav_path = _wav_sidecar_path(output_path)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Plugin-registered TTS providers for ``tools.tts_tool``.
|
||||
|
||||
Routes ``tts.provider: <name>`` values that are neither built-in nor a ``type: command``
|
||||
entry to a :class:`agent.tts_provider.TTSProvider` registered by a plugin. Discovery goes
|
||||
through ``hermes_cli.plugins._ensure_plugins_discovered`` (imported lazily so the tool
|
||||
module stays importable without the plugin machinery).
|
||||
"""Plugin-registered TTS providers for ``tools.tts_tool``: routes ``tts.provider: <name>`` values
|
||||
that are neither built-in nor ``type: command`` to a plugin :class:`agent.tts_provider.TTSProvider`.
|
||||
Discovery goes through ``hermes_cli.plugins._ensure_plugins_discovered`` (imported lazily so the
|
||||
tool module stays importable without the plugin machinery).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,10 +21,8 @@ def _lookup_plugin_provider(key: str, *, discover: bool = True, retry: bool = Fa
|
||||
``retry`` re-discovers with ``force=True`` on a miss (a long-lived session may predate the
|
||||
plugin's install). Raises on registry/discovery failure — callers decide if fatal."""
|
||||
from agent.tts_registry import get_provider
|
||||
|
||||
if discover:
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
plugin_provider = get_provider(key)
|
||||
if plugin_provider is None and retry:
|
||||
@@ -41,10 +37,8 @@ def _dispatch_to_plugin_provider(text: str, output_path: str, provider: str, tts
|
||||
Invariants re-checked here so a caller refactor can't break them: built-in names never reach
|
||||
the registry; a same-named ``type: command`` provider wins; only an exact registered name
|
||||
dispatches. Plugin exceptions propagate to ``text_to_speech_tool``'s error envelope."""
|
||||
if not provider:
|
||||
return None
|
||||
key = provider.lower().strip()
|
||||
if key in BUILTIN_TTS_PROVIDERS:
|
||||
key = (provider or "").lower().strip()
|
||||
if not key or key in BUILTIN_TTS_PROVIDERS:
|
||||
return None
|
||||
if _is_command_provider_config(_get_named_provider_config(tts_config, key)):
|
||||
return None
|
||||
@@ -55,9 +49,7 @@ def _dispatch_to_plugin_provider(text: str, output_path: str, provider: str, tts
|
||||
return None
|
||||
if plugin_provider is None:
|
||||
return None
|
||||
|
||||
# voice/model/speed/format are optional per the TTSProvider.synthesize contract;
|
||||
# providers fall back to their own defaults on None.
|
||||
# voice/model/speed/format are optional per TTSProvider.synthesize; providers default on None.
|
||||
cfg = tts_config if isinstance(tts_config, dict) else {}
|
||||
voice, model, speed = cfg.get("voice"), cfg.get("model"), cfg.get("speed")
|
||||
fmt = cfg.get("output_format", DEFAULT_COMMAND_TTS_OUTPUT_FORMAT)
|
||||
@@ -67,16 +59,13 @@ def _dispatch_to_plugin_provider(text: str, output_path: str, provider: str, tts
|
||||
model=model if isinstance(model, str) and model else None,
|
||||
speed=float(speed) if isinstance(speed, (int, float)) else None,
|
||||
format=str(fmt).lower() if fmt else "mp3")
|
||||
# Contract: returns the (possibly rewritten) output path; tolerate None.
|
||||
return written if isinstance(written, str) and written else output_path
|
||||
|
||||
|
||||
def _plugin_provider_is_voice_compatible(provider: str) -> bool:
|
||||
"""True when the registered plugin provider opts into voice-bubble delivery (any failure -> False)."""
|
||||
if not provider:
|
||||
return False
|
||||
key = provider.lower().strip()
|
||||
if key in BUILTIN_TTS_PROVIDERS:
|
||||
key = (provider or "").lower().strip()
|
||||
if not key or key in BUILTIN_TTS_PROVIDERS:
|
||||
return False
|
||||
try:
|
||||
plugin_provider = _lookup_plugin_provider(key, discover=False)
|
||||
|
||||
Reference in New Issue
Block a user