aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/backends/common.py46
-rw-r--r--app/backends/probe.py11
-rw-r--r--app/backends/servers.py189
-rw-r--r--app/backends/sglomni/catalog.py16
-rw-r--r--app/converter/audio.py13
-rw-r--r--app/converter/chunking.py473
-rw-r--r--app/converter/clients/__init__.py2
-rw-r--r--app/converter/clients/audiocpp.py178
-rw-r--r--app/converter/clients/base.py5
-rw-r--r--app/converter/clients/qwen.py18
-rw-r--r--app/converter/clients/sglomni.py15
-rw-r--r--app/converter/config.py7
-rw-r--r--app/converter/converter.py148
-rw-r--r--app/converter/extractors.py96
-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
-rw-r--r--app/ui/hub.py39
25 files changed, 2012 insertions, 184 deletions
diff --git a/app/backends/common.py b/app/backends/common.py
index 7974b58..20de432 100644
--- a/app/backends/common.py
+++ b/app/backends/common.py
@@ -9,6 +9,7 @@ TUI) so it can be reused without pulling curses into a non-interactive
run.
"""
+import ast
import os
import re
import shutil
@@ -306,25 +307,50 @@ def update_config_value(key: str, value,
"""Set ``KEY`` to VALUE in app/converter/config.py and in memory.
Only the value of the named assignment changes: indentation and any
- trailing comment are preserved. Strings render double-quoted; other
- literals (ints, booleans) render bare. After a successful write (or
- when the file already holds VALUE) the new value is mirrored onto the
- imported ``converter.config`` module, so a wizard's change takes
- effect immediately instead of only after the next process start.
- Returns True when the file now holds VALUE, False when it could not
- be read or written (or KEY has no line in it).
+ trailing comment are preserved. Strings render as proper Python
+ literals via ``repr`` (quoting with bare double quotes would instead
+ produce invalid syntax — or silently change the value — whenever the
+ string itself contains a quote or a backslash, corrupting
+ config.py); other literals (ints, booleans) render bare. After a
+ successful write (or when the file already holds VALUE) the new
+ value is mirrored onto the imported ``converter.config`` module, so
+ a wizard's change takes effect immediately instead of only after
+ the next process start. Returns True when the file now holds VALUE,
+ False when it could not be read or written (or KEY has no line in
+ it, or the edit would not parse).
"""
path = Path(config_path) if config_path is not None else CONFIG_PATH
- rendered = f'"{value}"' if isinstance(value, str) else str(value)
+ rendered = repr(value) if isinstance(value, str) else str(value)
try:
text = path.read_text(encoding="utf-8")
match = re.search(
- rf'(?m)^(\s*{re.escape(key)}\s*=\s*)("[^"]*"|\S+)(\s*(?:#.*)?)$',
+ rf'(?m)^(\s*{re.escape(key)}\s*=\s*)'
+ # Any valid Python string literal, single- or double-quoted
+ # (both spellings occur once repr() has written a value),
+ # else a bare literal token.
+ r'("[^"\\]*(?:\\.[^"\\]*)*"'
+ r"|'[^'\\]*(?:\\.[^'\\]*)*'"
+ r'|\S+)'
+ r'(\s*(?:#.*)?)$',
text)
if match is None:
return False
- if match.group(2) != rendered:
+ try:
+ # Semantic equality first: a file still holding the value in
+ # the old quoting style must not be rewritten (a no-op save
+ # stays a no-op), and the matched token may be any literal.
+ same = (match.group(2) == rendered
+ or ast.literal_eval(match.group(2)) == value)
+ except (ValueError, SyntaxError):
+ same = match.group(2) == rendered
+ if not same:
text = text[:match.start(2)] + rendered + text[match.end(2):]
+ try:
+ # Never write a file that fails to import: a broken
+ # config.py breaks every later process start.
+ compile(text, str(path), "exec")
+ except (SyntaxError, ValueError):
+ return False
path.write_text(text, encoding="utf-8")
except OSError:
return False
diff --git a/app/backends/probe.py b/app/backends/probe.py
index d54a9f3..c84c88d 100644
--- a/app/backends/probe.py
+++ b/app/backends/probe.py
@@ -107,8 +107,15 @@ def _identify_health(base: str, timeout: float) -> Optional[str]:
def _identify_gradio(base: str, timeout: float) -> Optional[str]:
- """Identify a qwen-tts Gradio demo from its ``/info`` named endpoints."""
- payload = _get_json(f"{base}/info", timeout)
+ """Identify a qwen-tts Gradio demo from its ``/info`` named endpoints.
+
+ Modern Gradio (>= 4.x / 5.x) routes its API under ``/gradio_api`` —
+ its ``/info`` lives at ``/gradio_api/info`` with the legacy ``/info``
+ path gone or deprecated — so both prefixes are probed.
+ """
+ payload = _get_json(f"{base}/gradio_api/info", timeout)
+ if payload is None:
+ payload = _get_json(f"{base}/info", timeout)
if payload is None:
return None
endpoints = payload.get("named_endpoints")
diff --git a/app/backends/servers.py b/app/backends/servers.py
index fd28866..781ecb5 100644
--- a/app/backends/servers.py
+++ b/app/backends/servers.py
@@ -22,6 +22,7 @@ boot screen, which renders the same events. Pid/log files live under
``app/logs/`` which is already gitignored.
"""
+import contextlib
import os
import re
import signal
@@ -142,6 +143,43 @@ def _pid_path(name: str) -> Path:
return LOG_DIR / f"{name}-server.pid"
+@contextlib.contextmanager
+def _start_lock(name: str):
+ """Serialize a ``start``'s stale-pid cleanup, spawn and pid
+ publication for NAME.
+
+ Two starters racing through ``start`` could otherwise unlink each
+ other's just-created, still-empty pid-file reservation (created
+ before the spawned pid is written) and both end up spawning a
+ server. The lock (an advisory fcntl/msvcrt lock, blocking so the
+ loser waits only as long as the winner's spawn takes) is held from
+ the liveness check until the pid file carries the spawned pid; the
+ boot wait happens outside it. The lock dies with its holder, so a
+ crashed starter never wedges later starts; platforms without file
+ locking degrade to the old unlocked behavior.
+ """
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
+ handle = open(LOG_DIR / f"{name}-server.start.lock", "w")
+ try:
+ try:
+ import fcntl
+ try:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
+ except OSError:
+ pass # degrade rather than fail the start
+ except ImportError:
+ try:
+ import msvcrt
+ handle.write("0")
+ handle.flush()
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
+ except (ImportError, OSError):
+ pass
+ yield
+ finally:
+ handle.close() # releases the lock
+
+
def _read_log_tail(name: str, lines: int = 20) -> List[str]:
"""Return the last LINES of the server's log (best-effort)."""
try:
@@ -338,10 +376,11 @@ def _kill_pid(pid: int) -> bool:
except PermissionError:
return False
for _ in range(int(STOP_GRACE_SECONDS * 10)):
- # Reap first so an exited (zombie) child ends the wait immediately
- # instead of keeping the killpg(0) probe "alive" until SIGKILL.
- if _reap_exited(pid):
- return True
+ # Reap each round so an exited (zombie) child stops keeping the
+ # killpg(0) probe "alive" until SIGKILL. Reaping the launcher is
+ # NOT proof the group is gone — workers can outlive it — so the
+ # group probe below, not the reap, decides when the wait ends.
+ _reap_exited(pid)
try:
os.killpg(pgid, 0)
except ProcessLookupError:
@@ -429,82 +468,86 @@ def start(spec, progress: ProgressCallback = None,
# Refuse to double-start: a live pid file means a previous start is
# still booting (or its process is wedged). Spawning a second server
# on the same port would orphan the first with no pid record left.
- if alive(spec.name):
- report({"kind": "error",
- "message": f"a {spec.name} server (pid "
- f"{pid_for(spec.name)}) is already starting or "
- "running; stop it first"})
- return False
- # Refuse to spawn onto a port a foreign process already holds: TCP-up
- # but probe-down means the listener is not a usable instance of this
- # server. A fresh spawn would then either die on the bind or (launchers
- # that fall back silently, like sglang-omni) move to a random port and
- # leave every client polling the taken one — the boot watchdog below
- # catches that late, so name the conflict here.
- if listening:
- report({"kind": "error", "message": _port_taken_message(spec)})
- return False
- pid_file = _pid_path(spec.name)
- if pid_file.exists():
+ # The start lock keeps the liveness check, stale cleanup, spawn and
+ # pid publication serialized against a concurrent starter (see
+ # _start_lock); the boot wait below runs outside it.
+ with _start_lock(spec.name):
+ if alive(spec.name):
+ report({"kind": "error",
+ "message": f"a {spec.name} server (pid "
+ f"{pid_for(spec.name)}) is already starting or "
+ "running; stop it first"})
+ return False
+ # Refuse to spawn onto a port a foreign process already holds: TCP-up
+ # but probe-down means the listener is not a usable instance of this
+ # server. A fresh spawn would then either die on the bind or (launchers
+ # that fall back silently, like sglang-omni) move to a random port and
+ # leave every client polling the taken one — the boot watchdog below
+ # catches that late, so name the conflict here.
+ if listening:
+ report({"kind": "error", "message": _port_taken_message(spec)})
+ return False
+ pid_file = _pid_path(spec.name)
+ if pid_file.exists():
+ try:
+ pid_file.unlink()
+ except OSError:
+ pass
+ # Reserve the slot atomically (O_EXCL): two concurrent starters can
+ # both pass the liveness check above, but only one wins the create —
+ # the loser refuses instead of spawning a duplicate server on the port.
try:
- pid_file.unlink()
+ pid_handle = pid_file.open("x")
+ except FileExistsError:
+ report({"kind": "error",
+ "message": f"a {spec.name} server is already starting "
+ "(its pid file appeared while this start was "
+ "running); stop it first"})
+ return False
except OSError:
- pass
- # Reserve the slot atomically (O_EXCL): two concurrent starters can
- # both pass the liveness check above, but only one wins the create —
- # the loser refuses instead of spawning a duplicate server on the port.
- try:
- pid_handle = pid_file.open("x")
- except FileExistsError:
- report({"kind": "error",
- "message": f"a {spec.name} server is already starting "
- "(its pid file appeared while this start was "
- "running); stop it first"})
- return False
- except OSError:
- pid_handle = None
-
- cwd = getattr(spec, "cwd", None)
- # Append so an earlier boot's output survives (crash-loop debugging);
- # the child inherits the handle and the parent's copy is closed right
- # after the spawn, so nothing leaks here.
- log_handle = _log_path(spec.name).open("a", encoding="utf-8")
- popen_kwargs = {"stdout": log_handle, "stderr": subprocess.STDOUT}
- if cwd is not None:
- popen_kwargs["cwd"] = str(cwd)
- if sys.platform == "win32":
- popen_kwargs["creationflags"] = \
- subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
- else:
- popen_kwargs["start_new_session"] = True
- try:
- proc = subprocess.Popen(argv, **popen_kwargs)
- except OSError as exc:
- report({"kind": "error",
- "message": f"could not start server: {exc}"})
+ pid_handle = None
+
+ cwd = getattr(spec, "cwd", None)
+ # Append so an earlier boot's output survives (crash-loop debugging);
+ # the child inherits the handle and the parent's copy is closed right
+ # after the spawn, so nothing leaks here.
+ log_handle = _log_path(spec.name).open("a", encoding="utf-8")
+ popen_kwargs = {"stdout": log_handle, "stderr": subprocess.STDOUT}
+ if cwd is not None:
+ popen_kwargs["cwd"] = str(cwd)
+ if sys.platform == "win32":
+ popen_kwargs["creationflags"] = \
+ subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
+ else:
+ popen_kwargs["start_new_session"] = True
+ try:
+ proc = subprocess.Popen(argv, **popen_kwargs)
+ except OSError as exc:
+ report({"kind": "error",
+ "message": f"could not start server: {exc}"})
+ log_handle.close()
+ if pid_handle is not None:
+ pid_handle.close()
+ try:
+ pid_file.unlink()
+ except OSError:
+ pass
+ return False
+ log_handle.write(f"\n=== boot {datetime.now():%Y-%m-%d %H:%M:%S} "
+ f"(pid {proc.pid}) ===\n")
+ log_handle.flush()
log_handle.close()
+
+ # "pid token": the process's start time where the platform provides
+ # one, so a recycled pid is never mistaken for our server (see
+ # _pid_owned). Empty token = bare-pid probing.
+ token = _process_start_token(proc.pid) or ""
if pid_handle is not None:
- pid_handle.close()
try:
- pid_file.unlink()
+ pid_handle.write(f"{proc.pid} {token}\n".strip() + "\n")
+ pid_handle.close()
except OSError:
pass
- return False
- log_handle.write(f"\n=== boot {datetime.now():%Y-%m-%d %H:%M:%S} "
- f"(pid {proc.pid}) ===\n")
- log_handle.flush()
- log_handle.close()
-
- # "pid token": the process's start time where the platform provides
- # one, so a recycled pid is never mistaken for our server (see
- # _pid_owned). Empty token = bare-pid probing.
- token = _process_start_token(proc.pid) or ""
- if pid_handle is not None:
- try:
- pid_handle.write(f"{proc.pid} {token}\n".strip() + "\n")
- pid_handle.close()
- except OSError:
- pass
report({"kind": "starting", "name": spec.name,
"argv": " ".join(str(a) for a in argv),
"cwd": str(cwd) if cwd is not None else None,
diff --git a/app/backends/sglomni/catalog.py b/app/backends/sglomni/catalog.py
index 8bfb1fa..2452d82 100644
--- a/app/backends/sglomni/catalog.py
+++ b/app/backends/sglomni/catalog.py
@@ -52,6 +52,16 @@ class ModelEntry:
system_hint: Optional[str] = None # remediation when the binary is absent
speakers: Optional[Tuple[str, ...]] = None # preset voices (speaker)
supports_seed: bool = False # request-scoped seed accepted (Qwen3-TTS Base)
+ # Whether the model's serving pipeline consumes a separate
+ # "instructions" field alongside its normal voice conditioning
+ # (verified against the installed sglang_omni code, not assumed from
+ # the HTTP schema): Qwen3-TTS Base (clone + instruction conditioning
+ # in request_builders.py), Qwen3-TTS CustomVoice and VoiceDesign, and
+ # the MOSS v1.5 pair (reference + instruction in the user message).
+ # False would mean an instructions field is silently ignored (Higgs,
+ # Voxtral, fish, dots, ZONOS2 take none; fish's inline event tags
+ # belong in the text, not this field).
+ supports_instructions: bool = False
# NOTE(unverified upstream): only the two Base entries are known to
# accept a request-scoped seed (Voxtral rejects one outright); qwen's
# demo client does send seeds to the CustomVoice/VoiceDesign models,
@@ -161,6 +171,7 @@ ENTRIES: Tuple[ModelEntry, ...] = (
requires_reference=False,
extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT,
speakers=QWEN_CUSTOMVOICE_SPEAKERS,
+ supports_instructions=True,
notes="built-in speakers, lightest model",
),
ModelEntry(
@@ -172,6 +183,7 @@ ENTRIES: Tuple[ModelEntry, ...] = (
requires_reference=True,
extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT,
supports_seed=True,
+ supports_instructions=True,
notes="voice cloning from a reference clip",
),
ModelEntry(
@@ -183,6 +195,7 @@ ENTRIES: Tuple[ModelEntry, ...] = (
requires_reference=True,
extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT,
supports_seed=True,
+ supports_instructions=True,
notes="voice cloning, higher quality",
),
ModelEntry(
@@ -193,6 +206,7 @@ ENTRIES: Tuple[ModelEntry, ...] = (
capability=CAPABILITY_DESIGN,
requires_reference=False,
extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT,
+ supports_instructions=True,
notes="voice described by instructions",
),
ModelEntry(
@@ -229,6 +243,7 @@ ENTRIES: Tuple[ModelEntry, ...] = (
config="moss_tts.yaml",
capability=CAPABILITY_CLONE,
requires_reference=True,
+ supports_instructions=True,
notes="voice cloning from a reference clip",
),
ModelEntry(
@@ -238,6 +253,7 @@ ENTRIES: Tuple[ModelEntry, ...] = (
config="moss_tts_local.yaml",
capability=CAPABILITY_CLONE,
requires_reference=False,
+ supports_instructions=True,
notes="48 kHz, narration without a reference or cloning",
),
ModelEntry(
diff --git a/app/converter/audio.py b/app/converter/audio.py
index 9df7aff..9536788 100644
--- a/app/converter/audio.py
+++ b/app/converter/audio.py
@@ -6,6 +6,7 @@ threads them through, so there is no module-global path to mutate.
"""
import logging
+import math
import re
import shutil
import subprocess
@@ -29,7 +30,12 @@ def atempo_filters(speed: float) -> str:
``atempo`` accepts 0.5..2.0 per filter; values outside that range are
handled by chaining multiple filters. Returns "" when ``speed`` is 1.0.
+ Non-finite values (``inf``, ``nan``) are rejected: they pass a bare
+ ``> 0`` check and would either loop forever (``inf / 2.0`` stays
+ ``inf``) or produce an invalid tempo expression.
"""
+ if not math.isfinite(speed):
+ raise ValueError(f"Speed must be a finite number, got {speed}")
if speed <= 0:
raise ValueError(f"Speed must be a positive number, got {speed}")
if abs(speed - 1.0) < SPEED_EPSILON:
@@ -252,7 +258,10 @@ def build_concat_command(concat_list: Path, output_path: Path, output_format: st
raise ValueError("speed_path is required when speed is not 1.0")
return [
"ffmpeg", "-y", *inputs, "-filter_complex",
- f"[0:a]split=2[base][spd];[spd]{filters}[spdout]",
+ # asplit (not split): split is a video filter and rejects
+ # audio streams, which would fail every speed-adjusted
+ # assembly after synthesis had already completed.
+ f"[0:a]asplit=2[base][spd];[spd]{filters}[spdout]",
"-map", "[base]", *encode, *tags, *cover_block, *container, str(output_path),
"-map", "[spdout]", *encode, *tags, *cover_block, *container, str(speed_path),
]
@@ -290,7 +299,7 @@ def build_m4b_chapters_command(concat_list: Path, metadata_file: Path, output_pa
if speed_path is not None:
return [
"ffmpeg", "-y", *inputs, "-filter_complex",
- f"[0:a]split=2[base][spd];[spd]{atempo_filters(speed)}[spdout]",
+ f"[0:a]asplit=2[base][spd];[spd]{atempo_filters(speed)}[spdout]",
"-map", "[base]", *cover_block, "-map_metadata", "1", "-map_chapters", "1",
*encode, *tags, *container, str(output_path),
"-map", "[spdout]", *cover_block, "-map_metadata", "2", "-map_chapters", "2",
diff --git a/app/converter/chunking.py b/app/converter/chunking.py
index 9ef6a5d..bf104bc 100644
--- a/app/converter/chunking.py
+++ b/app/converter/chunking.py
@@ -8,32 +8,419 @@ from . import config
logger = logging.getLogger(__name__)
+# Smart-chunking tuning, as fractions of the word limit (not exposed as
+# settings): once a smart chunk crosses the target it ends at the next
+# clean boundary, so most chunks land between the target and the limit
+# with some headroom under the configured size. When the next sentence
+# will not fit the running chunk, the break retreats to the previous
+# boundary that is clean and at least the floor, so a quotation is not
+# severed when a later clean boundary exists nearby.
+_TARGET_RATIO = 0.85
+_FLOOR_RATIO = 0.7
+# A single token with no whitespace cannot be broken at a word
+# boundary; if such a token runs longer than this many characters per
+# allowed word it is sliced at the hard character cap (a last-resort
+# bound for punctuation-free input such as some CJK text or damage from
+# text extraction). The slice also honors the word limit itself, since
+# every non-spaced-script character counts as one word.
+_MAX_CHARS_PER_WORD = 8
-def split_into_chunks(text: str, max_words: Optional[int] = None) -> List[str]:
- """Split text into chunks of at most ``max_words`` words.
+_ABBREVIATIONS = frozenset(
+ "mr mrs ms miss dr prof rev hon pres gov sen rep capt lt col gen "
+ "sgt maj cpl pvt jr sr st mt vs etc eg ie aka fig figs no nos vol "
+ "vols ch pp p pg dept univ ave blvd rd inc ltd co corp bros apt "
+ "ste est approx min sec hrs msg mme mlle".split())
- ``max_words`` defaults to ``config.CHUNK_SIZE`` (read at call time).
- There is no ceiling beyond that setting, but note that the TTS
- servers silently truncate audio when a single generation runs too
- long without reporting an error, so very large values are at your
- own risk (see CHUNK_SIZE in app/converter/config.py).
+_EVENT = re.compile(
+ r"(?P<end>[.!?…。!?][\"'”’»)}\]]*)"
+ r"|(?P<para>\n[ \t]*\n)"
+ r"|(?P<open>[“«])"
+ r"|(?P<close>[”»])"
+ r"|(?P<straight>\")")
- Splits on sentence boundaries. Sentences longer than the limit are
- split further at clause punctuation (which is kept attached for TTS
- prosody). Clause splits only happen at whitespace after punctuation,
- so tokens like "1,000,000" or "12:30" are never broken apart. A piece
- with no usable punctuation split point longer than the limit is split
- at word boundaries as a last resort: individual tokens stay intact,
- but whitespace between them is normalized.
+_PARA_AFTER = re.compile(r"[ \t\r]*\n[ \t]*\n")
+
+# Scripts whose characters are not whitespace-delimited: count each one
+# as a word so a long CJK passage is bounded like English text of the
+# same spoken length instead of reading as one giant "word".
+_NON_SPACED_RUN = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff"
+ r"\uf900-\ufaff]")
+
+
+class _Unit:
+ """A sentence-sized piece of text plus its packing metadata.
+
+ ``in_quote_end`` is the quotation state after the unit's terminator
+ (double-quote open marks and closes are tracked; apostrophes do not
+ count as quotes), and ``para_after`` marks a unit that a blank line
+ follows.
"""
- if max_words is None:
- max_words = config.CHUNK_SIZE
- if max_words < 1:
- max_words = 1
- if not text.strip():
- return []
+ __slots__ = ("text", "words", "para_after", "in_quote_end")
+
+ def __init__(self, text: str, words: int, para_after: bool,
+ in_quote_end: bool):
+ self.text = text
+ self.words = words
+ self.para_after = para_after
+ self.in_quote_end = in_quote_end
+
+
+def _count_words(text: str) -> int:
+ """Words in TEXT; non-spaced-script characters each count as one."""
+ return len(text.split()) + len(_NON_SPACED_RUN.findall(text))
+
+
+def _is_abbreviation(text: str, dot: int) -> bool:
+ """True when the '.' before which a sentence split is being
+ considered belongs to an abbreviation or a name initial (Mr. St.
+ U.S. J.), where a break would fall mid-sentence."""
+ start = dot
+ while start > 0 and (text[start - 1].isalnum()
+ or text[start - 1] in ".-'’"):
+ start -= 1
+ token = text[start:dot]
+ if not token:
+ return False
+ tail = token.split(".")[-1]
+ if len(tail) == 1 and tail.isalpha():
+ return True
+ return token.lower() in _ABBREVIATIONS
+
+
+def _sentence_continues(text: str, end: int, attach: bool) -> bool:
+ """True when the text after a terminator belongs to the same unit.
+
+ Only a closing quote or an ellipsis can pull a lowercase word into
+ the unit (dialogue tags: ``“Come here!” she said.``); a plain
+ period always ends the sentence, whatever follows.
+ """
+ match = re.compile(r"\S").search(text, end)
+ if match is None:
+ return False
+ return attach and match.group().islower()
+
+
+def _scan_units(text: str) -> List[_Unit]:
+ """Split TEXT into sentence units, tracking quotes and paragraphs.
+
+ Splits at sentence terminators (.!?…) plus any closing quotes or
+ brackets that trail them, suppressing false boundaries for
+ abbreviations, initials, and lowercase continuations. Blank lines
+ close a paragraph (and reset the quotation state, so one unclosed
+ quote cannot silence clean boundaries for the rest of a book).
+ Non-whitespace content is preserved; only whitespace between
+ boundaries may be normalized.
+ """
+ units: List[_Unit] = []
+ seg_start = 0
+ in_quote = False
+ depth = 0
+
+ def emit(end: int, para_after: bool) -> None:
+ nonlocal seg_start
+ segment = text[seg_start:end]
+ seg_start = end
+ if not segment.strip():
+ return
+ units.append(_Unit(segment, _count_words(segment), para_after,
+ in_quote or depth > 0))
+
+ for match in _EVENT.finditer(text):
+ kind = match.lastgroup
+ if kind == "end":
+ run = match.group()
+ for ch in run[1:]:
+ if ch == '"':
+ in_quote = not in_quote
+ elif ch in "”»":
+ depth = max(0, depth - 1)
+ end = match.end()
+ nxt = text[end:end + 1]
+ if nxt and not nxt.isspace() \
+ and not _NON_SPACED_RUN.match(nxt):
+ continue # glued punctuation: "3.14" is not a boundary
+ if (run[0] == "." and _is_abbreviation(text, match.start())) \
+ or _sentence_continues(text, end, len(run) > 1
+ or run[0] == "…"):
+ continue
+ emit(end, _PARA_AFTER.match(text, end) is not None)
+ elif kind == "para":
+ emit(match.start(), True)
+ in_quote = False
+ depth = 0
+ elif kind == "open":
+ depth += 1
+ elif kind == "close":
+ depth = max(0, depth - 1)
+ elif kind == "straight":
+ in_quote = not in_quote
+
+ emit(len(text), False)
+ return units
+
+
+def _join(units: List[_Unit]) -> str:
+ """Join units into one chunk, keeping paragraph breaks inside it."""
+ parts: List[str] = []
+ previous = None
+ for unit in units:
+ text = unit.text.strip()
+ if not text:
+ continue
+ if parts:
+ parts.append("\n\n" if previous.para_after else " ")
+ parts.append(text)
+ previous = unit
+ return "".join(parts)
+
+
+def _retreat_point(cur: List[_Unit], floor: int) -> int:
+ """Index in CUR to break at when the next unit will not fit.
+
+ Picks the largest prefix of at least FLOOR words that ends outside
+ a quotation; failing that, the largest prefix of at least FLOOR
+ words that ends a paragraph; failing both, the whole prefix (the
+ chunk keeps as much as fits and carries the open quotation into the
+ next chunk).
+ """
+ best_clean = 0
+ best_para = 0
+ words = 0
+ for k, unit in enumerate(cur, 1):
+ words += unit.words
+ if words < floor:
+ continue
+ if not unit.in_quote_end:
+ best_clean = k
+ elif unit.para_after:
+ best_para = k
+ return best_clean or best_para or len(cur)
+
+
+def _split_oversized(text: str, max_words: int) -> List[str]:
+ """Break a sentence longer than the limit into packable pieces.
+
+ First at clause punctuation (kept attached for TTS prosody, and
+ never at commas without whitespace so tokens like "1,000,000" and
+ "12:30" survive), then at word boundaries. A token with no
+ whitespace at all is sliced at the hard character cap and the word
+ limit so a punctuation-free run cannot exceed either bound.
+ """
+ parts = re.split(r"(?:(?<=[,;:])\s+|(?<=[,、;:]))", text)
+ pieces: List[str] = []
+ cap = max_words * _MAX_CHARS_PER_WORD
+ for part in parts:
+ part = part.strip()
+ if not part:
+ continue
+ if len(part) <= cap and _count_words(part) <= max_words:
+ pieces.append(part)
+ continue
+ pieces.extend(_hard_slices(part, max_words, cap))
+ return pieces
+
+def _slice_token(word: str, max_words: int, cap: int) -> List[str]:
+ """Slice a single whitespace-free token into packable pieces.
+
+ Cuts before the character cap is exceeded and before the word count
+ is exceeded: each non-spaced-script character counts as one word,
+ so a punctuation-free CJK token is bounded by the word limit too,
+ not only by the character cap.
+ """
+ slices: List[str] = []
+ cur: List[str] = []
+ cur_chars = 0
+ # Any slice is itself one whitespace-delimited word before its
+ # non-spaced-script characters are counted on top.
+ cur_words = 1
+ for ch in word:
+ ch_words = 1 if _NON_SPACED_RUN.match(ch) else 0
+ if cur and (cur_chars + 1 > cap or cur_words + ch_words > max_words):
+ slices.append("".join(cur))
+ cur, cur_chars, cur_words = [], 0, 1
+ cur.append(ch)
+ cur_chars += 1
+ cur_words += ch_words
+ if cur:
+ slices.append("".join(cur))
+ return slices
+
+
+def _hard_slices(text: str, max_words: int, cap: int) -> List[str]:
+ """Slice TEXT at word boundaries, then within tokens when a single
+ token (no whitespace to break at) exceeds the character cap or the
+ word limit on its own. Buffers never exceed the word limit or the
+ character cap, so every piece is packable."""
+ pieces: List[str] = []
+ buf: List[str] = []
+ buf_words = 0
+ buf_chars = 0
+ for word in text.split():
+ if len(word) > cap or _count_words(word) > max_words:
+ # A single token too big to buffer: slice it at the hard
+ # character cap and the word limit together.
+ if buf:
+ pieces.append(" ".join(buf))
+ buf, buf_words, buf_chars = [], 0, 0
+ pieces.extend(_slice_token(word, max_words, cap))
+ continue
+ words = _count_words(word)
+ if buf and buf_words + words <= max_words \
+ and buf_chars + 1 + len(word) <= cap:
+ buf.append(word)
+ buf_words += words
+ buf_chars += 1 + len(word)
+ else:
+ if buf:
+ pieces.append(" ".join(buf))
+ buf, buf_words, buf_chars = [word], words, len(word)
+ if buf:
+ pieces.append(" ".join(buf))
+ return pieces
+
+
+def _joined_chars(units: List[_Unit]) -> int:
+ """The character length of the text _join would produce."""
+ total = 0
+ previous = None
+ for unit in units:
+ text = unit.text.strip()
+ if not text:
+ continue
+ if previous is not None:
+ total += 2 if previous.para_after else 1
+ total += len(text)
+ previous = unit
+ return total
+
+
+def _quote_states(pieces: List[str], in_quote_end: bool) -> List[bool]:
+ """The quote state after each piece of an oversized sentence.
+
+ Pieces of one sentence can sit inside a quotation the sentence does
+ not close, so ending a chunk at such a piece would sever the quote
+ (and marking them clean lets the target flush cut mid-quote). The
+ scanner's quote tracking is replayed over the pieces from the state
+ the sentence started in; that start state is unknown, so both
+ possibilities are replayed and the one whose end state matches the
+ unit's known end state wins (a unit rarely starts mid-quote). When
+ neither matches, every piece takes the unit's conservative end
+ state.
+ """
+ def simulate(in_quote: bool, depth: int) -> List[bool]:
+ states: List[bool] = []
+ for piece in pieces:
+ for ch in piece:
+ if ch == '"':
+ in_quote = not in_quote
+ elif ch in "“«":
+ depth += 1
+ elif ch in "”»":
+ depth = max(0, depth - 1)
+ states.append(in_quote or depth > 0)
+ return states
+
+ states = simulate(False, 0)
+ if states and states[-1] == in_quote_end:
+ return states
+ states = simulate(True, 0)
+ if states and states[-1] == in_quote_end:
+ return states
+ return [in_quote_end] * len(pieces)
+
+
+def _split_smart(text: str, max_words: int) -> List[str]:
+ """Smart strategy: fill chunks up to MAX_WORDS at sentence
+ boundaries, end them at the target once a quotation has closed or
+ a paragraph ends, and retreat cleanly instead of breaking quotes
+ when the next sentence does not fit. Every chunk also respects the
+ hard character cap, so punctuation-free or non-spaced input stays
+ bounded."""
+ target = max(1, round(max_words * _TARGET_RATIO))
+ floor = max(1, round(max_words * _FLOOR_RATIO))
+ char_cap = max_words * _MAX_CHARS_PER_WORD
+ chunks: List[str] = []
+ cur: List[_Unit] = []
+ cur_words = 0
+ cur_chars = 0
+
+ def flush() -> None:
+ nonlocal cur, cur_words, cur_chars
+ if cur:
+ chunks.append(_join(cur))
+ cur, cur_words, cur_chars = [], 0, 0
+
+ for unit in _scan_units(text):
+ if cur and cur_words >= target \
+ and (not cur[-1].in_quote_end or cur[-1].para_after):
+ flush()
+ if unit.words <= max_words \
+ and len(unit.text.strip()) <= char_cap \
+ and _joined_chars(cur + [unit]) <= char_cap:
+ if cur_words + unit.words <= max_words:
+ cur.append(unit)
+ cur_words += unit.words
+ cur_chars = _joined_chars(cur)
+ continue
+ # The sentence does not fit: break earlier if a clean
+ # boundary keeps the chunk above the floor.
+ point = _retreat_point(cur, floor)
+ if point < len(cur):
+ chunks.append(_join(cur[:point]))
+ cur = cur[point:]
+ cur_words = sum(u.words for u in cur)
+ cur_chars = _joined_chars(cur)
+ if cur_words + unit.words <= max_words:
+ cur.append(unit)
+ cur_words += unit.words
+ cur_chars = _joined_chars(cur)
+ continue
+ flush()
+ cur.append(unit)
+ cur_words = unit.words
+ cur_chars = _joined_chars(cur)
+ continue
+ # One piece exceeds the limit itself: send its pieces out
+ # packed as tightly as the pieces allow, carrying the
+ # sentence's quotation state through the fallback so a chunk
+ # can end at a clean in-sentence boundary — and a piece still
+ # inside a quote is never mistaken for one.
+ flush()
+ piece_texts = _split_oversized(unit.text, max_words)
+ quote_states = _quote_states(piece_texts, unit.in_quote_end)
+ for index, piece_text in enumerate(piece_texts):
+ if _count_words(piece_text) > max_words \
+ or len(piece_text) > char_cap:
+ # A character-sliced piece of degenerate input: its own
+ # chunk (already at the hard cap).
+ flush()
+ chunks.append(piece_text)
+ continue
+ piece = _Unit(
+ piece_text, _count_words(piece_text),
+ unit.para_after and index == len(piece_texts) - 1,
+ quote_states[index])
+ if cur and cur_words + piece.words <= max_words \
+ and cur_chars + 1 + len(piece_text) <= char_cap:
+ cur.append(piece)
+ cur_words += piece.words
+ cur_chars += 1 + len(piece_text)
+ continue
+ flush()
+ cur.append(piece)
+ cur_words = piece.words
+ cur_chars = len(piece_text)
+ flush()
+ return [chunk for chunk in chunks if chunk.strip()]
+
+
+def _split_legacy(text: str, max_words: int) -> List[str]:
+ """The pre-smart splitter, kept intact for the Smart chunking=off
+ setting. Chunks pack whole sentences up to MAX_WORDS; sentences
+ longer than the limit split at clause punctuation, then at word
+ boundaries."""
sentences = re.split(r"(?<=[.!?])\s+", text)
chunks = []
current_chunk = ""
@@ -89,3 +476,49 @@ def split_into_chunks(text: str, max_words: Optional[int] = None) -> List[str]:
chunks.append(current_chunk.strip())
return [chunk for chunk in chunks if chunk.strip()]
+
+
+def split_into_chunks(text: str, max_words: Optional[int] = None,
+ smart: Optional[bool] = None) -> List[str]:
+ """Split text into chunks of at most ``max_words`` words.
+
+ ``max_words`` defaults to ``config.CHUNK_SIZE`` (read at call time).
+ There is no ceiling beyond that setting, but note that the TTS
+ servers silently truncate audio when a single generation runs too
+ long without reporting an error, so very large values are at your
+ own risk (see CHUNK_SIZE in app/converter/config.py).
+
+ With smart chunking on (``config.SMART_CHUNKING``, the default), a
+ chunk crossing the target (~85% of the limit) ends at the next
+ sentence end outside a quotation or at a paragraph break, so most
+ requests stop at natural boundaries instead of pressing against the
+ limit. When the next sentence will not fit, the break retreats to
+ the last boundary that ends outside a quotation (never smaller than
+ ~70% of the limit), so short quotations stay in one request when
+ possible; very long or unclosed quotes still split, carrying their
+ open state into the next chunk. Malformed punctuation never grows a
+ chunk beyond the limit: every break consumes text, and input without
+ sentence punctuation or whitespace (some CJK text, extraction
+ damage) falls back to clause punctuation, then word boundaries,
+ then a hard character cap. Content is preserved: no closing quote
+ or punctuation is ever invented or dropped.
+
+ Both strategies split on sentence boundaries. Sentences longer than
+ the limit are split further at clause punctuation (which is kept
+ attached for TTS prosody). Clause splits only happen at whitespace
+ after punctuation, so tokens like "1,000,000" or "12:30" are never
+ broken apart.
+ """
+ if max_words is None:
+ max_words = config.CHUNK_SIZE
+ if max_words < 1:
+ max_words = 1
+
+ if not text.strip():
+ return []
+
+ if smart is None:
+ smart = bool(getattr(config, "SMART_CHUNKING", False))
+ if smart:
+ return _split_smart(text, max_words)
+ return _split_legacy(text, max_words)
diff --git a/app/converter/clients/__init__.py b/app/converter/clients/__init__.py
index 5f38786..44f423b 100644
--- a/app/converter/clients/__init__.py
+++ b/app/converter/clients/__init__.py
@@ -47,6 +47,7 @@ from .audiocpp import (
AudioCppFamilyProfile,
AudioCppTTSClient,
audiocpp_entry_supports_design,
+ audiocpp_entry_supports_instructions,
audiocpp_entry_voice_capability,
audiocpp_family_narrates,
audiocpp_family_spec_tasks,
@@ -89,6 +90,7 @@ __all__ = [
"AudioCppFamilyProfile", "AUDIOCPP_DEFAULT_FAMILY_PROFILE",
"AUDIOCPP_FAMILY_PROFILES", "audiocpp_entry_voice_capability",
"audiocpp_entry_supports_design", "audiocpp_voice_for_run",
+ "audiocpp_entry_supports_instructions",
"audiocpp_family_narrates",
"audiocpp_family_spec_tasks", "audiocpp_family_voice_policy",
"audiocpp_request_error", "audiocpp_script_input",
diff --git a/app/converter/clients/audiocpp.py b/app/converter/clients/audiocpp.py
index 578a13b..9f52c40 100644
--- a/app/converter/clients/audiocpp.py
+++ b/app/converter/clients/audiocpp.py
@@ -62,10 +62,14 @@ AUDIOCPP_VOICE_DESIGN = "design" # voice described by --instructions (vdes)
# 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).
+# endpoint example send it there). audio.cpp's adapter translates the
+# top-level "instructions" field into that same "instruction" request
+# option for every family, so both channels reach the model.
AUDIOCPP_INSTRUCTION_FIELD = "field"
AUDIOCPP_INSTRUCTION_OPTION = "option"
+AUDIOCPP_FAMILY_BREEZE_TTS = "breeze_tts"
+
# 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.
@@ -76,6 +80,10 @@ AUDIOCPP_NON_RETRYABLE_ERRORS = (
# Cloning without the reference transcript (Qwen3-TTS Base ICL mode):
# the server-side voice has reference audio but no transcript for it.
"requires reference text",
+ # A CustomVoice entry given a voice preset that resolves to reference
+ # audio rather than a built-in speaker id: the model takes built-in
+ # speakers only.
+ "custom voice prefill requires speaker",
# The server cannot resolve a model contract for the family (its own
# hint text about model_specs/--model-spec-override follows the fragment).
"model contract spec not found for family",
@@ -168,6 +176,15 @@ AUDIOCPP_HINTED_ERRORS = (
"task and cannot generate audiobooks. Consider deleting the model "
"from the server configuration (re-run Configure Backends → "
"audio.cpp and unselect it)."),
+ # A CustomVoice entry whose selected server voice resolved to
+ # reference audio rather than a built-in speaker id (the flat voice
+ # list cannot distinguish them, so the connect-time check only
+ # warns): the model cannot clone reference audio.
+ ("custom voice prefill requires speaker",
+ "The CustomVoice model serves built-in speakers only: pick a "
+ "built-in speaker named on the entry (e.g. Vivian, Ryan, Uncle Fu) "
+ "with --voice, or select the Base model entry to clone a reference "
+ "voice."),
# Graph allocation failures: mostly device memory. The server's log
# carries the exact size the failed allocation attempted.
("failed to allocate", _ALLOCATION_HINT_TEXT()),
@@ -323,6 +340,11 @@ def audiocpp_family_voice_policy(family: str) -> str:
timbre reference despite the spec not declaring a clone task (Vevo2's
zero-shot TTS route). Unknown families (no local specs) keep the
conservative clone-only default the client has always applied.
+
+ _AUDIOCPP_KNOWN_FAMILY_POLICIES carries verified policies for
+ *specific* families, consulted before the local spec lookup: a family
+ whose local spec is absent (older audio.cpp checkout against a newer
+ server) or incomplete must not be misread as reference-required.
"""
if family == AUDIOCPP_FAMILY_QWEN3_TTS \
or family in AUDIOCPP_CLONE_ONLY_FAMILIES \
@@ -330,6 +352,8 @@ def audiocpp_family_voice_policy(family: str) -> str:
# Qwen3-TTS is entry-typed (speaker/clone/design capability per
# model id), so the family policy stays out of its way.
return AUDIOCPP_VOICE_REQUIRED
+ if family in _AUDIOCPP_KNOWN_FAMILY_POLICIES:
+ return _AUDIOCPP_KNOWN_FAMILY_POLICIES[family]
tasks = audiocpp_family_spec_tasks(family)
if not tasks:
return AUDIOCPP_VOICE_REQUIRED
@@ -585,6 +609,66 @@ AUDIOCPP_FAMILY_PROFILES = {
instruction_channel=AUDIOCPP_INSTRUCTION_OPTION),
}
+# Voice-policy overrides for families whose evidence contradicts their
+# local model spec (or whose spec predates audio.cpp's task declarations).
+# Kept as data so the spec lookup can stay the single generic path.
+_AUDIOCPP_KNOWN_FAMILY_POLICIES = {
+ # BreezeTTS 2 (upstream spec / implementation: tts, clone ("voice
+ # direction"), design). Older local checkouts carry no breeze_tts
+ # spec at all — without this fallback a remote Breeze entry reads as
+ # clone-only and an instructions-only run (voice direction without a
+ # reference) is refused at connect time.
+ AUDIOCPP_FAMILY_BREEZE_TTS: AUDIOCPP_VOICE_OPTIONAL,
+ # VibeVoice's spec declares only "tts", but its implementation
+ # explicitly accepts reference audio (the session uses a speaker
+ # reference when the request carries one): mixed tts+clone. The
+ # pick-falls-back rules then stop silently dropping the selected
+ # voice for it.
+ "vibevoice": AUDIOCPP_VOICE_OPTIONAL,
+}
+
+
+def audiocpp_entry_supports_instructions(family: str, task: str,
+ model_id: str) -> Optional[bool]:
+ """Whether an entry's model consumes a style instruction, if known.
+
+ Three-valued on purpose: True (the model's implementation reads the
+ instruction), False (proven not to), None (unknown — a spec the local
+ checkout does not describe or one without instruction markers).
+ Distinguishing unknown from unsupported keeps the TUI behind honest
+ wording instead of implying every family follows instructions.
+
+ Verified cases:
+ - task "vdes" entries: the voice comes from the instruction.
+ - BreezeTTS 2: the instruction conditions synthesis with or without a
+ reference (voice design / voice direction).
+ - Qwen3-TTS: the CustomVoice and VoiceDesign implementations read
+ "instruction"; the Base (cloning) implementation does not — a
+ variant-specific split, which is why Qwen is resolved per entry.
+ - Other families: True only when their local model spec declares an
+ "instruction"/"instruct" request option; None otherwise.
+ """
+ if task == AUDIOCPP_TASK_VDES:
+ return True
+ if family == AUDIOCPP_FAMILY_BREEZE_TTS:
+ return True
+ if family == AUDIOCPP_FAMILY_QWEN3_TTS:
+ lowered = (model_id or "").lower()
+ if "customvoice" in lowered:
+ return True
+ if "base" in lowered:
+ return False
+ return None
+ spec = _family_spec(family)
+ if spec is None:
+ return None
+ names = {str(option.get("name") or "")
+ for option in (spec.get("request_options") or [])
+ if isinstance(option, dict)}
+ if names & {"instruction", "instruct"}:
+ return True
+ return None
+
def audiocpp_script_input(prefix: str, text: str) -> str:
"""TEXT formatted as one "<PREFIX>: text" script line.
@@ -758,6 +842,15 @@ class AudioCppTTSClient(BaseTTSClient):
# Free-form per-request options (--option KEY=VALUE) forwarded in the
# request's "options" object; models ignore keys they don't know.
self.request_options: Dict[str, str] = dict(request_options or {})
+ # The instruction can also arrive as --option instruction=... The
+ # server folds both sources into one request option (the
+ # top-level field overwrites the option), so two *different*
+ # instructions would silently drop one — refuse instead, before a
+ # server is even contacted.
+ self._check_instruction_conflict()
+ # A guidance value recommended for instructed requests on specific
+ # families (resolved in _connect; None = no automatic guidance).
+ self._auto_guidance_scale: Optional[float] = None
# Set during _connect: design_mode for "vdes" entries, instruction_voice
# when a family without built-in speakers gets its voice from the
# instruction alone (no voice field), and plain_mode for plain-TTS
@@ -778,6 +871,51 @@ class AudioCppTTSClient(BaseTTSClient):
# Connection
# ------------------------------------------------------------------
+ def _resolve_auto_guidance(self) -> None:
+ """Select the request-strengthening guidance for instructed runs.
+
+ Breeze-TTS 2's model card recommends guidance scale 4 to
+ strengthen instruction-following (its own SDK examples carry
+ --cfg-scale 4 for voice direction and design); audio.cpp's
+ default is 1.0, which leaves the clone's reference delivery
+ dominant. When this run carries an instruction (the field or the
+ request option) and the user did not set their own guidance
+ value, the request gets the recommended one.
+ """
+ if self.family == AUDIOCPP_FAMILY_BREEZE_TTS \
+ and self.instructions \
+ and "guidance_scale" not in (getattr(
+ self, "request_options", {}) or {}):
+ self._auto_guidance_scale = 4.0
+ self._report("[INFO] Sending guidance_scale 4 with the "
+ "instruction (strengthens Breeze instruction "
+ "following; set --option guidance_scale=... to "
+ "override)")
+
+ def _check_instruction_conflict(self) -> None:
+ """Refuse two different instructions given at once.
+
+ The server normalizes both the top-level "instructions" field and
+ the "instruction" request option into the same request option,
+ with the field winning — so "Instructions: calm narration" plus
+ "--option instruction=screaming" would silently drop the field.
+ Identical values are allowed (same effective instruction).
+ """
+ option_instruction = (self.request_options.get("instruction")
+ or "").strip()
+ if self.instructions and option_instruction \
+ and self.instructions != option_instruction:
+ raise RuntimeError(
+ "Two conflicting instructions were given: the Instructions "
+ f"text ({self.instructions!r}) and the request option "
+ f"instruction={option_instruction!r}. Keep one source only: "
+ "an --option instruction=... overrides --instructions "
+ "silently, so use whichever you intend.")
+ if not self.instructions and option_instruction:
+ # Make the forwarded option visible where --instructions
+ # would be reported, for the run log's completeness.
+ self.instructions = option_instruction
+
def _connected(self, mode: str) -> None:
"""Report the resolved connection (MODE: speaker/voice/... label)."""
self._report(f"[OK] Connected to audio.cpp server at {self.api_url} "
@@ -833,6 +971,17 @@ class AudioCppTTSClient(BaseTTSClient):
f"--voice cannot be used with the voice design model "
f"'{self.model_id}': the voice is described by the "
"--instructions text instead (see README).")
+ if audiocpp_entry_voice_capability(
+ self.family, self.task, self.model_id) \
+ == AUDIOCPP_VOICE_SPEAKER \
+ and not is_builtin_speaker(self.voice):
+ self._report(
+ "[WARNING] The selected CustomVoice entry serves "
+ f"built-in speakers, and '{self.voice}' is not one "
+ "of them: unless this server preset maps to a "
+ "built-in speaker id, synthesis will fail with "
+ "'custom voice prefill requires speaker'. To clone "
+ "reference audio, select the Base model entry.")
self._check_voice()
self._connected(f"voice '{self.voice}'")
else:
@@ -892,6 +1041,21 @@ class AudioCppTTSClient(BaseTTSClient):
self._report(f"[INFO] Sending instruction with every request: {self.instructions}")
self._report("[INFO] Its effect (style, emotion, delivery) depends on the "
"model family; models without instruction support ignore it.")
+ # Breeze-TTS 2's model card recommends guidance scale 4 to
+ # strengthen instruction-following (its SDK default --cfg-scale
+ # 4 for voice direction); audio.cpp's default is 1.0, which
+ # leaves the clone's reference delivery dominant. When the run
+ # carries an instruction and the user did not set their own
+ # guidance value, send the recommended one.
+ self._resolve_auto_guidance()
+ logger.info(
+ "audio.cpp run settings: model=%s family=%s task=%s voice=%r "
+ "instruction=%r request_options=%s auto_guidance_scale=%s seed=%r",
+ self.model_id, self.family or "<unresolved>", self.task,
+ self.voice, self.instructions or None,
+ getattr(self, "request_options", {}) or {},
+ getattr(self, "_auto_guidance_scale", None),
+ None if self._seed < 0 else self._seed)
unload = (config.AUDIOCPP_UNLOAD_MODELS
if self._unload_models_override is None
else self._unload_models_override)
@@ -1210,11 +1374,21 @@ class AudioCppTTSClient(BaseTTSClient):
if self._seed >= 0:
# audio.cpp has no negative "randomize" seed; a negative seed
# means "let the server randomize", so the field is omitted.
- payload["seed"] = self._seed
+ # Above 2^53 a JSON number loses precision, so the full-range
+ # seeds audio.cpp documents (send uint64 as a decimal string)
+ # are sent as strings.
+ if self._seed > 2 ** 53:
+ payload["seed"] = str(self._seed)
+ else:
+ payload["seed"] = self._seed
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 getattr(self, "_auto_guidance_scale", None) is not None:
+ # Instructed Breeze request without an explicit guidance
+ # value: the model card's recommended instruction strength.
+ payload["guidance_scale"] = self._auto_guidance_scale
if self.instructions:
# Explicit voice-design or style instruction (required for task
# "vdes" entries; a voice/style control on families that read
diff --git a/app/converter/clients/base.py b/app/converter/clients/base.py
index 9cf6979..7e9d919 100644
--- a/app/converter/clients/base.py
+++ b/app/converter/clients/base.py
@@ -133,6 +133,11 @@ class BaseTTSClient:
try:
result = self.generate_chunk(text, chunk_num)
if result and Path(result).exists():
+ # A cancel that arrived while this request was in
+ # flight must still stop the run: the generated audio
+ # is scratch (cleaned per book) and must not be
+ # returned for assembly.
+ self._check_cancelled()
return Path(result)
logger.warning("Chunk %d attempt %d failed", chunk_num, attempt + 1)
except ConversionCancelled:
diff --git a/app/converter/clients/qwen.py b/app/converter/clients/qwen.py
index dd1c8ba..3f4025c 100644
--- a/app/converter/clients/qwen.py
+++ b/app/converter/clients/qwen.py
@@ -268,7 +268,16 @@ class QwenTTSClient(BaseTTSClient):
# ------------------------------------------------------------------
def _generate_custom_voice(self, text: str) -> Tuple:
- """Generate audio using CustomVoice mode with the run's speaker."""
+ """Generate audio using CustomVoice mode with the run's speaker.
+
+ When the endpoint exposes an instruction parameter and the run
+ carries one, it is sent as a delivery/style control (the demo's
+ run_instruct accepts an ``instruct`` argument alongside the
+ speaker; the model voices the text with that delivery instead of
+ the speaker's default). With no instruction the request is
+ unchanged.
+ """
+ instructions = getattr(self, "instructions", "") or ""
custom_api = self._resolve_api_name("/run_instruct", "/run_custom_voice", "/generate_custom_voice")
if custom_api == "/run_instruct":
payload = dict(
@@ -276,6 +285,9 @@ class QwenTTSClient(BaseTTSClient):
lang_disp=self.language,
spk_disp=speaker_display_name_for(self.speaker),
)
+ if instructions \
+ and self._endpoint_accepts_param(custom_api, "instruct"):
+ payload["instruct"] = instructions
else:
payload = dict(
text=text,
@@ -290,6 +302,10 @@ class QwenTTSClient(BaseTTSClient):
if self._endpoint_accepts_param(custom_api, "seed"):
payload["seed"] = self._seed
+ if instructions \
+ and self._endpoint_accepts_param(custom_api, "instruct"):
+ payload["instruct"] = instructions
+
return self.client.predict(**payload, api_name=custom_api)
def _generate_voice_design(self, text: str) -> Tuple:
diff --git a/app/converter/clients/sglomni.py b/app/converter/clients/sglomni.py
index 6b8493b..13b98d7 100644
--- a/app/converter/clients/sglomni.py
+++ b/app/converter/clients/sglomni.py
@@ -175,6 +175,11 @@ class SgOmniTTSClient(BaseTTSClient):
raise RuntimeError(
f"{entry.label} designs the voice from an instruction: "
'pass --instructions "..." describing the voice.')
+ if self.instructions and not entry.supports_instructions:
+ raise RuntimeError(
+ f"{entry.label} does not consume style instructions: the "
+ "server would silently ignore them. Remove --instructions, "
+ "or pick a model that supports them (Qwen3-TTS, MOSS-TTS).")
if entry.capability == "clone" and entry.requires_reference \
and not self.ref_audio:
raise RuntimeError(
@@ -377,6 +382,16 @@ class SgOmniTTSClient(BaseTTSClient):
payload["ref_audio"] = self._ref_audio_value()
if self.ref_text:
payload["ref_text"] = self.ref_text
+ if entry.supports_instructions and self.instructions:
+ # Reference + separate style instruction (Qwen3-TTS Base
+ # instruction conditioning, MOSS v1.5 user message). NOT
+ # the design task_type: the reference stays the voice.
+ payload["instructions"] = self.instructions
+ elif entry.supports_instructions and self.instructions:
+ # Speaker models (Qwen3-TTS CustomVoice) also shape the
+ # delivery with an instruction; unsupported models never get
+ # one (checked at connect).
+ payload["instructions"] = self.instructions
return payload
def _request_wav(self, text: str) -> bytes:
diff --git a/app/converter/config.py b/app/converter/config.py
index da73427..c021375 100644
--- a/app/converter/config.py
+++ b/app/converter/config.py
@@ -10,6 +10,13 @@ HEARTBEAT_INTERVAL_SECONDS = 30 # Print "still working" in console logs every N
# Words per TTS generation request (client-side chunking).
CHUNK_SIZE = 250
+# Chunking strategy. Smart chunking ends each request at the end of a
+# sentence or quotation instead of a mid-sentence overflow: chunks stay
+# at or below CHUNK_SIZE (most land slightly under it, aiming for a
+# natural boundary near 85%) and short quotations are kept in one
+# request when they fit. Turn it off for the exact legacy splitting.
+SMART_CHUNKING = True
+
# Where books are read from and where finished audiobooks are written.
# Relative paths resolve against the project root.
INPUT_DIR = "input"
diff --git a/app/converter/converter.py b/app/converter/converter.py
index 3b878c8..88e17a9 100644
--- a/app/converter/converter.py
+++ b/app/converter/converter.py
@@ -1,7 +1,9 @@
"""Orchestrates book-to-audiobook conversion."""
+import contextlib
import glob
import logging
+import math
import re
import shutil
import sys
@@ -64,6 +66,50 @@ CHUNKS_FOLDER = APP_DIR / "chunks" # Per-chunk scratch audio, cleaned per book
LOGS_FOLDER = logging_kit.LOG_DIR
DEBUG_FOLDER = APP_DIR / "debug" # --debug dumps, kept across runs
+
+@contextlib.contextmanager
+def _conversion_lock():
+ """An exclusive cross-process lock over the shared scratch folder.
+
+ Every converter run from this checkout synthesizes into the same
+ ``app/chunks`` directory (fixed chunk_NNNN names, cleaned per book), so
+ two overlapping runs would delete or overwrite each other's audio.
+ The lock makes a second run fail fast instead. It is an advisory
+ fcntl/msvcrt lock that dies with its process, so a crashed run never
+ leaves a stale lock wedging later starts; platforms without file
+ locking degrade to the old unlocked behavior.
+ """
+ CHUNKS_FOLDER.mkdir(parents=True, exist_ok=True)
+ handle = open(CHUNKS_FOLDER / ".conversion.lock", "w")
+ handle.write("0")
+ handle.flush()
+ try:
+ busy: Optional[OSError] = None
+ try:
+ import fcntl
+ try:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except OSError as exc:
+ busy = exc
+ except ImportError:
+ try:
+ import msvcrt
+ try:
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
+ except OSError as exc:
+ busy = exc
+ except ImportError:
+ logger.warning("No file locking on this platform; the "
+ "conversion lock is not enforced")
+ if busy is not None:
+ raise RuntimeError(
+ "Another conversion is already running from this "
+ "installation: the shared scratch folder (app/chunks) is "
+ "in use. Wait for the run to finish or stop it first.")
+ yield
+ finally:
+ handle.close() # releases the lock
+
# Output containers and supported input formats.
AUDIO_FORMATS = ("mp3", "m4b", "ogg", "flac")
SUPPORTED_FORMATS = [".txt", ".pdf", ".epub"]
@@ -137,9 +183,10 @@ def voice_mode_for(backend: str, voice: Optional[str] = None,
sglomni resolves from the selected model's capability — a design model
takes instructions, a clone-capable model clones when a reference .wav
is given and otherwise synthesizes its default voice, and a
- speaker-capable model takes a preset name; qwen designs with
- instructions, clones only with a reference .wav, and uses a built-in
- speaker otherwise), so the hub can run the pre-flight overwrite checks
+ speaker-capable model takes a preset name; qwen clones with a
+ reference .wav, takes a built-in speaker otherwise (instructions
+ sent as that model's delivery control), and designs with
+ instructions alone), so the hub can run the pre-flight overwrite checks
against exactly the output names the conversion will produce.
"""
if backend == BACKEND_FASTER:
@@ -157,6 +204,17 @@ def voice_mode_for(backend: str, voice: Optional[str] = None,
return VOICE_MODE_CUSTOM
# Unresolved model (the caller resolves it later): the qwen-style
# heuristic is the closest pre-flight approximation.
+ if backend == BACKEND_QWEN:
+ # Mirrors the CLI routing (audiobook.convert): a reference clone
+ # wins, then a built-in speaker (with instructions as that
+ # model's delivery control), then instructions alone (VoiceDesign).
+ if clone:
+ return VOICE_MODE_CLONE
+ if (voice or "").strip():
+ return VOICE_MODE_CUSTOM
+ if (instructions or "").strip():
+ return VOICE_MODE_DESIGN
+ return VOICE_MODE_CUSTOM
if (instructions or "").strip():
return VOICE_MODE_DESIGN
return VOICE_MODE_CLONE if clone else VOICE_MODE_CUSTOM
@@ -239,11 +297,11 @@ def chunk_clamp_needed(entry) -> bool:
def chunk_clamp_message(entry) -> List[str]:
"""The popup text for a chunk_words-capped ENTRY (short lines)."""
return [
- f"{entry.label} can narrate at most ~{entry.chunk_words} words "
- "per request: the server caps",
- "each request's prompt plus generation at a fixed window. "
- "Longer sub-chunks",
- "may cut off mid-sentence.",
+ f"{entry.label} narrates best at ~{entry.chunk_words} words "
+ "per request on this server:",
+ "each request's prompt plus generation must fit a fixed "
+ "window, so longer",
+ "sub-chunks may cut off mid-sentence.",
]
@@ -293,6 +351,9 @@ class AudiobookConverter:
# Class-level default so a partially-constructed instance (tests build
# these with __new__) behaves like a plain console run.
_progress = None
+ # Same for the per-run chunk override: without __init__ there is no
+ # clamp, so chunking follows the config.CHUNK_SIZE setting.
+ chunk_size = None
def __init__(self, voice_mode: str = VOICE_MODE_CUSTOM, voice_clone_ref_audio: Optional[str] = None,
voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False,
@@ -307,8 +368,12 @@ class AudiobookConverter:
unload_models: Optional[bool] = None,
progress: Optional[Callable[[dict], None]] = None,
cancel=None):
- if speed <= 0:
- raise ValueError(f"Speed must be a positive number, got {speed}")
+ # Non-finite speeds pass a bare "> 0" check (and float("inf") is
+ # "positive"): they would loop forever in atempo filter chaining,
+ # so they are rejected here at the boundary.
+ if not math.isfinite(speed) or speed <= 0:
+ raise ValueError(
+ f"Speed must be a finite positive number, got {speed}")
if output_format not in AUDIO_FORMATS:
raise ValueError(f"Unsupported output format: {output_format}")
if backend is None:
@@ -328,6 +393,12 @@ class AudiobookConverter:
self.backend = backend
self.voice = voice
self.debug = bool(debug)
+ # Per-run chunk-word override (the pre-flight popup's clamp for
+ # models whose engine caps one request below a full sub-chunk,
+ # SGLang-Omni's Higgs): caps the chapter chunks themselves so
+ # progress and debug dumps span one generation request each.
+ # None follows the config.CHUNK_SIZE setting.
+ self.chunk_size = chunk_size
# The run's model selection (audio.cpp: a server entry id, sglomni:
# the resolved catalog key, None elsewhere) — the startup banner
# reports it.
@@ -392,10 +463,9 @@ class AudiobookConverter:
"catalog key for --model (see the backend docs).")
model_id = entry.key
self.model_id = model_id
- # CHUNK_SIZE carries a per-run override (the pre-flight chunk
- # popup's clamp for models whose engine caps one request below
- # a full sub-chunk); None follows the config.CHUNK_SIZE setting.
- self.chunk_size = chunk_size
+ # The catalog/request shape is resolved below; the per-run
+ # chunk override (self.chunk_size) was recorded above and
+ # reaches the client here.
self.tts = SgOmniTTSClient(
chunks_dir=CHUNKS_FOLDER, model=model_id, voice=voice,
ref_audio=voice_clone_ref_audio,
@@ -747,6 +817,9 @@ class AudiobookConverter:
return False
output_path = AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}"
+ # Same rule as _convert_text: honor a cancel that arrived during
+ # the final chapter's synthesis instead of assembling anyway.
+ self._check_cancelled()
if not audio.combine_chapters_to_m4b(chapter_files, titles, output_path,
chunks_dir=CHUNKS_FOLDER,
speed=self.speed,
@@ -827,8 +900,14 @@ class AudiobookConverter:
return results
def _chapter_chunks(self, text: str) -> List[str]:
- """Split chapter text into CHUNK_SIZE-word TTS requests."""
- return chunking.split_into_chunks(text)
+ """Split chapter text into word-capped TTS request chunks.
+
+ Each chunk is one progress unit; when the pre-flight popup set
+ a per-run clamp (models whose engine caps one request below
+ CHUNK_SIZE), it caps these chunks too so a chunk is never
+ wider than one generation request.
+ """
+ return chunking.split_into_chunks(text, max_words=self.chunk_size)
def _convert_text(self, text: str, output_path: Path, start_time: float,
speed: Optional[float] = None,
@@ -884,6 +963,9 @@ class AudiobookConverter:
successful_chunks, total_chunks)
return False
+ # A cancel that arrived while the last chunk was generating
+ # must still stop the run here, before any output is produced.
+ self._check_cancelled()
success = audio.combine_chunks(total_chunks, output_path,
chunk_results=results,
chunks_dir=CHUNKS_FOLDER,
@@ -1061,6 +1143,27 @@ class AudiobookConverter:
tag = f"{name_tag}_{narrator_tag}" if name_tag else narrator_tag
names.append((book_file, f"{name}_{tag}"))
+ # The stem-collision suffix above can itself collide with another
+ # book's natural stem ("book.txt" and "book_txt.txt" both produce
+ # "book_txt_<tag>"), so make every planned name unique across the
+ # batch: two books sharing an output name would silently overwrite
+ # each other (ffmpeg writes with -y and both are approved here,
+ # before any output exists). Later occurrences get a numeric
+ # suffix; the list is sorted, so the result is deterministic.
+ used: set = set()
+ unique: List[Tuple[Path, str]] = []
+ for book_file, name in names:
+ if name in used:
+ suffix = 2
+ while f"{name}_{suffix}" in used:
+ suffix += 1
+ print(f"[INFO] Output name {name} is already planned for "
+ f"another book; {book_file.name} becomes {name}_{suffix}")
+ name = f"{name}_{suffix}"
+ used.add(name)
+ unique.append((book_file, name))
+ names = unique
+
# Ask every overwrite question up front, before any conversion
# starts, so the rest of the run is unattended.
planned: List[Tuple[Path, str]] = []
@@ -1110,6 +1213,19 @@ class AudiobookConverter:
self._emit({"kind": "done", "ok": 0, "total": 0})
return True
+ # The scratch folder is shared by every run from this checkout;
+ # hold the cross-process lock for the whole conversion.
+ with _conversion_lock():
+ return self._convert_planned_books(book_files, planned, run_start)
+
+ def _convert_planned_books(self, book_files, planned,
+ run_start: float) -> bool:
+ """Convert PLANNED books sequentially (the body of ``run``).
+
+ BOOK_FILES/PLANNED come from the pre-flight (outer or caller);
+ RUN_START timestamps the console summary. Returns True when every
+ planned book converted.
+ """
self._say(f"[INFO] Converting {len(planned)} of {len(book_files)} book(s)")
# Per-book outcome ({book file name: ok}), published on the
diff --git a/app/converter/extractors.py b/app/converter/extractors.py
index 8b01372..0e30ac1 100644
--- a/app/converter/extractors.py
+++ b/app/converter/extractors.py
@@ -2,11 +2,13 @@
import codecs
import logging
+import posixpath
import re
import zipfile
from html import unescape
from pathlib import Path
-from typing import List, NamedTuple, Optional
+from typing import Dict, List, NamedTuple, Optional
+from urllib.parse import unquote
try:
from bs4 import BeautifulSoup
@@ -216,8 +218,13 @@ def _epub_spine_order(epub_zip: zipfile.ZipFile, html_names: List[str]):
Parses the package OPF (located via META-INF/container.xml, else the
only *.opf member): manifest item id -> href, then the spine's
- idrefs. Returns member paths limited to HTML_NAMES; nav documents
- (``properties`` containing "nav") and non-HTML items are excluded.
+ idrefs. Hrefs are URL-encoded, may carry "./" or "../" segments and
+ XML entities, and are relative to the OPF's folder — they are
+ normalized before matching against the zip's member names, so valid
+ chapters are not silently dropped when their written form differs
+ from the archived path. Returns member paths limited to HTML_NAMES;
+ nav documents (``properties`` containing "nav") and non-HTML items
+ are excluded.
"""
container = "META-INF/container.xml"
opf_name = None
@@ -246,6 +253,25 @@ def _epub_spine_order(epub_zip: zipfile.ZipFile, html_names: List[str]):
base = "/".join(opf_name.split("/")[:-1])
item_tags = re.findall(r"<item\b[^>]*>", opf)
+ # Zip member names indexed under their normalized spellings, so an
+ # exact archive path can be found from any equivalent href form.
+ # (Percent-encoded and plain spellings both map to the same member.)
+ members: Dict[str, str] = {}
+ for name in html_names:
+ members.setdefault(posixpath.normpath(name), name)
+ members.setdefault(posixpath.normpath(unquote(name)), name)
+
+ def member_for_href(href: str) -> Optional[str]:
+ """The zip member an OPF manifest HREF points at (best effort)."""
+ path = unescape(href).strip()
+ path = path.split("#", 1)[0]
+ path = unquote(path).strip()
+ if not path:
+ return None
+ if base:
+ path = f"{base}/{path}"
+ return members.get(posixpath.normpath(path).lstrip("/"))
+
def is_nav(item_tag: str) -> bool:
properties = attr(item_tag, "properties") or ""
return "nav" in properties.split()
@@ -262,10 +288,9 @@ def _epub_spine_order(epub_zip: zipfile.ZipFile, html_names: List[str]):
href = attr(match, "href")
if not href:
continue
- path = (f"{base}/{href}" if base else href)
- path = re.sub(r"#.*$", "", path)
- if path in html_names and path not in order:
- order.append(path)
+ member = member_for_href(href)
+ if member and member not in order:
+ order.append(member)
return order or None
@@ -287,45 +312,82 @@ def _read_epub_manual(file_path: Path):
def clean_text(text: str) -> str:
- """Normalize whitespace and strip standalone page numbers.
+ """Normalize whitespace, strip standalone page numbers, and keep
+ paragraph breaks.
Page numbers are removed only when a short number (up to three digits)
appears alone on its own line (before whitespace collapsing), so inline
numbers like "42 years", "1,000" or "3.5" are preserved, as are
four-digit standalone lines, which are usually years ("1984") or
chapter numbers rather than page numbers.
+
+ Runs of blank lines survive as a single paragraph break (``\\n\\n``) so
+ the smart chunker can still end requests at paragraph boundaries and
+ reset its quotation state there; single line breaks (soft wraps) and
+ all other whitespace collapse to spaces.
"""
if not text:
return ""
# Standalone page numbers (digits alone on a line) must go BEFORE the
- # newline-collapsing step below.
+ # whitespace-collapsing steps below.
text = re.sub(r"(?m)^\s*\d{1,3}\s*$", " ", text)
- text = re.sub(r"\s+", " ", text)
+ # Normalize line breaks so the steps below see plain "\n".
+ text = re.sub(r"\r\n?", "\n", text)
+ # Any run of blank lines becomes exactly one paragraph break.
+ text = re.sub(r"\n[^\S\n]*\n(?:[^\S\n]*\n)*", "\n\n", text)
+ # Remaining single line breaks (soft wraps) become spaces.
+ text = re.sub(r"(?<!\n)\n(?!\n)", " ", text)
+ # Collapse every other whitespace run (including nbsp) to one space.
+ text = re.sub(r"[^\S\n]+", " ", text)
+ # Trim whatever sits against a paragraph break.
+ text = re.sub(r"[^\S\n]*\n\n[^\S\n]*", "\n\n", text)
return text.strip()
+# Block-level elements structure the document into paragraphs and lines:
+# replacing them with a paragraph break keeps the chunker's paragraph
+# boundaries (and headings separated from prose). Inline elements must NOT
+# break words, so they are left for text extraction to unwrap without
+# spaces ("un<em>believ</em>able" stays one word). The lookahead keeps
+# lookalike tags (<parameter>, <preheat>) out of the match.
+_BLOCK_BREAK_RE = re.compile(
+ r"</?(?:address|article|aside|blockquote|body|br|caption|center|col"
+ r"|colgroup|dd|div|dl|dt|fieldset|figcaption|figure|footer|form"
+ r"|h[1-6]|head|header|hr|html|legend|li|main|nav|ol|option|p|pre"
+ r"|section|summary|table|tbody|td|tfoot|th|thead|tr|ul)(?=[\s/>])"
+ r"[^>]*>",
+ re.IGNORECASE)
+
+
def clean_html(html_content: str) -> str:
- """Strip markup, scripts and styles from HTML content."""
+ """Strip markup, scripts and styles from HTML content.
+
+ Block-level tags become paragraph breaks (so the chunker sees the
+ document's paragraphs); inline tags vanish without adding spaces, so
+ no artificial word boundaries are injected.
+ """
if not html_content:
return ""
+ html_content = _BLOCK_BREAK_RE.sub("\n\n", html_content)
+
if BS4_AVAILABLE:
try:
soup = BeautifulSoup(html_content, "html.parser")
for tag in soup(["script", "style"]):
tag.decompose()
- text = soup.get_text(separator=" ")
- return re.sub(r"\s+", " ", text).strip()
+ return clean_text(soup.get_text(separator=""))
except Exception as exc:
logger.debug("BeautifulSoup cleaning failed, falling back to regex: %s", exc)
- # Fallback regex cleaning
+ # Fallback regex cleaning. Block tags are already paragraph breaks;
+ # every other tag (inline) disappears without adding a space, so no
+ # artificial word boundary is injected.
html_content = re.sub(r"<style[^>]*>.*?</style>", "", html_content, flags=re.DOTALL | re.IGNORECASE)
html_content = re.sub(r"<script[^>]*>.*?</script>", "", html_content, flags=re.DOTALL | re.IGNORECASE)
- html_content = re.sub(r"<[^>]+>", " ", html_content)
+ html_content = re.sub(r"<[^>]+>", "", html_content)
html_content = unescape(html_content)
- html_content = re.sub(r"\s+", " ", html_content)
- return html_content.strip()
+ return clean_text(html_content)
def _natural_key(name: str):
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()
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 7a6bfcc..1b6b5bd 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -2294,9 +2294,25 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None,
voice_field["value"] = ""
def instructions_validate(value) -> Optional[str]:
- if model_capability(fields) != "design" or str(value).strip():
+ """Required on design entries (the voice comes from it); an
+ optional delivery/style control on entries that support one."""
+ if str(value or "").strip():
return None
- return "Describe the voice, e.g. 'A warm female narrator'"
+ if model_capability(fields) == "design":
+ return "Describe the voice, e.g. 'A warm female narrator'"
+ return None
+
+ def instructions_help(entry) -> list:
+ if entry.capability == "design":
+ return ["Describe the voice to design, e.g.",
+ '"A warm adult female narrator with a British accent".']
+ return ["Delivery/style instruction (supported by this model), e.g.",
+ '"Speak in a calm, soothing, and happy tone."']
+
+ def instructions_visible(fs) -> bool:
+ entry = model_entry(fs)
+ return (entry.capability == "design"
+ or getattr(entry, "supports_instructions", False))
# The Model picker reads as a table, like the audio.cpp one: pad every
# label to the widest one, then render each entry's capabilities as
@@ -2357,10 +2373,9 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None,
"clone")},
{"key": prefix + "instructions", "label": "Instructions",
"kind": "text", "value": "",
- "help": ["Describe the voice to design, e.g.",
- '"A warm adult female narrator with a British accent".'],
+ "help": lambda fs: instructions_help(model_entry(fs)),
"validate": instructions_validate,
- "visible": lambda fs: model_capability(fs) == "design"},
+ "visible": instructions_visible},
]
def mapper(result) -> Optional[tuple]:
@@ -2380,6 +2395,13 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None,
kwargs["voice"] = pick
else:
kwargs["instructions"] = result[prefix + "instructions"]
+ # Style/delivery instructions forward on every entry that takes
+ # them (design required, supported entries optional — the client
+ # rejects unsupported combinations instead of dropping the text).
+ instructions = ((result.get(prefix + "instructions")
+ or "").strip() or None)
+ if instructions:
+ kwargs["instructions"] = instructions
# The run view's Model/Voice rows: the catalog label, and the
# pick — or the clone reference's file name (the .wav stems,
# like the narrator tags). Design models describe the voice,
@@ -2442,6 +2464,12 @@ def _settings_fields() -> list:
"validate": _validate_language},
{"key": "chunk_size", "label": "Chunk size (words)", "kind": "text",
"value": str(config.CHUNK_SIZE), "validate": _validate_chunk_size},
+ {"key": "smart_chunking", "label": "Smart chunking", "kind": "bool",
+ "value": config.SMART_CHUNKING,
+ "help": ["End each request at the end of a sentence or "
+ "quotation instead of mid-sentence.",
+ "Chunks may be shorter than Chunk size; it stays "
+ "the maximum."]},
{"key": "input_dir", "label": "Input Directory", "kind": "dir",
"value": converter_mod.resolve_dir(config.INPUT_DIR, "input"),
"validate": _validate_dir},
@@ -2622,6 +2650,7 @@ def _apply_settings(values: dict) -> None:
"AUDIO_BITRATE": bitrate,
"LANGUAGE": normalize_language(values["language"]),
"CHUNK_SIZE": chunk_size,
+ "SMART_CHUNKING": bool(values["smart_chunking"]),
"INPUT_DIR": input_dir,
"OUTPUT_DIR": output_dir,
"CLONE_WAV_DIR": clone_dir,