diff options
| author | historia <historiavg@proton.me> | 2026-09-09 15:55:06 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-09 15:55:06 -0400 |
| commit | 71f2c85aa5ea2aa5fe5f31537f5979459e82fcdd (patch) | |
| tree | 2bdb01e2d2e4d3bc334c9014cff4cffbb0bcda08 | |
| parent | 0157ce4a347f9625e1e9d09e2bbf0fbfad722557 (diff) | |
| download | tts-audiobook-generator-71f2c85aa5ea2aa5fe5f31537f5979459e82fcdd.tar.gz | |
feat: instructions options translated automatically by --instructions
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | app/converter/clients/__init__.py | 1 | ||||
| -rw-r--r-- | app/converter/clients/audiocpp.py | 39 | ||||
| -rw-r--r-- | app/converter/config.py | 6 | ||||
| -rw-r--r-- | app/tests/test_tts.py | 56 |
5 files changed, 96 insertions, 9 deletions
@@ -65,7 +65,8 @@ cd tts-audiobook-generator | `--output <dir>` | `./output` | Directory to write finished audiobooks to (default: the `OUTPUT_DIR` setting in `app/converter/config.py`, `./output`). | | `--input-file <file>` | `books/dune.epub` | Convert one specific book (`.txt`/`.pdf`/`.epub`) | | `--output-file <file>` | `out/dune.mp3` | Base output path for the audiobook from `--input-file` | -| `--instructions "..."` | `"A warm adult female narrator with a British accent"` | Voice design or style instruction. Usually used for voice design models, some clone models also accept this. | +| `--instructions "..."` | `"A warm adult female narrator with a British accent"` | Voice design or style instruction. Usually used for voice design models, some clone models also accept this. Families that read it as a request option (e.g. `--option instructions=` like BreezeTTS) get it translated automatically. | + | `--speed <n>` | `1.0` | Playback speed, pitch-preserving (`1.0` = normal). A normal-speed copy is also output. Defaults to the `SPEED` setting in `app/converter/config.py`. | | `--single-file` | | Merge all chapters into a single file. `m4b` is always one file. | | `--language <lang>` | `English` | Output language for the synthesized speech. May add an accent even if the text is English. | 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. |
