"""Long-form chunking, ffmpeg encoding, container repair and delivery packing (``tools.tts_tool``). 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 import logging import os import re import shlex import shutil import struct import subprocess import tempfile import uuid from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from hermes_cli._subprocess_compat import windows_hide_flags from tools.tts_command_provider import ( BUILTIN_TTS_PROVIDERS, DEFAULT_COMMAND_TTS_MAX_TEXT_LENGTH, _get_named_provider_config, _is_command_provider_config) logger = logging.getLogger("tools.tts_tool") def _origin(): """``tools.tts_tool``, resolved per call so seams monkeypatched there still apply.""" from tools import tts_tool return tts_tool def _section(tts_config: Any, key: str) -> Dict[str, Any]: """``tts.`` as a dict (``null``/non-dict sections read as empty).""" section = tts_config.get(key) if isinstance(tts_config, dict) else None return section if isinstance(section, dict) else {} FALLBACK_MAX_TEXT_LENGTH = 4000 # provider not recognised at all # Per-provider input-character caps (official docs); override: ``tts..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 "xai": 15000, # https://docs.x.ai/developers/model-capabilities/audio/text-to-speech "minimax": 10000, # https://platform.minimax.io/docs/api-reference/speech-t2a-http (sync) "mistral": 4000, # conservative; no published per-request cap "gemini": 32000, # 32k-token context window; char cap is conservative "elevenlabs": 10000, # fallback when model-aware lookup can't resolve (multilingual_v2) "neutts": 2000, # local model, quality falls off on long text "kittentts": 2000, # local 25MB model "piper": 5000, # local VITS model, phoneme-based; practical cap } # ElevenLabs caps vary by model_id. https://elevenlabs.io/docs/overview/models ELEVENLABS_MODEL_MAX_TEXT_LENGTH: Dict[str, int] = { "eleven_v3": 5000, "eleven_ttv_v3": 5000, "eleven_multilingual_v2": 10000, "eleven_multilingual_v1": 10000, "eleven_english_sts_v2": 10000, "eleven_english_sts_v1": 10000, "eleven_flash_v2": 30000, "eleven_flash_v2_5": 40000} def _positive_int(value: Any) -> Optional[int]: """*value* when it is a positive non-bool int, else None.""" 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: """Input-character cap for *provider*: ``tts..max_text_length`` > ElevenLabs model table > ``PROVIDER_MAX_TEXT_LENGTH`` > command provider's own ``max_text_length`` (else ``DEFAULT_COMMAND_TTS_MAX_TEXT_LENGTH``) > ``FALLBACK_MAX_TEXT_LENGTH``. Non-positive / non-int overrides fall through so a broken config can't disable truncation.""" if not provider: return FALLBACK_MAX_TEXT_LENGTH key = provider.lower().strip() cfg = tts_config or {} prov_cfg = _section(cfg, key) 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: return mapped if key in PROVIDER_MAX_TEXT_LENGTH: return PROVIDER_MAX_TEXT_LENGTH[key] if key not in BUILTIN_TTS_PROVIDERS: named = _get_named_provider_config(cfg, key) if _is_command_provider_config(named): return _positive_int(named.get("max_text_length")) or DEFAULT_COMMAND_TTS_MAX_TEXT_LENGTH return FALLBACK_MAX_TEXT_LENGTH # PCM output specs for Gemini TTS (fixed by the API): 24kHz mono 16-bit (L16). GEMINI_TTS_SAMPLE_RATE, GEMINI_TTS_CHANNELS, GEMINI_TTS_SAMPLE_WIDTH = 24000, 1, 2 # ffmpeg args producing the Ogg/Opus voice-bubble encoding Telegram & co expect. _OPUS_VOICE_ARGS = [ "-acodec", "libopus", "-ac", "1", "-b:a", "48k", "-vbr", "on", "-application", "voip", "-compression_level", "10"] # --- Text chunking and delivery profiles --- @dataclass(frozen=True) class AudioDeliveryProfile: """Destination-platform constraints for generated TTS audio.""" platform: str max_file_bytes: int safety_ratio: float = 0.85 @property def target_file_bytes(self) -> int: """Conservative packing target below the platform hard limit.""" return max(1, int(self.max_file_bytes * self.safety_ratio)) _PLATFORM_AUDIO_DEFAULTS: Dict[str, Dict[str, Any]] = { "discord": {"max_file_bytes": 10 * 1024 * 1024, "safety_ratio": 0.85}, "telegram": {"max_file_bytes": 50 * 1024 * 1024, "safety_ratio": 0.85}, "default": {"max_file_bytes": 10 * 1024 * 1024, "safety_ratio": 0.85}} def _resolve_audio_delivery_profile( platform: Optional[str], tts_config: Optional[Dict[str, Any]] = None) -> AudioDeliveryProfile: """Resolve upload constraints, including optional ``tts.delivery_profiles`` overrides.""" key = (platform or "default").lower().strip() or "default" defaults = dict(_PLATFORM_AUDIO_DEFAULTS.get(key) or _PLATFORM_AUDIO_DEFAULTS["default"]) overrides = _section(_section(tts_config, "delivery_profiles"), key) defaults.update({k: v for k, v in overrides.items() if v is not None}) max_file_bytes = (_positive_int(defaults.get("max_file_bytes")) or _PLATFORM_AUDIO_DEFAULTS["default"]["max_file_bytes"]) safety_ratio = defaults.get("safety_ratio", 0.85) if (isinstance(safety_ratio, bool) or not isinstance(safety_ratio, (int, float)) or not 0 < safety_ratio <= 1): safety_ratio = 0.85 return AudioDeliveryProfile(platform=key, max_file_bytes=max_file_bytes, safety_ratio=float(safety_ratio)) 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).""" chunks: List[str] = [] current = "" for piece in pieces: if slice_oversized and len(piece) > max_chars: if current: chunks.append(current) current = "" chunks.extend(piece[i:i + max_chars] for i in range(0, len(piece), max_chars)) continue candidate = f"{current} {piece}".strip() if current and len(candidate) > max_chars: chunks.append(current) current = piece else: current = candidate if current: chunks.append(current) return chunks def _split_oversized_sentence(sentence: str, max_chars: int) -> List[str]: """Split one over-limit sentence on word boundaries, then hard boundaries.""" return _pack_under_cap(sentence.split(), max_chars, slice_oversized=True) def _split_text_for_tts(text: str, max_chars: int) -> List[str]: """Split text under a provider cap without dropping normalized content.""" if max_chars <= 0: max_chars = FALLBACK_MAX_TEXT_LENGTH normalized = " ".join((text or "").split()) if not normalized: return [] if len(normalized) <= max_chars: return [normalized] expanded: List[str] = [] for sentence in filter(None, (s.strip() for s in re.split(r"(?<=[.!?;:,])\s+", normalized))): if len(sentence) <= max_chars: expanded.append(sentence) else: expanded.extend(_split_oversized_sentence(sentence, max_chars)) return _pack_under_cap(expanded, max_chars) def _pack_audio_files_for_delivery(audio_paths: List[str], profile: AudioDeliveryProfile) -> List[List[str]]: """Group final-encoded chunks under the size target; never mixes suffixes (can't concat-copy).""" groups: List[List[str]] = [] current: List[str] = [] current_size, current_suffix = 0, "" for path in audio_paths: size, suffix = Path(path).stat().st_size, Path(path).suffix.lower() if current and (current_size + size > profile.target_file_bytes or suffix != current_suffix): groups.append(current) current, current_size = [], 0 current.append(path) current_size, current_suffix = current_size + size, suffix return groups + [current] if current else groups # --- ffmpeg encoding helpers --- def _ffmpeg_run( ffmpeg: str, args: List[str], *, timeout: int = 30, check: bool = False, capture: bool = True, ) -> subprocess.CompletedProcess: """Run ``ffmpeg `` headless (no stdin, hidden window on Windows).""" return subprocess.run([ffmpeg, *args], capture_output=capture, check=check, timeout=timeout, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) def _remove_quietly(path: Optional[str]) -> None: """Best-effort unlink (missing/locked files are ignored); None is a no-op.""" if path: try: os.remove(path) except OSError: pass def _wav_sidecar_path(output_path: str) -> str: """Path a WAV-native engine writes to before conversion to *output_path*'s format.""" 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: """Move a WAV-native engine's output into the requested container: ffmpeg-convert when available, else rename so the tool stays usable (misleading extension, audio plays).""" if wav_path == output_path: return output_path ffmpeg = shutil.which("ffmpeg") if not ffmpeg: os.rename(wav_path, output_path) return output_path _ffmpeg_run(ffmpeg, ["-i", wav_path, "-y", "-loglevel", "error", output_path], check=True, capture=False) _remove_quietly(wav_path) return output_path 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.""" block_align = channels * sample_width 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).""" 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 try: ffmpeg = shutil.which("ffmpeg") if ffmpeg: opus = _OPUS_VOICE_ARGS if output_path.lower().endswith(".ogg") else [] result = _ffmpeg_run( ffmpeg, ["-i", wav_path, *opus, "-y", "-loglevel", "error", output_path]) if result.returncode != 0: stderr = result.stderr.decode("utf-8", errors="ignore")[:300] raise RuntimeError(f"ffmpeg conversion failed: {stderr}") else: logger.warning("ffmpeg not found; writing raw WAV to %s (extension may be misleading)", output_path) shutil.copyfile(wav_path, output_path) finally: _remove_quietly(wav_path) return output_path def _convert_to_opus(mp3_path: str) -> Optional[str]: """Convert any ffmpeg-readable audio file to OGG Opus next to it; None on failure.""" from gateway.platforms.base import transcode_to_ogg_opus return transcode_to_ogg_opus(mp3_path, bitrate="48k", timeout=30, output_path=mp3_path.rsplit(".", 1)[0] + ".ogg") # --- Container sniffing / repair --- # 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: return sniff_container(fh.read(12)) or "unknown" except OSError: return "unknown" def _repair_ogg_container(file_str: str) -> str: """Ensure a ``.ogg`` path really holds Ogg: transcode in place, else rename to the sniffed real extension so platforms get an honest file instead of a 0-second voice bubble.""" container = _sniff_audio_container(file_str) if file_str.endswith(".ogg") else "ogg" 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) from gateway.platforms.base import transcode_to_ogg_opus repaired = transcode_to_ogg_opus(file_str, bitrate="48k", timeout=30, output_path=file_str) if repaired: return repaired honest = f"{file_str[:-4]}.{container}" try: os.replace(file_str, 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.""" if not audio_paths: raise ValueError("No audio chunks to combine") if len(audio_paths) == 1: 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: 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"}: args += ["-c:a", "libopus", "-ac", "1", "-b:a", "64k", "-vbr", "off"] elif suffix == ".mp3" and all(Path(path).suffix.lower() == ".mp3" for path in audio_paths): args += ["-c:a", "copy"] result = _ffmpeg_run(ffmpeg, [*args, str(temp_output)], timeout=120) if result.returncode == 0 and temp_output.exists() and temp_output.stat().st_size > 0: os.replace(temp_output, destination) return str(destination) logger.warning("ffmpeg audio combine failed: %s", result.stderr.decode("utf-8", errors="ignore")[:500]) except (OSError, subprocess.TimeoutExpired) as exc: logger.warning("ffmpeg audio combine failed: %s", exc) finally: for path in (concat_path, temp_output): _remove_quietly(path) return None 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 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 returns the constituent files separately. A single chunk above the hard limit fails closed.""" if not audio_paths: raise ValueError("No final-encoded TTS audio chunks") for path in audio_paths: size = Path(path).stat().st_size if size > profile.max_file_bytes: 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, combine_index = False, 0 def emit(group: List[str]) -> List[str]: nonlocal combined_any, combine_index if len(group) == 1: return list(group) combine_index += 1 scratch = base.with_name(f".{base.stem}.delivery{combine_index:03d}.{uuid.uuid4().hex}{base.suffix}") combined = _concat_audio_files(group, str(scratch), voice_compatible=voice_compatible) if not combined: return list(group) scratch_outputs.append(combined) if Path(combined).stat().st_size <= profile.max_file_bytes: combined_any = True return [combined] _remove_quietly(combined) midpoint = max(1, len(group) // 2) return emit(group[:midpoint]) + emit(group[midpoint:]) 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): 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) os.replace(source, destination) 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: for scratch in scratch_outputs: if scratch not in final_paths: _remove_quietly(scratch)