diff options
Diffstat (limited to 'app/converter/clients/audiocpp.py')
| -rw-r--r-- | app/converter/clients/audiocpp.py | 96 |
1 files changed, 94 insertions, 2 deletions
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) |
