Files
juzi/tools/gen_voice.py
xslandCursor 531a7ddd14 fix: 拍照页对齐 Figma(正→右→左/镜像预览/WebView 口径定位/85%遮罩) + 语音调度重写 + 正脸照不镜像
- 拍照顺序改为正脸→右脸→左脸(对齐 Figma),左右以用户本人为准;送检前按 target 重排为 [正,左,右]
- 三步预览均镜像(Figma 侧脸轮廓按镜像绘制),后置镜头侧脸轮廓自动翻转;抓帧恒为原始视角
- 蒙版 viewBox 取 Figma 帧 y86–812(扣除微信导航栏)+ meet 缩放,文案/引导/提示按 726 高定位;框外 85% 黑,静态态白底
- 语音:流程音排队不截断、姿态提示挂起补播、倒计时等播报收尾、拍摄完成打断过时引导、出报告/退出清队列;去重仅限姿态提示
- 语音文件改名 step2-rightface / step3-leftface,同步清单、生成脚本与设计文档
- 精华卡去重复正文、推荐依据写明具体原因;年龄仅数字;toast 按字数延时;备注去内部质量码
- 新增无头浏览器冒烟测试 h5/test/e2e-browser.mjs

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 02:22:37 +08:00

233 lines
7.9 KiB
Python
Raw Permalink 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 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)
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,
)
# 倒计时走专用拼接路径,其余整句生成
normal = [(n, t) for n, t in todo if n != "countdown-321"]
do_countdown = any(n == "countdown-321" for n, _ in todo)
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)))
wav = wav / peak * PEAK_NORM
write_mp3("countdown-321", wav, sr)
# 清单始终覆盖文档全量已落地文件(缺文件则跳过写入名)
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(existing)} files)", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())