diff --git a/h5/assets/voice/analyze-fail.mp3 b/h5/assets/voice/analyze-fail.mp3 new file mode 100644 index 0000000..682c628 Binary files /dev/null and b/h5/assets/voice/analyze-fail.mp3 differ diff --git a/h5/assets/voice/analyzing.mp3 b/h5/assets/voice/analyzing.mp3 new file mode 100644 index 0000000..27475e6 Binary files /dev/null and b/h5/assets/voice/analyzing.mp3 differ diff --git a/h5/assets/voice/countdown-321.mp3 b/h5/assets/voice/countdown-321.mp3 new file mode 100644 index 0000000..274eb8f Binary files /dev/null and b/h5/assets/voice/countdown-321.mp3 differ diff --git a/h5/assets/voice/hint-far.mp3 b/h5/assets/voice/hint-far.mp3 new file mode 100644 index 0000000..eccf5d8 Binary files /dev/null and b/h5/assets/voice/hint-far.mp3 differ diff --git a/h5/assets/voice/hint-front.mp3 b/h5/assets/voice/hint-front.mp3 new file mode 100644 index 0000000..6c4d786 Binary files /dev/null and b/h5/assets/voice/hint-front.mp3 differ diff --git a/h5/assets/voice/hint-hold.mp3 b/h5/assets/voice/hint-hold.mp3 new file mode 100644 index 0000000..4c48fd9 Binary files /dev/null and b/h5/assets/voice/hint-hold.mp3 differ diff --git a/h5/assets/voice/hint-near.mp3 b/h5/assets/voice/hint-near.mp3 new file mode 100644 index 0000000..a5cd2cb Binary files /dev/null and b/h5/assets/voice/hint-near.mp3 differ diff --git a/h5/assets/voice/hint-noface.mp3 b/h5/assets/voice/hint-noface.mp3 new file mode 100644 index 0000000..ddeff5a Binary files /dev/null and b/h5/assets/voice/hint-noface.mp3 differ diff --git a/h5/assets/voice/hint-turn-left.mp3 b/h5/assets/voice/hint-turn-left.mp3 new file mode 100644 index 0000000..ced6fff Binary files /dev/null and b/h5/assets/voice/hint-turn-left.mp3 differ diff --git a/h5/assets/voice/hint-turn-right.mp3 b/h5/assets/voice/hint-turn-right.mp3 new file mode 100644 index 0000000..d7261f3 Binary files /dev/null and b/h5/assets/voice/hint-turn-right.mp3 differ diff --git a/h5/assets/voice/manifest.json b/h5/assets/voice/manifest.json new file mode 100644 index 0000000..db39a8f --- /dev/null +++ b/h5/assets/voice/manifest.json @@ -0,0 +1,18 @@ +{ + "files": [ + "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" + ] +} diff --git a/h5/assets/voice/shot-done.mp3 b/h5/assets/voice/shot-done.mp3 new file mode 100644 index 0000000..79b8d5d Binary files /dev/null and b/h5/assets/voice/shot-done.mp3 differ diff --git a/h5/assets/voice/step1-front.mp3 b/h5/assets/voice/step1-front.mp3 new file mode 100644 index 0000000..ce729a6 Binary files /dev/null and b/h5/assets/voice/step1-front.mp3 differ diff --git a/h5/assets/voice/step2-rightface.mp3 b/h5/assets/voice/step2-rightface.mp3 new file mode 100644 index 0000000..8ec2df6 Binary files /dev/null and b/h5/assets/voice/step2-rightface.mp3 differ diff --git a/h5/assets/voice/step3-leftface.mp3 b/h5/assets/voice/step3-leftface.mp3 new file mode 100644 index 0000000..1b6103b Binary files /dev/null and b/h5/assets/voice/step3-leftface.mp3 differ diff --git a/tools/gen_voice.py b/tools/gen_voice.py new file mode 100644 index 0000000..f913974 --- /dev/null +++ b/tools/gen_voice.py @@ -0,0 +1,170 @@ +#!/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()) diff --git a/tools/voice_wav_tmp/analyze-fail.wav b/tools/voice_wav_tmp/analyze-fail.wav new file mode 100644 index 0000000..0d556e1 Binary files /dev/null and b/tools/voice_wav_tmp/analyze-fail.wav differ diff --git a/tools/voice_wav_tmp/analyzing.wav b/tools/voice_wav_tmp/analyzing.wav new file mode 100644 index 0000000..ee03e55 Binary files /dev/null and b/tools/voice_wav_tmp/analyzing.wav differ diff --git a/tools/voice_wav_tmp/countdown-321.wav b/tools/voice_wav_tmp/countdown-321.wav new file mode 100644 index 0000000..cdaf6f2 Binary files /dev/null and b/tools/voice_wav_tmp/countdown-321.wav differ diff --git a/tools/voice_wav_tmp/hint-far.wav b/tools/voice_wav_tmp/hint-far.wav new file mode 100644 index 0000000..981357d Binary files /dev/null and b/tools/voice_wav_tmp/hint-far.wav differ diff --git a/tools/voice_wav_tmp/hint-front.wav b/tools/voice_wav_tmp/hint-front.wav new file mode 100644 index 0000000..a38a3c5 Binary files /dev/null and b/tools/voice_wav_tmp/hint-front.wav differ diff --git a/tools/voice_wav_tmp/hint-hold.wav b/tools/voice_wav_tmp/hint-hold.wav new file mode 100644 index 0000000..feb3f49 Binary files /dev/null and b/tools/voice_wav_tmp/hint-hold.wav differ diff --git a/tools/voice_wav_tmp/hint-near.wav b/tools/voice_wav_tmp/hint-near.wav new file mode 100644 index 0000000..db7a660 Binary files /dev/null and b/tools/voice_wav_tmp/hint-near.wav differ diff --git a/tools/voice_wav_tmp/hint-noface.wav b/tools/voice_wav_tmp/hint-noface.wav new file mode 100644 index 0000000..d32acf6 Binary files /dev/null and b/tools/voice_wav_tmp/hint-noface.wav differ diff --git a/tools/voice_wav_tmp/hint-turn-left.wav b/tools/voice_wav_tmp/hint-turn-left.wav new file mode 100644 index 0000000..d3d1ba8 Binary files /dev/null and b/tools/voice_wav_tmp/hint-turn-left.wav differ diff --git a/tools/voice_wav_tmp/hint-turn-right.wav b/tools/voice_wav_tmp/hint-turn-right.wav new file mode 100644 index 0000000..6450bec Binary files /dev/null and b/tools/voice_wav_tmp/hint-turn-right.wav differ diff --git a/tools/voice_wav_tmp/shot-done.wav b/tools/voice_wav_tmp/shot-done.wav new file mode 100644 index 0000000..2d0f373 Binary files /dev/null and b/tools/voice_wav_tmp/shot-done.wav differ diff --git a/tools/voice_wav_tmp/step1-front.wav b/tools/voice_wav_tmp/step1-front.wav new file mode 100644 index 0000000..bb68616 Binary files /dev/null and b/tools/voice_wav_tmp/step1-front.wav differ diff --git a/tools/voice_wav_tmp/step2-rightface.wav b/tools/voice_wav_tmp/step2-rightface.wav new file mode 100644 index 0000000..a24256f Binary files /dev/null and b/tools/voice_wav_tmp/step2-rightface.wav differ diff --git a/tools/voice_wav_tmp/step3-leftface.wav b/tools/voice_wav_tmp/step3-leftface.wav new file mode 100644 index 0000000..42f489d Binary files /dev/null and b/tools/voice_wav_tmp/step3-leftface.wav differ