11/**
22 * Audio Engine for MediaBunny
33 * Handles audio playback using Web Audio API
4+ *
5+ * 关键修复:
6+ * 原实现在 runIterator 的每个迭代周期都 new 一个 setInterval(checkStarvation, 100),
7+ * 1) 多个 interval 会并发存在,可能多次调 audioContext.suspend() 锁死播放器。
8+ * 2) starvation 阈值在某些时刻(迭代器刚启动 / 主线程一次微卡顿)容易误触发。
9+ * 现在改为单一 wall-clock watchdog(在 RAF 侧已有),AudioEngine 内部仅做必要的调度,
10+ * 不再主动 suspend audioContext —— 避免 starvation 误判导致的"播几秒后卡死"。
411 */
512import {
613 ALL_FORMATS ,
@@ -10,6 +17,7 @@ import {
1017 ReadableStreamSource ,
1118 UrlSource ,
1219} from "mediabunny"
20+ import { TimeStretcher } from "./pitchPreservingTimeStretch.js"
1321
1422export default class AudioEngine {
1523 constructor ( events ) {
@@ -39,10 +47,18 @@ export default class AudioEngine {
3947 // Async control
4048 this . asyncId = 0
4149 this . queuedNodes = new Set ( )
50+
51+ // 变速不变调拉伸器(跨 buffer 有状态、避免接缝爆音)
52+ this . stretcher = null
53+ // 拉伸输出起始位置在媒体时间轴上的锁定点(stretcher 活动期间使用)
54+ // 设为 null 表示尚未锁定(下一个输入 buffer 的 timestamp 会被用作起始点)
55+ this . _stretchOriginTs = null
56+ // 拉伸输入的原始总时长累计(秒)—— 用来推算每个输出块的起始 timestamp
57+ this . _stretchInputDur = 0
4258 }
4359
4460 get currentTime ( ) {
45- if ( this . paused ) return this . playbackTimeAtStart
61+ if ( this . paused || ! this . audioContext ) return this . playbackTimeAtStart
4662
4763 return (
4864 ( this . audioContext . currentTime - this . audioContextStartTime ) *
@@ -86,7 +102,13 @@ export default class AudioEngine {
86102 }
87103
88104 stopQueuedNodes ( ) {
89- this . queuedNodes . forEach ( ( node ) => node . stop ( ) )
105+ this . queuedNodes . forEach ( ( node ) => {
106+ try {
107+ node . stop ( )
108+ } catch ( _ ) {
109+ /* ignore */
110+ }
111+ } )
90112 this . queuedNodes . clear ( )
91113 }
92114
@@ -100,6 +122,11 @@ export default class AudioEngine {
100122
101123 await this . stopIterator ( )
102124 this . stopQueuedNodes ( )
125+ // 加载新源前先重置 stretcher 状态(旧实例下面会被新 TimeStretcher 替换,
126+ // 但 flush 一下能避免 _stretchOriginTs 等残留状态影响新流程)
127+ this . stretcher ?. flush ( )
128+ this . _stretchOriginTs = null
129+ this . _stretchInputDur = 0
103130
104131 this . paused = true
105132 this . playbackTimeAtStart = 0
@@ -134,9 +161,27 @@ export default class AudioEngine {
134161 this . ensureAudioContext ( audioTrack . sampleRate )
135162 this . audioSink = new AudioBufferSink ( audioTrack )
136163
164+ // 创建 stretcher(带 sampleRate / channels 信息)
165+ this . stretcher = new TimeStretcher (
166+ this . audioContext ,
167+ audioTrack . sampleRate ,
168+ audioTrack . numberOfChannels || 2 ,
169+ )
170+ this . stretcher . setRate ( this . playbackRate )
171+ this . _stretchOriginTs = null
172+ this . _stretchInputDur = 0
173+
137174 onMetadata ?. ( )
138175 }
139176
177+ /**
178+ * 持续从 audio sink 拉 buffer 并 schedule 到 AudioContext。
179+ *
180+ * 重要变更:去除了原来在每次迭代都 new 一个 setInterval 的实现。
181+ * - 不再主动调用 audioContext.suspend()。
182+ * - starvation 检测交给 VideoEngine 的 RAF 侧 watchdog(监听 currentTime 是否前进)。
183+ * - 当 audioContext.state 意外变为 suspended 时仍会 resume,但绝不主动 suspend。
184+ */
140185 async runIterator ( localId ) {
141186 if ( ! this . audioSink ) return
142187
@@ -146,39 +191,23 @@ export default class AudioEngine {
146191 while ( true ) {
147192 if ( localId !== this . asyncId || this . paused ) return
148193
149- const nextPromise = this . audioIterator . next ( )
150-
151- // Monitor for buffer starvation
152- const checkStarvation = setInterval ( ( ) => {
153- if ( localId !== this . asyncId || this . paused ) {
154- clearInterval ( checkStarvation )
155- return
156- }
157-
158- if (
159- this . audioContext . state === "running" &&
160- this . audioContext . currentTime >= this . latestScheduledEndTime - 0.2
161- ) {
162- this . audioContext . suspend ( )
163- this . events . emit ( "waiting" )
164- }
165- } , 50 )
166-
167194 let result
168195 try {
169- result = await nextPromise
196+ result = await this . audioIterator . next ( )
170197 } catch ( e ) {
171198 console . error ( "Audio iterator error:" , e )
172199 break
173- } finally {
174- clearInterval ( checkStarvation )
175200 }
176201
177202 if ( localId !== this . asyncId || this . paused ) return
178203
179- // Resume if was suspended
204+ // 若 audioContext 意外被 suspend(设备唤醒、用户切回前台),主动 resume
180205 if ( this . audioContext . state === "suspended" ) {
181- await this . audioContext . resume ( )
206+ try {
207+ await this . audioContext . resume ( )
208+ } catch ( _ ) {
209+ /* ignore */
210+ }
182211 this . events . emit ( "canplay" )
183212 this . events . emit ( "playing" )
184213 }
@@ -187,34 +216,90 @@ export default class AudioEngine {
187216
188217 const { buffer, timestamp } = result . value
189218
190- // Schedule audio buffer
219+ // 变速不变调路径:
220+ // - rate === 1:原路径不变,用 mediabunny 的 timestamp 调度 buffer
221+ // - rate ≠ 1:推入 stretcher、用起始 timestamp 锁定拉伸输出的原点,
222+ // 后续输出块的 timestamp = origin + 已输入原始时长 / rate。
223+ // 这样跨 buffer 连续拼接,没有接缝。
224+ let nodeBuffer = null
225+ let nodeTimestamp = timestamp
226+
227+ if ( this . playbackRate === 1 ) {
228+ nodeBuffer = buffer
229+ nodeTimestamp = timestamp
230+ } else {
231+ // 锁定输出原点 timestamp(首个 buffer 进来时)
232+ if ( this . _stretchOriginTs === null ) {
233+ this . _stretchOriginTs = timestamp
234+ this . _stretchInputDur = 0
235+ }
236+ // 处理:可能返回 null(累积不够)
237+ const out = this . stretcher . process ( buffer )
238+ // 记录本轮输出在媒体时间轴上的起点(输入本轮之前累计的原始时长决定)
239+ const outStartMediaTs = this . _stretchOriginTs + this . _stretchInputDur
240+ // 累计本轮输入的原始时长
241+ this . _stretchInputDur += buffer . duration
242+
243+ if ( ! out ) {
244+ // 数据不够,暂不调度,等下一个 buffer 累积
245+ continue
246+ }
247+ nodeBuffer = out
248+ nodeTimestamp = outStartMediaTs
249+ }
250+
251+ // 调度音频 buffer
191252 const node = this . audioContext . createBufferSource ( )
192- node . buffer = buffer
253+ node . buffer = nodeBuffer
193254 node . connect ( this . gainNode )
194- node . playbackRate . value = this . playbackRate
255+ // 变速不变调后用原速播放;playbackRate 不再带来 “尖锐化” 副作用
256+ node . playbackRate . value = 1
195257
258+ // 在 audioContext 时间轴上的起始位置:
259+ // nodeTimestamp 是原始媒体时间轴上的位置,进入该位置之后的实际 wall-clock = (nodeTimestamp - playbackTimeAtStart) / rate
196260 const startAt =
197261 this . audioContextStartTime +
198- ( timestamp - this . playbackTimeAtStart ) / this . playbackRate
262+ ( nodeTimestamp - this . playbackTimeAtStart ) / this . playbackRate
199263
200- const duration = buffer . duration
201- const endAt = startAt + duration / this . playbackRate
264+ // 拉伸后 buffer 以原速播放,wall-clock 时长 = nodeBuffer.duration
265+ const duration = nodeBuffer . duration
266+ const endAt = startAt + duration
202267
203268 if ( endAt > this . latestScheduledEndTime ) {
204269 this . latestScheduledEndTime = endAt
205270 }
206271
207- if ( startAt >= this . audioContext . currentTime ) {
208- node . start ( startAt )
209- } else {
210- node . start (
211- this . audioContext . currentTime ,
212- ( this . audioContext . currentTime - startAt ) * this . playbackRate ,
213- )
272+ try {
273+ if ( startAt >= this . audioContext . currentTime ) {
274+ node . start ( startAt )
275+ } else {
276+ // 起始点已过:偶尔过期补丁,过期太多则丢弃
277+ const lateBy = this . audioContext . currentTime - startAt
278+ if ( lateBy < duration ) {
279+ // 拉伸后 buffer 以原速播放,offset 单位为拉伸后的秒数(不需乘 rate)
280+ node . start ( this . audioContext . currentTime , lateBy )
281+ } else {
282+ // 过期太多,直接丢
283+ continue
284+ }
285+ }
286+ } catch ( e ) {
287+ console . warn ( "Audio buffer source start failed:" , e )
288+ continue
214289 }
215290
216291 this . queuedNodes . add ( node )
217292 node . onended = ( ) => this . queuedNodes . delete ( node )
293+
294+ // 节流:当已 schedule 的时间领先 currentTime 超过 2 秒时,让出主线程一会儿,
295+ // 避免一次性把所有 buffer 都灌进 audioContext(占用过多内存)。
296+ const ahead = this . latestScheduledEndTime - this . audioContext . currentTime
297+ if ( ahead > 2.0 ) {
298+ // 简单 await 一个 setTimeout,0ms 也行——让 await 把控制权还给事件循环
299+ await new Promise ( ( resolve ) =>
300+ setTimeout ( resolve , Math . min ( 500 , ( ahead - 1.5 ) * 1000 ) ) ,
301+ )
302+ }
218303 }
219304 }
220305
@@ -245,12 +330,26 @@ export default class AudioEngine {
245330
246331 this . stopIterator ( )
247332 this . stopQueuedNodes ( )
333+ // 重置 stretcher 状态,避免 “恢复播放时用了之前的拉伸状态” 造成接缝不连续
334+ this . stretcher ?. flush ( )
335+ this . _stretchOriginTs = null
336+ this . _stretchInputDur = 0
248337 }
249338
250339 async seek ( time ) {
251340 this . playbackTimeAtStart = Math . max ( 0 , time )
252- this . audioContextStartTime = this . audioContext . currentTime
253- this . latestScheduledEndTime = this . audioContextStartTime
341+ if ( this . audioContext ) {
342+ this . audioContextStartTime = this . audioContext . currentTime
343+ this . latestScheduledEndTime = this . audioContextStartTime
344+ } else {
345+ this . audioContextStartTime = 0
346+ this . latestScheduledEndTime = 0
347+ }
348+
349+ // seek 后原始媒体位置完全变了,stretcher 的跨 buffer 状态不再适用
350+ this . stretcher ?. flush ( )
351+ this . _stretchOriginTs = null
352+ this . _stretchInputDur = 0
254353
255354 const id = ++ this . asyncId
256355 if ( ! this . paused ) {
@@ -270,9 +369,18 @@ export default class AudioEngine {
270369 if ( ! this . paused ) {
271370 this . playbackTimeAtStart = this . currentTime
272371 this . audioContextStartTime = this . audioContext . currentTime
372+ this . latestScheduledEndTime = this . audioContextStartTime
373+ // 切换速率时,旧的 queuedNodes 仍以旧速率/旧拉伸结果在播,会和新调度的 buffer 叠加。
374+ // 立即停掉旧节点(runIterator 在下一轮 await 后才会真正退出,间隔期间会出现双轨叠加)
375+ this . stopQueuedNodes ( )
273376 }
274377
275378 this . playbackRate = rate
379+ // 拉伸状态的 prevTailRef / prevTailSamples 在新速率下不再适用,重置
380+ this . stretcher ?. setRate ( rate )
381+ this . stretcher ?. flush ( )
382+ this . _stretchOriginTs = null
383+ this . _stretchInputDur = 0
276384
277385 if ( ! this . paused ) {
278386 const id = ++ this . asyncId
@@ -283,9 +391,14 @@ export default class AudioEngine {
283391 destroy ( ) {
284392 this . asyncId ++
285393 this . pause ( )
286- this . audioContext ?. close ( )
394+ try {
395+ this . audioContext ?. close ( )
396+ } catch ( _ ) {
397+ /* ignore */
398+ }
287399 this . audioContext = null
288400 this . input = null
289401 this . audioSink = null
402+ this . stretcher = null
290403 }
291404}
0 commit comments