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.py442
1 files changed, 91 insertions, 351 deletions
diff --git a/app/converter/clients/audiocpp.py b/app/converter/clients/audiocpp.py
index 3637a50..98d64ba 100644
--- a/app/converter/clients/audiocpp.py
+++ b/app/converter/clients/audiocpp.py
@@ -1,20 +1,15 @@
"""Client for the audio.cpp audiocpp_server (native ggml TTS families)."""
-import array
-import base64
import json
import logging
import shutil
-import struct
import subprocess
-import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
-import wave
from pathlib import Path
-from typing import Any, Dict, List, Optional, Set, Tuple
+from typing import Any, Dict, List, Optional, Set
from .. import config
from ..audio import concat_audio_files
@@ -22,7 +17,8 @@ from ..chunking import split_into_chunks
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_for
+from .speakers import QWEN3_TTS_SPEAKERS, is_builtin_speaker, \
+ speaker_display_name_for
logger = logging.getLogger(__name__)
@@ -129,13 +125,10 @@ def _ALLOCATION_HINT_TEXT() -> str:
"The server ran out of device memory while building a compute "
"graph: check what else is using the GPU, and read the server's "
"log (app/logs/audiocpp-server.log), which records the exact "
- "allocation size it attempted. Cloning families that encode the "
- "whole reference with attention over its length (MOSS-TTS-Local) "
- "retry automatically with a shorter reference when the voice's "
- "wav is readable locally. For DramaBox, adding \"session_options\": "
- "{\"dramabox.mem_saver\": \"true\"} to its server.json model entry "
- "trades speed for a much lower memory peak (restart the server "
- "after editing).")
+ "allocation size it attempted. For DramaBox, adding "
+ "\"session_options\": {\"dramabox.mem_saver\": \"true\"} to its "
+ "server.json model entry trades speed for a much lower memory "
+ "peak (restart the server after editing).")
# Deterministic failures whose one-line server message is not actionable
@@ -224,18 +217,10 @@ AUDIOCPP_VOICE_REQUIRED = "required" # clone-only: a reference voice is mandato
AUDIOCPP_VOICE_OPTIONAL = "optional" # tts + clone: blank voice means plain TTS
AUDIOCPP_VOICE_NONE = "none" # pure TTS: no cloning, no voice at all
-# The allocation-failure fragments that trigger the server-log detail and
-# the trimmed-reference retry (see AUDIOCPP_NON_RETRYABLE_ERRORS).
+# The allocation-failure fragments that trigger the server-log detail
+# (see AUDIOCPP_NON_RETRYABLE_ERRORS and allocation_log_note).
AUDIOCPP_ALLOCATION_FRAGMENTS = ("failed to allocate", "allocation failed")
-# A cloned reference long enough to blow up reference-attention encoders
-# (MOSS-TTS-Local's codec encoder: memory grows with the reference's
-# square) is retried as this many seconds of the same voice, read from
-# the voice's local wav and sent as a base64 voice_ref (bounded by the
-# server's 5 MiB inline-reference limit).
-AUDIOCPP_REFERENCE_TRIM_SECONDS = 30.0
-_AUDIOCPP_VOICE_REF_MAX_BYTES = 5 * 1024 * 1024
-
# Warn about a nearly-full local GPU before the first request: with
# another process holding the memory, even small graph allocations fail.
_LOW_FREE_DEVICE_MIB = 4096
@@ -288,27 +273,6 @@ def audiocpp_family_spec_tasks(family: str) -> Optional[Set[str]]:
return {str(task) for task in spec["tasks"]}
-def spec_request_option_names(family: str) -> Set[str]:
- """FAMILY's accepted request-option names from its local model spec.
-
- Used to decide whether an option may be attached to a request (e.g.
- the reference_text carried alongside an inline voice_ref): families
- whose runtime validates request options against the spec would reject
- an unknown key outright. An unknown family (no local spec) yields an
- empty set — the caller then omits the option rather than risking a
- rejection.
- """
- spec = _family_spec(family)
- if not spec:
- return set()
- options = spec.get("options")
- request = options.get("request") if isinstance(options, dict) else None
- if not isinstance(request, list):
- return set()
- return {str(item["name"]) for item in request
- if isinstance(item, dict) and item.get("name")}
-
-
def audiocpp_entry_supports_design(family: str, task: str,
model_id: str) -> bool:
"""Whether a server model entry can design a voice from a description.
@@ -431,34 +395,51 @@ def _reference_text_error(voice: Optional[str], server_message: str) -> str:
)
+def _http_error_body(exc: urllib.error.HTTPError) -> str:
+ """The FULL decoded HTTP error body (classified before truncation).
+
+ Deterministic-error detection matches fragments that can sit deep in a
+ long server message, so the body must be read whole; only the final
+ user-facing text is capped (see audiocpp_request_error).
+ """
+ try:
+ return exc.read().decode("utf-8", errors="replace")
+ except Exception:
+ return ""
+
+
def audiocpp_request_error(status: int, detail: str,
voice: Optional[str] = None,
log_note: 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; clone-only
- hosting errors (AUDIOCPP_CLONE_ONLY_ERRORS) also carry the re-host
- hint, hinted errors (AUDIOCPP_HINTED_ERRORS) their per-fragment
- guidance; everything else returns the plain RuntimeError the retry
- loop has always retried. LOG_NOTE, when given for an allocation
- failure, appends the server log's own record of the failed
- allocation (the exact size it attempted, from the managed server's
- log file) to the non-retryable message.
+ DETAIL is the full HTTP error body. Deterministic request-
+ configuration errors (a fragment in AUDIOCPP_NON_RETRYABLE_ERRORS)
+ become NonRetryableTTSError so the chunk retry loop skips attempts
+ that cannot succeed; clone-only hosting errors
+ (AUDIOCPP_CLONE_ONLY_ERRORS) also carry the re-host hint, hinted
+ errors (AUDIOCPP_HINTED_ERRORS) their per-fragment guidance;
+ everything else returns the plain RuntimeError the retry loop has
+ always retried. Classification runs on the FULL body — only the
+ message quoted in the final text is truncated to keep it readable.
+ LOG_NOTE, when given for an allocation failure, appends the server
+ log's own record of the failed allocation (the exact size it
+ attempted, from the managed server's log file) to the non-retryable
+ message.
"""
message = _server_error_message(detail)
lowered = message.lower()
+ shown = message[:200]
allocation_failure = any(
fragment in lowered for fragment in AUDIOCPP_ALLOCATION_FRAGMENTS)
error: Exception
if _REFERENCE_TEXT_FRAGMENT in lowered:
error = NonRetryableTTSError(
- _reference_text_error(voice, message))
+ _reference_text_error(voice, shown))
elif any(fragment in lowered for fragment in AUDIOCPP_CLONE_ONLY_ERRORS):
error = NonRetryableTTSError(
f"audio.cpp server returned HTTP {status} (not retryable): "
- f"{message}. This model family only synthesizes by cloning a "
+ f"{shown}. This model family only synthesizes by cloning a "
"reference voice, so its server entry must be hosted with task "
'"clon" — re-run Configure Backends → audio.cpp (or edit '
"server.json) and restart the server.")
@@ -469,15 +450,15 @@ def audiocpp_request_error(status: int, detail: str,
if hinted is not None:
error = NonRetryableTTSError(
f"audio.cpp server returned HTTP {status} (not retryable): "
- f"{message}. {hinted}")
+ f"{shown}. {hinted}")
elif any(fragment in lowered
for fragment in AUDIOCPP_NON_RETRYABLE_ERRORS):
error = NonRetryableTTSError(
f"audio.cpp server returned HTTP {status} (not retryable): "
- f"{message}")
+ f"{shown}")
else:
error = RuntimeError(
- f"audio.cpp server returned HTTP {status}: {detail}")
+ f"audio.cpp server returned HTTP {status}: {shown}")
if allocation_failure and log_note \
and isinstance(error, NonRetryableTTSError):
error = NonRetryableTTSError(f"{error}{log_note}")
@@ -540,125 +521,18 @@ def nvidia_device_memory_report() -> Optional[str]:
return result.stdout.strip()
-def _pcm16_mono_samples(path: Path, max_seconds: float
- ) -> Optional[Tuple[int, List[int]]]:
- """The first MAX_SECONDS of a wav as (sample rate, mono PCM16 samples).
-
- Reads with the stdlib wave module (PCM u8/s16/s24/s32), mixes channels
- by averaging, and stops at MAX_SECONDS so a long reference costs only
- the frames actually sent. Returns None for files the wave module
- cannot parse (float-format wavs, non-WAV files) or that carry no
- samples — the trimmed-reference retry then does not fire.
- """
- max_frames = 0
- if max_seconds > 0:
- try:
- with wave.open(str(path), "rb") as probe:
- rate = probe.getframerate()
- max_frames = int(max_seconds * rate) + 1
- except (OSError, EOFError, wave.Error):
- return None
- try:
- with wave.open(str(path), "rb") as handle:
- rate = handle.getframerate()
- channels = handle.getnchannels()
- width = handle.getsampwidth()
- frames = handle.getnframes()
- raw = handle.readframes(min(frames, max_frames) if max_frames
- else frames)
- except (OSError, EOFError, wave.Error):
- return None
- if rate <= 0 or channels <= 0 or width not in (1, 2, 3, 4) or not raw:
- return None
- frame_bytes = width * channels
- frame_count = len(raw) // frame_bytes
- if frame_count <= 0:
- return None
- if width == 2:
- values = array.array("h")
- values.frombytes(raw[:frame_count * frame_bytes])
- if sys.byteorder == "big":
- values.byteswap()
- elif width == 1:
- # u8 -> s16, scaled to the full 16-bit range.
- values = array.array("h", ((byte - 128) << 8
- for byte in raw[:frame_count]))
- else:
- # s24/s32 -> s16 by dropping the low bits (keeps every value inside
- # int16 so the mixdown and the PCM16 container need no clipping).
- values = array.array(
- "i", (int.from_bytes(raw[i * width:i * width + width],
- "little", signed=True)
- for i in range(frame_count)))
- shift = 8 if width == 3 else 16
- values = array.array("h", (value >> shift for value in values))
- if channels == 1:
- return rate, values.tolist()
- mixed: List[int] = []
- for index in range(frame_count):
- start = index * channels
- mixed.append(sum(values[start:start + channels]) // channels)
- return rate, mixed
-
-
-def pcm16_wav_bytes(sample_rate: int, samples: List[int]) -> bytes:
- """Wrap mono PCM16 samples in a minimal RIFF/WAVE container."""
- data = array.array("h", samples)
- if sys.byteorder == "big":
- data.byteswap()
- payload = data.tobytes()
- return (b"RIFF"
- + struct.pack("<I", 36 + len(payload))
- + b"WAVEfmt " + struct.pack("<IHHIIHH", 16, 1, 1,
- sample_rate, sample_rate * 2, 2, 16)
- + b"data" + struct.pack("<I", len(payload))
- + payload)
-
-
-def build_trimmed_voice_reference(
- path: Optional[Path], max_seconds: float = AUDIOCPP_REFERENCE_TRIM_SECONDS,
- max_bytes: int = _AUDIOCPP_VOICE_REF_MAX_BYTES
- ) -> Optional[Tuple[str, float, str]]:
- """A base64 voice_ref of the first MAX_SECONDS of a reference wav.
-
- Returns (base64 wav, seconds used, file name), or None when PATH is
- missing or unreadable. The payload keeps the file's native sample rate
- and is mixed to mono; it is further capped so the decoded bytes stay
- within the server's 5 MiB inline-reference limit (16-bit mono means
- ~2.6 MB per 30 s at 44.1 kHz, comfortably inside).
- """
- if path is None:
- return None
- decoded = _pcm16_mono_samples(path, max_seconds)
- if decoded is None:
- return None
- rate, samples = decoded
- if not samples:
- return None
- byte_cap_seconds = max_bytes / (2 * rate)
- frames = int(min(max_seconds, byte_cap_seconds) * rate)
- samples = samples[:frames]
- if not samples:
- return None
- wav = pcm16_wav_bytes(rate, samples)
- return base64.b64encode(wav).decode("ascii"), len(samples) / rate, path.name
-
-
class AudioCppFamilyProfile:
"""Request conventions of one audio.cpp model family.
- Language style, whether the family reads a style/instruction prompt,
- and how the request text is formatted; these are family-level (every
- entry of a family shares them). Whether a *specific entry* has
- built-in speakers is an entry-level concern, decided by
- audiocpp_entry_voice_capability, not this profile.
+ Language style and how the request text is formatted; these are
+ family-level (every entry of a family shares them). Whether a
+ *specific entry* has built-in speakers is an entry-level concern,
+ decided by audiocpp_entry_voice_capability, not this profile.
"""
def __init__(self, language_style: str = AUDIOCPP_LANG_OMIT,
- sends_instructions: bool = False,
script_prefix: Optional[str] = None):
self.language_style = language_style
- self.sends_instructions = sends_instructions
# SCRIPT_PREFIX, when set, formats every request's text as one
# "<prefix>: text" script line (audiocpp_script_input): the
# family's server implementation parses the prompt as a
@@ -667,17 +541,15 @@ class AudioCppFamilyProfile:
# Generic profile for families not listed in AUDIOCPP_FAMILY_PROFILES:
-# clone-only, no style instructions, and no language field (the model
-# detects the language itself). Describes higgs_audio_tts, voxcpm2,
-# fish_audio, dots_tts, dramabox, omnivoice, outetts, glm_tts, miotts,
-# moss_tts_*, pocket_tts, ... as well as families added to
-# audio.cpp after this table was written.
+# clone-only and no language field (the model detects the language
+# itself). Describes higgs_audio_tts, voxcpm2, fish_audio, dots_tts,
+# dramabox, omnivoice, outetts, glm_tts, miotts, moss_tts_*, pocket_tts,
+# ... as well as families added to audio.cpp after this table was written.
AUDIOCPP_DEFAULT_FAMILY_PROFILE = AudioCppFamilyProfile()
AUDIOCPP_FAMILY_PROFILES = {
AUDIOCPP_FAMILY_QWEN3_TTS: AudioCppFamilyProfile(
language_style=AUDIOCPP_LANG_DISPLAY,
- sends_instructions=True,
),
# Families whose language option takes a code (e.g. "en") instead of
# a Qwen display name; otherwise clone-only like the default profile.
@@ -727,13 +599,37 @@ def audiocpp_entry_voice_capability(family: str, task: str,
return AUDIOCPP_VOICE_CLONE
-class AudioCppTTSClient(BaseTTSClient):
- # Class-level defaults so a partially-constructed instance behaves like
- # a fresh run (tests build clients via __new__; see BaseTTSClient).
- _voice_ref_b64: Optional[str] = None
- _voice_ref_reference_text: Optional[str] = None
- _reference_trim_attempted = False
+def audiocpp_voice_for_run(family: str, task: str, model_id: str,
+ picked: Optional[str],
+ entry_voices: List[str]) -> Optional[str]:
+ """The voice one conversion of this entry sends, given a shared pick.
+
+ The single source of the per-model voice resolution the Generate
+ form's "All (multiple generation)" pick uses: the picked voice wins
+ wherever the model accepts it (a built-in speaker on the CustomVoice
+ entry, a server-side preset on a clone-capable one); models the pick
+ cannot serve fall back to their own default — the first built-in
+ speaker, or the first server voice — or to no voice at all (design
+ entries take the Instructions text instead; pure-TTS families take
+ no voice). Pure, so the form can call it per configured entry.
+ """
+ capability = audiocpp_entry_voice_capability(family, task, model_id)
+ if capability == AUDIOCPP_VOICE_DESIGN:
+ return None
+ if capability == AUDIOCPP_VOICE_SPEAKER:
+ if picked and picked in QWEN3_TTS_SPEAKERS:
+ return picked
+ return QWEN3_TTS_SPEAKERS[0]
+ # Clone capability; the family policy decides whether a voice
+ # exists at all.
+ if audiocpp_family_voice_policy(family) == AUDIOCPP_VOICE_NONE:
+ return None
+ if picked and picked in entry_voices:
+ return picked
+ return entry_voices[0] if entry_voices else None
+
+class AudioCppTTSClient(BaseTTSClient):
"""Generates audio chunks through an audio.cpp audiocpp_server.
Talks to the OpenAI-style HTTP API of audiocpp_server, which hosts TTS
@@ -801,8 +697,9 @@ class AudioCppTTSClient(BaseTTSClient):
instructions: Optional[str] = None,
request_options: Optional[Dict[str, str]] = None,
quiet: bool = False,
- unload_models: Optional[bool] = None):
- super().__init__(chunks_dir, quiet=quiet)
+ unload_models: Optional[bool] = None,
+ cancel=None):
+ super().__init__(chunks_dir, quiet=quiet, cancel=cancel)
self.api_url = (api_url or config.AUDIOCPP_API_URL).rstrip("/")
# Per-run model selection: the --model CLI flag (or the Generate
# form's Model pick). An empty value is resolved at connect time
@@ -850,15 +747,6 @@ class AudioCppTTSClient(BaseTTSClient):
self.design_mode = False
self.instruction_voice = False
self.plain_mode = False
- # Trimmed-reference retry state (see _switch_to_trimmed_reference):
- # an inline base64 voice_ref that replaces the voice name after an
- # allocation failure, the transcript carried alongside it, and the
- # one-attempt guard. The class-level defaults above keep partially
- # constructed instances (tests via __new__) behaving like a fresh
- # run; the assignments here shadow them for this instance.
- self._voice_ref_b64 = None
- self._voice_ref_reference_text = None
- self._reference_trim_attempted = False
# Family and task of the selected model entry and the family's request
# profile; all are resolved from GET /v1/models during _connect.
self.family = ""
@@ -1058,13 +946,10 @@ class AudioCppTTSClient(BaseTTSClient):
with urllib.request.urlopen(url, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
- detail = ""
- try:
- detail = exc.read().decode("utf-8", errors="replace")[:200]
- except Exception:
- pass
+ detail = _http_error_body(exc)
raise RuntimeError(
- f"audio.cpp server returned HTTP {exc.code} for {path}: {detail}") from exc
+ f"audio.cpp server returned HTTP {exc.code} for {path}: "
+ f"{detail[:200]}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"audio.cpp request failed for {path}: {exc.reason}") from exc
@@ -1232,7 +1117,7 @@ class AudioCppTTSClient(BaseTTSClient):
)
# ------------------------------------------------------------------
- # Reference trimming and device diagnostics
+ # Device diagnostics
# ------------------------------------------------------------------
def _warn_low_device_memory(self) -> None:
@@ -1268,131 +1153,6 @@ class AudioCppTTSClient(BaseTTSClient):
"condition usually mean another process is using "
"the GPU.")
- def _voice_wav_path(self) -> Optional[Path]:
- """The selected voice's reference wav on this machine, when readable.
-
- Resolved from the local checkout's server.json exactly like the
- server resolves the request's voice name: the entry's
- ``voice_presets[name].voice_ref`` first, then ``voice_dir/<name>.wav``.
- Only preset-mode runs with a locally readable file return a path —
- remote-only servers (or voice names that only exist server-side)
- yield None and the trimmed-reference retry does not fire.
- """
- if not self.voice:
- return None
- name = self.voice
- if not name or name in (".", "..") or "/" in name or "\\" in name:
- return None
- try:
- # Imported lazily: backends.audiocpp imports this package, so a
- # module-level import would cycle.
- from backends.audiocpp.build import find_local_checkout
- checkout = find_local_checkout()
- except Exception: # noqa: BLE001 - best effort
- return None
- if checkout is None:
- return None
- server_json = checkout / "server.json"
- try:
- data = json.loads(server_json.read_text(encoding="utf-8"))
- except (OSError, ValueError):
- return None
- if not isinstance(data, dict):
- return None
- for entry in data.get("models") or []:
- if not isinstance(entry, dict) or entry.get("id") != self.model_id:
- continue
- presets = entry.get("voice_presets")
- if isinstance(presets, dict):
- preset = presets.get(name)
- if isinstance(preset, dict):
- ref = preset.get("voice_ref")
- if isinstance(ref, str) and ref:
- path = Path(ref)
- if not path.is_absolute():
- path = server_json.parent / ref
- if path.is_file():
- return path
- voice_dir = data.get("voice_dir")
- if isinstance(voice_dir, str) and voice_dir:
- candidate = Path(voice_dir) / f"{name}.wav"
- if candidate.is_file():
- return candidate
- return None
-
- def _voice_transcript(self) -> Optional[str]:
- """The voice library transcript for the selected voice, or None.
-
- Read from the voice directory's prompt_text mapping (the same file
- the server consults when it resolves a voice NAME); only carried
- alongside an inline voice_ref, where the server's own injection is
- bypassed.
- """
- if not self.voice:
- return None
- try:
- # Imported lazily: backends.audiocpp imports this package, so a
- # module-level import would cycle.
- from backends.common import PROMPT_TEXT_FILENAME, read_prompt_text
- from backends.audiocpp.build import find_local_checkout
- checkout = find_local_checkout()
- except Exception: # noqa: BLE001 - best effort
- return None
- if checkout is None:
- return None
- try:
- data = json.loads((checkout / "server.json")
- .read_text(encoding="utf-8"))
- except (OSError, ValueError):
- return None
- voice_dir = data.get("voice_dir") if isinstance(data, dict) else None
- if not isinstance(voice_dir, str) or not voice_dir:
- return None
- try:
- return (read_prompt_text(Path(voice_dir) / PROMPT_TEXT_FILENAME)
- .get(self.voice) or None)
- except OSError:
- return None
-
- def _switch_to_trimmed_reference(self, server_message: str) -> bool:
- """Switch the cloning reference to a trimmed local wav, once.
-
- Some families encode the whole reference with attention over its
- length (MOSS-TTS-Local's codec encoder: the required memory grows
- with the reference's square), so a long voice reference fails the
- graph allocation regardless of how much VRAM the device has. When
- the selected voice resolves to a locally readable wav, replace the
- voice name with an inline base64 voice_ref cut to
- AUDIOCPP_REFERENCE_TRIM_SECONDS and retry the request once; the
- trimmed reference then applies to the rest of the run. Only fires
- on allocation-failure messages for preset-mode runs; everything
- else keeps the original behavior.
- """
- lowered = server_message.lower()
- if self._reference_trim_attempted \
- or not any(fragment in lowered
- for fragment in AUDIOCPP_ALLOCATION_FRAGMENTS) \
- or self.design_mode or self.instruction_voice \
- or self.plain_mode or not self.voice:
- return False
- self._reference_trim_attempted = True
- trimmed = build_trimmed_voice_reference(self._voice_wav_path())
- if trimmed is None:
- return False
- b64, seconds, source = trimmed
- self._voice_ref_b64 = b64
- transcript = self._voice_transcript()
- if transcript and "reference_text" in spec_request_option_names(
- self.family):
- self._voice_ref_reference_text = transcript
- self._report(
- f"[INFO] {source}'s family encodes the whole reference with "
- f"attention over its length; retrying with the first "
- f"{seconds:.0f}s of voice '{self.voice}' as the cloning "
- "reference (the trimmed reference applies to the rest of this "
- "run).")
- return True
-
# ------------------------------------------------------------------
# HTTP requests
# ------------------------------------------------------------------
@@ -1413,13 +1173,8 @@ class AudioCppTTSClient(BaseTTSClient):
# Design models take no voice field (the voice comes from the
# instruction); instruction-voice runs on families without built-in
# speakers omit it too, since no speaker or preset was requested;
- # plain-TTS runs (no reference voice needed) omit it likewise. A
- # trimmed-reference retry replaces the voice name with an inline
- # base64 voice_ref (see _switch_to_trimmed_reference).
- if self._voice_ref_b64 is not None:
- payload["voice_ref"] = {"type": "base64",
- "data": self._voice_ref_b64}
- elif not self.design_mode and not self.instruction_voice \
+ # plain-TTS runs (no reference voice needed) omit it likewise.
+ if not self.design_mode and not self.instruction_voice \
and not self.plain_mode:
payload["voice"] = self.voice
if self.profile.language_style == AUDIOCPP_LANG_DISPLAY:
@@ -1440,17 +1195,10 @@ class AudioCppTTSClient(BaseTTSClient):
# Explicit voice-design or style instruction (required for task
# "vdes" entries; a Ctrl/style control on families that read it).
payload["instructions"] = self.instructions
- options = dict(self.request_options)
- if self._voice_ref_reference_text \
- and "reference_text" not in options:
- # The server only injects the voice library's transcript when it
- # resolves the voice NAME; an inline voice_ref bypasses that, so
- # carry the transcript explicitly for families that accept it.
- options["reference_text"] = self._voice_ref_reference_text
- if options:
+ if self.request_options:
# Generic per-model controls (--option KEY=VALUE): forwarded
# verbatim; the model ignores keys it does not know.
- payload["options"] = options
+ payload["options"] = dict(self.request_options)
request = urllib.request.Request(
url, data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}, method="POST")
@@ -1459,16 +1207,8 @@ class AudioCppTTSClient(BaseTTSClient):
with urllib.request.urlopen(request, timeout=timeout) as response:
wav = response.read()
except urllib.error.HTTPError as exc:
- detail = ""
- try:
- detail = exc.read().decode("utf-8", errors="replace")[:200]
- except Exception:
- pass
+ detail = _http_error_body(exc)
message = _server_error_message(detail)
- if self._switch_to_trimmed_reference(message):
- # One retry with the trimmed reference; if that fails too the
- # error below carries the server log's allocation detail.
- return self._request_wav(text)
raise audiocpp_request_error(exc.code, detail,
voice=self.voice,
log_note=allocation_log_note(