diff options
Diffstat (limited to 'app/converter/clients/audiocpp.py')
| -rw-r--r-- | app/converter/clients/audiocpp.py | 442 |
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( |
