igerry commited on
Commit
f33008e
·
verified ·
1 Parent(s): 9d5395d

Upload comfy/custom_nodes/ComfyUI-IndexTTS2/render_klaus_episode.py with huggingface_hub

Browse files
comfy/custom_nodes/ComfyUI-IndexTTS2/render_klaus_episode.py ADDED
@@ -0,0 +1,505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """render_klaus_episode.py — render a Klaus episode .md into one final audio FLAC.
3
+
4
+ Runs on the server (must have IndexTTS-2 + ffmpeg installed). Pipeline:
5
+ 1. Parse .md: drop H1 heading, strip **emphasis** and non-pause (…) stage cues,
6
+ split on (停顿) into fragments.
7
+ 2. For each fragment: apply klaus_lexicon (v04 tone-only, locked-in profile);
8
+ IndexTTS-2 infer with the slowed Hugh ref → per-fragment .wav.
9
+ 3. ffmpeg concat all fragments with 0.6s silence padding between every two.
10
+ 4. ffmpeg atempo=0.95 post-process → final ~0.95× effective speed on the
11
+ clean Hugh ref (current production). Stacking a pre-slowed atempo=0.85
12
+ ref on top → ~0.81× (opt-in; ref no longer on server — see CLAUDE.md).
13
+
14
+ Usage (on server):
15
+ cd /root/IndexTTS2
16
+ .venv/bin/python render_klaus_episode.py /path/to/script.md /tmp/klaus_ep01_full.flac
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import queue
24
+ import re
25
+ import subprocess
26
+ import sys
27
+ import threading
28
+ import time
29
+ from pathlib import Path
30
+
31
+ sys.path.insert(0, str(Path(__file__).parent))
32
+ import klaus_lexicon # noqa: E402
33
+
34
+ from indextts.infer_v2 import IndexTTS2 # noqa: E402
35
+
36
+ # Locked-in Klaus voice config (tuned 2026-05-03 — see ts-tts-cached-flurry.md plan)
37
+ LEXICON_PROFILE = dict(
38
+ tone_4to1_prob=0.95,
39
+ tone_3to2_prob=0.95,
40
+ retroflex_loss_prob=0.0,
41
+ u_prob=0.0,
42
+ )
43
+ SEED = 42
44
+ PAUSE_SEC = 0.6 # silence between (停顿)-delimited fragments
45
+
46
+ # Dropout guard (2026-05-11 architecture: persistent verifier daemon + per-frag
47
+ # full-scan audit + 1× respace retry).
48
+ # IndexTTS-2's GPT2 occasionally drops a syllable on tightly-packed phrases —
49
+ # ep01 frag 5 lost `sai` in 下场比赛见, classic `任重道远 → 任重远`. After all
50
+ # fragments render, we spawn verify_klaus_dropout.py in --daemon mode (loads
51
+ # whisperx ONCE, ~15s amortized over the whole sweep) and ASR-check every
52
+ # fragment via pinyin-sequence diff (tone-stripped, digit-normalized). Any
53
+ # fragment with a `delete` opcode on its pinyin sequence is a real dropout —
54
+ # we respace-retry once (`任重道远 → 任 重 道 远` forces GPT2 attention
55
+ # boundaries) and re-verify. Persistent failures ship anyway with stderr
56
+ # warning — never blocks episode rendering. The Layer 1 duration check was
57
+ # removed: structurally blind to 1-syllable drops in moderate-length fragments
58
+ # (49-char frag 5 had < 2% duration delta — drowned in baseline noise).
59
+ DEFAULT_VERIFIER_PYTHON = "/root/miniconda3/envs/whisperx/bin/python"
60
+ VERIFIER_SCRIPT = str(Path(__file__).parent / "verify_klaus_dropout.py")
61
+ # Default --speed is the post-process atempo on the master FLAC. 0.95 alone
62
+ # gives ~0.95× effective speed on the clean Hugh ref (current production).
63
+ # Stacking a pre-slowed atempo=0.85 ref on top → ~0.81× (the original
64
+ # locked-in cadence trick; opt-in, slow ref no longer kept on server).
65
+ # Web UI overrides via per-character sidecar (Voices/<char>/.character.json
66
+ # `speed`, default 1.0). Range 0.6–1.5.
67
+ DEFAULT_SPEED = 0.95
68
+
69
+
70
+ def md_to_fragments(md: str) -> list[str]:
71
+ body = "\n".join(ln for ln in md.splitlines() if not ln.lstrip().startswith("#"))
72
+ # 任何含「停顿」的括注都当作完整停顿:(停顿)/(停顿,XXX)/(XXX,停顿)/(A,停顿,B)。
73
+ # 多余表演词随括号被下面的 re.sub(r"([^)]*)", ...) 丢弃。
74
+ raw = re.split(r"\n*\s*([^)]*停顿[^)]*)\s*\n*", body)
75
+ out: list[str] = []
76
+ for s in raw:
77
+ s = re.sub(r"\*\*(.+?)\*\*", r"\1", s)
78
+ s = re.sub(r"([^)]*)", "", s)
79
+ s = re.sub(r"\n{3,}", "\n\n", s).strip()
80
+ if s:
81
+ out.append(s)
82
+ return out
83
+
84
+
85
+ def _ffprobe_duration(path: Path) -> float:
86
+ out = subprocess.check_output(
87
+ ["ffprobe", "-v", "error", "-show_entries", "format=duration",
88
+ "-of", "csv=p=0", str(path)]
89
+ )
90
+ return float(out.decode().strip())
91
+
92
+
93
+ def _respace_han(text: str) -> str:
94
+ """Insert ASCII space between consecutive Han chars to force GPT2 attention
95
+ boundaries (the documented 任重道远 → 任 重 道 远 hard-fix). klaus_lexicon
96
+ is char-by-char and passes spaces through verbatim, so the lexicon profile
97
+ still applies on top."""
98
+ out: list[str] = []
99
+ prev_han = False
100
+ for ch in text:
101
+ is_han = "一" <= ch <= "鿿"
102
+ if is_han and prev_han:
103
+ out.append(" ")
104
+ out.append(ch)
105
+ prev_han = is_han
106
+ return "".join(out)
107
+
108
+
109
+ def _render_one(tts: IndexTTS2, text: str, ref_wav: str, wav_path: Path) -> tuple[str, dict, float]:
110
+ """Single TTS infer; returns (mangled_text, lexicon_stats, elapsed_sec)."""
111
+ mangled, stats = klaus_lexicon.transform(text, seed=SEED, **LEXICON_PROFILE)
112
+ t0 = time.perf_counter()
113
+ tts.infer(spk_audio_prompt=ref_wav, text=mangled, output_path=str(wav_path), verbose=False)
114
+ return mangled, stats, time.perf_counter() - t0
115
+
116
+
117
+ class _VerifierDaemon:
118
+ """Long-running [verify_klaus_dropout.py](IndexTTS2/verify_klaus_dropout.py)
119
+ subprocess in --daemon mode. Spawn once per episode → whisperx loads once
120
+ (~15 s) instead of cold-spawning per call (cold-spawn racing IndexTTS-2
121
+ on the GPU previously hit 180 s timeouts in production). Protocol is
122
+ line-delimited JSON over stdin/stdout.
123
+
124
+ Usage:
125
+ with _VerifierDaemon(py_path, script_path) as v:
126
+ verdict = v.verify(wav_path, expected_text) # returns dict
127
+ """
128
+
129
+ _HANDSHAKE_TIMEOUT = 60.0 # whisperx model load ceiling
130
+ _CALL_TIMEOUT = 60.0 # per-frag ASR inference ceiling
131
+
132
+ def __init__(self, python_path: str, script_path: str):
133
+ self.proc = subprocess.Popen(
134
+ [python_path, script_path, "--daemon"],
135
+ stdin=subprocess.PIPE,
136
+ stdout=subprocess.PIPE,
137
+ stderr=subprocess.DEVNULL, # ML libs are chatty on stderr; we don't surface it
138
+ text=True, bufsize=1,
139
+ encoding="utf-8",
140
+ )
141
+ line = self._readline(self._HANDSHAKE_TIMEOUT)
142
+ if not line:
143
+ raise RuntimeError("verifier daemon exited during startup (no handshake)")
144
+ msg = json.loads(line)
145
+ if msg.get("status") != "ready":
146
+ raise RuntimeError(f"verifier daemon unexpected handshake: {msg}")
147
+
148
+ def _readline(self, timeout: float) -> str:
149
+ # subprocess.Popen.stdout.readline has no timeout; rely on the daemon
150
+ # being responsive (model load already done at handshake time, per-call
151
+ # is just inference). If it ever wedges, the user kills the parent.
152
+ #
153
+ # Defensive: skip lines that aren't JSON objects. whisperx/pyannote
154
+ # historically leaked INFO log lines to stdout despite our basicConfig
155
+ # → we belt+suspender it with _stdout_to_stderr() on the daemon side,
156
+ # and this client-side skip handles any future regression.
157
+ while True:
158
+ line = self.proc.stdout.readline()
159
+ if not line:
160
+ return ""
161
+ stripped = line.strip()
162
+ if stripped.startswith("{"):
163
+ return line
164
+ # else: non-JSON line (log spam), ignore and read next
165
+
166
+ def verify(self, audio_path: Path, expected: str) -> dict:
167
+ req = {"audio": str(audio_path), "expected": expected}
168
+ self.proc.stdin.write(json.dumps(req, ensure_ascii=False) + "\n")
169
+ self.proc.stdin.flush()
170
+ line = self._readline(self._CALL_TIMEOUT)
171
+ if not line:
172
+ raise RuntimeError("verifier daemon closed stdout mid-stream")
173
+ return json.loads(line)
174
+
175
+ def shutdown(self) -> None:
176
+ try:
177
+ if self.proc.stdin and not self.proc.stdin.closed:
178
+ self.proc.stdin.write("QUIT\n")
179
+ self.proc.stdin.flush()
180
+ self.proc.stdin.close()
181
+ self.proc.wait(timeout=10)
182
+ except (subprocess.TimeoutExpired, Exception):
183
+ self.proc.kill()
184
+ try:
185
+ self.proc.wait(timeout=5)
186
+ except subprocess.TimeoutExpired:
187
+ pass
188
+
189
+ def __enter__(self):
190
+ return self
191
+
192
+ def __exit__(self, exc_type, exc, tb):
193
+ self.shutdown()
194
+
195
+
196
+ class _StreamingAuditor:
197
+ """Concurrent daemon + worker thread for streaming per-fragment audit.
198
+
199
+ Lifecycle (2026-05-11 architecture A+B):
200
+ auditor = _StreamingAuditor(py, script)
201
+ ↳ kicks off two threads: daemon loader (~15s whisperx) + queue worker
202
+ ↳ caller continues with IndexTTS-2 load + Phase 1 render in parallel
203
+
204
+ for i, frag in render_loop:
205
+ render_one(...)
206
+ auditor.submit(i, frag, wav_path) # non-blocking; worker picks up
207
+
208
+ auditor.drain() # signal end of submissions, wait for worker
209
+ auditor.results # dict[idx -> verdict]; ready for Phase 2 retries
210
+
211
+ for idx, verdict in auditor.results.items():
212
+ if not verdict["ok"]:
213
+ retry...
214
+ verdict2 = auditor.verify_direct(wav, frag) # main thread only, post-drain
215
+
216
+ auditor.shutdown()
217
+
218
+ Threading model: daemon's stdin/stdout protocol is single-threaded. The
219
+ worker thread owns the daemon during Phase 1 streaming. After `drain()`,
220
+ the worker exits and the main thread takes over for retries via
221
+ `verify_direct()`. No concurrent access ever — no lock needed.
222
+ """
223
+
224
+ def __init__(self, python_path: str, script_path: str):
225
+ self.queue: queue.Queue = queue.Queue()
226
+ self.results: dict[int, dict] = {}
227
+ self.daemon: _VerifierDaemon | None = None
228
+ self.daemon_error: Exception | None = None
229
+ self._daemon_ready = threading.Event()
230
+
231
+ # Thread 1: load whisperx (~15s). Parallel with IndexTTS-2 load
232
+ # happening in the main thread.
233
+ self._loader = threading.Thread(
234
+ target=self._load, args=(python_path, script_path), daemon=True,
235
+ )
236
+ self._loader.start()
237
+
238
+ # Thread 2: worker. Waits for daemon to be ready, then drains queue.
239
+ self._worker = threading.Thread(target=self._run, daemon=True)
240
+ self._worker.start()
241
+
242
+ def _load(self, python_path: str, script_path: str) -> None:
243
+ try:
244
+ self.daemon = _VerifierDaemon(python_path, script_path)
245
+ except Exception as e:
246
+ self.daemon_error = e
247
+ finally:
248
+ self._daemon_ready.set()
249
+
250
+ def _run(self) -> None:
251
+ self._daemon_ready.wait()
252
+ while True:
253
+ item = self.queue.get()
254
+ if item is None:
255
+ break
256
+ if self.daemon is None:
257
+ continue # daemon failed to load — drop the request
258
+ idx, frag, wav = item
259
+ try:
260
+ self.results[idx] = self.daemon.verify(wav, frag)
261
+ except Exception as e:
262
+ print(f" ⚠ audit frag {idx}: daemon error: {e}",
263
+ file=sys.stderr, flush=True)
264
+
265
+ def submit(self, idx: int, frag: str, wav: Path) -> None:
266
+ self.queue.put((idx, frag, wav))
267
+
268
+ def drain(self) -> None:
269
+ """Signal end of submissions and wait for the worker to process them all."""
270
+ self.queue.put(None)
271
+ self._worker.join()
272
+
273
+ def verify_direct(self, wav: Path, frag: str) -> dict | None:
274
+ """Synchronous verify on the daemon (no queue). Only safe AFTER
275
+ `drain()` — used for Phase 2 retries when the worker thread is gone
276
+ and the main thread has exclusive access to the daemon."""
277
+ if self.daemon is None:
278
+ return None
279
+ return self.daemon.verify(wav, frag)
280
+
281
+ def shutdown(self) -> None:
282
+ if self.daemon is not None:
283
+ self.daemon.shutdown()
284
+
285
+
286
+ def _process_audit_results(
287
+ auditor: _StreamingAuditor,
288
+ tts: IndexTTS2,
289
+ fragments: list[str],
290
+ frag_wavs: list[Path],
291
+ ref_wav: str,
292
+ ) -> list[tuple[int, list[str]]]:
293
+ """Iterate the streaming auditor's results; for each fragment flagged DROP,
294
+ respace-retry once and re-verify. Returns list of `(idx_1based, dropped_pinyin)`
295
+ for fragments still failing after retry."""
296
+ failures: list[tuple[int, list[str]]] = []
297
+
298
+ for idx in sorted(auditor.results.keys()):
299
+ verdict = auditor.results[idx]
300
+ if verdict.get("error"):
301
+ print(f" ⚠ frag {idx}: daemon error: {verdict['error']}",
302
+ file=sys.stderr, flush=True)
303
+ continue
304
+ if verdict.get("ok"):
305
+ continue
306
+
307
+ frag = fragments[idx - 1]
308
+ wav_path = frag_wavs[idx - 1]
309
+ dropped = verdict.get("pinyin_dropped", [])
310
+ py_exp = verdict.get("pinyin_expected_count")
311
+ py_got = verdict.get("pinyin_got_count")
312
+ print(f" ⚠ frag {idx}: dropped pinyin [{' '.join(dropped)}] "
313
+ f"(exp={py_exp} got={py_got}) — respace retry", flush=True)
314
+
315
+ respaced = _respace_han(frag)
316
+ _, _, elapsed = _render_one(tts, respaced, ref_wav, wav_path)
317
+ actual_dur = _ffprobe_duration(wav_path)
318
+ print(f" retry: {len(respaced):>4} chars → wav "
319
+ f"({elapsed:.1f}s render, {actual_dur:.2f}s audio)", flush=True)
320
+
321
+ verdict2 = auditor.verify_direct(wav_path, frag)
322
+ if verdict2 is None:
323
+ print(f" ⚠ frag {idx}: re-verify daemon unavailable",
324
+ file=sys.stderr, flush=True)
325
+ continue
326
+ if verdict2.get("ok"):
327
+ print(f" ✓ frag {idx}: retry cleared the dropout", flush=True)
328
+ continue
329
+
330
+ dropped2 = verdict2.get("pinyin_dropped", [])
331
+ print(f" ⚠⚠⚠ frag {idx}: still dropping after retry: [{' '.join(dropped2)}] — shipping anyway",
332
+ file=sys.stderr, flush=True)
333
+ failures.append((idx, dropped2))
334
+
335
+ return failures
336
+
337
+
338
+ def main() -> int:
339
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
340
+ ap.add_argument("script", type=Path, help=".md episode script")
341
+ ap.add_argument("output", type=Path, help="final .flac path")
342
+ ap.add_argument("--workdir", type=Path, default=Path("/tmp/klaus_render"))
343
+ ap.add_argument("--ep-id", default=None,
344
+ help="ep_id used for the segments.json sidecar filename. "
345
+ "Sidecar is written next to <output> as {ep_id}-segments.json. "
346
+ "Skip writing sidecar when omitted.")
347
+ ap.add_argument("--ref-wav", required=True,
348
+ help="voice clone reference path (relative to cwd or absolute). "
349
+ "Web UI persists per-character refs at refs/web/<voice>.flac; "
350
+ "the standalone Klaus pipeline historically used "
351
+ "refs/hugh_184_slow_0.85.flac (Hugh Jackman atempo=0.85). "
352
+ "No default — pass explicitly so the chosen voice is unambiguous.")
353
+ ap.add_argument("--speed", type=float, default=DEFAULT_SPEED,
354
+ help=f"final ffmpeg atempo factor on the master FLAC + scales "
355
+ f"sidecar timings. Default {DEFAULT_SPEED} (Klaus "
356
+ f"production cadence trick); Web UI passes the per-character "
357
+ f"sidecar value (default 1.0). Range 0.6–1.5.")
358
+ ap.add_argument("--no-dropout-guard", action="store_true",
359
+ help="disable the Layer-1 duration check + Layer-2 ASR confirm + "
360
+ "respace retry pass. Default ON; use this only for A/B or when "
361
+ "the WhisperX env on the server is broken and you need to ship.")
362
+ ap.add_argument("--verifier-python", default=DEFAULT_VERIFIER_PYTHON,
363
+ help=f"path to the whisperx conda env's python (it loads whisperx, "
364
+ f"which the IndexTTS-2 venv doesn't have). "
365
+ f"Default {DEFAULT_VERIFIER_PYTHON}.")
366
+ args = ap.parse_args()
367
+ if not (0.6 <= args.speed <= 1.5):
368
+ sys.exit(f"--speed must be in [0.6, 1.5], got {args.speed}")
369
+
370
+ ref_wav = args.ref_wav
371
+ if not Path(ref_wav).exists():
372
+ sys.exit(f"REF missing: {ref_wav} (cwd={Path.cwd()})")
373
+ if not args.script.exists():
374
+ sys.exit(f"script missing: {args.script}")
375
+ args.workdir.mkdir(parents=True, exist_ok=True)
376
+ for old in args.workdir.glob("frag*.wav"):
377
+ old.unlink()
378
+
379
+ fragments = md_to_fragments(args.script.read_text())
380
+ if not fragments:
381
+ sys.exit("no fragments parsed")
382
+ print(f">> {len(fragments)} fragments, lengths: {[len(f) for f in fragments]}", flush=True)
383
+
384
+ # Early-spawn the streaming auditor: daemon's whisperx load (~15s) runs in
385
+ # a background thread, parallel with IndexTTS-2 load (~17s) below. By the
386
+ # time Phase 1 ends, the audit worker has typically consumed the entire
387
+ # queue, leaving Phase 2 with just retries to do.
388
+ enable_guard = not args.no_dropout_guard
389
+ auditor: _StreamingAuditor | None = None
390
+ if enable_guard:
391
+ if not Path(args.verifier_python).exists() or not Path(VERIFIER_SCRIPT).exists():
392
+ print(f">> warning: verifier python ({args.verifier_python}) or script "
393
+ f"({VERIFIER_SCRIPT}) missing — skipping streaming audit",
394
+ file=sys.stderr, flush=True)
395
+ else:
396
+ print(">> spawning verifier daemon (whisperx loads in parallel with IndexTTS-2)…",
397
+ flush=True)
398
+ auditor = _StreamingAuditor(args.verifier_python, VERIFIER_SCRIPT)
399
+
400
+ print(">> loading IndexTTS2…", flush=True)
401
+ t0 = time.perf_counter()
402
+ tts = IndexTTS2(cfg_path="checkpoints/config.yaml", model_dir="checkpoints", use_fp16=True)
403
+ print(f">> loaded in {time.perf_counter()-t0:.1f}s on {tts.device}", flush=True)
404
+
405
+ # Phase 1: render each fragment, submit to streaming audit queue.
406
+ frag_wavs: list[Path] = []
407
+ total_overrides = 0
408
+ total_han = 0
409
+ for i, frag in enumerate(fragments, 1):
410
+ wav_path = args.workdir / f"frag{i:03d}.wav"
411
+ _, stats, elapsed = _render_one(tts, frag, ref_wav, wav_path)
412
+ actual_dur = _ffprobe_duration(wav_path)
413
+ print(f" frag {i:>3}/{len(fragments)}: {len(frag):>4} chars → wav "
414
+ f"({elapsed:.1f}s render, {actual_dur:.2f}s audio)", flush=True)
415
+ total_overrides += stats["overrides_emitted"]
416
+ total_han += stats["total_han"]
417
+ frag_wavs.append(wav_path)
418
+ if auditor is not None:
419
+ auditor.submit(i, frag, wav_path)
420
+
421
+ rate = total_overrides / max(total_han, 1)
422
+ print(f">> total overrides {total_overrides}/{total_han} ({rate:.0%})", flush=True)
423
+
424
+ # Phase 2: drain audit worker (most ASR already done in parallel with
425
+ # Phase 1) → respace-retry any DROP verdicts.
426
+ dropout_failures: list[tuple[int, list[str]]] = []
427
+ if auditor is not None:
428
+ print(">> draining audit worker (ASR ran in parallel with Phase 1)…", flush=True)
429
+ auditor.drain()
430
+ if auditor.daemon_error:
431
+ print(f">> warning: verifier daemon failed to load: {auditor.daemon_error} — "
432
+ f"no audit done", file=sys.stderr, flush=True)
433
+ elif auditor.daemon is None:
434
+ print(">> warning: verifier daemon unavailable — no audit done",
435
+ file=sys.stderr, flush=True)
436
+ else:
437
+ dropout_failures = _process_audit_results(auditor, tts, fragments, frag_wavs, ref_wav)
438
+ auditor.shutdown()
439
+
440
+ if dropout_failures:
441
+ idxs = [i for i, _ in dropout_failures]
442
+ print(f"\n⚠⚠⚠ DROPOUTS PERSISTED in fragments: {idxs}", file=sys.stderr, flush=True)
443
+ for i, dropped in dropout_failures:
444
+ print(f" frag {i}: dropped pinyin = {dropped}", file=sys.stderr, flush=True)
445
+ print(f" The respaced retries are in the final FLAC; play them and decide whether to ship.",
446
+ file=sys.stderr, flush=True)
447
+ print(f" To hard-fix, edit the .md to insert spaces (任 重 道 远) or (停顿) around the "
448
+ f"affected chars and re-render.", file=sys.stderr, flush=True)
449
+
450
+ # ffmpeg pad-concat: every fragment but the last gets apad=PAUSE_SEC silence trailer
451
+ raw_concat = args.workdir / "concat_raw.wav"
452
+ inputs: list[str] = []
453
+ for w in frag_wavs:
454
+ inputs += ["-i", str(w)]
455
+ n = len(frag_wavs)
456
+ if n == 1:
457
+ fc = "[0:a]anull[out]"
458
+ else:
459
+ pads = ";".join(f"[{i}:a]apad=pad_dur={PAUSE_SEC}[a{i}]" for i in range(n - 1))
460
+ chain = "".join(f"[a{i}]" for i in range(n - 1)) + f"[{n-1}:a]"
461
+ fc = f"{pads};{chain}concat=n={n}:v=0:a=1[out]"
462
+ cmd = ["ffmpeg", "-y", *inputs, "-filter_complex", fc, "-map", "[out]", "-c:a", "flac", str(raw_concat)]
463
+ print(f">> ffmpeg concat ({n} frags + {PAUSE_SEC}s pads)…", flush=True)
464
+ subprocess.run(cmd, check=True, capture_output=True)
465
+
466
+ # ffmpeg atempo post-process
467
+ final = args.output.resolve()
468
+ final.parent.mkdir(parents=True, exist_ok=True)
469
+ cmd = ["ffmpeg", "-y", "-i", str(raw_concat), "-filter:a", f"atempo={args.speed}", "-c:a", "flac", str(final)]
470
+ print(f">> ffmpeg atempo={args.speed} → {final}", flush=True)
471
+ subprocess.run(cmd, check=True, capture_output=True)
472
+
473
+ dur = subprocess.check_output(
474
+ ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(final)]
475
+ ).decode().strip()
476
+ print(f">> done: {final} ({dur}s)", flush=True)
477
+
478
+ # Sidecar — consumed by Wan2.2-s2v-infinite/render_episode.py for per-group sub-render.
479
+ # Durations are POST-atempo (master-FLAC timeline), so render_episode's
480
+ # compute_reset_points lands (停顿) boundaries at the right master-t.
481
+ if args.ep_id:
482
+ per_frag_durs: list[float] = []
483
+ for w in frag_wavs:
484
+ d = subprocess.check_output(
485
+ ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(w)]
486
+ ).decode().strip()
487
+ per_frag_durs.append(float(d))
488
+ sidecar_path = final.parent / f"{args.ep_id}-segments.json"
489
+ import json as _json
490
+ sidecar_path.write_text(_json.dumps(
491
+ {
492
+ "pause_sec": PAUSE_SEC / args.speed,
493
+ "segments": [
494
+ {"index": i + 1, "chars": len(s), "duration_sec": d / args.speed}
495
+ for i, (s, d) in enumerate(zip(fragments, per_frag_durs))
496
+ ],
497
+ },
498
+ indent=2, ensure_ascii=False,
499
+ ))
500
+ print(f">> sidecar: {sidecar_path}", flush=True)
501
+ return 0
502
+
503
+
504
+ if __name__ == "__main__":
505
+ sys.exit(main())