Files
hermes-agent/tools/tts_tool_delivery.py

550 lines
20 KiB
Python

"""Long-form chunking, ffmpeg encoding, container repair and delivery packing.
Everything here is 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.
"""
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
logger = logging.getLogger("tools.tts_tool")
# Final fallback when provider isn't recognised at all.
FALLBACK_MAX_TEXT_LENGTH = 4000
# PCM output specs for Gemini TTS (fixed by the API)
GEMINI_TTS_SAMPLE_RATE = 24000
GEMINI_TTS_CHANNELS = 1
GEMINI_TTS_SAMPLE_WIDTH = 2 # 16-bit PCM (L16)
# 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"])
profiles = (tts_config or {}).get("delivery_profiles")
overrides = profiles.get(key, {}) if isinstance(profiles, dict) else {}
if isinstance(overrides, dict):
defaults.update({k: v for k, v in overrides.items() if v is not None})
max_file_bytes = defaults.get("max_file_bytes")
if isinstance(max_file_bytes, bool) or not isinstance(max_file_bytes, int) or max_file_bytes <= 0:
max_file_bytes = _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) -> List[str]:
"""Greedily join *pieces* with single spaces, starting a new chunk past *max_chars*."""
chunks: List[str] = []
current = ""
for piece in pieces:
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.
An over-long word flushes the running chunk and emits its slices as their
own chunks (the tail slice is not merged with following words).
"""
chunks: List[str] = []
current = ""
for word in sentence.split():
if len(word) > max_chars:
if current:
chunks.append(current)
current = ""
chunks.extend(word[i:i + max_chars] for i in range(0, len(word), max_chars))
continue
candidate = f"{current} {word}".strip()
if current and len(candidate) > max_chars:
chunks.append(current)
current = word
else:
current = candidate
if current:
chunks.append(current)
return chunks
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 re.split(r"(?<=[.!?;:,])\s+", normalized):
sentence = sentence.strip()
if not sentence:
continue
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 already-final-encoded chunks under the conservative size target.
A group never mixes container suffixes (they can't be concat-copied).
"""
groups: List[List[str]] = []
current: List[str] = []
current_size = 0
current_suffix = ""
for path in audio_paths:
size = Path(path).stat().st_size
suffix = 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 += size
current_suffix = suffix
if current:
groups.append(current)
return groups
# ===========================================================================
# ffmpeg encoding helpers
# ===========================================================================
def _has_ffmpeg() -> bool:
return shutil.which("ffmpeg") is not None
def _ffmpeg_run(args: List[str], *, timeout: int = 30) -> subprocess.CompletedProcess:
"""Run ``ffmpeg <args>`` headless (no stdin, hidden window on Windows)."""
return subprocess.run(
["ffmpeg", *args],
capture_output=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
creationflags=windows_hide_flags(),
)
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"
def _finalize_wav_output(wav_path: str, output_path: str) -> str:
"""Move a WAV-native engine's output into the caller's requested container.
Shared by NeuTTS / Piper / KittenTTS: ffmpeg-convert when available,
otherwise rename the WAV to the expected path so the tool stays usable
(the extension is then misleading but the audio plays).
"""
if wav_path == output_path:
return output_path
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
subprocess.run(
[ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path],
check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags(),
)
try:
os.remove(wav_path)
except OSError:
pass
else:
os.rename(wav_path, output_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."""
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
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.
``.wav`` is written directly; ``.ogg`` is forced to Opus (ffmpeg's .ogg
default is Vorbis, which voice bubbles reject); anything else is a plain
ffmpeg conversion. 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 []
cmd = [ffmpeg, "-i", wav_path, *opus, "-y", "-loglevel", "error", output_path]
result = subprocess.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
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:
try:
os.remove(wav_path)
except OSError:
pass
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."""
if not _has_ffmpeg():
return None
return _ffmpeg_transcode_to_opus(mp3_path, mp3_path.rsplit(".", 1)[0] + ".ogg")
def _ffmpeg_transcode_to_opus(input_path: str, ogg_path: str) -> Optional[str]:
"""Transcode *input_path* to real Ogg/Opus at *ogg_path* via ffmpeg.
Safe when ``input_path == ogg_path`` (writes to a temp file, then
replaces). Returns the output path on success, None on failure.
"""
if not _has_ffmpeg():
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:
result = _ffmpeg_run(["-i", input_path, *_OPUS_VOICE_ARGS, "-f", "ogg", work_path, "-y"])
if result.returncode != 0:
logger.warning("ffmpeg conversion failed with return code %d: %s",
result.returncode, result.stderr.decode('utf-8', errors='ignore')[:200])
return None
if os.path.exists(work_path) and os.path.getsize(work_path) > 0:
if in_place:
os.replace(work_path, ogg_path)
return ogg_path
except subprocess.TimeoutExpired:
logger.warning("ffmpeg OGG conversion timed out after 30s")
except FileNotFoundError:
logger.warning("ffmpeg not found in PATH")
except Exception as e:
logger.warning("ffmpeg OGG conversion failed: %s", e, exc_info=True)
finally:
if in_place and os.path.exists(work_path):
try:
os.remove(work_path)
except OSError:
pass
return None
# ===========================================================================
# 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.
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)
except OSError:
return "unknown"
return sniff_container(head) or "unknown"
def _repair_ogg_container(file_str: str) -> str:
"""Ensure a path claiming ``.ogg`` actually contains an Ogg container.
MP3/WAV/FLAC bytes are transcoded in place to real Ogg/Opus. On failure
the file is renamed to its sniffed real extension so platforms get an
honest file instead of a 0-second voice bubble.
"""
if not file_str.endswith(".ogg"):
return file_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
# ===========================================================================
# 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.
OGG/Opus is always decoded and re-encoded (even without voice opt-in);
matching MP3 chunks keep their encoded frames (``-c:a copy``). Structured
containers are never byte-joined. Returns ``None`` when ffmpeg is missing
or fails so callers keep the individually valid files.
"""
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)
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")
command = [
ffmpeg, "-y", "-loglevel", "error", "-f", "concat", "-safe", "0",
"-i", str(concat_path), "-vn",
]
suffix = destination.suffix.lower()
if voice_compatible or suffix in {".ogg", ".opus"}:
command.extend(["-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):
command.extend(["-c:a", "copy"])
command.append(str(temp_output))
result = subprocess.run(
command,
capture_output=True,
timeout=120,
stdin=subprocess.DEVNULL,
creationflags=windows_hide_flags(),
)
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):
try:
path.unlink()
except OSError:
pass
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 and enforce the hard upload limit.
Groups are packed against the conservative target, then every combined
artifact is checked at its real post-encoding 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. Returns
``(final_paths, combined_any)``.
"""
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 = False
combine_index = 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]
try:
Path(combined).unlink()
except OSError:
pass
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))
final_paths: List[str] = []
for index, source in enumerate(packed, start=1):
if len(packed) == 1:
destination = base
else:
source_suffix = Path(source).suffix or base.suffix
destination = base.with_name(f"{base.stem}.part{index:02d}{source_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:
try:
Path(scratch).unlink()
except OSError:
pass