aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/converter/clients/__init__.py1
-rw-r--r--app/converter/clients/audiocpp.py39
-rw-r--r--app/converter/config.py6
-rw-r--r--app/tests/test_tts.py56
4 files changed, 94 insertions, 8 deletions
diff --git a/app/converter/clients/__init__.py b/app/converter/clients/__init__.py
index d2a7f8d..5f38786 100644
--- a/app/converter/clients/__init__.py
+++ b/app/converter/clients/__init__.py
@@ -83,6 +83,7 @@ __all__ = [
"AUDIOCPP_FAMILY_QWEN3_TTS", "AUDIOCPP_TASK_TTS", "AUDIOCPP_TASK_VDES",
"AUDIOCPP_SYNTHESIS_TASKS", "AUDIOCPP_VOICE_SPEAKER",
"AUDIOCPP_VOICE_CLONE", "AUDIOCPP_VOICE_DESIGN",
+ "AUDIOCPP_INSTRUCTION_FIELD", "AUDIOCPP_INSTRUCTION_OPTION",
"AUDIOCPP_CLONE_ONLY_FAMILIES", "AUDIOCPP_VOICE_REQUIRED",
"AUDIOCPP_VOICE_OPTIONAL", "AUDIOCPP_VOICE_NONE",
"AudioCppFamilyProfile", "AUDIOCPP_DEFAULT_FAMILY_PROFILE",
diff --git a/app/converter/clients/audiocpp.py b/app/converter/clients/audiocpp.py
index 98d64ba..578a13b 100644
--- a/app/converter/clients/audiocpp.py
+++ b/app/converter/clients/audiocpp.py
@@ -58,6 +58,14 @@ 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)
+# How a family's speech endpoint consumes --instructions: the OpenAI-style
+# top-level "instructions" field (the default, read by most design/style
+# implementations), or the "instruction" request option inside the
+# "options" object (BreezeTTS 2: --request-option instruction=... and the
+# endpoint example send it there; its loader ignores the top-level field).
+AUDIOCPP_INSTRUCTION_FIELD = "field"
+AUDIOCPP_INSTRUCTION_OPTION = "option"
+
# 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.
@@ -531,13 +539,19 @@ class AudioCppFamilyProfile:
"""
def __init__(self, language_style: str = AUDIOCPP_LANG_OMIT,
- script_prefix: Optional[str] = None):
+ script_prefix: Optional[str] = None,
+ instruction_channel: str = AUDIOCPP_INSTRUCTION_FIELD):
self.language_style = language_style
# 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
# speaker-script and silently drops unprefixed lines (VibeVoice).
self.script_prefix = script_prefix
+ # INSTRUCTION_CHANNEL picks where --instructions go: the OpenAI
+ # "instructions" field (AUDIOCPP_INSTRUCTION_FIELD) or the
+ # "instruction" request option (AUDIOCPP_INSTRUCTION_OPTION,
+ # BreezeTTS 2 — see its profile below).
+ self.instruction_channel = instruction_channel
# Generic profile for families not listed in AUDIOCPP_FAMILY_PROFILES:
@@ -563,6 +577,12 @@ AUDIOCPP_FAMILY_PROFILES = {
# client flattens each request into one Speaker-1 line (the server
# renormalizes the lowest speaker id to zero — the cloned reference).
"vibevoice": AudioCppFamilyProfile(script_prefix="Speaker 1"),
+ # BreezeTTS 2 (text design/clone/direction) reads the instruction as a
+ # request option, not the OpenAI "instructions" field: upstream's own
+ # endpoint example sends {"options": {"instruction": "..."}}. The
+ # model detects the language (zh/en) itself, so no language field.
+ "breeze_tts": AudioCppFamilyProfile(
+ instruction_channel=AUDIOCPP_INSTRUCTION_OPTION),
}
@@ -1191,14 +1211,23 @@ class AudioCppTTSClient(BaseTTSClient):
# audio.cpp has no negative "randomize" seed; a negative seed
# means "let the server randomize", so the field is omitted.
payload["seed"] = self._seed
- if self.instructions:
- # Explicit voice-design or style instruction (required for task
- # "vdes" entries; a Ctrl/style control on families that read it).
- payload["instructions"] = self.instructions
if self.request_options:
# Generic per-model controls (--option KEY=VALUE): forwarded
# verbatim; the model ignores keys it does not know.
payload["options"] = dict(self.request_options)
+ if self.instructions:
+ # Explicit voice-design or style instruction (required for task
+ # "vdes" entries; a voice/style control on families that read
+ # it). Families on the option channel take it as a request
+ # option; an explicit --option instruction=... from the caller
+ # lands here too and is never overwritten.
+ if self.profile.instruction_channel == AUDIOCPP_INSTRUCTION_OPTION:
+ if not (self.request_options or {}).get("instruction", ""):
+ options = dict(self.request_options)
+ options["instruction"] = self.instructions
+ payload["options"] = options
+ else:
+ payload["instructions"] = self.instructions
request = urllib.request.Request(
url, data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}, method="POST")
diff --git a/app/converter/config.py b/app/converter/config.py
index 6fd65dd..da73427 100644
--- a/app/converter/config.py
+++ b/app/converter/config.py
@@ -12,14 +12,14 @@ CHUNK_SIZE = 250
# Where books are read from and where finished audiobooks are written.
# Relative paths resolve against the project root.
-INPUT_DIR = "/home/workhorse/projects/tts-audiobook-generator/input"
-OUTPUT_DIR = "/home/workhorse/projects/tts-audiobook-generator/output"
+INPUT_DIR = "input"
+OUTPUT_DIR = "output"
# Where voice-cloning reference .wavs live; the TUI Settings menu exposes
# this as "Clone .wav directory" and the qwen-tts Base / SGLang-Omni voice
# pickers list the .wav files found here. Relative paths resolve against
# the project root.
-CLONE_WAV_DIR = "/home/workhorse/downloads/git/tts-audiobook-generator/voices"
+CLONE_WAV_DIR = "voices"
# Output audiobook file at a different tempo.
SPEED = 1.0
diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py
index 7170262..c0ba666 100644
--- a/app/tests/test_tts.py
+++ b/app/tests/test_tts.py
@@ -1042,6 +1042,14 @@ class AudioCppFamilyDetectionTests(unittest.TestCase):
self.assertIs(client.profile, AUDIOCPP_DEFAULT_FAMILY_PROFILE)
self.assertEqual(client.profile.language_style, AUDIOCPP_LANG_OMIT)
+ def test_breeze_tts_uses_the_option_instruction_channel(self):
+ # BreezeTTS 2 reads its instruction from the request options, not
+ # the OpenAI-style "instructions" field.
+ profile = AUDIOCPP_FAMILY_PROFILES["breeze_tts"]
+ self.assertEqual(profile.instruction_channel,
+ audiocpp_client.AUDIOCPP_INSTRUCTION_OPTION)
+ self.assertEqual(profile.language_style, AUDIOCPP_LANG_OMIT)
+
def test_speech_to_speech_only_family_rejected_at_connect(self):
# A family whose model spec has no text-synthesis task
# (PersonaPlex, s2s-only) cannot narrate regardless of its hosted
@@ -1612,6 +1620,54 @@ class AudioCppTTSClientRequestTests(unittest.TestCase):
payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
self.assertEqual(payload["instructions"], "Read whisper quiet.")
+ def test_option_channel_family_sends_the_instruction_as_a_request_option(self):
+ # BreezeTTS 2 ignores the top-level OpenAI "instructions" field;
+ # the instruction goes inside "options" for those families.
+ client = self._make_client(
+ family="breeze_tts", preset_mode=True, voice="narrator",
+ instructions="Speak slowly with a restrained tone.")
+ with patch("converter.clients.faster.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertNotIn("instructions", payload)
+ self.assertEqual(payload["voice"], "narrator")
+ self.assertEqual(payload["options"],
+ {"instruction": "Speak slowly with a restrained tone."})
+
+ def test_option_channel_family_keeps_other_request_options(self):
+ client = self._make_client(
+ family="breeze_tts", instructions="Calm and steady.",
+ request_options={"guidance_scale": "2.5"})
+ with patch("converter.clients.faster.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertEqual(payload["options"],
+ {"guidance_scale": "2.5", "instruction": "Calm and steady."})
+
+ def test_option_channel_family_respects_the_callers_instruction_option(self):
+ # An explicit --option instruction=... is the caller's instruction
+ # and is not overwritten by the --instructions value.
+ client = self._make_client(
+ family="breeze_tts", instructions="from the flag",
+ request_options={"instruction": "from the option"})
+ with patch("converter.clients.faster.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertEqual(payload["options"]["instruction"], "from the option")
+
+ def test_option_channel_family_omits_options_when_no_instruction(self):
+ client = self._make_client(family="breeze_tts", preset_mode=True,
+ voice="narrator")
+ with patch("converter.clients.faster.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertNotIn("options", payload)
+ self.assertNotIn("instructions", payload)
+
def test_preset_mode_sends_instructions_alongside_voice(self):
# Clone + style control: both the server-side voice and the
# instruction reach the model.