Files
juzi/tools/gen_voice.py
T
Ubuntu 4e3d915b7d feat: 拍摄流程对齐 Figma(正→左→右顺序/描摹轮廓蒙版/正脸只提示回正) + 报告页按 Figma 08 重排
- 拍照顺序 正脸→左脸→右脸:步骤文案/语音文件(step2-leftface/step3-rightface)/payload 归属对齐
- 蒙版轮廓由 Figma 拍照示意位图 alpha 镂空区逐像素描摹生成(0 正脸含耳/1 左脸鼻朝左/2 右脸鼻朝右),框外纯黑
- 侧脸步预览与抓帧不镜像(左右不再迷惑),HeadPose 侧脸自校准阈值 30 帧防误翻转
- 语音:「保持不动」播完再进倒计时(afterCurrent 接续,不再拦腰切断)
- 正脸步角度不合格只提示「请正对屏幕」,阈值放宽 |yaw|≤15°/|pitch|≤18°;转头提示仅侧脸步
- 抓帧改完整帧(修 Face++ INVALID_IMAGE_FACE)
- 报告页按 Figma 08:内联白底顶栏(返回+标题)、总分双独立白卡(30px 大数字+15px 单位后缀)、
  维度短竖分隔线+最低分标红、Tab 黄条、免责左对齐、护理建议去 tag 加粗+收尾句、
  产品大标题移出白卡、COREⅡ 换 Figma 导出 SVG 字标、产品图 151×174、卡圆角 10px
- 精华卡判据措辞用户化(均分档→综合均分/最差项约束→最低分维度/年龄组档→年龄段,引擎值不动)
- 修报告页滚动 P0(去 position:relative 覆写),文档同步(语音文档/README)
2026-09-28 00:57:28 +08:00

233 lines
7.9 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-leftface", "请慢慢向右转头,露出左脸"),
("step3-rightface", "请保持单人出镜,慢慢向左转头,露出右脸"),
("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())