#!/usr/bin/env python3 # -*- coding: utf-8 -*- """voice 资产尾部静默审计(2026-09 句尾剪字排查的固化工具)。 用途:量化每个 voice 文件「最后一段人声能量 → 文件末尾」的静默长度,观察 生产管线的尾部裁剪松紧、发现异常文件(如 0 静默且能量戛然而止的可疑样本)。 ⚠ 判读口径(2026-09 排查结论,勿推翻): - 本项目 TTS 管线峰值归一 + 静音尾裁剪很紧,**绝大多数文件尾部静默 <120ms 属预期**,不等于剪字。tail 静默长度不能单独作为「句尾被剪」的证据。 - 「句子是否读完」的验收在**引擎层**(voice 自然结束以源消耗位置判定 + 300ms 管线排空,见 web/engine/audio.js _finishVoice 与 web/harness/engine-voice.test.mjs),不在资产层。 - 资产侧怀疑剪字时,用 SenseVoice ASR 抽查可疑文件并核对末字/语气词 (管线:.venv-asr/bin/python,参照 research/new8 排查记录)。 用法: .venv-tts/bin/python tools/audit_voice_tails.py # 全部 8 游戏 .venv-tts/bin/python tools/audit_voice_tails.py --tail-min 0.08 """ import argparse import array import os import subprocess ROOT = os.environ.get( "SG_ROOT", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), ) FF = os.path.join(ROOT, "tools", "bin", "ffmpeg") WEB = os.path.join(ROOT, "web") GAMES = ["storyline", "escape", "simon", "rhythm", "space", "rpg", "radio", "dungeon"] ap = argparse.ArgumentParser(description="voice 资产尾部静默审计") ap.add_argument("--tail-min", type=float, default=0.120, help="尾部静默阈值(秒),低于此列入报告(默认 0.120)") TAIL_MIN = ap.parse_args().tail_min def profile(path): """解码 16k mono pcm_s16le,返回 (dur, head_sil, tail_sil) 秒。""" p = subprocess.run([FF, "-v", "error", "-i", path, "-ac", "1", "-ar", "16000", "-f", "s16le", "-"], capture_output=True) data = p.stdout if len(data) < 3200: return None a = array.array('h') a.frombytes(data) n = len(a) win = 320 # 20ms @16k def rms(i): seg = a[i:i + win] return (sum(x * x for x in seg) / win) ** 0.5 last, first = -1, -1 for i in range(0, n - win, win): if rms(i) > 500: # ≈ -30dBFS 记为有人声 if first < 0: first = i last = i if last < 0: return None dur = n / 16000.0 tail = (n - (last + win)) / 16000.0 head = first / 16000.0 return dur, head, tail def main(): bad, total = [], 0 for g in GAMES: vdir = os.path.join(WEB, g, "audio", "voice") for dp, _dn, fs in os.walk(vdir): for f in fs: if not f.endswith((".ogg", ".m4a")): continue total += 1 r = profile(os.path.join(dp, f)) if not r: continue dur, _head, tail = r if tail < TAIL_MIN: bad.append((os.path.relpath(os.path.join(dp, f), WEB), dur, tail)) print(f"scanned {total} voice files") print(f"tail silence < {int(TAIL_MIN*1000)}ms: {len(bad)}(判读口径见文件头注)") for rel, dur, tail in sorted(bad, key=lambda x: x[2])[:40]: print(f" tail={int(tail*1000):4d}ms dur={dur:5.2f}s {rel}") if __name__ == "__main__": main()