diff --git a/h5/assets/voice/countdown-321.mp3 b/h5/assets/voice/countdown-321.mp3 index 274eb8f..ecc37bf 100644 Binary files a/h5/assets/voice/countdown-321.mp3 and b/h5/assets/voice/countdown-321.mp3 differ diff --git a/tools/gen_voice.py b/tools/gen_voice.py index f913974..ea8e026 100644 --- a/tools/gen_voice.py +++ b/tools/gen_voice.py @@ -112,7 +112,69 @@ def to_mp3(wav_path: Path, mp3_path: Path) -> None: ) +def normalize_clip(wav: np.ndarray, sr: int) -> np.ndarray: + wav = trim_edges(np.asarray(wav, dtype=np.float32).reshape(-1), sr) + peak = float(np.max(np.abs(wav))) + if peak < 0.05: + raise RuntimeError(f"peak too low ({peak:.4f})") + wav = wav / peak * PEAK_NORM + dur = len(wav) / sr + if dur < 0.35: + wav = np.concatenate([wav, np.zeros(int((0.35 - dur) * sr), dtype=np.float32)]) + return wav + + +def write_mp3(name: str, wav: np.ndarray, sr: int) -> None: + wav_path = STAGING / f"{name}.wav" + mp3_path = OUT_DIR / f"{name}.mp3" + sf.write(str(wav_path), wav, sr) + to_mp3(wav_path, mp3_path) + dur = len(wav) / sr + print(f"[ok] {name}.mp3 {dur:.2f}s {mp3_path.stat().st_size} bytes", flush=True) + + +def gen_countdown(model) -> tuple[np.ndarray, int]: + """倒计时单独按字生成再拼接,避免「三、二、一」整句被读成播报腔、与提示音气质漂移。 + + 与提示音共用同一 speaker + instruct;句间留约 0.35s,总长约 2.5s。 + """ + digits = ["三", "二", "一"] + # 同批带上一条短提示作音色锚点,丢弃锚点音频 + texts = ["保持不动", *digits] + wavs, sr = model.generate_custom_voice( + text=texts, + language=["Chinese"] * len(texts), + speaker=[SPEAKER] * len(texts), + instruct=[INSTRUCT] * len(texts), + max_new_tokens=2048, + ) + gap = np.zeros(int(0.35 * sr), dtype=np.float32) + parts: list[np.ndarray] = [] + for i, digit in enumerate(digits): + clip = normalize_clip(wavs[i + 1], sr) + parts.append(clip) + if i < len(digits) - 1: + parts.append(gap) + print(f" countdown digit {digit}: {len(clip) / sr:.2f}s", flush=True) + return np.concatenate(parts), sr + + def main() -> int: + import argparse + + ap = argparse.ArgumentParser() + ap.add_argument( + "--only", + default="", + help="逗号分隔的 id,只生成这些;空则全量", + ) + args = ap.parse_args() + only = {x.strip() for x in args.only.split(",") if x.strip()} + todo = [(n, t) for n, t in LINES if not only or n in only] + if only and not todo: + print(f"unknown --only ids: {sorted(only)}", flush=True) + return 1 + OUT_DIR.mkdir(parents=True, exist_ok=True) STAGING.mkdir(parents=True, exist_ok=True) @@ -123,46 +185,46 @@ def main() -> int: dtype=torch.bfloat16, ) - names = [n for n, _ in LINES] - texts = [t for _, t in LINES] - print(f"generating {len(names)} lines with speaker={SPEAKER} ...", flush=True) - wavs, sr = model.generate_custom_voice( - text=texts, - language=["Chinese"] * len(names), - speaker=[SPEAKER] * len(names), - instruct=[INSTRUCT] * len(names), - max_new_tokens=2048, - ) + # 倒计时走专用拼接路径,其余整句生成 + normal = [(n, t) for n, t in todo if n != "countdown-321"] + do_countdown = any(n == "countdown-321" for n, _ in todo) - manifest_ids: list[str] = [] - for name, wav in zip(names, wavs): - wav = np.asarray(wav, dtype=np.float32).reshape(-1) - wav = trim_edges(wav, sr) + if normal: + names = [n for n, _ in normal] + texts = [t for _, t in normal] + print(f"generating {len(names)} lines with speaker={SPEAKER} ...", flush=True) + wavs, sr = model.generate_custom_voice( + text=texts, + language=["Chinese"] * len(names), + speaker=[SPEAKER] * len(names), + instruct=[INSTRUCT] * len(names), + max_new_tokens=2048, + ) + for name, wav in zip(names, wavs): + try: + write_mp3(name, normalize_clip(wav, sr), sr) + except RuntimeError as e: + print(f"[FAIL] {name}: {e}", flush=True) + return 1 + + if do_countdown: + print(f"generating countdown-321 (digit stitch, speaker={SPEAKER}) ...", flush=True) + wav, sr = gen_countdown(model) + # 整段再峰值归一一次,保证与提示音响度一致 peak = float(np.max(np.abs(wav))) - if peak < 0.05: - print(f"[FAIL] {name}: peak too low ({peak:.4f})", flush=True) - return 1 wav = wav / peak * PEAK_NORM - dur = len(wav) / sr - if dur < 0.35: - pad = np.zeros(int((0.35 - dur) * sr), dtype=np.float32) - wav = np.concatenate([wav, pad]) - dur = len(wav) / sr + write_mp3("countdown-321", wav, sr) - wav_path = STAGING / f"{name}.wav" - mp3_path = OUT_DIR / f"{name}.mp3" - sf.write(str(wav_path), wav, sr) - to_mp3(wav_path, mp3_path) - size = mp3_path.stat().st_size - print(f"[ok] {name}.mp3 {dur:.2f}s {size} bytes", flush=True) - manifest_ids.append(name) - - manifest = {"files": manifest_ids} + # 清单始终覆盖文档全量已落地文件(缺文件则跳过写入名) + existing = [ + n for n, _ in LINES if (OUT_DIR / f"{n}.mp3").is_file() + ] + manifest = {"files": existing} (OUT_DIR / "manifest.json").write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) - print(f"wrote {OUT_DIR / 'manifest.json'} ({len(manifest_ids)} files)", flush=True) + print(f"wrote {OUT_DIR / 'manifest.json'} ({len(existing)} files)", flush=True) return 0 diff --git a/tools/voice_wav_tmp/countdown-321.wav b/tools/voice_wav_tmp/countdown-321.wav index cdaf6f2..47eea3c 100644 Binary files a/tools/voice_wav_tmp/countdown-321.wav and b/tools/voice_wav_tmp/countdown-321.wav differ