Skip to content

Commit 553fe41

Browse files
committed
fix: fix a bug which will lost audio when clip is too much
1 parent a7b8e5d commit 553fe41

2 files changed

Lines changed: 165 additions & 199 deletions

File tree

Lines changed: 102 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,20 @@
11
import type { EditorCore } from "@/core";
22
import type { AudioClipSource } from "@/lib/media/audio";
33
import { createAudioContext, collectAudioClips } from "@/lib/media/audio";
4-
import {
5-
ALL_FORMATS,
6-
AudioBufferSink,
7-
BlobSource,
8-
Input,
9-
type WrappedAudioBuffer,
10-
} from "mediabunny";
114

125
export 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

Comments
 (0)