mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
158 lines
8.2 KiB
Python
158 lines
8.2 KiB
Python
"""Per-provider upload limits and long-audio splitting for cloud STT.
|
|
|
|
Every cloud transcription endpoint caps one request by size, and some models also cap it by
|
|
duration. ``transcribe_audio`` asks :func:`exceeds_upload_limit` before dispatch. An
|
|
oversized input is re-encoded to the compact STT m4a first, which is the vendors' own advice
|
|
and fits most recordings into a single request with no seams. When it still does not fit,
|
|
the file is cut into segments at silences close to the limit. Each segment is dispatched on
|
|
its own and the transcripts are joined in order.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import math
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from tempfile import TemporaryDirectory
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
|
from tools.transcription_audio import (
|
|
_find_ffmpeg_binary, _probe_audio_duration, _run_quiet, _transcode_audio_for_stt)
|
|
from tools.transcription_common import MAX_FILE_SIZE, _error_result, _ok_result
|
|
|
|
logger = logging.getLogger("tools.transcription_tools")
|
|
|
|
_MB = 1024 * 1024
|
|
# Vendor-documented per-request upload caps. Providers not listed here (DeepInfra's
|
|
# OpenAI-compatible endpoint, command and plugin providers) get the OpenAI-shaped default.
|
|
_PROVIDER_MAX_BYTES = {
|
|
"openai": 25 * _MB, # developers.openai.com/api/docs/guides/speech-to-text: "Files can be up to 25 MB"
|
|
"groq": 25 * _MB, # console.groq.com/docs/speech-to-text: 25 MB free tier (100 MB dev tier)
|
|
"mistral": 500 * _MB, # docs.mistral.ai/resources/known-limitations: 500 MB, 60 minutes
|
|
"xai": 500 * _MB, # docs.x.ai speech-to-text: "Max 500 MB" (413 above it)
|
|
"elevenlabs": 5 * 1024 * _MB - 1, # elevenlabs.io speech-to-text convert: "must be less than 5.0GB"
|
|
}
|
|
_PROVIDER_MAX_SECONDS = {"mistral": 3600.0}
|
|
# OpenAI per-model duration caps, all measured live (the vendor docs give none in seconds):
|
|
# - gpt-4o-transcribe / gpt-4o-mini-transcribe have a documented 2,000 max output tokens. 1322 s of
|
|
# dense speech came back cut off at output_tokens=2048, and the API refuses more than 1400 s
|
|
# outright. 450 s keeps fast speech (~200 wpm) under the token ceiling.
|
|
# - whisper-1 answers in ~3% of real time (600 s -> 17.8 s, 1200 s -> 55 s), so 600 s segments
|
|
# finish well inside the default 60 s ``stt.openai.timeout``.
|
|
# - gpt-transcribe returned 1322 s complete in 16.7 s and accepted 1558 s, so it gets no cap.
|
|
_OPENAI_MODEL_MAX_SECONDS = {"gpt-4o-transcribe": 450.0, "gpt-4o-mini-transcribe": 450.0, "whisper-1": 600.0}
|
|
# Margin under each cap so container overhead and duration rounding never tip a segment over.
|
|
_SEGMENT_HEADROOM = 0.9
|
|
# How far back from the ideal cut a silence may sit and still be used as the cut point.
|
|
_MAX_SILENCE_LOOKBACK_SECONDS = 60.0
|
|
|
|
|
|
def upload_limit(provider: str, model: Optional[str]) -> tuple[int, Optional[float]]:
|
|
"""``(max_bytes, max_seconds or None)`` for one request to *provider* with *model*."""
|
|
max_seconds = _PROVIDER_MAX_SECONDS.get(provider)
|
|
if provider == "openai":
|
|
max_seconds = _OPENAI_MODEL_MAX_SECONDS.get(model or "")
|
|
return _PROVIDER_MAX_BYTES.get(provider, MAX_FILE_SIZE), max_seconds
|
|
|
|
|
|
def _fits(path: str, limit: tuple[int, Optional[float]], duration: Optional[float]) -> bool:
|
|
max_bytes, max_seconds = limit
|
|
if os.path.getsize(path) > max_bytes:
|
|
return False
|
|
# An unknown duration (no ffprobe) is left for the vendor to judge.
|
|
return max_seconds is None or duration is None or duration <= max_seconds
|
|
|
|
|
|
def exceeds_upload_limit(path: str, limit: tuple[int, Optional[float]]) -> bool:
|
|
"""True when *path* is over the byte cap, or over a duration cap ffprobe can measure."""
|
|
if os.path.getsize(path) > limit[0]:
|
|
return True
|
|
return limit[1] is not None and not _fits(path, limit, _probe_audio_duration(path))
|
|
|
|
|
|
def _too_large(path: str, max_bytes: int, reason: str) -> dict[str, Any]:
|
|
size_mb = os.path.getsize(path) / _MB
|
|
return _error_result(
|
|
f"File too large: {size_mb:.1f}MB (max {max_bytes / _MB:.0f}MB) and it could not be split: {reason}")
|
|
|
|
|
|
def _silence_midpoints(ffmpeg: str, path: str) -> list[float]:
|
|
"""Midpoints (seconds) of the pauses ffmpeg's silencedetect finds; [] on any failure."""
|
|
try:
|
|
result = _run_quiet([ffmpeg, "-hide_banner", "-nostats", "-i", path,
|
|
"-af", "silencedetect=noise=-35dB:d=0.4", "-f", "null", "-"], timeout=300)
|
|
except (OSError, subprocess.SubprocessError) as exc: # silence-aware cuts are an optimisation
|
|
logger.debug("silencedetect failed for %s: %s", path, exc)
|
|
return []
|
|
starts = [float(v) for v in re.findall(r"silence_start: (-?[\d.]+)", result.stderr)]
|
|
ends = [float(v) for v in re.findall(r"silence_end: ([\d.]+)", result.stderr)]
|
|
return [(max(start, 0.0) + end) / 2 for start, end in zip(starts, ends)]
|
|
|
|
|
|
def _cut_points(duration: float, target: float, silences: list[float]) -> list[float]:
|
|
"""Cut times giving segments no longer than *target*, each at the latest pause before the ideal cut."""
|
|
cuts: list[float] = []
|
|
position = 0.0
|
|
lookback = min(_MAX_SILENCE_LOOKBACK_SECONDS, target / 4)
|
|
while duration - position > target:
|
|
ideal = position + target
|
|
nearby = [s for s in silences if ideal - lookback <= s <= ideal and s > position]
|
|
position = max(nearby) if nearby else ideal
|
|
cuts.append(position)
|
|
return cuts
|
|
|
|
|
|
def _split(ffmpeg: str, path: str, work_dir: str, cuts: list[float]) -> list[str]:
|
|
pattern = os.path.join(work_dir, "part%03d.m4a")
|
|
_run_quiet([ffmpeg, "-y", "-loglevel", "error", "-i", path, "-vn", "-c", "copy", "-f", "segment",
|
|
"-segment_times", ",".join(f"{c:.3f}" for c in cuts), "-reset_timestamps", "1", pattern],
|
|
timeout=300)
|
|
return sorted(os.path.join(work_dir, name) for name in os.listdir(work_dir) if name.startswith("part"))
|
|
|
|
|
|
def transcribe_oversized(
|
|
path: str, limit: tuple[int, Optional[float]], dispatch: Callable[[str], dict[str, Any]],
|
|
) -> dict[str, Any]:
|
|
"""Fit *path* under *limit* (re-encode, else split at silences) and run *dispatch* per piece."""
|
|
from tools.voice_mode_transcript import is_whisper_hallucination
|
|
|
|
max_bytes, max_seconds = limit
|
|
ffmpeg = _find_ffmpeg_binary()
|
|
if not ffmpeg:
|
|
return _too_large(path, max_bytes, "ffmpeg was not found")
|
|
with TemporaryDirectory(prefix="hermes-stt-split-", ignore_cleanup_errors=True) as work_dir:
|
|
compact, error = _transcode_audio_for_stt(path, work_dir)
|
|
if error or not compact:
|
|
return _too_large(path, max_bytes, error or "re-encoding produced no file")
|
|
duration = _probe_audio_duration(compact)
|
|
if _fits(compact, limit, duration):
|
|
logger.info("Re-encoded oversized audio %s to %.1fMB for a single STT upload",
|
|
os.path.basename(path), os.path.getsize(compact) / _MB)
|
|
return dispatch(compact)
|
|
if not duration:
|
|
return _too_large(path, max_bytes, "its duration could not be read")
|
|
target = max_bytes * _SEGMENT_HEADROOM * duration / os.path.getsize(compact)
|
|
if max_seconds:
|
|
target = min(target, max_seconds * _SEGMENT_HEADROOM)
|
|
parts = _split(ffmpeg, compact, work_dir, _cut_points(duration, target, _silence_midpoints(ffmpeg, compact)))
|
|
logger.info("Transcribing %s (%.0fs) in %d segments of <= %.0fs",
|
|
os.path.basename(path), duration, len(parts), math.ceil(target))
|
|
transcripts: list[str] = []
|
|
provider = ""
|
|
for index, part in enumerate(parts, start=1):
|
|
result = dispatch(part)
|
|
provider = result.get("provider") or provider
|
|
if result.get("no_speech"):
|
|
continue
|
|
if not result.get("success"):
|
|
return _error_result(f"Segment {index}/{len(parts)} failed: {result.get('error', 'unknown error')}",
|
|
provider=provider)
|
|
text = (result.get("transcript") or "").strip()
|
|
if text and not is_whisper_hallucination(text):
|
|
transcripts.append(text)
|
|
if not transcripts:
|
|
return _error_result("Transcription returned an empty transcript", no_speech=True, provider=provider)
|
|
return {**_ok_result(" ".join(transcripts), provider), "segments": len(parts)}
|