diff --git a/tools/transcription_audio.py b/tools/transcription_audio.py index 15413f370a..00d6c25c25 100644 --- a/tools/transcription_audio.py +++ b/tools/transcription_audio.py @@ -14,6 +14,7 @@ import os import shutil import subprocess import tempfile +from functools import partial from pathlib import Path from typing import Any, Dict, Optional @@ -21,8 +22,7 @@ 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, -) + _config_number, _error_result, _lazy_ensure_quietly, _process_error_detail) # Log-record parity with the origin module. logger = logging.getLogger("tools.transcription_tools") @@ -37,16 +37,9 @@ def _find_binary(binary_name: str) -> Optional[str]: 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") +_find_ffmpeg_binary = partial(_find_binary, "ffmpeg") +_find_ffprobe_binary = partial(_find_binary, "ffprobe") +_find_whisper_binary = partial(_find_binary, "whisper") def _run_quiet(command: list, *, timeout: float, env: Optional[dict] = None) -> subprocess.CompletedProcess: @@ -54,8 +47,7 @@ def _run_quiet(command: list, *, timeout: float, env: Optional[dict] = None) -> 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(), - ) + stdin=subprocess.DEVNULL, env=env, creationflags=windows_hide_flags()) # Shared encode profile for every STT-bound m4a (transcode and silence-trim): @@ -65,19 +57,15 @@ _STT_M4A_ENCODE_ARGS = ("-vn", "-ac", "1", "-ar", "16000", "-c:a", "aac", "-b:a" 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 semantics.""" - 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) + filter_args = ["-af", audio_filter] if audio_filter else [] + _run_quiet([ffmpeg, "-y", "-i", input_path, *filter_args, *_STT_M4A_ENCODE_ARGS, output_path], + 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; ``(converted_path, None)`` or ``(None, error)``. - - Newer OpenAI models reject containers ``whisper-1`` accepted (notably Ogg/Opus - voice notes) and gateway downloads may carry a misleading extension. - """ + Newer OpenAI models reject containers ``whisper-1`` accepted (notably Ogg/Opus voice notes) and + gateway downloads may carry a misleading extension.""" from tools.transcription_tools import _find_ffmpeg_binary, _run_ffmpeg_stt_encode ffmpeg = _find_ffmpeg_binary() if not ffmpeg: @@ -121,13 +109,10 @@ def _validate_audio_source_file(file_path: str, *, enforce_size_limit: bool = Tr 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 + if source_error or suffix.lower() in SUPPORTED_FORMATS: + return source_error + return _error_result(f"Unsupported format: {suffix}. Supported: {', '.join(sorted(SUPPORTED_FORMATS))}") def _prepare_audio_for_transcription(file_path: str) -> tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: @@ -143,12 +128,10 @@ def _prepare_audio_for_transcription(file_path: str) -> tuple[Optional[str], Opt 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") @@ -165,11 +148,9 @@ def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str], 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) @@ -194,9 +175,7 @@ def _convert_caf_to_wav(file_path: str) -> Optional[str]: ("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 + for label, command in ((label, cmd) for label, cmd in candidates if cmd): try: _run_quiet(command, timeout=300) return wav_path @@ -227,20 +206,17 @@ _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. - """ + """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()) + probe = _run_quiet([ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", + "default=noprint_wrappers=1:nokey=1", file_path], timeout=30) + return float(probe.stdout.strip()) except Exception: # noqa: BLE001 - probe is best-effort return None @@ -257,9 +233,7 @@ def _cloud_trim_settings(stt_config: Dict[str, Any]) -> tuple[bool, int, int]: 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 (= upload the original). - - On success the caller owns deleting the returned file's parent directory. - """ + 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: @@ -274,19 +248,15 @@ def _trim_silence_for_cloud_stt(file_path: str, stt_config: Dict[str, Any]) -> O 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, - ) + 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. 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}" - ) + 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: output that is solely kept pause must never upload as "speech". @@ -296,20 +266,15 @@ def _trim_silence_for_cloud_stt(file_path: str, stt_config: Dict[str, Any]) -> O _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, - ) + 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, - ) + 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), - ) + 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 diff --git a/tools/transcription_cloud.py b/tools/transcription_cloud.py index f3a3414b4b..aca2f9a2de 100644 --- a/tools/transcription_cloud.py +++ b/tools/transcription_cloud.py @@ -21,11 +21,14 @@ from tools.transcription_audio import _transcode_audio_for_stt from tools.transcription_common import ( DEFAULT_GROQ_STT_MODEL, DEFAULT_STT_MODEL, ELEVENLABS_STT_BASE_URL, GROQ_BASE_URL, GROQ_MODELS, OPENAI_BASE_URL, OPENAI_MODELS, XAI_STT_BASE_URL, _error_result, _get_stt_section, - _lazy_ensure_quietly, _log_prompt_unsupported, _ok_result, -) + _lazy_ensure_quietly, _log_prompt_unsupported, _ok_result) # Log-record parity with the origin module. logger = logging.getLogger("tools.transcription_tools") +# Voxtral-style ``language xx ...`` prefix some SDK responses carry. +_ASR_TEXT_RE = re.compile( + r"\s*language\s+[\w.-]+(?:\s*[^<]*)?\s*\s*(?P.*)", + flags=re.IGNORECASE | re.DOTALL) def _has_xai_stt_credentials() -> bool: @@ -35,10 +38,8 @@ def _has_xai_stt_credentials() -> bool: def _with_openai_client(api_key: str, base_url: Optional[str], file_path: str, log_label: str, body): """Run ``body(client)`` on a fresh OpenAI SDK client (30s timeout, no retries); always closed. - - Errors map to the shared envelope. APIConnectionError is checked before APITimeoutError - (its subclass) so timeouts report as connection errors, as they always have. - """ + Errors map to the shared envelope. APIConnectionError is checked before APITimeoutError (its + subclass) so timeouts report as connection errors, as they always have.""" try: from openai import OpenAI client = OpenAI(api_key=api_key, base_url=base_url, timeout=30, max_retries=0) @@ -92,27 +93,24 @@ def _transcribe_groq( language = language or _resolve_stt_language("groq") def _run(client): - create_kwargs = {"model": model_name, "response_format": "text", **_sdk_prompt_kwargs(language, prompt)} with open(file_path, "rb") as audio_file: - transcription = client.audio.transcriptions.create(file=audio_file, **create_kwargs) + transcription = client.audio.transcriptions.create(file=audio_file, model=model_name, + response_format="text", + **_sdk_prompt_kwargs(language, prompt)) transcript_text = str(transcription).strip() logger.info("Transcribed %s via Groq API (%s, lang=%s, %d chars)", Path(file_path).name, model_name, language or "auto", len(transcript_text)) return _ok_result(transcript_text, "groq") - return _with_openai_client(api_key, GROQ_BASE_URL, file_path, "Groq", _run) def _transcribe_openai( file_path: str, model_name: str, *, api_key: Optional[str] = None, base_url: Optional[str] = None, provider_label: str = "openai", language: Optional[str] = None, - prompt: Optional[str] = None, -) -> Dict[str, Any]: - """Transcribe via the OpenAI ``audio.transcriptions.create`` SDK shape. - - Shared by every OpenAI-compatible endpoint (DeepInfra etc.): explicit ``api_key``/ - ``base_url`` skip the OpenAI-only auth chain; ``provider_label`` names the response's provider. - """ + prompt: Optional[str] = None) -> Dict[str, Any]: + """Transcribe via the OpenAI ``audio.transcriptions.create`` SDK shape, shared by every + OpenAI-compatible endpoint (DeepInfra etc.): explicit ``api_key``/``base_url`` skip the + OpenAI-only auth chain; ``provider_label`` names the response's provider.""" from tools.transcription_tools import _HAS_OPENAI, _resolve_openai_audio_client_config, _resolve_stt_language if api_key is None: try: @@ -137,8 +135,8 @@ def _transcribe_openai( "model": model_name, "response_format": "text" if model_name == "whisper-1" else "json", } if language: + # gpt-transcribe takes a ``languages`` list and rejects the legacy field. if model_name == "gpt-transcribe": - # gpt-transcribe takes a ``languages`` list and rejects the legacy field. create_kwargs["extra_body"] = {"languages": [language]} else: create_kwargs["language"] = language @@ -147,13 +145,11 @@ def _transcribe_openai( create_kwargs["prompt"] = prompt with open(path, "rb") as audio_file: return client.audio.transcriptions.create(file=audio_file, **create_kwargs) - with tempfile.TemporaryDirectory(prefix="hermes-stt-") as work_dir: try: transcription = _create_transcription(file_path) except BadRequestError as exc: - message = str(exc).lower() - if not any(k in message for k in ("unsupported", "corrupted", "invalid file")): + if not any(k in str(exc).lower() for k in ("unsupported", "corrupted", "invalid file")): raise # Newer models reject containers whisper-1 accepted (Ogg/Opus voice notes): transcode, retry once. converted_path, transcode_error = _transcode_audio_for_stt(file_path, work_dir) @@ -166,7 +162,6 @@ def _transcribe_openai( logger.info("Transcribed %s via %s (%s, %d chars)", Path(file_path).name, provider_label, model_name, len(transcript_text)) return _ok_result(transcript_text, provider_label) - return _with_openai_client(api_key, base_url, file_path, provider_label, _run) @@ -181,24 +176,21 @@ def _transcribe_mistral( try: _lazy_ensure_quietly("stt.mistral") from mistralai.client import Mistral - with Mistral(api_key=api_key) as client: - with open(file_path, "rb") as audio_file: - # Language: hook override > stt.mistral.language > stt.language > env > auto. - language = language or _resolve_stt_language("mistral") - result = client.audio.transcriptions.complete( - model=model_name, file={"content": audio_file, "file_name": Path(file_path).name}, - **_sdk_prompt_kwargs(language, prompt), - ) - transcript_text = _extract_transcript_text(result) - logger.info("Transcribed %s via Mistral API (%s, %d chars)", - Path(file_path).name, model_name, len(transcript_text)) - return _ok_result(transcript_text, "mistral") + with Mistral(api_key=api_key) as client, open(file_path, "rb") as audio_file: + # Language: hook override > stt.mistral.language > stt.language > env > auto. + language = language or _resolve_stt_language("mistral") + result = client.audio.transcriptions.complete( + model=model_name, file={"content": audio_file, "file_name": Path(file_path).name}, + **_sdk_prompt_kwargs(language, prompt)) + transcript_text = _extract_transcript_text(result) + logger.info("Transcribed %s via Mistral API (%s, %d chars)", + Path(file_path).name, model_name, len(transcript_text)) + return _ok_result(transcript_text, "mistral") except Exception as e: return _cloud_failure(e, file_path, "Mistral transcription", type(e).__name__) # ---- REST multipart backends (xAI, ElevenLabs) ---------------------------- - def _post_audio_multipart(url: str, headers: Dict[str, str], file_path: str, data: Dict[str, str]): import requests with open(file_path, "rb") as audio_file: @@ -208,14 +200,11 @@ def _post_audio_multipart(url: str, headers: Dict[str, str], file_path: str, dat def _rest_provider( file_path: str, provider: str, label: str, post: Callable[[], Any], extract_detail, - extract_text, log: Callable[[str, Dict[str, Any]], None], -) -> Dict[str, Any]: - """Shared multipart REST flow: ``post()`` -> ``log(text, body)`` -> ok envelope. - - Non-200 -> ``"