diff options
| author | historia <historiavg@proton.me> | 2026-08-28 00:59:47 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-28 00:59:47 -0400 |
| commit | 975053f1789771ba5cb9dbe50ba7fe0aa396f0ab (patch) | |
| tree | 9e249f025860a8a0bb89751159b412b3d41b25ba | |
| parent | 967b60342af11ada1cd6a27c935dd336523fb1cd (diff) | |
| download | tts-audiobook-generator-975053f1789771ba5cb9dbe50ba7fe0aa396f0ab.tar.gz | |
fix: silently failing whisper transcription
| -rw-r--r-- | app/backends/audiocpp/voices.py | 43 | ||||
| -rw-r--r-- | app/backends/audiocpp/wizard.py | 13 | ||||
| -rw-r--r-- | app/converter/clients/__init__.py | 7 | ||||
| -rw-r--r-- | app/converter/clients/audiocpp.py | 96 | ||||
| -rw-r--r-- | app/converter/clients/base.py | 18 | ||||
| -rw-r--r-- | app/converter/clients/transcribe.py | 147 | ||||
| -rw-r--r-- | app/docs/backend-audiocpp.md | 23 | ||||
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 163 | ||||
| -rw-r--r-- | app/tests/test_tts.py | 190 |
9 files changed, 646 insertions, 54 deletions
diff --git a/app/backends/audiocpp/voices.py b/app/backends/audiocpp/voices.py index b26a0be..d19cb18 100644 --- a/app/backends/audiocpp/voices.py +++ b/app/backends/audiocpp/voices.py @@ -2,19 +2,22 @@ import argparse from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Dict, Optional, Tuple from backends.common import (PROMPT_TEXT_FILENAME, find_wav_files, read_prompt_text) -from converter.clients import (transcribe_reference_audio, - whisper_backend_available) +from converter.clients import (transcribe_reference_audio_detailed, + whisper_backend_problem) def transcribe_wav_dir(wav_files: list, whisper_model: str, cancel=None) -> Dict[str, str]: - """Transcribe each wav file and return a mapping of stem -> transcript. + """Transcribe each wav file and return a stem -> transcript mapping. CANCEL (a ``threading.Event``) is checked between files so the in-TUI - task view can stop a long transcription early. + task view can stop a long transcription early. A wav that yields no + transcript keeps its '' entry, and the per-file warning says WHY + (backend missing, import broken, transcribe error, or no speech) + instead of a bare 'no transcript' line. """ transcripts: Dict[str, str] = {} for wav_file in wav_files: @@ -23,13 +26,15 @@ def transcribe_wav_dir(wav_files: list, whisper_model: str, break name = wav_file.stem print(f"[INFO] Transcribing {wav_file.name} (voice '{name}')...") - text = transcribe_reference_audio(str(wav_file), model_name=whisper_model) + text, reason = transcribe_reference_audio_detailed( + str(wav_file), model_name=whisper_model) if text: print(f"[OK] {name}: {text}") else: - print(f"[WARNING] No transcript for '{name}'; cloning works best " - "with an accurate transcript — consider editing prompt_text " - "by hand before starting the server") + print(f"[WARNING] No transcript for '{name}': {reason}") + print("[WARNING] Cloning works best with an accurate transcript; " + "consider editing prompt_text by hand before starting " + "the server") transcripts[name] = text or "" return transcripts @@ -79,11 +84,10 @@ def _transcribe(args: argparse.Namespace, plan: Optional[dict], "already transcribed, nothing new to transcribe") return existing, False - if whisper_backend_available() is None: - print("[WARNING] Neither faster_whisper nor whisper was found, so " - "reference .wav files cannot be transcribed automatically and " - "every transcript will be empty.") - print(" Install whisper (or faster_whisper) in your " + problem = whisper_backend_problem() + if problem is not None: + print(f"[WARNING] No usable Whisper backend: {problem}") + print(" Install faster-whisper (or openai-whisper) in your " "audiobook environment to transcribe automatically; otherwise " "transcripts must be added by hand (see the warning at the end).") @@ -95,6 +99,17 @@ def _transcribe(args: argparse.Namespace, plan: Optional[dict], else: transcripts = transcribe_wav_dir(wav_files, args.whisper_model, cancel=cancel) + + # A failed transcription must never wipe a good transcript: keep the + # existing text for any voice whose new transcription came back empty. + # A blank prompt_text entry makes the server reject every clone request + # for that voice, so overwriting known-good text with '' is always a + # regression, whatever went wrong with the re-transcription. + for name, text in transcripts.items(): + if not text.strip() and existing.get(name, "").strip(): + transcripts[name] = existing[name] + print(f"[WARNING] Kept the existing transcript for '{name}' " + "(the new transcription came back empty)") return transcripts, True diff --git a/app/backends/audiocpp/wizard.py b/app/backends/audiocpp/wizard.py index 91a8522..46b6638 100644 --- a/app/backends/audiocpp/wizard.py +++ b/app/backends/audiocpp/wizard.py @@ -600,6 +600,19 @@ def _execute_lanes(settings: dict, transcripts, write_prompt = {}, False state["transcripts"] = transcripts state["write_prompt"] = write_prompt + # A blank transcript means the affected clone voices cannot work: + # transcript-conditioned families (Qwen3-TTS Base ICL) reject every + # request without it. Report the step as failed instead of a silent + # [OK]; the setup continues (warn-and-continue) and still writes + # server.json and prompt_text. + blank = sorted(name for name, text in transcripts.items() + if not text.strip()) + if blank: + print("[ERROR] No transcript for: " + ", ".join(blank)) + print("[ERROR] Those clone voices will NOT work until prompt_text " + "carries an accurate transcript for each one; see the " + "summary below for how to fix them by hand.") + return 1 return 0 def write(emit, cancel): diff --git a/app/converter/clients/__init__.py b/app/converter/clients/__init__.py index 1fcfd1a..d02fd9f 100644 --- a/app/converter/clients/__init__.py +++ b/app/converter/clients/__init__.py @@ -20,7 +20,9 @@ from .languages import LANGUAGE_CHOICES, LANGUAGE_ISO_CODES, TTS_LANGUAGES, \ TTS_LANGUAGE_ALIASES, normalize_language from .speakers import QWEN3_TTS_SPEAKERS, SPEAKER_DISPLAY_NAMES, \ is_builtin_speaker, speaker_display_name, speaker_display_name_for -from .transcribe import transcribe_reference_audio, whisper_backend_available +from .transcribe import (transcribe_reference_audio, + transcribe_reference_audio_detailed, + whisper_backend_available, whisper_backend_problem) from .qwen import CUSTOM_VOICE_MODEL_ID, MODEL_SIZE, QwenTTSClient from .faster import SAMPLE_RATE, FasterTTSClient from .audiocpp import ( @@ -57,7 +59,8 @@ __all__ = [ "QWEN3_TTS_SPEAKERS", "SPEAKER_DISPLAY_NAMES", "speaker_display_name", "speaker_display_name_for", "is_builtin_speaker", # transcription - "transcribe_reference_audio", "whisper_backend_available", + "transcribe_reference_audio", "transcribe_reference_audio_detailed", + "whisper_backend_available", "whisper_backend_problem", # audio.cpp family profiles "AUDIOCPP_LANG_DISPLAY", "AUDIOCPP_LANG_ISO", "AUDIOCPP_LANG_OMIT", "AUDIOCPP_FAMILY_QWEN3_TTS", "AUDIOCPP_TASK_TTS", "AUDIOCPP_TASK_VDES", diff --git a/app/converter/clients/audiocpp.py b/app/converter/clients/audiocpp.py index 4a161cb..8c446d3 100644 --- a/app/converter/clients/audiocpp.py +++ b/app/converter/clients/audiocpp.py @@ -13,7 +13,8 @@ from typing import Any, Dict, List, Optional from .. import config from ..audio import concat_audio_files from ..chunking import split_into_chunks -from .base import BaseTTSClient, ConversionCancelled, resolve_request_seed +from .base import (BaseTTSClient, ConversionCancelled, + NonRetryableTTSError, resolve_request_seed) from .languages import LANGUAGE_ISO_CODES, normalize_language from .speakers import (is_builtin_speaker, speaker_display_name, speaker_display_name_for) @@ -56,6 +57,92 @@ AUDIOCPP_VOICE_SPEAKER = "speaker" # built-in speaker name (Qwen CustomVoice) AUDIOCPP_VOICE_CLONE = "clone" # server-side preset / voice_dir (Base, others) AUDIOCPP_VOICE_DESIGN = "design" # voice described by --instructions (vdes) +# HTTP error body fragments identifying deterministic request-configuration +# problems: the identical request will fail on every retry, so the chunk +# loop must give up immediately instead of burning its attempt budget. +# Matched case-insensitively against the server's error message; the +# fragments come from audio.cpp itself, so they hold for every hosted +# family (none are model-specific). +AUDIOCPP_NON_RETRYABLE_ERRORS = ( + # Cloning without the reference transcript (Qwen3-TTS Base ICL mode): + # the server-side voice has reference audio but no transcript for it. + "requires reference text", + # The server cannot resolve a model contract for the family (its own + # hint text about model_specs/--model-spec-override follows the fragment). + "model contract spec not found for family", + "does not embed an audio.cpp model spec", + "embeds a legacy model spec", + # The request named a model the server does not host. + "unknown model id", +) +_REFERENCE_TEXT_FRAGMENT = AUDIOCPP_NON_RETRYABLE_ERRORS[0] + + +def _server_error_message(detail: str) -> str: + """The server's error message from an HTTP error body, else the body. + + The speech endpoint wraps failures as {"error": {"message": ...}}; + the inner message is what matches AUDIOCPP_NON_RETRYABLE_ERRORS and + what the user should see. Unparseable bodies are returned as-is. + """ + try: + payload = json.loads(detail) + except ValueError: + return detail + if isinstance(payload, dict): + error = payload.get("error") + if isinstance(error, dict) and isinstance(error.get("message"), str): + return error["message"] + if isinstance(error, str): + return error + return detail + + +def _reference_text_error(voice: Optional[str], server_message: str) -> str: + """Actionable message for the missing-reference-transcript failure. + + The server resolved the requested voice to reference audio but has no + transcript for it, so its ICL voice-clone path rejects every request. + The fix is server-side data, not a client retry: prompt_text (or the + voice preset's reference_text) supplies it, read per request, so no + server restart is needed. x_vector_only_mode is the transcript-free + escape hatch, at the cost of speaker similarity. + """ + name = f"'{voice}'" if voice else "the requested voice" + return ( + f"The audio.cpp server cannot clone voice {name}: its reference " + "audio has no transcript, and this model family's voice cloning " + f"requires one ({server_message}). Add the transcript to the " + "prompt_text file in the server's voice directory (one " + "'<voice>|<transcript>' line per voice) or set reference_text on " + "the voice preset in server.json; the server reads it per request, " + "no restart needed. Re-running the audio.cpp setup re-transcribes " + "the reference wavs with whisper. Alternatively rerun with " + "--option x_vector_only_mode=true to clone from the speaker " + "embedding alone (no transcript needed; lower similarity)." + ) + + +def audiocpp_request_error(status: int, detail: str, + voice: Optional[str] = None) -> Exception: + """The exception for a failed audio.cpp speech request. + + Deterministic request-configuration errors (a fragment in + AUDIOCPP_NON_RETRYABLE_ERRORS) become NonRetryableTTSError so the + chunk retry loop skips attempts that cannot succeed; everything else + returns the plain RuntimeError the retry loop has always retried. + """ + message = _server_error_message(detail) + lowered = message.lower() + if _REFERENCE_TEXT_FRAGMENT in lowered: + return NonRetryableTTSError( + _reference_text_error(voice, message)) + if any(fragment in lowered for fragment in AUDIOCPP_NON_RETRYABLE_ERRORS): + return NonRetryableTTSError( + f"audio.cpp server returned HTTP {status} (not retryable): " + f"{message}") + return RuntimeError(f"audio.cpp server returned HTTP {status}: {detail}") + class AudioCppFamilyProfile: """Request conventions of one audio.cpp model family. @@ -665,7 +752,8 @@ class AudioCppTTSClient(BaseTTSClient): detail = exc.read().decode("utf-8", errors="replace")[:200] except Exception: pass - raise RuntimeError(f"audio.cpp server returned HTTP {exc.code}: {detail}") from exc + raise audiocpp_request_error(exc.code, detail, + voice=self.voice) from exc except urllib.error.URLError as exc: raise RuntimeError(f"audio.cpp request failed: {exc.reason}") from exc if len(wav) < 12 or wav[:4] != b"RIFF" or wav[8:12] != b"WAVE": @@ -710,6 +798,10 @@ class AudioCppTTSClient(BaseTTSClient): except ConversionCancelled: raise + except NonRetryableTTSError: + # Propagate past the generic handler so the retry loop skips + # its remaining attempts for deterministic server errors. + raise except Exception as exc: logger.error("audio.cpp chunk processing failed for chunk %d: %s", chunk_num, exc) diff --git a/app/converter/clients/base.py b/app/converter/clients/base.py index d0d57fb..8a0b4e4 100644 --- a/app/converter/clients/base.py +++ b/app/converter/clients/base.py @@ -17,6 +17,19 @@ class ConversionCancelled(Exception): """Raised when the run's cancel event is set (between requests).""" +class NonRetryableTTSError(RuntimeError): + """A deterministic server-side request/config error; retrying cannot help. + + Raised by clients for failures that will reproduce identically on every + attempt (missing server-side reference transcripts, unknown model ids, + missing model contracts, ...). process_chunk_with_retry skips its + remaining attempts and back-off sleeps for these and re-raises, so the + converter aborts with the actionable message instead of burning the + retry budget on a request that can never succeed. Subclasses RuntimeError + so except-handlers written for the plain HTTP-error case keep working. + """ + + # How a run supplies its voice: a built-in CustomVoice speaker, by cloning # a reference audio clip (the faster and audiocpp backends always clone # server-side; only the Qwen client branches on this at request time), or @@ -109,6 +122,8 @@ class BaseTTSClient: Returns the generated chunk file's path, or None when all attempts failed. Raises ConversionCancelled when the run was cancelled. + NonRetryableTTSError is logged once and re-raised without consuming + the remaining attempts (the identical request can never succeed). """ for attempt in range(config.MAX_RETRIES): self._check_cancelled() @@ -119,6 +134,9 @@ class BaseTTSClient: logger.warning("Chunk %d attempt %d failed", chunk_num, attempt + 1) except ConversionCancelled: raise + except NonRetryableTTSError as exc: + logger.error("Chunk %d error (not retried): %s", chunk_num, exc) + raise except Exception as exc: logger.warning("Chunk %d attempt %d error: %s", chunk_num, attempt + 1, exc) diff --git a/app/converter/clients/transcribe.py b/app/converter/clients/transcribe.py index d2db9f1..28c4907 100644 --- a/app/converter/clients/transcribe.py +++ b/app/converter/clients/transcribe.py @@ -1,39 +1,115 @@ """Optional local Whisper transcription of reference audio.""" import logging -from typing import Optional +import threading +from typing import Dict, Optional, Tuple logger = logging.getLogger(__name__) +# One loaded Whisper model per (backend, model size), reused across calls. +# The audio.cpp setup transcribes every reference wav in one run; loading +# the model once instead of per file saves seconds per voice. The lock +# keeps a TUI lane and a concurrent conversion from racing the load. +_MODEL_LOCK = threading.Lock() +_MODELS: Dict[Tuple[str, str], object] = {} -def transcribe_reference_audio(audio_path: str, model_name: str = "base") -> Optional[str]: - """Transcribe reference audio locally using an optional Whisper backend. - The current qwen-tts demo does not expose a transcription endpoint, so - transcription is done client-side when a Whisper package is available. - Returns None if no backend is installed. +def _cached_model(key: Tuple[str, str], loader): + """The model for KEY, loaded by LOADER() on first use and cached.""" + with _MODEL_LOCK: + model = _MODELS.get(key) + if model is None: + model = loader() + _MODELS[key] = model + return model + + +def _import_failure_reason(backend: str, exc: ImportError) -> str: + """Why importing BACKEND failed: missing package or broken install. + + A backend whose own compiled dependency fails to load raises the same + ImportError as a missing package (e.g. faster-whisper whose av build + cannot load its shared libraries); telling the two apart turns 'was it + even installed?' into the actual fix. A ModuleNotFoundError naming the + backend itself is a plain missing package; anything else is checked + against the installed distributions. """ - for backend in ("faster_whisper", "whisper"): - try: - if backend == "faster_whisper": + if isinstance(exc, ModuleNotFoundError) \ + and getattr(exc, "name", "") == backend: + return f"{backend} is not installed" + try: + import importlib.util + present = importlib.util.find_spec(backend) is not None + except (ImportError, ValueError): + present = True + if not present: + return f"{backend} is not installed" + return f"{backend} is installed but failed to import: {exc}" + + +def _transcribe_with(backend: str, audio_path: str, + model_name: str) -> Tuple[Optional[str], str]: + """One transcription attempt with BACKEND; returns (text, reason). + + TEXT is the transcript, or None on any failure; REASON then explains + why in one human-readable line (missing package, broken import, model + or transcribe error, or an empty result), so callers can surface the + cause instead of a bare 'no transcript'. + """ + try: + if backend == "faster_whisper": + def load(): from faster_whisper import WhisperModel - model = WhisperModel(model_name, device="cpu", compute_type="int8") - segments, _ = model.transcribe(audio_path) - text = " ".join(seg.text.strip() for seg in segments).strip() - else: + return WhisperModel(model_name, device="cpu", compute_type="int8") + model = _cached_model(("faster_whisper", model_name), load) + segments, _ = model.transcribe(audio_path) + text = " ".join(seg.text.strip() for seg in segments).strip() + else: + def load(): import whisper - model = whisper.load_model(model_name) - result = model.transcribe(audio_path) - text = (result.get("text") or "").strip() - if text: - logger.info("Transcription complete via %s: %s", backend, text) - return text - except ImportError: - continue - except Exception as exc: - logger.warning("%s transcription failed: %s", backend, exc) - logger.warning("No Whisper backend available; transcription skipped.") - return None + return whisper.load_model(model_name) + model = _cached_model(("whisper", model_name), load) + result = model.transcribe(audio_path) + text = (result.get("text") or "").strip() + except ImportError as exc: + return None, _import_failure_reason(backend, exc) + except Exception as exc: + return None, f"{backend} transcription failed: {exc}" + if not text: + return None, f"{backend} heard no speech in this audio" + logger.info("Transcription complete via %s: %s", backend, text) + return text, "ok" + + +def transcribe_reference_audio_detailed( + audio_path: str, model_name: str = "base") -> Tuple[Optional[str], str]: + """Transcribe one reference wav locally; returns (text, reason). + + The current qwen-tts demo does not expose a transcription endpoint, so + transcription is done client-side when a Whisper backend is available. + TEXT is the transcript, or None when no backend produced one; REASON + is "ok" on success and otherwise explains the failure (tried backends + in order), e.g. "faster_whisper is installed but failed to import: + ...; whisper is not installed" — what the audio.cpp setup prints so a + voice that cannot be transcribed is never a silent blank. + """ + reasons = [] + for backend in ("faster_whisper", "whisper"): + text, reason = _transcribe_with(backend, audio_path, model_name) + if text: + return text, "ok" + reasons.append(reason) + return None, "; ".join(reasons) + + +def transcribe_reference_audio(audio_path: str, model_name: str = "base") -> Optional[str]: + """Transcribe reference audio locally using an optional Whisper backend. + + Returns None if no backend is installed or transcription failed; + transcribe_reference_audio_detailed also explains why. + """ + text, _ = transcribe_reference_audio_detailed(audio_path, model_name) + return text def whisper_backend_available() -> Optional[str]: @@ -52,3 +128,24 @@ def whisper_backend_available() -> Optional[str]: continue return backend return None + + +def whisper_backend_problem() -> Optional[str]: + """None when a Whisper backend is importable, else why none is usable. + + One line per backend in probe order, distinguishing 'not installed' + from 'installed but failed to import: <error>' — the setup prints this + before transcribing so a broken compiled dependency (which surfaces as + the same ImportError as a missing package) is visible as such. + """ + problems = [] + for backend in ("faster_whisper", "whisper"): + try: + __import__(backend) + except ImportError as exc: + problems.append(_import_failure_reason(backend, exc)) + except Exception as exc: + problems.append(f"{backend} failed to import: {exc}") + else: + return None + return "; ".join(problems) diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md index 47115ef..353fe1b 100644 --- a/app/docs/backend-audiocpp.md +++ b/app/docs/backend-audiocpp.md @@ -98,6 +98,29 @@ python audiobook.py --backend audiocpp --model <id> --voice narrator \ --option emotion=neutral --option speed=1.1 ``` +### Voice cloning transcripts (prompt_text) + +Some clone families require the **transcript of the reference audio** with +every request (Qwen3-TTS's Base model clones in ICL mode and rejects each +request without it: "voice clone ICL mode requires reference text"). The +server supplies the transcript itself, from either of two places: + +- `prompt_text` in the configured `voice_dir`: one `<voice>|<transcript>` line + per wav (the setup wizard writes it from whisper transcriptions), or +- `reference_text` on a `voice_presets` entry in `server.json`. + +An **empty or missing transcript makes every clone request for that voice +fail**, so `audiobook.py` aborts on the first chunk with instructions instead +of retrying. The setup warns loudly about any voice whose transcript came +back empty — fill those in before converting (edit `prompt_text` by hand and +restart nothing: the server re-reads it per request), or rerun the audio.cpp +setup to re-transcribe. A re-transcription that fails keeps any existing +non-empty transcript rather than overwriting it with a blank. + +If accurate transcripts are not available, cloning without one is possible +per run with `--option x_vector_only_mode=true` (speaker-embedding-only +cloning — no transcript needed, noticeably lower speaker similarity). + In the hub's **Generate Audiobooks** form the Model picker shows each entry's voice capability (`speaker` / `clone` / `design`). The Voice field is labelled **Built-in voice** on CustomVoice entries (listing the model's speakers) and **Voice to clone** everywhere else (listing the server's preset/voice_dir entries). Instructions are shown for every entry: required for `vdes` design models, an optional style/delivery instruction elsewhere — and on families without built-in speakers that read instructions, a description alone can define the voice, so leaving Voice empty is fine there. A Request options field accepts the same `KEY=VALUE` items as `--option`, and appears only for model families whose audio.cpp checkout spec declares request options (e.g. Neutts, Outetts, F5-TTS — not Qwen3-TTS or Higgs Audio). Editing Instructions or Request options shows a short dim hint with an example (for Instructions: `"Speak in a calm, soothing, and happy tone."`). Language is a static picker over the languages of audio.cpp's WebUI menus (it shows the same "Check model documentation for supported languages." hint while editing) and overrides the global setting for this run only. The hub also works with an audio.cpp server that runs somewhere else (another checkout, another machine): set `AUDIOCPP_REMOTE_URL` in `app/converter/config.py` (or the TUI **Settings** → "audio.cpp remote URL") to its `host:port`. The hub probes that URL and, when it answers, offers an `audio.cpp [remote]` entry in **Generate Audiobooks…** whose models and voices are queried live (`GET /v1/models` and `GET /v1/audio/voices`) — alongside the managed `audio.cpp` entry, which keeps reading the local `server.json`. The remote URL defaults to `127.0.0.1:8080`, so a server started outside this tool on the local port is found automatically. On the CLI, pass `--api-url http://host:port` (and `--model`/`--voice` matching that server's config). diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 3e97b82..6f97438 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -1,5 +1,6 @@ """Tests for the audio.cpp backend setup module (backends/audiocpp.py).""" +import argparse import io import json import sys @@ -983,9 +984,10 @@ class TranscribeWavDirTests(unittest.TestCase): self._td.cleanup() def test_transcribes_to_stem_map_with_absolute_paths(self): - transcripts = {str(self.narrator): "First.", - str(self.other): "Second."} - with patch.object(make_server.voices, "transcribe_reference_audio", + transcripts = {str(self.narrator): ("First.", "ok"), + str(self.other): ("Second.", "ok")} + with patch.object(make_server.voices, + "transcribe_reference_audio_detailed", side_effect=lambda path, model_name="base": transcripts[path]): result = make_server.voices.transcribe_wav_dir( @@ -994,14 +996,27 @@ class TranscribeWavDirTests(unittest.TestCase): self.assertEqual(result["narrator"], "First.") def test_failed_transcription_keeps_empty_string(self): - with patch.object(make_server.voices, "transcribe_reference_audio", - return_value=None): + with patch.object(make_server.voices, + "transcribe_reference_audio_detailed", + return_value=(None, "no speech detected")): result = make_server.voices.transcribe_wav_dir([self.narrator], "base") self.assertEqual(result["narrator"], "") + def test_failed_transcription_prints_the_reason(self): + buffer = io.StringIO() + with patch.object(make_server.voices, + "transcribe_reference_audio_detailed", + return_value=(None, "faster_whisper heard no speech")), \ + redirect_stdout(buffer): + make_server.voices.transcribe_wav_dir([self.narrator], "base") + output = buffer.getvalue() + self.assertIn("No transcript for 'narrator'", output) + self.assertIn("faster_whisper heard no speech", output) + def test_whisper_model_name_passed_through(self): - with patch.object(make_server.voices, "transcribe_reference_audio", - return_value="text") as mock_transcribe: + with patch.object(make_server.voices, + "transcribe_reference_audio_detailed", + return_value=("text", "ok")) as mock_transcribe: make_server.voices.transcribe_wav_dir([self.narrator], "large-v3") self.assertEqual(mock_transcribe.call_args.kwargs["model_name"], "large-v3") @@ -1015,6 +1030,122 @@ class TranscribeWavDirTests(unittest.TestCase): self.assertIn("other|World.", text) +class TranscribePlanTests(unittest.TestCase): + """_transcribe: plan application and transcript wipe protection.""" + + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.folder = Path(self._td.name) + self.narrator = self.folder / "narrator.wav" + self.narrator.write_bytes(b"x") + self.args = argparse.Namespace(input_dir=self.folder, + whisper_model="base") + + def tearDown(self): + self._td.cleanup() + + def test_all_mode_retranscribes_everything(self): + with patch.object(make_server.voices, + "transcribe_reference_audio_detailed", + return_value=("New words.", "ok")): + transcripts, write = make_server.voices._transcribe( + self.args, {"mode": "all", "missing": [], "existing": {}}) + self.assertTrue(write) + self.assertEqual(transcripts, {"narrator": "New words."}) + + def test_empty_retranscription_keeps_existing_transcript(self): + # A failed re-transcription must never overwrite known-good text + # with a blank: a blank prompt_text entry makes the server reject + # every clone request for that voice. + with patch.object(make_server.voices, + "transcribe_reference_audio_detailed", + return_value=(None, "backend broken")), \ + redirect_stdout(io.StringIO()) as buffer: + transcripts, write = make_server.voices._transcribe( + self.args, {"mode": "all", "missing": [], + "existing": {"narrator": "Good words."}}) + self.assertTrue(write) + self.assertEqual(transcripts, {"narrator": "Good words."}) + self.assertIn("Kept the existing transcript for 'narrator'", + buffer.getvalue()) + + def test_missing_mode_merges_new_with_existing(self): + with patch.object(make_server.voices, + "transcribe_reference_audio_detailed", + return_value=("Fresh text.", "ok")): + transcripts, _write = make_server.voices._transcribe( + self.args, {"mode": "missing", "missing": [self.narrator], + "existing": {}}) + self.assertEqual(transcripts, {"narrator": "Fresh text."}) + + def test_unusable_backend_warns_with_the_reason(self): + buffer = io.StringIO() + with patch.object(make_server.voices, "whisper_backend_problem", + return_value="faster_whisper is installed but " + "failed to import: boom"), \ + patch.object(make_server.voices, + "transcribe_reference_audio_detailed", + return_value=("text", "ok")), \ + redirect_stdout(buffer): + make_server.voices._transcribe( + self.args, {"mode": "all", "missing": [], "existing": {}}) + output = buffer.getvalue() + self.assertIn("No usable Whisper backend", output) + self.assertIn("failed to import: boom", output) + + +class WizardTranscribeStepTests(unittest.TestCase): + """The setup lane's transcribe step: rc reflects unusable transcripts.""" + + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.folder = Path(self._td.name) + (self.folder / "narrator.wav").write_bytes(b"x") + self.args = argparse.Namespace(input_dir=None, whisper_model="base") + self.settings = { + "audiocpp_dir": self.folder, + "wav_dir": self.folder, + "include_clone": True, + "plan": {"mode": "all", "missing": [], "existing": {}}, + "build": None, + "model_entries": [], + } + + def tearDown(self): + self._td.cleanup() + + def _transcribe_step(self): + lanes = make_server.wizard._execute_lanes(self.settings, self.args) + return lanes[0].steps[0] + + def test_blank_transcripts_fail_the_step(self): + step = self._transcribe_step() + with patch.object(make_server.voices, + "transcribe_reference_audio_detailed", + return_value=(None, "broken backend")), \ + redirect_stdout(io.StringIO()) as buffer: + rc = step.work(None, None) + self.assertEqual(rc, 1) + self.assertIn("No transcript for: narrator", buffer.getvalue()) + + def test_good_transcripts_pass_the_step(self): + step = self._transcribe_step() + with patch.object(make_server.voices, + "transcribe_reference_audio_detailed", + return_value=("Words.", "ok")), \ + redirect_stdout(io.StringIO()): + self.assertEqual(step.work(None, None), 0) + + def test_no_clone_families_passes_without_transcribing(self): + self.settings["include_clone"] = False + self.settings["plan"] = None + step = self._transcribe_step() + with patch.object(make_server.voices, + "transcribe_reference_audio_detailed") as mock_transcribe: + self.assertEqual(step.work(None, None), 0) + mock_transcribe.assert_not_called() + + class DesignPackageTests(unittest.TestCase): """Voice-design package detection.""" @@ -1594,14 +1725,24 @@ class NonInteractiveMainTests(unittest.TestCase): argv = ["backends/audiocpp.py"] + argv transcribe_effect = transcribe if transcribe is not None \ else MagicMock() + + def detailed(path, model_name="base"): + result = transcribe_effect(path, model_name=model_name) + if isinstance(result, tuple): + return result + return (result, "ok" if result + else "faster_whisper is not installed (test stub)") + with patch.object(sys, "argv", argv), \ patch.object(make_server.build, "find_local_checkout", return_value=None if no_checkout else self.checkout), \ - patch.object(make_server.voices, "transcribe_reference_audio", - side_effect=transcribe_effect), \ - patch.object(make_server.voices, "whisper_backend_available", - return_value=whisper): + patch.object(make_server.voices, + "transcribe_reference_audio_detailed", + side_effect=detailed), \ + patch.object(make_server.voices, "whisper_backend_problem", + return_value=None if whisper else + "faster_whisper is not installed"): return make_server.wizard.main() def _args(self, *extra): diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index cf67c6c..ce2dbb6 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -39,7 +39,10 @@ from converter.clients import ( QwenTTSClient, audiocpp_entry_voice_capability, normalize_language, + transcribe_reference_audio_detailed, + whisper_backend_problem, ) +from converter.clients.base import NonRetryableTTSError from converter.converter import AudiobookConverter # Chunks folder handed to clients whose tests never write chunk files. @@ -1408,6 +1411,83 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): self.assertIn("500", str(ctx.exception)) self.assertIn("bad voice", str(ctx.exception)) + def test_reference_text_error_is_not_retryable(self): + # Qwen3-TTS Base cloning without a server-side transcript fails + # identically on every attempt: the error must carry the fix + # (prompt_text / x_vector_only_mode) and skip the retry budget. + client = self._make_client(preset_mode=True, voice="narrator") + error = urllib.error.HTTPError( + "http://127.0.0.1:8080/v1/audio/speech", 500, + "Server Error", {}, + io.BytesIO(b'{"error":{"message":"Qwen3 voice clone ICL mode ' + b'requires reference text","type":"server_error"}}')) + with patch("converter.clients.faster.urllib.request.urlopen", + side_effect=error): + with self.assertRaises(NonRetryableTTSError) as ctx: + client._request_wav("Hello.") + message = str(ctx.exception) + self.assertIn("requires reference text", message) + self.assertIn("'narrator'", message) + self.assertIn("prompt_text", message) + self.assertIn("x_vector_only_mode", message) + + def test_model_contract_error_is_not_retryable(self): + client = self._make_client(preset_mode=True, voice="narrator") + error = urllib.error.HTTPError( + "http://127.0.0.1:8080/v1/audio/speech", 500, + "Server Error", {}, + io.BytesIO(b'{"error":{"message":"model contract spec not found ' + b"for family 'qwen3_tts' (provide --model-spec-override)\"}}")) + with patch("converter.clients.faster.urllib.request.urlopen", + side_effect=error): + with self.assertRaises(NonRetryableTTSError) as ctx: + client._request_wav("Hello.") + message = str(ctx.exception) + self.assertIn("not retryable", message) + self.assertIn("model contract spec not found for family 'qwen3_tts'", + message) + self.assertIn("--model-spec-override", message) + + def test_unknown_model_id_error_is_not_retryable(self): + client = self._make_client(preset_mode=True, voice="narrator") + error = urllib.error.HTTPError( + "http://127.0.0.1:8080/v1/audio/speech", 500, + "Server Error", {}, + io.BytesIO(b'{"error":{"message":"unknown model id: nope"}}')) + with patch("converter.clients.faster.urllib.request.urlopen", + side_effect=error): + with self.assertRaises(NonRetryableTTSError) as ctx: + client._request_wav("Hello.") + message = str(ctx.exception) + self.assertIn("not retryable", message) + self.assertIn("unknown model id: nope", message) + + def test_unmatched_server_error_stays_retryable(self): + # Only known-deterministic fragments skip the retry budget; device + # hiccups, OOM, and anything unrecognized keep the plain error the + # retry loop has always retried. + client = self._make_client() + error = urllib.error.HTTPError( + "http://127.0.0.1:8080/v1/audio/speech", 500, + "Server Error", {}, + io.BytesIO(b'{"error":{"message":"CUDA error at ggml-cuda.cu"}}')) + with patch("converter.clients.faster.urllib.request.urlopen", + side_effect=error): + with self.assertRaises(RuntimeError) as ctx: + client._request_wav("Hello.") + self.assertNotIsInstance(ctx.exception, NonRetryableTTSError) + self.assertIn("CUDA error", str(ctx.exception)) + + def test_non_retryable_error_skips_remaining_attempts(self): + client = self._make_client() + with patch.object(client, "generate_chunk", + side_effect=NonRetryableTTSError("nope")) as mock_gen, \ + patch("converter.clients.base.time.sleep") as mock_sleep: + with self.assertRaises(NonRetryableTTSError): + client.process_chunk_with_retry(1, "Hello.") + self.assertEqual(mock_gen.call_count, 1) + mock_sleep.assert_not_called() + def test_transient_failure_fails_the_chunk_attempt(self): # Retrying is the chunk-level policy's job # (process_chunk_with_retry); one generate_chunk call makes one @@ -1474,6 +1554,116 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): self.assertEqual(remaining, ["chunk_0001.wav"]) +class TranscribeReasonTests(unittest.TestCase): + """transcribe_reference_audio_detailed: a reason for every empty result. + + The audio.cpp setup prints the reason per voice, so each failure class + must be distinguishable: missing package vs broken import vs transcribe + error vs a silent no-speech result. + """ + + def _transcribe_with_models(self, models, spec_present=True): + """Run one detailed transcription with _cached_model stubbed. + + MODELS maps backend name -> model object (or exception instance to + raise in its place). The whisper fallback sees its own entry or a + ModuleNotFoundError so no real package import ever happens; + importlib.util.find_spec is pinned so the not-installed vs + installed-but-broken distinction is deterministic in any env. + """ + def fake_cached(key, loader): + backend = key[0] + entry = models.get(backend) + if isinstance(entry, Exception): + raise entry + return entry + with patch("converter.clients.transcribe._cached_model", + side_effect=fake_cached), \ + patch("importlib.util.find_spec", + return_value=MagicMock() if spec_present else None): + return transcribe_reference_audio_detailed("clip.wav") + + def test_success_returns_text_and_ok(self): + model = MagicMock() + model.transcribe.return_value = (iter([MagicMock(text=" Hello. ")]), + MagicMock()) + text, reason = self._transcribe_with_models( + {"faster_whisper": model, "whisper": ModuleNotFoundError()}) + self.assertEqual(text, "Hello.") + self.assertEqual(reason, "ok") + + def test_missing_backend_is_not_called_broken(self): + text, reason = self._transcribe_with_models({ + "faster_whisper": ModuleNotFoundError( + "No module named 'faster_whisper'"), + "whisper": ModuleNotFoundError("No module named 'whisper'"), + }, spec_present=False) + self.assertIsNone(text) + self.assertIn("faster_whisper is not installed", reason) + self.assertIn("whisper is not installed", reason) + + def test_broken_import_is_distinguished_from_missing(self): + text, reason = self._transcribe_with_models({ + "faster_whisper": ImportError( + "Error loading shared library ld-linux-x86-64.so.2"), + "whisper": ModuleNotFoundError("No module named 'whisper'", + name="whisper"), + }) + self.assertIsNone(text) + self.assertIn("faster_whisper is installed but failed to import", + reason) + self.assertIn("ld-linux-x86-64.so.2", reason) + self.assertIn("whisper is not installed", reason) + + def test_transcribe_error_carries_the_exception(self): + model = MagicMock() + model.transcribe.side_effect = RuntimeError("decode failed") + text, reason = self._transcribe_with_models( + {"faster_whisper": model, + "whisper": ModuleNotFoundError("No module named 'whisper'")}) + self.assertIsNone(text) + self.assertIn("faster_whisper transcription failed: decode failed", + reason) + + def test_empty_result_reports_no_speech(self): + model = MagicMock() + model.transcribe.return_value = (iter([]), MagicMock()) + text, reason = self._transcribe_with_models( + {"faster_whisper": model, + "whisper": ModuleNotFoundError("No module named 'whisper'")}) + self.assertIsNone(text) + self.assertIn("faster_whisper heard no speech", reason) + + def test_whisper_fallback_used_when_faster_whisper_fails(self): + failing = MagicMock() + failing.transcribe.side_effect = RuntimeError("boom") + good = MagicMock() + # The openai-whisper interface returns a dict with "text". + good.transcribe.return_value = {"text": " Hi. "} + text, reason = self._transcribe_with_models( + {"faster_whisper": failing, "whisper": good}) + self.assertEqual(text, "Hi.") + self.assertEqual(reason, "ok") + + def test_backend_problem_reports_broken_import(self): + def fake_import(name, *args, **kwargs): + raise ImportError("lib load failure") + with patch("builtins.__import__", side_effect=fake_import), \ + patch("importlib.util.find_spec", return_value=MagicMock()): + problem = whisper_backend_problem() + self.assertIn("faster_whisper is installed but failed to import", + problem) + self.assertIn("whisper is installed but failed to import", problem) + + def test_backend_problem_none_when_a_backend_imports(self): + def fake_import(name, *args, **kwargs): + if name == "faster_whisper": + return MagicMock() + raise ImportError("should not be probed") + with patch("builtins.__import__", side_effect=fake_import): + self.assertIsNone(whisper_backend_problem()) + + class AudioCppHeartbeatTests(unittest.TestCase): """The heartbeat reports chunk progress while a request generates.""" |
