fix(engine): voice 句尾剪字根治——自然结束改以源真实消耗位置判定 + 300ms 管线排空窗(用户真机人耳反馈)
- 根因:结束定时器=墙钟估算+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 同步
This commit is contained in:
@@ -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)可用。
|
||||
|
||||
|
||||
+22
-2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user