diff --git a/HANDOVER.md b/HANDOVER.md index 024123db..6e5d646c 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -49,10 +49,11 @@ bash web/serve.sh 每款游戏都有无头试玩测试(mock 浏览器 + 虚拟时钟,模拟玩家通关,见 §5): ```bash -for g in storyline escape simon rhythm space rpg; do +for g in storyline escape simon rhythm space rpg radio dungeon; do tools/node/bin/node web/$g/test/playtest.mjs || echo "FAILED: $g" done tools/node/bin/node web/harness/guide.test.mjs # 向导层专项(guide=full 行为断言) +tools/node/bin/node web/harness/engine-voice.test.mjs # 引擎 voice 句尾保真专项(见 §6) ``` 退出码 0 = 全绿。当前 6 款 + 向导专项全部通过。**注意:playtest 一律在 setup 里 `settings.set({guide:'off'})` 验证纯玩法逻辑;向导行为断言只住在 guide.test.mjs。** @@ -262,6 +263,11 @@ source env.d/mureka.sh ### 5.3 其他质量手段 - JS 语法校验:`tools/node/bin/node --check web//game.js`。 +- 引擎 voice 句尾保真:`tools/node/bin/node web/harness/engine-voice.test.mjs` + (I1 源未耗尽不得结算 / I2 耗尽后排空窗才断开 / I3 mock 零消耗走旧路径; + 2026-09 真机人耳反馈固化,改动 `audio.js _finishVoice` 必跑)。 +- voice 资产尾部静默审计:`.venv-tts/bin/python tools/audit_voice_tails.py` + (量化尾部裁剪松紧;注意判读口径——尾静默紧≠剪字,句尾完整性验收在引擎层)。 - 资产终检:`tools/check_all_assets.py`(§3.4)。 - 真机听测 checklist(听感参数只能真机验):[docs/design/roadmap.md](docs/design/roadmap.md) §4。 @@ -271,6 +277,7 @@ source env.d/mureka.sh |---|---|---| | ~~引擎无公开 `isVoicePlaying`~~ | **已解决(R7)**:`audio.isVoicePlaying()` 公开,游戏层全部收编 | — | | ~~长按快进无 per-game 开关~~ | **已解决(R7)**:`audio.setRateBoostEnabled(false)`,rhythm/space 演奏期已豁免 | — | +| ~~voice 句尾剪字(每句末尾音节缺失)~~ | **已根治(2026-09 真机人耳反馈)**:旧结束定时器(墙钟+120ms)< 管线固有延迟 ~190ms,句尾音节尚在 SPN/SoundTouch 缓冲即被 disconnect。现 `_finishVoice` 以源真实消耗位置(`filter.sourcePosition`)判定 + 300ms 排空窗;回归 `web/harness/engine-voice.test.mjs` 三不变式钉死;真机 A/B(同 id 节点存活 +0.02s→+0.33s)验证 | 引擎层已固化,新游戏零改动继承;合成侧经 ASR 抽查排除剪字(尾静默紧是裁剪工艺,判读口径见 `tools/audit_voice_tails.py` 头注) | | 向导 earcon 为运行时合成音 | 音色是「可用」级不是「精美」级(正弦/三角/方波包络) | 若升级为录制品 earcon:保持 8 音词表与参数边界不变(14 号文档 §2),只换渲染后端 | | escape「需要帮助吗」问而不等 | P1 终评唯一「擦边当白痴」时刻 | v2 手势宪法专项:真问答 + PLAY 期目标重听入口 | | space/simon 无「局」与结算仪式;storyline 链尾无轻量 turn 信号、二周目重播开场教学;rhythm 教学首误先震后教 | round4 终评 P0 全部裁定入 v2(research/04-playtests/round4/synthesis.md §2) | L5 会话层专项 + 微 polish 清单 | diff --git a/docs/asset-pipeline.md b/docs/asset-pipeline.md index 7564107d..d0f0ee6d 100644 --- a/docs/asset-pipeline.md +++ b/docs/asset-pipeline.md @@ -33,6 +33,12 @@ flock .gpu.lock .venv-tts/bin/python app/src/escape/gen/gen_voice.py ``` - 每条生成后自动校验:**时长 > 0.3s 且峰值 > 0.05**,不达标直接 assert 失败。 +- **尾部裁剪与句尾完整性(2026-09 口径)**:管线峰值归一 + 静音尾裁剪很紧, + 绝大多数 voice 文件尾部静默 <120ms 属预期,**不等于剪字**。「句子是否读完」 + 的验收在引擎层(源消耗位置判定 + 300ms 排空窗,见 `web/engine/audio.js + _finishVoice` 与 `web/harness/engine-voice.test.mjs`);资产侧怀疑剪字时用 + ASR 抽查末字/语气词核对,量化工具 `tools/audit_voice_tails.py`(判读口径 + 见其文件头注)。 - 音色分配见各游戏文档(docs/games/*.md):旁白 Uncle_Fu、林晚 Serena、 猎狗 Dylan、小满/广播 Vivian;escape 全员 Uncle_Fu 靠 instruct 区分语气。 - 已知不稳条目:reflex 的 `num_6`("六"),单独用 diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md index 97fcee8a..15030a53 100644 --- a/docs/design/roadmap.md +++ b/docs/design/roadmap.md @@ -58,6 +58,9 @@ 通用前置:安卓 Chrome + iOS Safari 各过一遍;戴有线耳机测一轮、蓝牙耳机测一轮(延迟差异);iOS 项注意"无震动"是预期行为。 +**全游戏通用(2026-09 新增,来自真机人耳反馈)**: +- [ ] **句尾完整性**:随机抽 10 句长短不一的对白听到自然收尾,末字/语气词(了/吧/呀)不缺失、不被下一句或音效咬尾(引擎已固化排空机制 + `web/harness/engine-voice.test.mjs` 回归;本项验真机实际听感与打断时序) + ### 声命线 storyline - [ ] 旁白切角色音时不出戏(重点:female 旁白 vs 小满/广播的 Vivian 家族) - [ ] 8 个抉择点左/右滑方向与选项播报一致 diff --git a/research/02-methodology/methodology.md b/research/02-methodology/methodology.md index 76464f24..e4b92fdf 100644 --- a/research/02-methodology/methodology.md +++ b/research/02-methodology/methodology.md @@ -115,6 +115,10 @@ - **状态可辨识性**:任意转写点,仅凭最近 2s 的声音事件能否判断宏观状态(抽查,目标 ≥95%)。 2. **玩家代理评审(每轮必跑)**:4 人设(视障/大学生/白领/工人)读转写写心得,按「状态感知自评 1-5 / 我知道下一步该做什么吗 / 是否愿意继续玩 / P0 问题清单」结构化输出。**结题门槛见章程 §4**。 3. **真机人类听测(发布前)**:音色美感、响度舒适、双耳定位精度、蓝牙延迟——代理无法替代(roadmap §4 既有流程)。 + **2026-09 教训固化**:代理评审的盲区不止「美感/响度」——**句尾剪字**(引擎定时器早于 + 管线排空,每句尾字被吃)在转写与 ASR 判定里完全不可见(判定窗口宽、文本对照容错), + 首个真人玩家一句就听出来了。凡「媒体是否播完」类问题,转写层无效,必须真人耳测 + + 引擎级断言(本次固化:`engine-voice.test.mjs` 三不变式 + roadmap §4 句尾完整性清单项)。 ## 6. 反模式清单(一票否决) diff --git a/tools/audit_voice_tails.py b/tools/audit_voice_tails.py new file mode 100644 index 00000000..9e3db264 --- /dev/null +++ b/tools/audit_voice_tails.py @@ -0,0 +1,92 @@ +#!/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() diff --git a/web/engine/audio.js b/web/engine/audio.js index 19284de4..e54f1df7 100644 --- a/web/engine/audio.js +++ b/web/engine/audio.js @@ -497,7 +497,7 @@ export class WebAudioEngine { */ _finishVoice(token) { if (this._voice !== token || token.stopped) return; // 被打断:不回调 - const consumedSec = token.filter.sourceSound.position / token.buf.sampleRate; + const consumedSec = token.filter.sourcePosition / token.buf.sampleRate; if (consumedSec > 0 && consumedSec < token.buf.duration - 0.02) { // 源未耗尽:墙钟先到/主线程卡顿/后台节流——等真实消耗追上再结算 token.endTimer = setTimeout(() => this._finishVoice(token), 80); diff --git a/web/harness/engine-voice.test.mjs b/web/harness/engine-voice.test.mjs new file mode 100644 index 00000000..4bec463d --- /dev/null +++ b/web/harness/engine-voice.test.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +/** + * engine-voice.test.mjs — 引擎 voice 句尾保真回归(2026-09 真机人耳反馈固化)。 + * + * 固化的教训:voice 自然结束**不能**用墙钟定时器判定(SPN 4096帧@48k≈85ms + + * SoundTouch 固有延迟 ≈190ms > 任何小余量),必须以「源真实消耗位置」为准, + * 耗尽后留排空窗再 disconnect。本测试用可手动驱动的 ScriptProcessorNode mock + * 模拟真实消耗,把该语义钉死,防止未来重构回潮。 + * + * 三条不变式: + * I1 源未耗尽时,_finishVoice 提前触发不得结算(必须短重排等待); + * I2 源耗尽后第一次触发只挂排空旗,排空窗(VOICE_DRAIN_MS)过后才真结算; + * I3 消耗位置恒为 0(playtest mock / SPN 首回调未到)走旧立即结算路径。 + * + * 运行:tools/node/bin/node web/harness/engine-voice.test.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const WEB = path.resolve(HERE, '..'); +let passed = 0; const failures = []; +const assert = (cond, msg) => { if (cond) { passed++; console.log(' ✓ ' + msg); } else { failures.push(msg); console.log(' ✗ ' + msg); } }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ---- 全局 mock(在 import 引擎前就位)---- +const storageMap = new Map(); +globalThis.window = { + AudioContext: null, // 下面赋值 + addEventListener() {}, +}; +globalThis.localStorage = { + getItem: (k) => (storageMap.has(k) ? storageMap.get(k) : null), + setItem: (k, v) => storageMap.set(k, String(v)), + removeItem: (k) => storageMap.delete(k), +}; +globalThis.document = { addEventListener() {}, getElementById: () => null, body: { appendChild() {}, classList: { add() {}, remove() {} } } }; +try { globalThis.navigator = { userAgent: 'test', vibrate: () => true }; } catch (_) { /* Node22 自带只读 navigator */ } +globalThis.performance = globalThis.performance || { now: () => Date.now() }; + +const SR = 44100; +const DUR = 2.0; // 测试语音 2 秒 +const FRAMES = Math.round(SR * DUR); + +class FakeParam { constructor(v = 1) { this.value = v; } setValueAtTime() {} linearRampToValueAtTime() {} cancelScheduledValues() {} } + +class DrivableSPN { + constructor() { this.connect = () => {}; this.disconnectCount = 0; this.onaudioprocess = null; } + disconnect() { this.disconnectCount++; } + /** 模拟一次真实音频回调:SoundTouch 从源拉取至多 4096 帧 */ + pump() { + if (!this.onaudioprocess) return 0; + const out = { outputBuffer: { getChannelData: () => new Float32Array(4096 * 2) } }; + this.onaudioprocess(out); + return 1; + } +} + +class MockAudioContext { + constructor() { this.state = 'running'; this.destination = {}; this.sampleRate = SR; } + get currentTime() { return performance.now() / 1000; } + createGain() { return { gain: new FakeParam(), connect() {}, disconnect() {} }; } + createDynamicsCompressor() { const n = this.createGain(); n.threshold = new FakeParam(); n.ratio = new FakeParam(); n.attack = new FakeParam(); n.release = new FakeParam(); return n; } + createScriptProcessor() { return new DrivableSPN(); } + decodeAudioData() { + return Promise.resolve({ + duration: DUR, sampleRate: SR, numberOfChannels: 1, + getChannelData: (i) => new Float32Array(FRAMES), // 真实长度:SoundTouch extract 按帧索引 + }); + } + resume() { this.state = 'running'; return Promise.resolve(); } +} +globalThis.window.AudioContext = MockAudioContext; + +globalThis.fetch = async () => ({ ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(8) }); + +// ---- 导入真实引擎 ---- +const { WebAudioEngine } = await import(path.join(WEB, 'engine', 'audio.js')); + +const engine = new WebAudioEngine('audio/'); +await engine.unlock(); +const completed = []; +const makeOnComplete = (tag) => () => completed.push(tag); + +console.log('\n=== I1 源未耗尽:提前触发不结算 ==='); +engine.play('voice/tail_test', { onComplete: makeOnComplete('A') }); +await sleep(30); // 等 _playVoice 的 await _load 落地 +let token = engine._voice; +assert(token && token.buf && token.filter, 'voice token 已建立(含 filter 引用)'); +for (let i = 0; i < 3; i++) token.node.pump(); // 消耗 ~12288 帧 ≈ 0.28s << 2s +const consumedEarly = token.filter.sourcePosition / token.buf.sampleRate; +assert(consumedEarly > 0 && consumedEarly < DUR - 0.02, `消耗位置已推进且未耗尽(${consumedEarly.toFixed(2)}s / ${DUR}s)`); +engine._finishVoice(token); // 模拟旧式墙钟定时器提前到点 +assert(engine._voice === token && !token.stopped, 'I1 未耗尽时不结算(token 仍存活,等待重排)'); +assert(completed.length === 0, 'I1 onComplete 未被提前调用'); + +console.log('\n=== I2 源耗尽:排空窗过后才真结算 ==='); +let guard = 0; +while (token.filter.sourcePosition / SR < DUR && guard++ < 200) token.node.pump(); +const consumedFull = token.filter.sourcePosition / token.buf.sampleRate; +assert(consumedFull >= DUR - 0.02, `源已耗尽(${consumedFull.toFixed(2)}s)`); +if (token.endTimer !== null) { clearTimeout(token.endTimer); token.endTimer = null; } // 摘掉墙钟定时器,纯手动驱动 +const t0 = Date.now(); +engine._finishVoice(token); // 第一次:应只挂排空旗 +assert(token.drained === true && engine._voice === token, 'I2 耗尽后第一触发只置排空旗(不结算)'); +await sleep(VOICE_DRAIN_PLUS()); // 排空窗 +engine._finishVoice(token); // 第二次:真结算 +assert(engine._voice === null && token.stopped, 'I2 排空窗后真结算(_voice 清空)'); +assert(completed.includes('A'), 'I2 onComplete 恰好回调一次'); +assert(token.node.disconnectCount >= 1, 'I2 结算时节点已 disconnect'); +assert(Date.now() - t0 >= 280, `I2 排空窗确实等待了 ≥280ms(实测 ${Date.now() - t0}ms)`); +function VOICE_DRAIN_PLUS() { return 380; } + +console.log('\n=== I3 消耗位置恒 0(mock/SPN 未回调):旧立即结算路径 ==='); +engine.play('voice/tail_zero', { onComplete: makeOnComplete('B') }); +await sleep(30); +const token2 = engine._voice; +assert(token2 && token2.filter.sourceSound.position === 0, '第二句从未被 pump(position=0)'); +engine._finishVoice(token2); +assert(engine._voice === null && token2.stopped, 'I3 position=0 时立即结算(playtest mock 兼容,链推进不受影响)'); +assert(completed.includes('B'), 'I3 onComplete 立即回调'); + +console.log('\n=== 附加:打断语义不受影响(新句顶掉旧句,旧句 onComplete 不回调) ==='); +engine.play('voice/tail_test2', { onComplete: makeOnComplete('C1') }); +await sleep(30); +const t3 = engine._voice; +for (let i = 0; i < 3; i++) t3.node.pump(); +engine.play('voice/tail_test3', { onComplete: makeOnComplete('C2') }); +await sleep(30); +assert(engine._voice && engine._voice !== t3 && t3.stopped, '新 voice 顶掉旧 voice(旧 token 已停)'); +assert(!completed.includes('C1'), '被打断的旧句不回调 onComplete(排队链语义保持)'); +engine._finishVoice(engine._voice); if (engine._voice) { await sleep(380); engine._finishVoice(engine._voice); } +engine.stopVoice(); +engine._voice = null; + +console.log(`\n========================================`); +console.log(`断言通过 ${passed} 条,失败 ${failures.length} 条`); +if (failures.length) { failures.forEach((m) => console.log('FAILED: ' + m)); process.exit(1); } +console.log('全部通过。');