11import type { EditorCore } from "@/core" ;
22import type { AudioClipSource } from "@/lib/media/audio" ;
33import { createAudioContext , collectAudioClips } from "@/lib/media/audio" ;
4- import {
5- ALL_FORMATS ,
6- AudioBufferSink ,
7- BlobSource ,
8- Input ,
9- type WrappedAudioBuffer ,
10- } from "mediabunny" ;
114
125export class AudioManager {
136 private audioContext : AudioContext | null = null ;
147 private masterGain : GainNode | null = null ;
158 private playbackStartTime = 0 ;
169 private playbackStartContextTime = 0 ;
17- private scheduleTimer : number | null = null ;
18- private lookaheadSeconds = 2 ;
19- private scheduleIntervalMs = 500 ;
2010 private clips : AudioClipSource [ ] = [ ] ;
21- private sinks = new Map < string , AudioBufferSink > ( ) ;
22- private inputs = new Map < string , Input > ( ) ;
23- private activeClipIds = new Set < string > ( ) ;
24- private clipIterators = new Map <
25- string ,
26- AsyncGenerator < WrappedAudioBuffer , void , unknown >
27- > ( ) ;
11+ private decodedBuffers = new Map < string , AudioBuffer > ( ) ;
2812 private queuedSources = new Set < AudioBufferSourceNode > ( ) ;
2913 private playbackSessionId = 0 ;
3014 private lastIsPlaying = false ;
3115 private lastVolume = 1 ;
3216 private unsubscribers : Array < ( ) => void > = [ ] ;
17+ private timelineChangeTimer : number | null = null ;
3318
3419 constructor ( private editor : EditorCore ) {
3520 this . lastVolume = this . editor . playback . getVolume ( ) ;
@@ -46,14 +31,18 @@ export class AudioManager {
4631
4732 dispose ( ) : void {
4833 this . stopPlayback ( ) ;
34+ if ( this . timelineChangeTimer !== null ) {
35+ window . clearTimeout ( this . timelineChangeTimer ) ;
36+ this . timelineChangeTimer = null ;
37+ }
4938 for ( const unsub of this . unsubscribers ) {
5039 unsub ( ) ;
5140 }
5241 this . unsubscribers = [ ] ;
5342 if ( typeof window !== "undefined" ) {
5443 window . removeEventListener ( "playback-seek" , this . handleSeek ) ;
5544 }
56- this . disposeSinks ( ) ;
45+ this . decodedBuffers . clear ( ) ;
5746 if ( this . audioContext ) {
5847 void this . audioContext . close ( ) ;
5948 this . audioContext = null ;
@@ -100,11 +89,20 @@ export class AudioManager {
10089 } ;
10190
10291 private handleTimelineChange = ( ) : void => {
103- this . disposeSinks ( ) ;
92+ if ( this . timelineChangeTimer !== null ) {
93+ window . clearTimeout ( this . timelineChangeTimer ) ;
94+ }
10495
105- if ( ! this . editor . playback . getIsPlaying ( ) ) return ;
96+ this . timelineChangeTimer = window . setTimeout ( ( ) => {
97+ this . timelineChangeTimer = null ;
98+ this . decodedBuffers . clear ( ) ;
99+
100+ if ( ! this . editor . playback . getIsPlaying ( ) ) return ;
106101
107- void this . startPlayback ( { time : this . editor . playback . getCurrentTime ( ) } ) ;
102+ void this . startPlayback ( {
103+ time : this . editor . playback . getCurrentTime ( ) ,
104+ } ) ;
105+ } , 300 ) ;
108106 } ;
109107
110108 private ensureAudioContext ( ) : AudioContext | null {
@@ -123,18 +121,13 @@ export class AudioManager {
123121 this . masterGain . gain . value = this . lastVolume ;
124122 }
125123
126- private getPlaybackTime ( ) : number {
127- if ( ! this . audioContext ) return this . playbackStartTime ;
128- const elapsed = this . audioContext . currentTime - this . playbackStartContextTime ;
129- return this . playbackStartTime + elapsed ;
130- }
131-
132124 private async startPlayback ( { time } : { time : number } ) : Promise < void > {
133125 const audioContext = this . ensureAudioContext ( ) ;
134126 if ( ! audioContext ) return ;
135127
136128 this . stopPlayback ( ) ;
137129 this . playbackSessionId ++ ;
130+ const sessionId = this . playbackSessionId ;
138131
139132 const tracks = this . editor . timeline . getTracks ( ) ;
140133 const mediaAssets = this . editor . media . getAssets ( ) ;
@@ -148,195 +141,123 @@ export class AudioManager {
148141
149142 this . clips = await collectAudioClips ( { tracks, mediaAssets } ) ;
150143 if ( ! this . editor . playback . getIsPlaying ( ) ) return ;
144+ if ( sessionId !== this . playbackSessionId ) return ;
151145
152146 this . playbackStartTime = time ;
153147 this . playbackStartContextTime = audioContext . currentTime ;
154148
155- this . scheduleUpcomingClips ( ) ;
156-
157- if ( typeof window !== "undefined" ) {
158- this . scheduleTimer = window . setInterval ( ( ) => {
159- this . scheduleUpcomingClips ( ) ;
160- } , this . scheduleIntervalMs ) ;
161- }
149+ await this . scheduleAllClips ( { time, sessionId } ) ;
162150 }
163151
164- private scheduleUpcomingClips ( ) : void {
165- if ( ! this . editor . playback . getIsPlaying ( ) ) return ;
166-
167- const currentTime = this . getPlaybackTime ( ) ;
168- const windowEnd = currentTime + this . lookaheadSeconds ;
152+ private async scheduleAllClips ( {
153+ time,
154+ sessionId,
155+ } : {
156+ time : number ;
157+ sessionId : number ;
158+ } ) : Promise < void > {
159+ const audioContext = this . audioContext ;
160+ if ( ! audioContext ) return ;
169161
170162 for ( const clip of this . clips ) {
171163 if ( clip . muted ) continue ;
172- if ( this . activeClipIds . has ( clip . id ) ) continue ;
173164
174165 const clipEnd = clip . startTime + clip . duration ;
175- if ( clipEnd <= currentTime ) continue ;
176- if ( clip . startTime > windowEnd ) continue ;
177-
178- this . activeClipIds . add ( clip . id ) ;
179- void this . runClipIterator ( { clip, startTime : currentTime , sessionId : this . playbackSessionId } ) ;
180- }
181- }
182-
183- private stopPlayback ( ) : void {
184- if ( this . scheduleTimer && typeof window !== "undefined" ) {
185- window . clearInterval ( this . scheduleTimer ) ;
186- }
187- this . scheduleTimer = null ;
188-
189- for ( const iterator of this . clipIterators . values ( ) ) {
190- void iterator . return ( ) ;
191- }
192- this . clipIterators . clear ( ) ;
193- this . activeClipIds . clear ( ) ;
166+ if ( clipEnd <= time ) continue ;
167+ if ( sessionId !== this . playbackSessionId ) return ;
194168
195- for ( const source of this . queuedSources ) {
196169 try {
197- source . stop ( ) ;
198- } catch { }
199- source . disconnect ( ) ;
170+ const buffer = await this . getDecodedBuffer ( { clip } ) ;
171+ if ( ! buffer ) continue ;
172+ if ( sessionId !== this . playbackSessionId ) return ;
173+ if ( ! this . editor . playback . getIsPlaying ( ) ) return ;
174+
175+ this . scheduleClipNode ( { clip, buffer, time } ) ;
176+ } catch ( error ) {
177+ console . warn ( "Failed to schedule audio clip:" , clip . id , error ) ;
178+ }
200179 }
201- this . queuedSources . clear ( ) ;
202180 }
203181
204- private async runClipIterator ( {
182+ private scheduleClipNode ( {
205183 clip,
206- startTime ,
207- sessionId ,
184+ buffer ,
185+ time ,
208186 } : {
209187 clip : AudioClipSource ;
210- startTime : number ;
211- sessionId : number ;
212- } ) : Promise < void > {
213- const audioContext = this . ensureAudioContext ( ) ;
214- if ( ! audioContext ) return ;
215-
216- const sink = await this . getAudioSink ( { clip } ) ;
217- if ( ! sink || ! this . editor . playback . getIsPlaying ( ) ) return ;
218- if ( sessionId !== this . playbackSessionId ) return ;
188+ buffer : AudioBuffer ;
189+ time : number ;
190+ } ) : void {
191+ const audioContext = this . audioContext ;
192+ if ( ! audioContext || ! this . masterGain ) return ;
219193
220- const clipStart = clip . startTime ;
221- const clipEnd = clip . startTime + clip . duration ;
222194 const rate = clip . playbackRate ;
223-
224- const iteratorStartTime = Math . max ( startTime , clipStart ) ;
225- const sourceStartTime =
226- clip . trimStart + ( iteratorStartTime - clip . startTime ) * rate ;
227-
228- const iterator = sink . buffers ( sourceStartTime ) ;
229- this . clipIterators . set ( clip . id , iterator ) ;
230-
231- for await ( const { buffer, timestamp } of iterator ) {
232- if ( ! this . editor . playback . getIsPlaying ( ) ) return ;
233- if ( sessionId !== this . playbackSessionId ) return ;
234-
235- const timelineTime = clip . startTime + ( timestamp - clip . trimStart ) / rate ;
236- if ( timelineTime >= clipEnd ) break ;
237-
238- const node = audioContext . createBufferSource ( ) ;
239- node . buffer = buffer ;
240- node . playbackRate . value = rate ;
241- node . connect ( this . masterGain ?? audioContext . destination ) ;
242-
243- const startTimestamp =
244- this . playbackStartContextTime +
245- ( timelineTime - this . playbackStartTime ) ;
246-
247- if ( startTimestamp >= audioContext . currentTime ) {
248- node . start ( startTimestamp ) ;
195+ const elapsed = Math . max ( 0 , time - clip . startTime ) ;
196+ const sourceOffset = clip . trimStart + elapsed * rate ;
197+ const remainingDuration = clip . duration - elapsed ;
198+
199+ if ( remainingDuration <= 0 ) return ;
200+
201+ const timelineStart = Math . max ( clip . startTime , time ) ;
202+ const scheduleTime =
203+ this . playbackStartContextTime +
204+ ( timelineStart - this . playbackStartTime ) ;
205+
206+ const node = audioContext . createBufferSource ( ) ;
207+ node . buffer = buffer ;
208+ node . playbackRate . value = rate ;
209+ node . connect ( this . masterGain ) ;
210+
211+ if ( scheduleTime >= audioContext . currentTime ) {
212+ node . start ( scheduleTime , sourceOffset , remainingDuration ) ;
213+ } else {
214+ const late = audioContext . currentTime - scheduleTime ;
215+ const adjustedOffset = sourceOffset + late * rate ;
216+ const adjustedDuration = remainingDuration - late ;
217+ if ( adjustedDuration > 0 ) {
218+ node . start ( audioContext . currentTime , adjustedOffset , adjustedDuration ) ;
249219 } else {
250- const offset = audioContext . currentTime - startTimestamp ;
251- if ( offset < buffer . duration ) {
252- node . start ( audioContext . currentTime , offset ) ;
253- } else {
254- continue ;
255- }
256- }
257-
258- this . queuedSources . add ( node ) ;
259- node . addEventListener ( "ended" , ( ) => {
260- node . disconnect ( ) ;
261- this . queuedSources . delete ( node ) ;
262- } ) ;
263-
264- const aheadTime = timelineTime - this . getPlaybackTime ( ) ;
265- if ( aheadTime >= 1 ) {
266- await this . waitUntilCaughtUp ( { timelineTime, targetAhead : 1 } ) ;
267- if ( sessionId !== this . playbackSessionId ) return ;
220+ return ;
268221 }
269222 }
270223
271- this . clipIterators . delete ( clip . id ) ;
272- // don't remove from activeClipIds - prevents scheduler from restarting this clip
273- // the set is cleared on stopPlayback anyway
274- }
275-
276- private waitUntilCaughtUp ( {
277- timelineTime,
278- targetAhead,
279- } : {
280- timelineTime : number ;
281- targetAhead : number ;
282- } ) : Promise < void > {
283- return new Promise ( ( resolve ) => {
284- const checkInterval = setInterval ( ( ) => {
285- if ( ! this . editor . playback . getIsPlaying ( ) ) {
286- clearInterval ( checkInterval ) ;
287- resolve ( ) ;
288- return ;
289- }
290-
291- const playbackTime = this . getPlaybackTime ( ) ;
292- if ( timelineTime - playbackTime < targetAhead ) {
293- clearInterval ( checkInterval ) ;
294- resolve ( ) ;
295- }
296- } , 100 ) ;
224+ this . queuedSources . add ( node ) ;
225+ node . addEventListener ( "ended" , ( ) => {
226+ node . disconnect ( ) ;
227+ this . queuedSources . delete ( node ) ;
297228 } ) ;
298229 }
299230
300- private disposeSinks ( ) : void {
301- for ( const iterator of this . clipIterators . values ( ) ) {
302- void iterator . return ( ) ;
303- }
304- this . clipIterators . clear ( ) ;
305- this . activeClipIds . clear ( ) ;
306-
307- for ( const input of this . inputs . values ( ) ) {
308- input . dispose ( ) ;
309- }
310- this . inputs . clear ( ) ;
311- this . sinks . clear ( ) ;
312- }
313-
314- private async getAudioSink ( {
231+ private async getDecodedBuffer ( {
315232 clip,
316233 } : {
317234 clip : AudioClipSource ;
318- } ) : Promise < AudioBufferSink | null > {
319- const existingSink = this . sinks . get ( clip . sourceKey ) ;
320- if ( existingSink ) return existingSink ;
235+ } ) : Promise < AudioBuffer | null > {
236+ const cached = this . decodedBuffers . get ( clip . sourceKey ) ;
237+ if ( cached ) return cached ;
321238
322- try {
323- const input = new Input ( {
324- source : new BlobSource ( clip . file ) ,
325- formats : ALL_FORMATS ,
326- } ) ;
327- const audioTrack = await input . getPrimaryAudioTrack ( ) ;
328- if ( ! audioTrack ) {
329- input . dispose ( ) ;
330- return null ;
331- }
239+ const audioContext = this . audioContext ;
240+ if ( ! audioContext ) return null ;
332241
333- const sink = new AudioBufferSink ( audioTrack ) ;
334- this . inputs . set ( clip . sourceKey , input ) ;
335- this . sinks . set ( clip . sourceKey , sink ) ;
336- return sink ;
242+ try {
243+ const arrayBuffer = await clip . file . arrayBuffer ( ) ;
244+ // .slice(0) avoids the detached-buffer error on repeated decodes
245+ const buffer = await audioContext . decodeAudioData ( arrayBuffer . slice ( 0 ) ) ;
246+ this . decodedBuffers . set ( clip . sourceKey , buffer ) ;
247+ return buffer ;
337248 } catch ( error ) {
338- console . warn ( "Failed to initialize audio sink:" , error ) ;
249+ console . warn ( "Failed to decode audio:" , clip . sourceKey , error ) ;
339250 return null ;
340251 }
341252 }
253+
254+ private stopPlayback ( ) : void {
255+ for ( const source of this . queuedSources ) {
256+ try {
257+ source . stop ( ) ;
258+ } catch { }
259+ source . disconnect ( ) ;
260+ }
261+ this . queuedSources . clear ( ) ;
262+ }
342263}
0 commit comments