"""Audio preprocessing for STT. Binary discovery, the shared ffmpeg m4a encode (transcode + silence trim), source/format validation, WeChat .silk decoding, CAF conversion and the best-effort cloud pre-upload silence trim. Split out of ``tools/transcription_tools.py``, which re-imports every name (patch surface) and is imported lazily here so origin patches still intercept. """ from __future__ import annotations import logging import os import shutil import subprocess import tempfile from pathlib import Path from typing import Any, Dict, Optional from hermes_cli._subprocess_compat import windows_hide_flags from utils import is_truthy_value from tools.transcription_common import ( COMMON_LOCAL_BIN_DIRS, LOCAL_NATIVE_AUDIO_FORMATS, MAX_FILE_SIZE, SUPPORTED_FORMATS, _config_number, _error_result, _lazy_ensure_quietly, _process_error_detail, ) # Log-record parity with the origin module. logger = logging.getLogger("tools.transcription_tools") def _find_binary(binary_name: str) -> Optional[str]: """Find a local binary, checking common Homebrew/local prefixes as well as PATH.""" for directory in COMMON_LOCAL_BIN_DIRS: candidate = Path(directory) / binary_name if candidate.exists() and os.access(candidate, os.X_OK): return str(candidate) return shutil.which(binary_name) def _find_ffmpeg_binary() -> Optional[str]: return _find_binary("ffmpeg") def _find_ffprobe_binary() -> Optional[str]: return _find_binary("ffprobe") def _find_whisper_binary() -> Optional[str]: return _find_binary("whisper") def _run_quiet(command: list, *, timeout: float, env: Optional[dict] = None) -> subprocess.CompletedProcess: """``subprocess.run`` for STT helper binaries: checked, captured, utf-8 text, no stdin, hidden window.""" return subprocess.run( command, check=True, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, stdin=subprocess.DEVNULL, env=env, creationflags=windows_hide_flags(), ) # Shared encode profile for every STT-bound m4a (transcode and silence-trim): # 16 kHz mono 32 kbps AAC, faststart. One owner so codec/bitrate never drift. _STT_M4A_ENCODE_ARGS = ( "-vn", "-ac", "1", "-ar", "16000", "-c:a", "aac", "-b:a", "32k", "-movflags", "+faststart", ) def _run_ffmpeg_stt_encode(ffmpeg: str, input_path: str, output_path: str, *, audio_filter: Optional[str] = None) -> None: """Run the shared STT m4a encode, optionally with an ``-af`` filter. Raises on failure — callers own the error semantics (transcode reports, trim swallows). """ command = [ffmpeg, "-y", "-i", input_path] if audio_filter: command += ["-af", audio_filter] command += [*_STT_M4A_ENCODE_ARGS, output_path] _run_quiet(command, timeout=120) def _transcode_audio_for_stt(file_path: str, work_dir: str) -> tuple[Optional[str], Optional[str]]: """Transcode to a compact 16 kHz mono AAC/m4a for STT upload. Newer OpenAI models reject containers ``whisper-1`` accepted (notably Ogg/Opus voice notes) and gateway downloads may carry a misleading extension. Returns ``(converted_path, None)`` or ``(None, error)``. """ from tools.transcription_tools import _find_ffmpeg_binary, _run_ffmpeg_stt_encode ffmpeg = _find_ffmpeg_binary() if not ffmpeg: return None, "audio needs transcoding for the STT API, but ffmpeg was not found" converted_path = os.path.join(work_dir, f"{Path(file_path).stem or 'audio'}-stt.m4a") try: _run_ffmpeg_stt_encode(ffmpeg, file_path, converted_path) return converted_path, None except subprocess.CalledProcessError as exc: details = _process_error_detail(exc) logger.error("ffmpeg STT transcode failed for %s: %s", file_path, details) return None, f"failed to transcode audio for the STT API: {details}" except Exception as exc: # noqa: BLE001 - transcode is best-effort logger.error("unexpected STT transcode failure for %s: %s", file_path, exc, exc_info=True) return None, f"failed to transcode audio for the STT API: {exc}" def _validate_audio_file_size(audio_path: Path, *, enforce_size_limit: bool = True) -> Optional[Dict[str, Any]]: """Return an error when *audio_path* is inaccessible or (if enforced) exceeds the remote upload cap.""" try: file_size = audio_path.stat().st_size except OSError as e: return _error_result(f"Failed to access file: {e}") if enforce_size_limit and file_size > MAX_FILE_SIZE: return _error_result( f"File too large: {file_size / (1024*1024):.1f}MB (max {MAX_FILE_SIZE / (1024*1024):.0f}MB)" ) return None def _validate_audio_source_file(file_path: str, *, enforce_size_limit: bool = True) -> Optional[Dict[str, Any]]: """Validate source path safety (and optionally size) before any decoder runs.""" audio_path = Path(file_path) if os.path.islink(audio_path): return _error_result(f"Path is a symbolic link: {file_path}") if not audio_path.exists(): return _error_result(f"Audio file not found: {file_path}") if not audio_path.is_file(): return _error_result(f"Path is not a file: {file_path}") return _validate_audio_file_size(audio_path, enforce_size_limit=enforce_size_limit) def _validate_audio_file(file_path: str, *, enforce_size_limit: bool = True) -> Optional[Dict[str, Any]]: """Validate a supported, decoder-safe audio file.""" source_error = _validate_audio_source_file(file_path, enforce_size_limit=enforce_size_limit) if source_error: return source_error suffix = Path(file_path).suffix if suffix.lower() not in SUPPORTED_FORMATS: return _error_result( f"Unsupported format: {suffix}. Supported: {', '.join(sorted(SUPPORTED_FORMATS))}" ) return None def _prepare_audio_for_transcription(file_path: str) -> tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: """Convert a decoder-safe .silk source to a temporary supported WAV file.""" from tools.transcription_tools import _HAS_PILK, _safe_find_spec audio_path = Path(file_path) if audio_path.suffix.lower() != ".silk": return file_path, None, None if not _HAS_PILK: # pilk is a tiny silk-v3 codec binding — lazy-install on first .silk # voice note instead of bloating the base install. _lazy_ensure_quietly("stt.silk") if not _safe_find_spec("pilk"): return None, None, _error_result( "Unsupported format: .silk. Install the optional 'pilk' dependency to enable WeChat voice transcription." ) temp_dir = tempfile.mkdtemp(prefix="hermes-silk-") converted_path = os.path.join(temp_dir, f"{audio_path.stem}.wav") try: import pilk pilk.silk_to_wav(file_path, converted_path) if not Path(converted_path).is_file() or Path(converted_path).stat().st_size == 0: raise RuntimeError("pilk did not produce a readable WAV file") return converted_path, temp_dir, None except Exception as exc: shutil.rmtree(temp_dir, ignore_errors=True) logger.error("Failed to convert .silk audio %s: %s", file_path, exc, exc_info=True) return None, None, _error_result(f"Failed to convert .silk audio for transcription: {exc}") def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str], Optional[str]]: """Normalize audio for local CLI STT when needed.""" from tools.transcription_tools import _find_ffmpeg_binary audio_path = Path(file_path) if audio_path.suffix.lower() in LOCAL_NATIVE_AUDIO_FORMATS: return file_path, None ffmpeg = _find_ffmpeg_binary() if not ffmpeg: return None, "Local STT fallback requires ffmpeg for non-WAV inputs, but ffmpeg was not found" converted_path = os.path.join(work_dir, f"{audio_path.stem}.wav") try: _run_quiet([ffmpeg, "-y", "-i", file_path, converted_path], timeout=300) return converted_path, None except subprocess.TimeoutExpired: logger.error("ffmpeg conversion timed out for %s", file_path) return None, "Audio conversion for local STT timed out" except subprocess.CalledProcessError as e: details = _process_error_detail(e) logger.error("ffmpeg conversion failed for %s: %s", file_path, details) return None, f"Failed to convert audio for local STT: {details}" def _convert_caf_to_wav(file_path: str) -> Optional[str]: """Convert CAF to WAV using ffmpeg or afconvert (macOS).""" from tools.transcription_tools import _find_ffmpeg_binary audio_path = Path(file_path) wav_path = os.path.join(audio_path.parent, f"{audio_path.stem}.wav") ffmpeg = _find_ffmpeg_binary() afconvert = shutil.which("afconvert") candidates = ( ("ffmpeg", [ffmpeg, "-y", "-i", file_path, wav_path] if ffmpeg else None), ("afconvert", [afconvert, file_path, wav_path, "-d", "LEI16", "-f", "WAVE"] if afconvert else None), ) for label, command in candidates: if not command: continue try: _run_quiet(command, timeout=300) return wav_path except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: logger.warning("%s CAF to WAV failed for %s: %s", label, file_path, e) return None # ---- Cloud pre-upload silence trim -------------------------------------- # # Local faster-whisper gets Silero VAD; cloud providers get the raw file, so # every second of silence is paid for twice (upload + per-minute billing) and # cloud Whisper hallucinates on it. Before uploading to a built-in cloud # provider we collapse long pauses with ffmpeg's silenceremove, keeping # ``stt.cloud_trim_keep_ms`` of each pause so word boundaries survive. # Purely best-effort — ANY of these uploads the original untouched: # ``stt.cloud_trim_silence: false``, ffmpeg/ffprobe missing, trim failure or # timeout, a ~empty result (the provider, not a dB heuristic, decides "no # speech"), or <10% saving. Command-type and plugin providers are NOT trimmed: # they may wrap local CLIs that want the original bytes. _CLOUD_TRIM_THRESHOLD_DB_DEFAULT = -40 # audio below this level counts as silence _CLOUD_TRIM_KEEP_MS_DEFAULT = 300 # how much of each pause survives the trim _CLOUD_TRIM_MIN_SAVING = 0.10 # use the trimmed file only when >=10% shorter _CLOUD_TRIM_MIN_RESULT_SECONDS = 0.3 # all-silence guard floor: never upload ~empty audio # Below this the trim can't pay for itself (several providers bill a 10s # minimum per request) and the encode would sit on the synchronous voice-note path. _CLOUD_TRIM_MIN_INPUT_SECONDS = 12.0 def _probe_audio_duration(file_path: str) -> Optional[float]: """Return the audio duration in seconds via ffprobe, or None. Canonical sync probe; ``gateway/run.py._probe_audio_duration`` and the Telegram adapter carry local variants — keep the command shape in sync. """ from tools.transcription_tools import _find_ffprobe_binary ffprobe = _find_ffprobe_binary() if not ffprobe: return None command = [ ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", file_path, ] try: return float(_run_quiet(command, timeout=30).stdout.strip()) except Exception: # noqa: BLE001 - probe is best-effort return None def _cloud_trim_settings(stt_config: Dict[str, Any]) -> tuple[bool, int, int]: """Resolve (enabled, threshold_db, keep_ms) for the cloud silence trim.""" cfg = stt_config if isinstance(stt_config, dict) else {} # is_truthy_value: a YAML string "false" must disable, exactly like is_stt_enabled. enabled = is_truthy_value(cfg.get("cloud_trim_silence", True), default=True) threshold_db = _config_number(cfg, "cloud_trim_threshold_db", _CLOUD_TRIM_THRESHOLD_DB_DEFAULT, int) keep_ms = _config_number(cfg, "cloud_trim_keep_ms", _CLOUD_TRIM_KEEP_MS_DEFAULT, int) return enabled, threshold_db, max(keep_ms, 0) def _trim_silence_for_cloud_stt(file_path: str, stt_config: Dict[str, Any]) -> Optional[str]: """Return a silence-trimmed copy of *file_path* for cloud upload, or None. ``None`` always means "upload the original" (disabled, tools missing, clip too short, trim failed, mostly silence, or not enough saving). On success the caller owns deleting the returned file's parent directory. """ from tools.transcription_tools import _find_ffmpeg_binary, _probe_audio_duration, _run_ffmpeg_stt_encode enabled, threshold_db, keep_ms = _cloud_trim_settings(stt_config) if not enabled: return None ffmpeg = _find_ffmpeg_binary() if not ffmpeg: logger.debug("Cloud STT silence trim skipped: ffmpeg not found") return None original_duration = _probe_audio_duration(file_path) if not original_duration or original_duration <= 0: logger.debug("Cloud STT silence trim skipped: could not probe %s", file_path) return None name = Path(file_path).name if original_duration < _CLOUD_TRIM_MIN_INPUT_SECONDS: logger.debug( "Cloud STT silence trim skipped for %s: %.1fs is below the %.0fs gate", name, original_duration, _CLOUD_TRIM_MIN_INPUT_SECONDS, ) return None keep_seconds = keep_ms / 1000.0 # start_periods=1 strips leading silence; stop_periods=-1 collapses every # interior/trailing silence, keeping ``keep_seconds`` of each pause. filter_expr = ( f"silenceremove=" f"start_periods=1:start_threshold={threshold_db}dB:start_silence={keep_seconds}:" f"stop_periods=-1:stop_threshold={threshold_db}dB:stop_silence={keep_seconds}" ) work_dir = tempfile.mkdtemp(prefix="hermes-stt-trim-") trimmed_path = os.path.join(work_dir, f"{Path(file_path).stem or 'audio'}-trimmed.m4a") # Scale the all-silence guard with keep_ms: an output consisting solely # of kept pause must never be uploaded as "speech". min_result_seconds = max(_CLOUD_TRIM_MIN_RESULT_SECONDS, 2 * keep_seconds) keep_result = False try: _run_ffmpeg_stt_encode(ffmpeg, file_path, trimmed_path, audio_filter=filter_expr) trimmed_duration = _probe_audio_duration(trimmed_path) if not trimmed_duration or trimmed_duration < min_result_seconds: logger.debug( "Cloud STT silence trim discarded for %s: trimmed result ~empty (%.2fs)", name, trimmed_duration or 0.0, ) return None if trimmed_duration > original_duration * (1 - _CLOUD_TRIM_MIN_SAVING): logger.debug( "Cloud STT silence trim discarded for %s: saves <%.0f%% (%.1fs -> %.1fs)", name, _CLOUD_TRIM_MIN_SAVING * 100, original_duration, trimmed_duration, ) return None logger.info( "Trimmed silence from %s before cloud STT upload (%.1fs -> %.1fs, -%d%%)", name, original_duration, trimmed_duration, round((1 - trimmed_duration / original_duration) * 100), ) keep_result = True return trimmed_path except Exception as exc: # noqa: BLE001 - trim is best-effort logger.debug("Cloud STT silence trim failed for %s: %s", file_path, exc) return None finally: if not keep_result: shutil.rmtree(work_dir, ignore_errors=True)