#!/usr/bin/env python3 # -*- coding: utf-8 -*- """R6 缺口语音生成(docs/design/11-rpg-battle-rebalance.md §6):tut_b0 / tut_b6 / btl_flee_fail / boss1_warn 四句,双性别(male=Uncle_Fu / female=Serena), instruct 沿用 tools/gen_voice_dual.py 的 rpg 旁白常量。 用法(GPU 串行): source env.d/tts.sh flock .gpu.lock .venv-tts/bin/python web/rpg/gen/gen_gap_voice.py # 生成 staging wav .venv-tts/bin/python web/rpg/gen/gen_gap_voice.py --convert # 转 ogg+m4a 落盘 .venv-tts/bin/python web/rpg/gen/gen_gap_voice.py --check # 两性时长离群检查 staging: web/rpg/gen/staging/voice//.wav(断点续跑,删 wav 即重录该句) 落盘: web/rpg/audio/voice//.{ogg,m4a} 质量控制同主管线:时长 >0.3s、峰值 >0.05、峰值归一 0.89、>40 字拆句拼接(句间 0.12s)。 """ import argparse import os import subprocess import sys os.environ.setdefault("HF_HOME", "/home/ubuntu/SoundGame/models/hf") os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com") ROOT = "/home/ubuntu/SoundGame" sys.path.insert(0, os.path.join(ROOT, "tools")) from gen_voice_dual import split_text, valid_wav, PEAK_NORM, GAP_SEC # noqa: E402 只读复用主管线 GAME = "rpg" STAGING = os.path.join(ROOT, "web", "rpg", "gen", "staging", "voice") OUT = os.path.join(ROOT, "web", GAME, "audio", "voice") FFMPEG = os.path.join(ROOT, "tools", "bin", "ffmpeg") SPEAKERS = {"male": "Uncle_Fu", "female": "Serena"} INSTRUCT = { "male": "低沉醇厚的男声,像说书人,平静有韵味", "female": "平静清晰的年轻女声,像在讲武侠故事", } # 台词全文照录 11 号文档 §6 LINES = [ ("tut_b0", "战斗里,左右滑选招式,单击出手。听到这个声音,就是你的回合。先滑一滑,试试。"), ("tut_b6", "来不及格挡,就选防御。左右滑,选防御,单击。"), ("btl_flee_fail", "没甩掉!它追上来了。"), ("boss1_warn", "里面就是黑风寨主。你现在回去练练再来,也来得及。"), ] def staging_path(gender, fid): return os.path.join(STAGING, gender, fid + ".wav") def generate(): import numpy as np import soundfile as sf import torch from qwen_tts import Qwen3TTSModel model = Qwen3TTSModel.from_pretrained( "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", device_map="cuda:0", dtype=torch.bfloat16, ) print("model loaded", flush=True) for gender in ("male", "female"): speaker = SPEAKERS[gender] instruct = INSTRUCT[gender] os.makedirs(os.path.join(STAGING, gender), exist_ok=True) # 展开成 (fid, chunk_idx, text),跳过已完成 items = [] for fid, text in LINES: if valid_wav(staging_path(gender, fid)): continue for ci, ctext in enumerate(split_text(text)): items.append((fid, ci, ctext)) if not items: print(f"[{gender}] nothing to do", flush=True) continue results = {} for i in range(0, len(items), 6): batch = items[i:i + 6] wavs, sr = model.generate_custom_voice( text=[b[2] for b in batch], language=["Chinese"] * len(batch), speaker=[speaker] * len(batch), instruct=[instruct] * len(batch), max_new_tokens=2048, ) for (fid, ci, _t), wav in zip(batch, wavs): results.setdefault(fid, {})[ci] = np.asarray(wav, dtype=np.float32) print(f"[{gender}] batch {i // 6 + 1} (n={len(batch)})", flush=True) gap = np.zeros(int(GAP_SEC * sr), dtype=np.float32) for fid, text in LINES: path = staging_path(gender, fid) if valid_wav(path): continue got = results.get(fid, {}) n_chunks = len(split_text(text)) assert len(got) == n_chunks, f"{gender}/{fid}: 缺 chunk {len(got)}/{n_chunks}" wav = got[0] for ci in range(1, n_chunks): wav = np.concatenate([wav, gap, got[ci]]) peak = float(np.max(np.abs(wav))) assert peak > 0.05, f"{gender}/{fid}: peak {peak:.3f}" wav = wav / peak * PEAK_NORM dur = len(wav) / sr assert dur > 0.3, f"{gender}/{fid}: dur {dur:.2f}s" sf.write(path, wav, sr) print(f"[ok] {gender}/{fid} {dur:.2f}s chunks={n_chunks}", flush=True) def check(): import numpy as np import soundfile as sf problems = [] for fid, _text in LINES: durs = {} for gender in ("male", "female"): path = staging_path(gender, fid) if not os.path.exists(path): durs[gender] = None continue data, sr = sf.read(path) peak = float(np.max(np.abs(data))) durs[gender] = len(data) / sr if durs[gender] <= 0.3 or peak <= 0.05: problems.append(f"{gender}/{fid}: 质量不达标 dur={durs[gender]:.2f} peak={peak:.3f}") m, f = durs["male"], durs["female"] if m is None or f is None: problems.append(f"{fid}: 缺 {'male' if m is None else 'female'}") continue ratio = max(m, f) / min(m, f) flag = " <-- OUTLIER" if ratio > 3 else "" if ratio > 3: problems.append(f"{fid}: 时长离群 m={m:.2f}s f={f:.2f}s ratio={ratio:.1f}") print(f" {fid:16s} m={m:6.2f}s f={f:6.2f}s{flag}") if problems: print("PROBLEMS:") for p in problems: print(" ", p) return 1 print("ALL OK") return 0 def convert(): total = 0 for gender in ("male", "female"): dst_dir = os.path.join(OUT, gender) os.makedirs(dst_dir, exist_ok=True) for fid, _text in LINES: src = staging_path(gender, fid) if not os.path.exists(src): print(f"[convert] WARN missing {gender}/{fid}.wav") continue base = os.path.join(dst_dir, fid) subprocess.run([FFMPEG, "-y", "-v", "error", "-i", src, "-ar", "44100", "-c:a", "libvorbis", "-q:a", "3", base + ".ogg"], check=True) subprocess.run([FFMPEG, "-y", "-v", "error", "-i", src, "-ar", "44100", "-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True) total += 1 print(f"converted {total} files (ogg+m4a each)") def main(): ap = argparse.ArgumentParser() ap.add_argument("--check", action="store_true") ap.add_argument("--convert", action="store_true") args = ap.parse_args() if args.check: sys.exit(check()) if args.convert: convert() return generate() if __name__ == "__main__": main()