- 拍照顺序 正脸→左脸→右脸:步骤文案/语音文件(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)
233 lines
7.9 KiB
Python
233 lines
7.9 KiB
Python
#!/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())
|