aboutsummaryrefslogtreecommitdiff
path: root/app/converter/clients/audiocpp.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/converter/clients/audiocpp.py')
-rw-r--r--app/converter/clients/audiocpp.py96
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)