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);