diff options
26 files changed, 2066 insertions, 214 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%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, diff --git a/audiobook.py b/audiobook.py index 302bb17..19f5290 100755 --- a/audiobook.py +++ b/audiobook.py @@ -19,6 +19,7 @@ externally-run server and never touches server state. import argparse import logging +import math import sys import traceback from pathlib import Path @@ -394,9 +395,22 @@ def convert(backend: str, voice: str = None, clone: str = None, print("[INFO] Conversion cancelled; nothing was converted") return 0 else: - # qwen: instructions design the voice (VoiceDesign model), a - # reference .wav clones one (Base), otherwise a built-in speaker. - if (instructions or "").strip(): + # qwen: a built-in speaker (CustomVoice — with the instructions + # text, if given, sent as that model's delivery/style control), a + # reference .wav clone (Base), or instructions alone designing the + # voice (VoiceDesign). Instructions never *switch* the mode: + # speaker+instructions is a directed CustomVoice run, and + # clone+instructions is refused — the Base demo has no + # voice-direction route, so silently redesigning the voice would + # drop the chosen reference. + wants_instructions = bool((instructions or "").strip()) + if clone and wants_instructions: + raise ValueError( + "The qwen-tts Base (voice cloning) demo cannot take style " + "instructions. Drop --instructions, or pick a CustomVoice " + "speaker with --voice (which sends them as delivery " + "control) or the VoiceDesign model (--instructions only).") + if wants_instructions and not (voice or "").strip(): voice_mode = VOICE_MODE_DESIGN else: voice_mode = VOICE_MODE_CLONE if clone else VOICE_MODE_CUSTOM @@ -713,14 +727,15 @@ Examples: args = parser.parse_args() # An explicit --speed overrides the config SPEED setting; either way - # the value must be a positive number. + # the value must be a finite positive number (a bare positivity check + # lets --speed inf / 1e309 through, which would loop forever building + # ffmpeg atempo filters during assembly). speed = args.speed if args.speed is not None else config.SPEED - try: - bad_speed = not isinstance(speed, (int, float)) or speed <= 0 - except TypeError: - bad_speed = True + bad_speed = (not isinstance(speed, (int, float)) + or not math.isfinite(speed) + or speed <= 0) if bad_speed: - parser.error(f"--speed must be a positive number (got {speed!r})") + parser.error(f"--speed must be a finite positive number (got {speed!r})") # The directory flags and the single-book flags are two different # ways to choose what to convert and where it goes; mixing a pair @@ -828,27 +843,36 @@ Examples: if not args.clone and (args.transcription or args.no_transcription): print("[WARNING] --transcription/--no-transcription " "are ignored without --clone") - try: - from backends.sglomni import models as sg_models - installed = sg_models.installed_keys() - except Exception: - installed = None - if installed is not None: - if not installed: - parser.error("--backend sglomni: no models are downloaded " - "— install one via the TUI's Configure " - "Backends → SGLang-Omni first") - if args.model is None: - if len(installed) > 1: - parser.error( - "--backend sglomni requires --model when several " - "models are installed (installed: " - f"{', '.join(installed)})") - args.model = installed[0] - elif sg_models.entry_by_key(args.model) is None: - parser.error(f"--model {args.model!r} is not an " - "SGLang-Omni catalog key (installed: " - f"{', '.join(installed)})") + if args.api_url is not None: + # A remote server brings its own models: local installs are + # irrelevant here, but the catalog key to request must be + # named (the converter has no default for remote runs). + if not args.model: + parser.error("--backend sglomni with --api-url requires " + "--model (a catalog key, e.g. " + "higgs_audio_v3_tts; see the backend docs)") + else: + try: + from backends.sglomni import models as sg_models + installed = sg_models.installed_keys() + except Exception: + installed = None + if installed is not None: + if not installed: + parser.error("--backend sglomni: no models are downloaded " + "— install one via the TUI's Configure " + "Backends → SGLang-Omni first") + if args.model is None: + if len(installed) > 1: + parser.error( + "--backend sglomni requires --model when several " + "models are installed (installed: " + f"{', '.join(installed)})") + args.model = installed[0] + elif sg_models.entry_by_key(args.model) is None: + parser.error(f"--model {args.model!r} is not an " + "SGLang-Omni catalog key (installed: " + f"{', '.join(installed)})") if args.clone and not Path(args.clone).is_file(): parser.error(f"--clone: no such reference audio file: {args.clone}") else: |
