Files
SoundGame/tools/audit_voice_tails.py
xsl f5eae6d37c test+docs: 「对白没读完」教训固化到框架(引擎回归测试 + 审计工具 + 四处文档)
- web/harness/engine-voice.test.mjs:引擎 voice 句尾保真三不变式(I1 源未耗尽不得结算 /
  I2 耗尽后排空窗才断开 / I3 零消耗走旧路径),可驱动 SPN mock 模拟真实消耗,
  15 断言全绿;防止未来重构回潮
- tools/audit_voice_tails.py:voice 资产尾部静默审计工具入库(含判读口径头注:
  尾静默紧=裁剪工艺≠剪字,句尾完整性验收在引擎层)
- 文档:HANDOVER(§1.4 回归清单 / §5.3 质量手段 / §6 已解决账)、roadmap §4
  新增全游戏「句尾完整性」真机听测项、asset-pipeline QC 口径、methodology §5
  度量盲区教训(媒体播完类问题转写层无效,必须真人耳测+引擎断言)、
  web-engine-api voice 自然结束语义
- 修正:结束判定改用 filter.sourcePosition(SimpleFilter 单调精确计数器);
  10 套件全绿(8 playtest + guide + engine-voice)
2026-09-06 16:08:18 +08:00

93 lines
3.4 KiB
Python

#!/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()