aboutsummaryrefslogtreecommitdiff
path: root/app/tests
diff options
context:
space:
mode:
Diffstat (limited to 'app/tests')
-rw-r--r--app/tests/test_audio.py30
-rw-r--r--app/tests/test_backends_audiocpp.py2
-rw-r--r--app/tests/test_backends_servers.py37
-rw-r--r--app/tests/test_chunking.py170
-rw-r--r--app/tests/test_cleaning.py61
-rw-r--r--app/tests/test_converter.py37
-rw-r--r--app/tests/test_converter_progress.py11
-rw-r--r--app/tests/test_extractors.py60
-rw-r--r--app/tests/test_hub.py113
-rw-r--r--app/tests/test_instruction_capabilities.py419
10 files changed, 904 insertions, 36 deletions
diff --git a/app/tests/test_audio.py b/app/tests/test_audio.py
index 75de97c..04a6afc 100644
--- a/app/tests/test_audio.py
+++ b/app/tests/test_audio.py
@@ -51,6 +51,36 @@ class AtempoFiltersTests(unittest.TestCase):
with self.assertRaises(ValueError):
atempo_filters(-1.5)
+ def test_infinite_speed_rejected(self):
+ # inf passes a bare positivity check; without the finite guard
+ # atempo chaining would loop forever (inf / 2.0 stays inf).
+ with self.assertRaises(ValueError):
+ atempo_filters(float("inf"))
+
+ def test_overflowing_speed_rejected(self):
+ with self.assertRaises(ValueError):
+ atempo_filters(1e309)
+
+ def test_nan_speed_rejected(self):
+ with self.assertRaises(ValueError):
+ atempo_filters(float("nan"))
+
+ def test_asplit_used_for_audio_streams(self):
+ # split is a video filter and rejects an audio stream, so a
+ # speed-adjusted copy needs asplit — in both command builders.
+ concat_cmd = build_concat_command(
+ Path("/tmp/_concat.txt"), Path("/tmp/out.mp3"), "mp3",
+ speed=1.5, speed_path=Path("/tmp/out_1.5.mp3"))
+ m4b_cmd = build_m4b_chapters_command(
+ Path("/tmp/_concat.txt"), Path("/tmp/_meta.txt"),
+ Path("/tmp/out.m4b"), speed=1.5,
+ speed_path=Path("/tmp/out_1.5.m4b"),
+ speed_metadata_file=Path("/tmp/_meta2.txt"))
+ for cmd in (concat_cmd, m4b_cmd):
+ filter_complex = cmd[cmd.index("-filter_complex") + 1]
+ self.assertIn("[0:a]asplit=2", filter_complex)
+ self.assertNotIn("[0:a]split=2", filter_complex)
+
class CleanupChunksTests(unittest.TestCase):
def test_removes_only_chunk_files(self):
diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py
index 9392ba1..1800267 100644
--- a/app/tests/test_backends_audiocpp.py
+++ b/app/tests/test_backends_audiocpp.py
@@ -252,7 +252,7 @@ class UpdateConfigPortTests(unittest.TestCase):
self.assertTrue(changed)
text = self.config_path.read_text(encoding="utf-8")
self.assertIn(
- 'AUDIOCPP_API_URL = "http://127.0.0.1:8080" # audio.cpp audiocpp_server',
+ "AUDIOCPP_API_URL = 'http://127.0.0.1:8080' # audio.cpp audiocpp_server",
text)
self.assertIn('LANGUAGE = "English"', text)
self.assertIn("CHUNK_SIZE = 250", text)
diff --git a/app/tests/test_backends_servers.py b/app/tests/test_backends_servers.py
index 9fd71f2..283df95 100644
--- a/app/tests/test_backends_servers.py
+++ b/app/tests/test_backends_servers.py
@@ -370,19 +370,44 @@ class ReapTests(unittest.TestCase):
class KillPidTests(unittest.TestCase):
- """_kill_pid: the reap check ends the grace wait before SIGKILL."""
+ """_kill_pid: the group probe decides the wait's end, not the reap.
- def test_reaped_child_ends_wait_without_sigkill(self):
+ Reaping the launcher proves nothing about the rest of its process
+ group: workers can outlive it and must still get the SIGKILL
+ escalation.
+ """
+
+ def test_empty_group_ends_wait_without_sigkill(self):
+ # Single-process server: the launcher is reaped and the group is
+ # empty (killpg(0) raises) — success immediately, no SIGKILL.
with patch("os.getpgid", return_value=4242), \
- patch("os.killpg") as mk_killpg, \
- patch("os.waitpid", return_value=(4242, 0)) as mk_waitpid, \
+ patch("os.killpg",
+ side_effect=[None, ProcessLookupError]) as mk_killpg, \
+ patch("os.waitpid", return_value=(4242, 0)), \
patch("time.sleep") as mk_sleep:
ok = servers._kill_pid(4242)
self.assertTrue(ok)
- mk_killpg.assert_called_once_with(4242, signal.SIGTERM)
- mk_waitpid.assert_called_once_with(4242, os.WNOHANG)
+ self.assertEqual(mk_killpg.call_args_list[0].args,
+ (4242, signal.SIGTERM))
+ self.assertEqual(mk_killpg.call_args_list[-1].args, (4242, 0))
mk_sleep.assert_not_called()
+ def test_reaped_leader_with_live_workers_escalates_to_sigkill(self):
+ # A launcher that dies on SIGTERM while its workers ignore it:
+ # reaping the leader must not end the stop, the surviving group
+ # keeps burning the grace period and then gets SIGKILLed.
+ with patch("os.getpgid", return_value=4242), \
+ patch("os.killpg", return_value=None) as mk_killpg, \
+ patch("os.waitpid", return_value=(4242, 0)), \
+ patch("time.sleep"):
+ ok = servers._kill_pid(4242)
+ self.assertTrue(ok)
+ calls = mk_killpg.call_args_list
+ self.assertEqual(calls[0].args, (4242, signal.SIGTERM))
+ self.assertEqual(calls[-1].args, (4242, signal.SIGKILL))
+ # The group was probed repeatedly while waiting for the workers.
+ self.assertGreater(len(calls), 2)
+
def test_escalates_to_sigkill_when_child_stays_alive(self):
with patch("os.getpgid", return_value=4242), \
patch("os.killpg") as mk_killpg, \
diff --git a/app/tests/test_chunking.py b/app/tests/test_chunking.py
index 2904e40..5c6f60b 100644
--- a/app/tests/test_chunking.py
+++ b/app/tests/test_chunking.py
@@ -113,6 +113,176 @@ class SplitIntoChunksTests(unittest.TestCase):
self.assertNotIn("1, 000", joined)
self.assertNotIn("12: 30", joined)
+ def test_smart_strategy_distributes_sentences(self):
+ # The smart strategy leaves whole sentences intact: a chunk
+ # crossing the target (85% of the limit) ends at the next clean
+ # boundary early; legacy keeps packing until the limit.
+ sentences = " ".join(
+ " ".join(f"Sw{j}" for j in range(n)) + "."
+ for n in (9, 8, 3, 9, 8, 3))
+ smart = split_into_chunks(sentences, max_words=20, smart=True)
+ legacy = split_into_chunks(sentences, max_words=20, smart=False)
+ self.assertEqual([len(c.split()) for c in smart], [17, 20, 3])
+ self.assertEqual([len(c.split()) for c in legacy], [20, 20])
+ self.assertEqual(self._content(smart),
+ self._content(sentences.split(" ")))
+
+ def test_dialogue_tag_stays_with_its_quote(self):
+ # A closing quote followed by a lowercase attribution ("she
+ # said.") is one sentence unit: the chunk never separates them.
+ text = "“Come here!” she said. “Are you coming?” He did not answer."
+ chunks = split_into_chunks(text, max_words=10, smart=True)
+ self.assertIn("“Come here!” she said.", chunks[0])
+ self._assert_bounded(chunks, 10)
+ self._assert_content(text, chunks)
+
+ def test_abbreviations_and_initials_do_not_split(self):
+ text = ("Dr. Smith met J. K. R. at the U.S. border with No. 3 "
+ "shortly. He continued onward.")
+ chunks = split_into_chunks(text, max_words=50, smart=True)
+ self.assertEqual(len(chunks), 1)
+ self.assertIn("Dr. Smith", chunks[0])
+ self.assertIn("J. K. R.", chunks[0])
+ self.assertIn("U.S.", chunks[0])
+ self._assert_content(text, chunks)
+
+ def test_decimals_are_never_boundaries(self):
+ text = "Pi is 3.14159 and the toll was 1,000,000 miles. Next one."
+ chunks = split_into_chunks(text, max_words=12, smart=True)
+ joined = " ".join(chunks)
+ self.assertIn("3.14159", joined)
+ self.assertIn("1,000,000", joined)
+ self._assert_bounded(chunks, 12)
+ self._assert_content(text, chunks)
+
+ def test_unclosed_quote_still_respects_the_limit(self):
+ # A quote with no closing mark must not grow chunks or silence
+ # later boundaries; units carry the open state and stay bounded.
+ text = "“No closing quote follows. " + "filler words here. " * 20
+ chunks = split_into_chunks(text, max_words=10, smart=True)
+ self._assert_bounded(chunks, 10)
+ self._assert_content(text, chunks)
+
+ def test_paragraph_boundary_resets_an_open_quote(self):
+ # Blank lines reset the quotation state: an unclosed quote in
+ # one paragraph cannot trap later paragraphs inside the quote.
+ text = ("“Still open. filler words inside the quote here.\n\n"
+ "New paragraph. Words outside any quote now, plenty.")
+ chunks = split_into_chunks(text, max_words=10, smart=True)
+ self._assert_bounded(chunks, 10)
+ self._assert_content(text, chunks)
+ second = [c for c in chunks if "New paragraph." in c]
+ self.assertTrue(second)
+ # The paragraph's text starts a fresh chunk: the open-quote
+ # prefix did not swallow it.
+ self.assertTrue(
+ any(chunk.startswith("New paragraph.") or
+ "“Still open." not in chunk
+ for chunk in chunks))
+
+ def test_quote_retreat_keeps_short_quotes_whole(self):
+ # When the next sentence would overflow, the break retreats to
+ # the last boundary that ended outside a quotation — before the
+ # quote — so a short quotation lands in one request.
+ text = ("Plain opening words here to fill. "
+ "“A short quote. With two sentences. Inside.” "
+ "More trailing prose follows this quote. And more.")
+ chunks = split_into_chunks(text, max_words=14, smart=True)
+ self._assert_bounded(chunks, 14)
+ self._assert_content(text, chunks)
+ quote_chunks = [c for c in chunks if "short quote" in c]
+ self.assertTrue(quote_chunks)
+ self.assertIn("Inside.", quote_chunks[0])
+
+ def test_oversized_single_tokens_respect_char_cap(self):
+ # Degenerate text (punctuation-free run): bounded by the hard
+ # character cap, with every character preserved.
+ text = "a" * 5000
+ chunks = split_into_chunks(text, max_words=250, smart=True)
+ self.assertGreater(len(chunks), 1)
+ self.assertTrue(all(len(c) <= 250 * 8 for c in chunks))
+ self.assertEqual("".join(chunks), text)
+
+ def test_non_spaced_script_respects_the_word_limit(self):
+ # A punctuation-free CJK token longer than the limit counts one
+ # word per character: it must be sliced by the word limit too,
+ # not only by the character cap (which alone would allow chunks
+ # up to CHUNK_SIZE * 8 characters).
+ from converter.chunking import _count_words
+
+ text = "字" * 500
+ chunks = split_into_chunks(text, max_words=250, smart=True)
+ self.assertGreater(len(chunks), 1)
+ self.assertTrue(all(_count_words(c) <= 250 for c in chunks),
+ [(_count_words(c), c[:20]) for c in chunks])
+ self.assertEqual("".join(chunks), text)
+
+ text = "字" * 5000
+ chunks = split_into_chunks(text, max_words=250, smart=True)
+ self.assertTrue(all(_count_words(c) <= 250 for c in chunks),
+ [(_count_words(c), c[:20]) for c in chunks])
+ self.assertEqual("".join(chunks), text)
+
+ def test_oversized_sentence_keeps_quote_state_through_fallback(self):
+ # An oversized opening sentence of a dialogue must not present
+ # its mid-quote remainder as a clean boundary: the fallback
+ # carries the sentence's quote state, so the close of the quote
+ # stays attached to the words before it in the next chunk.
+ text = "“" + "go " * 17 + "go. End now.”"
+ chunks = split_into_chunks(text, max_words=10, smart=True)
+ self._assert_bounded(chunks, 10)
+ self._assert_content(text, chunks)
+ self.assertEqual([len(c.split()) for c in chunks], [10, 10])
+
+ def test_oversized_sentence_breaks_cleanly_when_quote_closes_mid_sentence(self):
+ # A quote opened and closed inside one long sentence: the
+ # clause-split pieces after the closing quote are clean
+ # boundaries and can be packed together.
+ text = ("He answered " + "very " * 8 + "“calmly” and then kept "
+ "talking on and on for a while.")
+ chunks = split_into_chunks(text, max_words=6, smart=True)
+ self._assert_bounded(chunks, 6)
+ self._assert_content(text, chunks)
+
+ def test_non_spaced_script_stays_bounded(self):
+ # CJK text: sentence terminators break units without
+ # whitespace, and each ideograph counts as a word.
+ text = "。".join(["字" * 15 for _ in range(30)]) + "。"
+ chunks = split_into_chunks(text, max_words=40, smart=True)
+ self._assert_bounded(chunks, 40)
+ self._assert_content(text, chunks)
+ joined = " ".join(chunks).split()
+ self.assertTrue(all(len(j) <= 33 for j in joined))
+
+ def test_legacy_and_smart_matching_smart_negation(self):
+ # smart=False preserves the legacy splitting exactly.
+ sentences = " ".join(
+ " ".join(f"Sw{j}" for j in range(9)) + "." for _ in range(10))
+ with patch.object(config, "CHUNK_SIZE", 25):
+ legacy_default = split_into_chunks(sentences, smart=False)
+ self.assertEqual([len(c.split()) for c in legacy_default],
+ [18] * 5)
+ self.assertEqual(self._content(legacy_default),
+ self._content(sentences.split()))
+
+ # -- helpers ---------------------------------------------------------
+
+ @staticmethod
+ def _content(chunks_or_words):
+ """Non-whitespace content of the pieces (or of a token list)."""
+ pieces = (chunks_or_words if isinstance(chunks_or_words, str)
+ else " ".join(chunks_or_words))
+ return "".join(pieces.split())
+
+ def _assert_content(self, text, chunks):
+ self.assertEqual(self._content(chunks), self._content(text))
+
+ def _assert_bounded(self, chunks, max_words):
+ from converter.chunking import _count_words
+ for chunk in chunks:
+ self.assertLessEqual(_count_words(chunk), max_words,
+ chunk[:40])
+
if __name__ == "__main__":
unittest.main()
diff --git a/app/tests/test_cleaning.py b/app/tests/test_cleaning.py
index 41f4ed7..40f2d53 100644
--- a/app/tests/test_cleaning.py
+++ b/app/tests/test_cleaning.py
@@ -10,8 +10,24 @@ class CleanTextTests(unittest.TestCase):
self.assertEqual(clean_text(""), "")
self.assertEqual(clean_text(None), "")
- def test_collapses_whitespace(self):
- self.assertEqual(clean_text("a\n\n b \t c"), "a b c")
+ def test_collapses_whitespace_inside_paragraphs(self):
+ self.assertEqual(clean_text("a b \t c"), "a b c")
+
+ def test_paragraph_breaks_survive(self):
+ # Blank lines are the smart chunker's paragraph boundaries (and
+ # reset its quotation state), so they survive cleaning while a
+ # run of blank lines collapses to a single break.
+ self.assertEqual(clean_text("a\n\n b \t c"), "a\n\nb c")
+ self.assertEqual(clean_text("a\n\n\n\nb"), "a\n\nb")
+ self.assertEqual(clean_text("a\n \nb"), "a\n\nb")
+ self.assertEqual(clean_text("line one\nline two"), "line one line two")
+ self.assertEqual(clean_text("a \n\n b"), "a\n\nb")
+
+ def test_paragraph_breaks_not_glued_to_text(self):
+ self.assertEqual(
+ clean_text("End of chapter.\n\n\n New chapter. \n\nStarts here."),
+ "End of chapter.\n\nNew chapter.\n\nStarts here.",
+ )
def test_preserves_inline_numbers(self):
self.assertEqual(clean_text("He was 42 years old."), "He was 42 years old.")
@@ -23,14 +39,16 @@ class CleanTextTests(unittest.TestCase):
)
def test_removes_standalone_page_numbers(self):
+ # The page number's own line becomes a paragraph break (a safe
+ # chunk boundary), not a glued sentence.
self.assertEqual(
clean_text("End of page.\n7\nNext page text."),
- "End of page. Next page text.",
+ "End of page.\n\nNext page text.",
)
- def test_page_number_removal_leaves_single_spacing(self):
+ def test_page_number_removal_leaves_paragraph_break(self):
result = clean_text("Chapter one\n\n12\n\nChapter two")
- self.assertEqual(result, "Chapter one Chapter two")
+ self.assertEqual(result, "Chapter one\n\nChapter two")
self.assertNotIn(" ", result)
@@ -49,6 +67,39 @@ class CleanHtmlTests(unittest.TestCase):
self.assertEqual(clean_html(""), "")
self.assertEqual(clean_html(None), "")
+ def test_inline_markup_never_splits_words(self):
+ # Inline tags must not inject spaces mid-word (they become chunk
+ # boundaries and corrupt pronunciation).
+ self.assertEqual(
+ clean_html("<p>He was un<em>believ</em>able and didn<i>'</i>t stop.</p>"),
+ "He was unbelievable and didn't stop.",
+ )
+
+ def test_block_tags_become_paragraph_breaks(self):
+ self.assertEqual(
+ clean_html("<p>First para.</p><p>Second para.</p><h2>Head</h2>"),
+ "First para.\n\nSecond para.\n\nHead",
+ )
+
+ def test_div_sections_keep_paragraph_boundaries(self):
+ html = ('<div>“An unfinished quotation.</div>'
+ '<div>A new paragraph outside the quotation.</div>')
+ self.assertEqual(
+ clean_html(html),
+ "“An unfinished quotation.\n\nA new paragraph outside the quotation.",
+ )
+
+ def test_table_cells_do_not_glue(self):
+ self.assertEqual(
+ clean_html("<table><tr><td>A</td><td>B</td></tr>"
+ "<tr><td>C</td><td>D</td></tr></table>"),
+ "A\n\nB\n\nC\n\nD",
+ )
+
+ def test_regex_fallback_matches_bs4_behavior(self):
+ html = "<p>un<em>believ</em>able</p><div>After a block.</div>"
+ self.assertEqual(clean_html(html), "unbelievable\n\nAfter a block.")
+
if __name__ == "__main__":
unittest.main()
diff --git a/app/tests/test_converter.py b/app/tests/test_converter.py
index 85ae6ca..3795fbc 100644
--- a/app/tests/test_converter.py
+++ b/app/tests/test_converter.py
@@ -585,6 +585,24 @@ class ChunkClampPromptTests(unittest.TestCase):
with patch.object(config, "CHUNK_SIZE", 80):
self.assertFalse(chunk_clamp_needed(self._entry()))
+ def test_per_run_clamp_caps_the_chapter_chunks(self):
+ # The popup's clamp caps the chapter chunks themselves, so each
+ # progress unit and debug dump spans one generation request.
+ converter = AudiobookConverter.__new__(AudiobookConverter)
+ converter.chunk_size = 4
+ with patch.object(config, "CHUNK_SIZE", 250):
+ chunks = converter._chapter_chunks(" ".join(f"Sw{i} ." for i in range(12)))
+ self.assertGreater(len(chunks), 1)
+ self.assertTrue(all(len(c.split()) <= 4 for c in chunks))
+
+ def test_chapter_chunks_follow_chunk_size_without_a_clamp(self):
+ converter = AudiobookConverter.__new__(AudiobookConverter)
+ self.assertIsNone(converter.chunk_size)
+ with patch.object(config, "CHUNK_SIZE", 8):
+ chunks = converter._chapter_chunks(" ".join(f"Sw{i} ." for i in range(20)))
+ self.assertGreater(len(chunks), 1)
+ self.assertTrue(all(len(c.split()) <= 8 for c in chunks))
+
def test_prompt_answers(self):
entry = self._entry()
with patch("builtins.input", return_value=""):
@@ -691,6 +709,25 @@ class PreflightOverwritesTests(unittest.TestCase):
names = {name for _book, name in planned}
self.assertEqual(names, {"book_txt_m1_Vivian", "book_epub_m1_Vivian"})
+ def test_planned_names_are_unique_across_the_batch(self):
+ # "book.txt" and "book_txt.txt" both resolve to the stem
+ # "book_txt" (the suffix disambiguation's target), which without
+ # a uniqueness pass would make the later book silently overwrite
+ # the earlier one's audiobook.
+ (converter_mod.BOOKS_FOLDER / "book.epub").write_text("x",
+ encoding="utf-8")
+ (converter_mod.BOOKS_FOLDER / "book.txt").write_text("x",
+ encoding="utf-8")
+ (converter_mod.BOOKS_FOLDER / "book_txt.txt").write_text(
+ "x", encoding="utf-8")
+ with patch("builtins.input", side_effect=AssertionError("should not prompt")):
+ _book_files, planned = AudiobookConverter.preflight_overwrites(
+ BACKEND_QWEN, "Vivian", VOICE_MODE_CUSTOM, None, "mp3")
+ names = [name for _book, name in planned]
+ self.assertEqual(len(names), len(set(names)))
+ self.assertEqual(names, ["book_epub_Vivian", "book_txt_Vivian",
+ "book_txt_Vivian_2"])
+
class ComputeModelTagTests(unittest.TestCase):
"""compute_model_tag: the sanitized model id used in output names."""
diff --git a/app/tests/test_converter_progress.py b/app/tests/test_converter_progress.py
index 0a44725..7fae974 100644
--- a/app/tests/test_converter_progress.py
+++ b/app/tests/test_converter_progress.py
@@ -53,14 +53,19 @@ class VoiceModeForTests(unittest.TestCase):
VOICE_MODE_CUSTOM)
def test_qwen_instructions_design(self):
- # Qwen: instructions alone select the VoiceDesign model, taking
- # precedence over a clone reference.
+ # Qwen: instructions alone select the VoiceDesign model. With a
+ # clone reference or a built-in speaker, the reference/speaker
+ # wins and the instructions only ride along (a directed run on
+ # models that support it).
self.assertEqual(voice_mode_for(BACKEND_QWEN,
instructions="A warm narrator"),
VOICE_MODE_DESIGN)
self.assertEqual(voice_mode_for(BACKEND_QWEN, clone="x.wav",
instructions="A warm narrator"),
- VOICE_MODE_DESIGN)
+ VOICE_MODE_CLONE)
+ self.assertEqual(voice_mode_for(BACKEND_QWEN, voice="Vivian",
+ instructions="A warm narrator"),
+ VOICE_MODE_CUSTOM)
self.assertEqual(voice_mode_for(BACKEND_QWEN, instructions=" "),
VOICE_MODE_CUSTOM)
diff --git a/app/tests/test_extractors.py b/app/tests/test_extractors.py
index 619fe5a..b7f14d9 100644
--- a/app/tests/test_extractors.py
+++ b/app/tests/test_extractors.py
@@ -133,6 +133,51 @@ class EpubZipfileFallbackTests(unittest.TestCase):
self.assertEqual([title for title, _ in items], ["a", "b"])
+ def test_spine_hrefs_are_normalized_before_matching(self):
+ # Hrefs are URL-encoded, entity-escaped and relative (possibly
+ # with ./ or ../ segments): every equivalent spelling must
+ # resolve to its archived chapter, or the chapter silently
+ # disappears from the book.
+ import zipfile
+
+ from converter.extractors import _read_epub_zipfile
+
+ container = ("<?xml version=\"1.0\"?>"
+ "<container><rootfiles>"
+ "<rootfile full-path=\"OEBPS/content.opf\"/>"
+ "</rootfiles></container>")
+ opf = ("<?xml version=\"1.0\"?>"
+ "<package xmlns=\"http://www.idpf.org/2007/opf\">"
+ "<manifest>"
+ "<item id=\"nav\" href=\"nav.xhtml\" properties=\"nav\"/>"
+ "<item id=\"c1\" href=\"./text/chapterA.xhtml\"/>"
+ "<item id=\"c2\" href=\"../OEBPS/text/chapterB.xhtml\"/>"
+ "<item id=\"c3\" href=\"text/chapter&#37;20C.xhtml\"/>"
+ "</manifest>"
+ "<spine><itemref idref=\"nav\"/>"
+ "<itemref idref=\"c1\"/><itemref idref=\"c2\"/>"
+ "<itemref idref=\"c3\"/></spine>"
+ "</package>")
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "book.epub"
+ with zipfile.ZipFile(path, "w") as zf:
+ zf.writestr("mimetype", "application/epub+zip")
+ zf.writestr("META-INF/container.xml", container)
+ zf.writestr("OEBPS/content.opf", opf)
+ zf.writestr("OEBPS/nav.xhtml",
+ "<html><body><p>Contents</p></body></html>")
+ zf.writestr("OEBPS/text/chapterA.xhtml",
+ "<html><body><p>Alpha text.</p></body></html>")
+ zf.writestr("OEBPS/text/chapterB.xhtml",
+ "<html><body><p>Beta text.</p></body></html>")
+ zf.writestr("OEBPS/text/chapter%20C.xhtml",
+ "<html><body><p>Gamma text.</p></body></html>")
+ items = _read_epub_zipfile(path)
+
+ titles = [title for title, _ in items]
+ self.assertNotIn("nav", titles)
+ self.assertEqual(titles, ["chapterA", "chapterB", "chapter%20C"])
+
def _build_test_epub(path: Path, chapters=(("One", "First chapter text."),
("Two", "Second chapter text."))) -> None:
@@ -183,15 +228,20 @@ class EpubExtractionTests(unittest.TestCase):
@requires_epub
def test_epub_extraction_follows_spine_order(self):
+ # extract_text() only handles TXT and PDF; EPUB books (with their
+ # per-chapter structure) come through extract_sections().
+ from converter.extractors import extract_sections
+
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "book.epub"
_build_test_epub(path)
- text = extract_text(path)
+ sections = extract_sections(path)
- self.assertIn("First chapter text.", text)
- self.assertIn("Second chapter text.", text)
- self.assertLess(text.index("First chapter text."),
- text.index("Second chapter text."))
+ texts = [section.text for section in sections]
+ self.assertIn("First chapter text.", " ".join(texts))
+ self.assertIn("Second chapter text.", " ".join(texts))
+ self.assertLess(" ".join(texts).index("First chapter text."),
+ " ".join(texts).index("Second chapter text."))
class ExtractSectionsTests(unittest.TestCase):
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 84796ee..183e8aa 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -2873,7 +2873,10 @@ class ConvertFlowTests(unittest.TestCase):
elif entry.capability == "clone":
if entry.requires_reference:
overrides["voice"] = str(ref)
- else: # design
+ if entry.supports_instructions \
+ or entry.capability == "design":
+ # Supported entries show the optional
+ # delivery/style field; design requires it.
overrides["instructions"] = "A warm narrator."
self._answer_form(**overrides)
if hub.converter_mod.chunk_clamp_needed(entry):
@@ -2924,10 +2927,15 @@ class ConvertFlowTests(unittest.TestCase):
else: # design
expected = {"sglomni.model_id",
"sglomni.instructions"}
- self.assertEqual(kwargs.get("instructions"),
- "A warm narrator.")
self.assertNotIn("voice", kwargs)
self.assertNotIn("clone", kwargs)
+ if entry.supports_instructions:
+ # Design requires it; supported entries forward
+ # it as an optional delivery/style control.
+ expected = set(expected) | \
+ {"sglomni.instructions"}
+ self.assertEqual(kwargs.get("instructions"),
+ "A warm narrator.")
self.assertEqual(shown, expected)
@@ -3521,7 +3529,8 @@ class SettingsTests(unittest.TestCase):
# Keys _apply_settings persists; every test that triggers a real or
# fake config write restores these afterwards.
_SETTING_KEYS = ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE",
- "CHUNK_SIZE", "INPUT_DIR", "OUTPUT_DIR",
+ "CHUNK_SIZE", "SMART_CHUNKING", "INPUT_DIR",
+ "OUTPUT_DIR",
"CLONE_WAV_DIR",
"SPEED", "DEBUG", "STOP_SERVER_AND_EXIT",
"AUDIOCPP_UNLOAD_MODELS",
@@ -3556,12 +3565,13 @@ class SettingsTests(unittest.TestCase):
self.assertTrue(hub.common.update_config_value(
key, value, config_path=path))
text = path.read_text(encoding="utf-8")
+ # Strings render as proper Python literals (repr).
self.assertEqual(
text,
"# Default output options\n"
- 'AUDIO_FORMAT = "mp3"\n'
- 'AUDIO_BITRATE = "192k"\n'
- 'LANGUAGE = "Japanese"\n'
+ "AUDIO_FORMAT = 'mp3'\n"
+ "AUDIO_BITRATE = '192k'\n"
+ "LANGUAGE = 'Japanese'\n"
"\n"
"CHUNK_SIZE = 300 # words per request\n")
# The imported module mirrors the file immediately.
@@ -3596,6 +3606,7 @@ class SettingsTests(unittest.TestCase):
original_folders[1])
values = {"audio_format": "ogg", "audio_bitrate": " 192k ",
"language": "en", "chunk_size": "300",
+ "smart_chunking": True,
"input_dir": " /books ", "output_dir": "/audiobooks",
"clone_wav_dir": " /refs/wavs ",
"speed": "1.5", "debug": True,
@@ -3618,6 +3629,7 @@ class SettingsTests(unittest.TestCase):
"AUDIO_BITRATE": "192k",
"LANGUAGE": "English",
"CHUNK_SIZE": 300,
+ "SMART_CHUNKING": True,
"INPUT_DIR": "/books",
"OUTPUT_DIR": "/audiobooks",
"CLONE_WAV_DIR": "/refs/wavs",
@@ -3644,6 +3656,7 @@ class SettingsTests(unittest.TestCase):
self.assertEqual(hub.config.AUDIO_BITRATE, "192k")
self.assertEqual(hub.config.LANGUAGE, "English")
self.assertEqual(hub.config.CHUNK_SIZE, 300)
+ self.assertEqual(hub.config.SMART_CHUNKING, True)
self.assertEqual(hub.config.INPUT_DIR, "/books")
self.assertEqual(hub.config.OUTPUT_DIR, "/audiobooks")
self.assertEqual(hub.config.CLONE_WAV_DIR, "/refs/wavs")
@@ -3663,6 +3676,7 @@ class SettingsTests(unittest.TestCase):
self._snapshot_settings()
base = {"audio_format": "m4b", "audio_bitrate": "128k",
"language": "English", "chunk_size": "250",
+ "smart_chunking": True,
"input_dir": "./input", "output_dir": "./output",
"clone_wav_dir": "./voices",
"speed": "1.0", "debug": False,
@@ -3693,9 +3707,46 @@ class SettingsTests(unittest.TestCase):
"audiocpp_remote_url": "not a url"})
mk_update.assert_not_called()
+ def test_smart_chunking_toggle_persists(self):
+ self._snapshot_settings()
+
+ def fake_update(key, value, config_path=None):
+ setattr(hub.config, key, value)
+ return True
+
+ values = {"audio_format": "m4b", "audio_bitrate": "128k",
+ "language": "English", "chunk_size": "250",
+ "smart_chunking": False,
+ "input_dir": "./input", "output_dir": "./output",
+ "clone_wav_dir": "./voices",
+ "speed": "1.0", "debug": False,
+ "stop_and_exit": True, "unload_models": True,
+ "qwen_port": "7860",
+ "faster_port": "8000", "audiocpp_port": "8080",
+ "sglomni_port": "8100"}
+ with patch.object(hub.common, "update_config_value", fake_update), \
+ patch.object(hub, "_sync_audiocpp_server_port"):
+ hub._apply_settings(values)
+ self.assertEqual(hub.config.SMART_CHUNKING, False)
+
+ values["smart_chunking"] = True
+ with patch.object(hub.common, "update_config_value", fake_update), \
+ patch.object(hub, "_sync_audiocpp_server_port"):
+ hub._apply_settings(values)
+ self.assertEqual(hub.config.SMART_CHUNKING, True)
+
+ def test_smart_chunking_field_defaults_on_with_help(self):
+ fields = hub._settings_fields()
+ field = next(f for f in fields if f["key"] == "smart_chunking")
+ self.assertEqual(field["kind"], "bool")
+ self.assertEqual(field["value"], hub.config.SMART_CHUNKING)
+ self.assertTrue(field["help"])
+ self.assertTrue(field["label"], "Smart chunking")
+
def test_field_validators(self):
self.assertIsNone(hub._validate_bitrate("128k"))
self.assertIsNotNone(hub._validate_bitrate(" "))
+ self.assertIsNotNone(hub._validate_bitrate(" "))
self.assertIsNone(hub._validate_language("English"))
self.assertIsNone(hub._validate_language("en"))
self.assertIsNotNone(hub._validate_language("Klingon"))
@@ -3786,6 +3837,7 @@ class SettingsTests(unittest.TestCase):
captured["fields"] = fields
return {"audio_format": "ogg", "audio_bitrate": "192k",
"language": "English", "chunk_size": "300",
+ "smart_chunking": True,
"input_dir": "/books", "output_dir": "/audiobooks",
"clone_wav_dir": "/refs/wavs",
"speed": "1.0", "debug": False,
@@ -3809,7 +3861,8 @@ class SettingsTests(unittest.TestCase):
hub._Hub(None).screen_settings()
self.assertEqual([f["key"] for f in captured["fields"]],
["audio_format", "audio_bitrate", "language",
- "chunk_size", "input_dir", "output_dir",
+ "chunk_size", "smart_chunking", "input_dir",
+ "output_dir",
"clone_wav_dir",
"speed", "debug", "stop_and_exit",
"unload_models",
@@ -3859,6 +3912,7 @@ class SettingsTests(unittest.TestCase):
"audio_bitrate": "192k",
"language": "English",
"chunk_size": "300",
+ "smart_chunking": True,
"input_dir": "/books",
"output_dir": "/audiobooks",
"clone_wav_dir": "/refs/wavs",
@@ -4012,7 +4066,8 @@ class SettingsTests(unittest.TestCase):
original = {name: getattr(hub.config, name) for name in
("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE",
- "CHUNK_SIZE", "INPUT_DIR", "OUTPUT_DIR",
+ "CHUNK_SIZE", "SMART_CHUNKING", "INPUT_DIR",
+ "OUTPUT_DIR",
"CLONE_WAV_DIR",
"SPEED", "DEBUG", "STOP_SERVER_AND_EXIT",
"AUDIOCPP_UNLOAD_MODELS",
@@ -4034,6 +4089,7 @@ class SettingsTests(unittest.TestCase):
'LANGUAGE = "English"\n'
"\n"
"CHUNK_SIZE = 250\n"
+ "SMART_CHUNKING = True\n"
'INPUT_DIR = "./input"\n'
'OUTPUT_DIR = "./output"\n'
'CLONE_WAV_DIR = "./voices"\n'
@@ -4062,10 +4118,11 @@ class SettingsTests(unittest.TestCase):
text = path.read_text(encoding="utf-8")
self.assertIn('AUDIO_FORMAT = "m4b"', text)
self.assertIn("CHUNK_SIZE = 300", text)
- # The settings-only fields are written back unchanged.
- self.assertIn('INPUT_DIR = "', text)
- self.assertIn('OUTPUT_DIR = "', text)
- self.assertIn('CLONE_WAV_DIR = "', text)
+ # The settings-only fields are written back (as repr string
+ # literals for the values the menu saved).
+ self.assertIn("INPUT_DIR = '/workspace/input'", text)
+ self.assertIn("OUTPUT_DIR = '/workspace/output'", text)
+ self.assertIn("CLONE_WAV_DIR = '/workspace/voices'", text)
self.assertIn("SPEED = 1.0", text)
self.assertIn("DEBUG = False", text)
# The running session also picked up the change in-memory.
@@ -4092,9 +4149,33 @@ class SettingsTests(unittest.TestCase):
("AUDIOCPP_API_URL", "http://127.0.0.1:8081")):
hub.common.update_config_value(key, value, config_path=path)
text = path.read_text(encoding="utf-8")
- self.assertIn('QWEN_API_URL = "http://127.0.0.1:7862"', text)
- self.assertIn('FASTER_API_URL = "http://127.0.0.1:8001"', text)
- self.assertIn('AUDIOCPP_API_URL = "http://127.0.0.1:8081"', text)
+ self.assertIn("QWEN_API_URL = 'http://127.0.0.1:7862'", text)
+ self.assertIn("FASTER_API_URL = 'http://127.0.0.1:8001'", text)
+ self.assertIn("AUDIOCPP_API_URL = 'http://127.0.0.1:8081'", text)
+
+ def test_update_config_value_escapes_quotes_and_backslashes(self):
+ # Strings containing quotes or backslashes must stay valid,
+ # unchanging Python: bare double-quote quoting would corrupt
+ # config.py (invalidating every later start) or silently alter
+ # the value once backslashes became escapes.
+ import tempfile
+ self._snapshot_settings()
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.py"
+ path.write_text('INPUT_DIR = "input"\n', encoding="utf-8")
+ self.assertTrue(hub.common.update_config_value(
+ "INPUT_DIR", '/books/A "quoted" title\\', config_path=path))
+ text = path.read_text(encoding="utf-8")
+ compiled = compile(text, str(path), "exec")
+ scope = {}
+ exec(compiled, scope)
+ # The matcher must still find the (now weirdly quoted) value
+ # to update it again.
+ self.assertTrue(hub.common.update_config_value(
+ "INPUT_DIR", "plain", config_path=path))
+ self.assertIn("INPUT_DIR = 'plain'",
+ path.read_text(encoding="utf-8"))
+ self.assertEqual(scope["INPUT_DIR"], '/books/A "quoted" title\\')
class AudiocppServerConfigTests(unittest.TestCase):
diff --git a/app/tests/test_instruction_capabilities.py b/app/tests/test_instruction_capabilities.py
new file mode 100644
index 0000000..440ee08
--- /dev/null
+++ b/app/tests/test_instruction_capabilities.py
@@ -0,0 +1,419 @@
+"""Instruction-support and guidance regressions across the TTS backends.
+
+Covers the capabilities the models actually implement (verified against
+each backend's serving code) and what the clients send for them:
+Breeze-TTS 2's recommended guidance strength with instructions, the
+audio.cpp Qwen3-TTS variant split (CustomVoice reads instructions, the
+Base cloner does not), the SGLang models that consume a separate style
+instruction alongside their voice conditioning, and the Qwen demo's
+CustomVoice instruction parameter.
+"""
+
+import io
+import json
+import tempfile
+import unittest
+import wave
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+from converter.clients import (
+ BACKEND_QWEN, AudioCppTTSClient, QwenTTSClient,
+ VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, VOICE_MODE_DESIGN,
+)
+from converter.clients.audiocpp import (
+ AUDIOCPP_FAMILY_BREEZE_TTS,
+ AUDIOCPP_FAMILY_PROFILES,
+ AUDIOCPP_VOICE_OPTIONAL,
+ audiocpp_entry_supports_instructions,
+ audiocpp_family_voice_policy,
+)
+
+
+_WAV_BYTES = b"RIFF\x18\x00\x00\x00WAVEfmt \x10\x00\x00\x00"
+
+
+# ---------------------------------------------------------------------------
+# Pure helpers
+# ---------------------------------------------------------------------------
+
+class VoicePolicyKnownFamiliesTests(unittest.TestCase):
+ """Families whose verified policy must survive a stale local spec."""
+
+ def test_breeze_is_tts_plus_clone_even_without_a_local_spec(self):
+ # Remote Breeze entries against older local checkouts carry no
+ # breeze_tts spec at all: the fallback keeps instructions-only
+ # voice direction connectable instead of demanding a reference.
+ self.assertEqual(
+ audiocpp_family_voice_policy(AUDIOCPP_FAMILY_BREEZE_TTS),
+ AUDIOCPP_VOICE_OPTIONAL)
+
+ def test_vibevoice_accepts_reference_audio_despite_its_spec(self):
+ # vibevoice.json declares only "tts", but the implementation
+ # accepts reference audio: a mixed tts+clone family, so a picked
+ # voice must not be silently dropped.
+ self.assertEqual(audiocpp_family_voice_policy("vibevoice"),
+ AUDIOCPP_VOICE_OPTIONAL)
+
+
+class EntryInstructionSupportTests(unittest.TestCase):
+ """audiocpp_entry_supports_instructions: True/False/None per entry."""
+
+ def test_breeze_supports_instructions(self):
+ self.assertIs(
+ audiocpp_entry_supports_instructions(
+ AUDIOCPP_FAMILY_BREEZE_TTS, "tts", "Breeze-TTS-2-GGUF"),
+ True)
+
+ def test_qwen_customvoice_and_design_support_instructions(self):
+ self.assertIs(
+ audiocpp_entry_supports_instructions(
+ "qwen3_tts", "tts", "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF"),
+ True)
+ self.assertIs(
+ audiocpp_entry_supports_instructions(
+ "qwen3_tts", "vdes", "Qwen3-TTS-12Hz-1.7B-VoiceDesign"),
+ True)
+
+ def test_qwen_base_cloner_provably_does_not(self):
+ self.assertIs(
+ audiocpp_entry_supports_instructions(
+ "qwen3_tts", "tts", "Qwen3-TTS-12Hz-1.7B-Base-GGUF"),
+ False)
+
+ def test_unknown_families_are_unknown_not_unsupported(self):
+ self.assertIsNone(
+ audiocpp_entry_supports_instructions("chatterbox",
+ "tts", "x"))
+
+
+# ---------------------------------------------------------------------------
+# audio.cpp request payloads (instructed Breeze runs)
+# ---------------------------------------------------------------------------
+
+def _breeze_client(instructions=None, request_options=None,
+ voice="narrator", seed=-1):
+ """A fully-initialized Breeze client (no HTTP machinery touched)."""
+ with patch.object(AudioCppTTSClient, "_connect"):
+ client = AudioCppTTSClient(
+ Path("."), voice=voice, instructions=instructions,
+ request_options=request_options)
+ client.api_url = "http://127.0.0.1:8080"
+ client.model_id = "Breeze-TTS-2-GGUF"
+ client.family = AUDIOCPP_FAMILY_BREEZE_TTS
+ client.task = "tts"
+ client.profile = AUDIOCPP_FAMILY_PROFILES[AUDIOCPP_FAMILY_BREEZE_TTS]
+ client.design_mode = False
+ client.instruction_voice = False
+ client.plain_mode = False
+ client.preset_mode = True
+ client.speaker_mode = False
+ client._seed = seed
+ client._resolve_auto_guidance()
+ return client
+
+
+def _captured_payload(client):
+ """The JSON body _request_wav sends, via a stubbed urlopen."""
+ response = MagicMock()
+ response.read.return_value = _WAV_BYTES
+ response.__enter__ = lambda self: response
+ response.__exit__ = lambda self, *exc: None
+ with patch("converter.clients.audiocpp.urllib.request.urlopen") \
+ as urlopen:
+ urlopen.return_value = response
+ client._request_wav("Hello there.")
+ request = urlopen.call_args[0][0]
+ return json.loads(request.data.decode("utf-8"))
+
+
+class BreezeGuidanceDefaultTests(unittest.TestCase):
+ """Breeze guidance: recommended 4 with instructions, otherwise none."""
+
+ def test_instructed_clone_carries_guidance_4_and_the_instruction(self):
+ client = _breeze_client(instructions="Screaming, crazed, yelling")
+ self.assertEqual(client._auto_guidance_scale, 4.0)
+ payload = _captured_payload(client)
+ self.assertEqual(payload["guidance_scale"], 4.0)
+ self.assertEqual(payload["voice"], "narrator")
+ self.assertEqual(
+ payload["options"],
+ {"instruction": "Screaming, crazed, yelling"})
+ self.assertNotIn("instructions", payload)
+
+ def test_option_instruction_also_gets_the_guidance_default(self):
+ client = _breeze_client(request_options={
+ "instruction": "Read slowly and warmly."})
+ self.assertEqual(client._auto_guidance_scale, 4.0)
+ payload = _captured_payload(client)
+ self.assertEqual(payload["guidance_scale"], 4.0)
+ self.assertEqual(payload["options"]["instruction"],
+ "Read slowly and warmly.")
+
+ def test_explicit_guidance_option_is_preserved(self):
+ client = _breeze_client(
+ instructions="Screaming, crazed, yelling",
+ request_options={"guidance_scale": "2.5"})
+ self.assertIsNone(client._auto_guidance_scale)
+ payload = _captured_payload(client)
+ self.assertNotIn("guidance_scale", payload)
+ self.assertEqual(payload["options"]["guidance_scale"], "2.5")
+
+ def test_guidance_0_override_still_counts_as_explicit(self):
+ # 0 selects the instruction-free branch: a deliberate setting.
+ client = _breeze_client(
+ instructions="Screaming",
+ request_options={"guidance_scale": "0"})
+ payload = _captured_payload(client)
+ self.assertNotIn("guidance_scale", payload)
+
+ def test_plain_clone_without_instructions_uses_the_backend_default(self):
+ client = _breeze_client()
+ self.assertIsNone(client._auto_guidance_scale)
+ payload = _captured_payload(client)
+ self.assertNotIn("guidance_scale", payload)
+ self.assertNotIn("options", payload)
+ self.assertEqual(payload["voice"], "narrator")
+
+
+class InstructionConflictTests(unittest.TestCase):
+ """Two different instruction sources are refused before connecting."""
+
+ def test_conflicting_instructions_and_option_raise_without_a_server(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ _breeze_client(instructions="calm narration",
+ request_options={"instruction": "screaming"})
+ self.assertIn("Two conflicting instructions",
+ str(ctx.exception))
+
+ def test_identical_instructions_from_both_sources_are_accepted(self):
+ client = _breeze_client(
+ instructions="calm narration",
+ request_options={"instruction": "calm narration"})
+ self.assertEqual(client.instructions, "calm narration")
+
+ def test_option_only_instruction_is_folded_into_the_reports(self):
+ client = _breeze_client(
+ request_options={"instruction": "calm narration"})
+ self.assertEqual(client.instructions, "calm narration")
+
+
+class SeedPrecisionTests(unittest.TestCase):
+ """Full-range uint64 seeds travel as decimal strings (audio.cpp docs)."""
+
+ def test_seed_above_2_pow_53_is_sent_as_a_string(self):
+ seed = 2 ** 53 + 3 # beyond the exact JSON-number integer range
+ client = _breeze_client(seed=seed)
+ payload = _captured_payload(client)
+ self.assertEqual(payload["seed"], str(seed))
+
+ def test_ordinary_seeds_stay_numbers(self):
+ client = _breeze_client(seed=42)
+ payload = _captured_payload(client)
+ self.assertEqual(payload["seed"], 42)
+
+
+# ---------------------------------------------------------------------------
+# SGLang-Omni: instructions on supported pipelines
+# ---------------------------------------------------------------------------
+
+class SgOmniInstructionTests(unittest.TestCase):
+ """instructions reach the payload only where the serving code reads it."""
+
+ _tmp_dir = None
+ _REF = None
+
+ @classmethod
+ def setUpClass(cls):
+ buffer = io.BytesIO()
+ with wave.open(buffer, "wb") as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(24000)
+ wav_file.writeframes(b"\x01\x00" * 16)
+ cls._tmp_dir = tempfile.TemporaryDirectory()
+ cls._REF = Path(cls._tmp_dir.name) / "narrator.wav"
+ cls._REF.write_bytes(buffer.getvalue())
+ cls.addClassCleanup(cls._tmp_dir.cleanup)
+
+ def _client(self, model, **kwargs):
+ from converter.clients import SgOmniTTSClient
+ with patch.object(SgOmniTTSClient, "_connect"):
+ client = SgOmniTTSClient(
+ Path("."), model=model, ref_audio=str(self._REF),
+ ref_text="Hello transcript.", instructions="screaming",
+ **kwargs)
+ entry = client.entry
+ payload = client._request_payload("Hello there.")
+ return entry, payload
+
+ def test_qwen_base_clone_carries_ref_and_instruction(self):
+ entry, payload = self._client("qwen3_tts_1_7b_base")
+ self.assertIn("ref_audio", payload)
+ self.assertNotEqual(payload.get("task_type"), "VoiceDesign")
+ self.assertEqual(payload["instructions"], "screaming")
+
+ def test_moss_clone_carries_ref_and_instruction(self):
+ entry, payload = self._client("moss_tts")
+ self.assertIn("ref_audio", payload)
+ self.assertEqual(payload["instructions"], "screaming")
+
+ def test_customvoice_speaker_with_instruction(self):
+ entry, payload = self._client("qwen3_tts_0_6b_customvoice",
+ voice="Vivian")
+ self.assertEqual(payload["voice"], "Vivian")
+ self.assertEqual(payload["instructions"], "screaming")
+ self.assertNotIn("task_type", payload)
+
+ def test_design_remains_voice_design_with_instruction(self):
+ from converter.clients import SgOmniTTSClient
+ with patch.object(SgOmniTTSClient, "_connect"):
+ client = SgOmniTTSClient(
+ Path("."), model="qwen3_tts_1_7b_voicedesign",
+ instructions="a warm narrator")
+ payload = client._request_payload("Hello there.")
+ self.assertEqual(payload["task_type"], "VoiceDesign")
+ self.assertEqual(payload["instructions"], "a warm narrator")
+ self.assertNotIn("ref_audio", payload)
+
+ def test_unsupported_model_refuses_instructions_at_connect(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client("higgs_audio_v3_tts")
+ self.assertIn("does not consume style instructions",
+ str(ctx.exception))
+
+
+# ---------------------------------------------------------------------------
+# Qwen demo: CustomVoice instruction parameter
+# ---------------------------------------------------------------------------
+
+class QwenCustomVoiceInstructionTests(unittest.TestCase):
+ """The run_instruct endpoint takes an ``instruct`` delivery control."""
+
+ def test_run_instruct_sends_instruct_alongside_the_speaker(self):
+ client = QwenTTSClient.__new__(QwenTTSClient)
+ client.voice_mode = VOICE_MODE_CUSTOM
+ client.speaker = "Vivian"
+ client.language = "Auto"
+ client.instructions = "screaming, crazed"
+ client._seed = -1
+ client.client = MagicMock()
+ client._resolve_api_name = lambda *names: names[0]
+ client._endpoint_accepts_param = MagicMock(return_value=True)
+ client._generate_custom_voice("Hello there.")
+ predict = client.client.predict
+ predict.assert_called_once_with(
+ text="Hello there.", lang_disp="Auto",
+ spk_disp="Vivian", instruct="screaming, crazed",
+ api_name="/run_instruct")
+
+ def test_custom_voice_without_instructions_is_unchanged(self):
+ client = QwenTTSClient.__new__(QwenTTSClient)
+ client.voice_mode = VOICE_MODE_CUSTOM
+ client.speaker = "Vivian"
+ client.language = "Auto"
+ client.instructions = ""
+ client._seed = -1
+ client.client = MagicMock()
+ client._resolve_api_name = lambda *names: names[0]
+ client._endpoint_accepts_param = MagicMock(return_value=True)
+ client._generate_custom_voice("Hello there.")
+ _, kwargs = client.client.predict.call_args
+ self.assertNotIn("instruct", kwargs)
+
+
+# ---------------------------------------------------------------------------
+# audiobook voice-mode routing (qwen)
+# ---------------------------------------------------------------------------
+
+class CatalogInstructionFlagsTests(unittest.TestCase):
+ """Only the verified pipelines carry supports_instructions."""
+
+ def test_catalog_marks_only_the_verified_pipelines(self):
+ from backends.sglomni.catalog import ENTRIES
+ supported = {"qwen3_tts_0_6b_customvoice", "qwen3_tts_0_6b_base",
+ "qwen3_tts_1_7b_base", "qwen3_tts_1_7b_voicedesign",
+ "moss_tts", "moss_tts_local"}
+ for entry in ENTRIES:
+ with self.subTest(entry=entry.key):
+ self.assertEqual(entry.supports_instructions,
+ entry.key in supported)
+
+
+class GradioPrefixProbeTests(unittest.TestCase):
+ """Qwen demos under modern Gradio sit behind /gradio_api."""
+
+ def _identify(self, modern_payload, legacy_payload=None):
+ import backends.probe as probe
+ seen = []
+
+ def fake_get_json(url, timeout):
+ seen.append(url)
+ if url == "http://x/gradio_api/info":
+ return modern_payload
+ if url == "http://x/info":
+ return legacy_payload
+ return None
+
+ with patch.object(probe, "_get_json", side_effect=fake_get_json):
+ with patch.object(probe.common, "server_running",
+ return_value=True):
+ identity = probe._identify_gradio("http://x", 1.0)
+ return identity, seen
+
+ def test_modern_prefix_is_probed_first_and_identifies(self):
+ payload = {"named_endpoints": {"/run_instruct": {}}}
+ identity, seen = self._identify(payload)
+ self.assertEqual(identity, probe_identity("qwen-custom"))
+ self.assertEqual(seen, ["http://x/gradio_api/info"])
+
+ def test_legacy_info_still_identifies_older_gradio(self):
+ payload = {"named_endpoints": {"/run_voice_clone": {}}}
+ identity, seen = self._identify(None, payload)
+ self.assertEqual(identity, probe_identity("qwen-clone"))
+ self.assertEqual(seen, ["http://x/gradio_api/info",
+ "http://x/info"])
+
+ def test_neither_prefix_answers_none(self):
+ identity, _ = self._identify(None)
+ self.assertIsNone(identity)
+
+
+def probe_identity(name):
+ """The probe's IDENTITY_* constant for a backend NAME (local import)."""
+ import backends.probe as probe
+ return {"qwen-custom": probe.IDENTITY_QWEN_CUSTOM,
+ "qwen-clone": probe.IDENTITY_QWEN_CLONE,
+ }[name]
+
+
+# ---------------------------------------------------------------------------
+# audiobook voice-mode routing (qwen)
+# ---------------------------------------------------------------------------
+
+class QwenVoiceModeRoutingTests(unittest.TestCase):
+ """speaker + instructions is a directed CustomVoice run, not Design."""
+
+ def test_voice_mode_for_qwen_combinations(self):
+ from converter.converter import voice_mode_for
+ cases = [
+ (dict(voice=None, clone=None, instructions=None),
+ VOICE_MODE_CUSTOM),
+ (dict(voice=None, clone=None, instructions="screaming"),
+ VOICE_MODE_DESIGN),
+ (dict(voice="Vivian", clone=None, instructions="screaming"),
+ VOICE_MODE_CUSTOM),
+ (dict(voice=None, clone="ref.wav", instructions=None),
+ VOICE_MODE_CLONE),
+ ]
+ for kwargs, expected in cases:
+ with self.subTest(**kwargs):
+ self.assertEqual(
+ voice_mode_for(BACKEND_QWEN, voice=kwargs["voice"],
+ clone=kwargs["clone"],
+ instructions=kwargs["instructions"]),
+ expected)
+
+
+if __name__ == "__main__":
+ unittest.main()