From da01c2d20b7e635f8f9e9cd2c557348cd048b3a9 Mon Sep 17 00:00:00 2001 From: xiangsilian Date: Sun, 6 Sep 2026 13:25:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(engine):=20voice=20=E5=8F=A5=E5=B0=BE?= =?UTF-8?q?=E5=89=AA=E5=AD=97=E6=A0=B9=E6=B2=BB=E2=80=94=E2=80=94=E8=87=AA?= =?UTF-8?q?=E7=84=B6=E7=BB=93=E6=9D=9F=E6=94=B9=E4=BB=A5=E6=BA=90=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E6=B6=88=E8=80=97=E4=BD=8D=E7=BD=AE=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=20+=20300ms=20=E7=AE=A1=E7=BA=BF=E6=8E=92=E7=A9=BA=E7=AA=97?= =?UTF-8?q?=EF=BC=88=E7=94=A8=E6=88=B7=E7=9C=9F=E6=9C=BA=E4=BA=BA=E8=80=B3?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:结束定时器=墙钟估算+120ms,但 SPN(4096帧@48k≈85ms)+SoundTouch 固有延迟≈190ms, disconnect 时句尾音节仍在管线缓冲里从未播出(每句系统性剪尾;ASR 窗口宽、转写评审均不可见, 属研究章程写明的「真机人耳」盲区,为首个真人耳测反馈) - 修法:_finishVoice 三段判定——源未耗尽→80ms 短重排等待;耗尽→300ms 排空;排空毕→真结算。 consumedSec==0(playtest mock/SPN 首回调未到)走旧路径,9 套件+guide 全绿零扰动 - 合成侧排除:2786 个 voice 文件尾部静默审计 + 5 个最可疑文件(尾静默 0-16ms)ASR 逐句验证, 末字/语气词完整(「…是不是我做错了什么呀?」)——资产无剪字,仅静音尾裁剪紧 - 真机 A/B(radio 确定性链,修复前后同 id 对比):节点存活从「文件时长+0.02s」→「+0.33s」, 五句全部一致;web-engine-api.md 同步 --- docs/web-engine-api.md | 7 +++++-- web/engine/audio.js | 24 ++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/web-engine-api.md b/docs/web-engine-api.md index 226cdddf..b91aafe6 100644 --- a/docs/web-engine-api.md +++ b/docs/web-engine-api.md @@ -164,8 +164,11 @@ new WebAudioEngine(basePath = 'audio/', opts?) `vendor/soundtouch.js`(SoundTouch 0.3.0,LGPL v2.1,ScriptProcessorNode 实现):`WebAudioBufferSource → SimpleFilter(SoundTouch tempo) → ScriptProcessorNode → gain → voiceBus`。`settings.speed` 只改 tempo 不改 -pitch,1.0/1.35/1.7 均不变调;自然结束由"源消耗位置 ÷ tempo"定时器判定, -onComplete/打断/排队语义与旧 BufferSource 实现完全一致。注意 voice 有 +pitch,1.0/1.35/1.7 均不变调;自然结束以「源真实消耗位置」(SoundTouch +source.position)为准——源未耗尽时定时器只做短重排等待,耗尽后再留 +300ms 排空窗让管线缓冲的尾音播出,然后才 disconnect(2026-09 真机人耳 +反馈修复:旧实现纯墙钟定时器 +120ms 一到即断,管线固有延迟 ~190ms 意味着 +每句尾字被剪)。onComplete/打断/排队语义不变。注意 voice 有 ~100ms 处理延迟(对白场景无感),且 ScriptProcessorNode 已废弃但全平台 (含 iOS Safari)可用。 diff --git a/web/engine/audio.js b/web/engine/audio.js index 6aa96ac0..19284de4 100644 --- a/web/engine/audio.js +++ b/web/engine/audio.js @@ -56,6 +56,7 @@ import { SoundTouch, SimpleFilter, WebAudioBufferSource, getWebAudioNode } from const EXT_FALLBACKS = ['.ogg', '.m4a', '.mp3', '.wav']; const LONGPRESS_BOOST = 1.7; // 长按快进倍速 +const VOICE_DRAIN_MS = 300; // voice 源耗尽后的管线排空窗(SPN 4096帧@48k≈85ms + SoundTouch 内部延迟) /** * 语音风暴保险丝(dungeon R2 D0-1,全线保护):同一 voiceId 在 3s 窗口内被调度 @@ -463,7 +464,7 @@ export class WebAudioEngine { gain.connect(this._voiceBus); const token = { - node, gain, st, buf, + node, gain, st, buf, filter, stopped: false, onComplete, posSec: 0, // 已消耗的源时长(秒,按 tempo 累计) @@ -486,9 +487,28 @@ export class WebAudioEngine { this._armVoiceEnd(token); } - /** 结算 voice 自然播完(onComplete 链 + 队列推进)。 */ + /** + * 结算 voice 自然播完(onComplete 链 + 队列推进)。 + * 尾部保真:以「源真实消耗位置」(SoundTouch 源 position,SPN 回调推进)为准, + * 不信墙钟估算——管线有 ~200ms 固有延迟且消耗速率受主线程抖动/后台节流影响, + * 旧实现纯定时器 +120ms 一到就 disconnect,仍留在管线缓冲里的句尾音节会被 + * 剪掉(真机人耳实测每句尾字缺失;ASR 窗口宽看不到,转写评审也看不到)。 + * consumedSec==0(mock 环境/首个 SPN 回调未到)走旧定时器路径,零行为变化。 + */ _finishVoice(token) { if (this._voice !== token || token.stopped) return; // 被打断:不回调 + const consumedSec = token.filter.sourceSound.position / token.buf.sampleRate; + if (consumedSec > 0 && consumedSec < token.buf.duration - 0.02) { + // 源未耗尽:墙钟先到/主线程卡顿/后台节流——等真实消耗追上再结算 + token.endTimer = setTimeout(() => this._finishVoice(token), 80); + return; + } + if (consumedSec > 0 && !token.drained) { + // 源已耗尽:留一个排空窗,让已进入 SPN/SoundTouch 缓冲的尾音真正播出 + token.drained = true; + token.endTimer = setTimeout(() => this._finishVoice(token), VOICE_DRAIN_MS); + return; + } token.stopped = true; this._voice = null; this._killVoice(token);