Files
juzi/tools/gen_voice.py
T
2026-09-27 00:03:34 +08:00

171 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""按 docs/语音播报内容设计文档_V1.0.md 生成 h5/assets/voice/*.mp3。
音色:Qwen3-TTS CustomVoice · Serena(温暖柔和年轻女声)+ 甜美亲和 instruct。
后处理:裁首尾静音 ≤150ms → 响度 −16 LUFS → MP3 48kbps CBR 单声道 22050Hz。
依赖 SoundGame 的 .venv-tts 与模型缓存(见 /home/xsl/SoundGame/env.d/tts.sh)。
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
os.environ.setdefault("HF_HOME", "/home/xsl/SoundGame/models/hf")
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
import numpy as np
import soundfile as sf
import torch
from qwen_tts import Qwen3TTSModel
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "h5" / "assets" / "voice"
STAGING = ROOT / "tools" / "voice_wav_tmp"
SPEAKER = "Serena"
# 甜美、亲和、语速适中偏缓;全表同一 instruct,保证步骤/姿态/倒计时同音色同气质
INSTRUCT = (
"甜美柔和的年轻女声,亲切温暖,像贴心的美肤顾问,"
"语速适中偏缓,吐字清晰,带着微笑感"
)
# (文件名不含扩展名, 文案) — 与设计文档第 2 节逐字一致
LINES: list[tuple[str, str]] = [
("step1-front", "请单人正脸出镜,面部对准取景框"),
("step2-rightface", "请保持单人出镜,慢慢向左转头,露出右脸"),
("step3-leftface", "请慢慢向右转头,露出左脸"),
("hint-noface", "未检测到人脸,请正对镜头"),
("hint-near", "请靠近一点"),
("hint-far", "请远一点"),
("hint-front", "请正对屏幕"),
("hint-turn-left", "请向左转头"),
("hint-turn-right", "请向右转头"),
("hint-hold", "保持不动"),
("countdown-321", "三、二、一"),
("shot-done", "拍摄完成"),
("analyzing", "正在生成您的肌肤报告,请稍候"),
("analyze-fail", "检测失败,请调整后重新拍摄"),
]
PEAK_NORM = 0.89
MAX_EDGE_SILENCE_MS = 150
SILENCE_THRESH = 0.01
def trim_edges(wav: np.ndarray, sr: int) -> np.ndarray:
"""裁掉过长首尾静音,保留 ≤150ms 边沿,避免提示显迟钝。"""
abs_w = np.abs(wav)
above = np.where(abs_w > SILENCE_THRESH)[0]
if len(above) == 0:
return wav
keep = int(sr * MAX_EDGE_SILENCE_MS / 1000)
start = max(0, int(above[0]) - keep)
end = min(len(wav), int(above[-1]) + 1 + keep)
return wav[start:end]
def to_mp3(wav_path: Path, mp3_path: Path) -> None:
"""响度归一 −16 LUFS + 转 MP3 48k CBR mono 22050Hz。"""
# 两遍 loudnorm:先测再应用。探测不能用 -v error,否则 JSON 统计会被压掉。
probe = subprocess.run(
[
"ffmpeg", "-hide_banner", "-nostats",
"-i", str(wav_path),
"-af", "loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json",
"-f", "null", "-",
],
capture_output=True,
text=True,
check=True,
)
err = probe.stderr
start = err.rfind("{")
end = err.rfind("}")
if start < 0 or end < 0:
raise RuntimeError(f"loudnorm probe failed for {wav_path}: {err[-500:]}")
stats = json.loads(err[start : end + 1])
af = (
f"loudnorm=I=-16:TP=-1.5:LRA=11:"
f"measured_I={stats['input_i']}:"
f"measured_TP={stats['input_tp']}:"
f"measured_LRA={stats['input_lra']}:"
f"measured_thresh={stats['input_thresh']}:"
f"offset={stats['target_offset']}:"
f"linear=true"
)
subprocess.run(
[
"ffmpeg", "-y", "-v", "error",
"-i", str(wav_path),
"-af", af,
"-ar", "22050",
"-ac", "1",
"-c:a", "libmp3lame",
"-b:a", "48k",
str(mp3_path),
],
check=True,
)
def main() -> int:
OUT_DIR.mkdir(parents=True, exist_ok=True)
STAGING.mkdir(parents=True, exist_ok=True)
print("loading Qwen3-TTS CustomVoice ...", flush=True)
model = Qwen3TTSModel.from_pretrained(
"Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
device_map="cuda:0",
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,
)
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)
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
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}
(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)
return 0
if __name__ == "__main__":
sys.exit(main())