@@ -5,6 +5,7 @@ import type { VideoMuxer } from "./muxer";
55const AUDIO_BITRATE = 128_000 ;
66const DECODE_BACKPRESSURE_LIMIT = 20 ;
77const MIN_SPEED_REGION_DELTA_MS = 0.0001 ;
8+ const SEEK_TIMEOUT_MS = 5_000 ;
89
910export class AudioProcessor {
1011 private cancelled = false ;
@@ -18,9 +19,9 @@ export class AudioProcessor {
1819 demuxer : WebDemuxer ,
1920 muxer : VideoMuxer ,
2021 videoUrl : string ,
21- trimRegions ? : TrimRegion [ ] ,
22- speedRegions ? : SpeedRegion [ ] ,
23- readEndSec ? : number ,
22+ trimRegions : TrimRegion [ ] | undefined ,
23+ speedRegions : SpeedRegion [ ] | undefined ,
24+ validatedDurationSec : number ,
2425 ) : Promise < void > {
2526 const sortedTrims = trimRegions ? [ ...trimRegions ] . sort ( ( a , b ) => a . startMs - b . startMs ) : [ ] ;
2627 const sortedSpeedRegions = speedRegions
@@ -35,14 +36,19 @@ export class AudioProcessor {
3536 videoUrl ,
3637 sortedTrims ,
3738 sortedSpeedRegions ,
39+ validatedDurationSec ,
3840 ) ;
39- if ( ! this . cancelled ) {
41+ if ( ! this . cancelled && renderedAudioBlob . size > 0 ) {
4042 await this . muxRenderedAudioBlob ( renderedAudioBlob , muxer ) ;
4143 return ;
4244 }
45+ return ;
4346 }
4447
4548 // No speed edits: keep the original demux/decode/encode path with trim timestamp remap.
49+ // The +0.5s buffer mirrors streamingDecoder.decodeAll's read window so the trim-only
50+ // and speed-aware paths agree on how far to read past the validated duration boundary.
51+ const readEndSec = validatedDurationSec + 0.5 ;
4652 await this . processTrimOnlyAudio ( demuxer , muxer , sortedTrims , readEndSec ) ;
4753 }
4854
@@ -55,7 +61,7 @@ export class AudioProcessor {
5561 ) : Promise < void > {
5662 let audioConfig : AudioDecoderConfig ;
5763 try {
58- audioConfig = ( await demuxer . getDecoderConfig ( "audio" ) ) as AudioDecoderConfig ;
64+ audioConfig = await demuxer . getDecoderConfig ( "audio" ) ;
5965 } catch {
6066 console . warn ( "[AudioProcessor] No audio track found, skipping" ) ;
6167 return ;
@@ -80,11 +86,10 @@ export class AudioProcessor {
8086 typeof readEndSec === "number" && Number . isFinite ( readEndSec )
8187 ? Math . max ( 0 , readEndSec )
8288 : undefined ;
83- const audioStream = (
89+ const audioStream =
8490 safeReadEndSec !== undefined
8591 ? demuxer . read ( "audio" , 0 , safeReadEndSec )
86- : demuxer . read ( "audio" )
87- ) as ReadableStream < EncodedAudioChunk > ;
92+ : demuxer . read ( "audio" ) ;
8893 const reader = audioStream . getReader ( ) ;
8994
9095 try {
@@ -187,6 +192,7 @@ export class AudioProcessor {
187192 videoUrl : string ,
188193 trimRegions : TrimRegion [ ] ,
189194 speedRegions : SpeedRegion [ ] ,
195+ validatedDurationSec : number ,
190196 ) : Promise < Blob > {
191197 const media = document . createElement ( "audio" ) ;
192198 media . src = videoUrl ;
@@ -211,15 +217,44 @@ export class AudioProcessor {
211217 const destinationNode = audioContext . createMediaStreamDestination ( ) ;
212218 sourceNode . connect ( destinationNode ) ;
213219
214- const { recorder, recordedBlobPromise } = this . startAudioRecording ( destinationNode . stream ) ;
215220 let rafId : number | null = null ;
221+ let recorder : MediaRecorder | null = null ;
222+ let recordedBlobPromise : Promise < Blob > | null = null ;
216223
217224 try {
218225 if ( audioContext . state === "suspended" ) {
219226 await audioContext . resume ( ) ;
220227 }
221228
222- await this . seekTo ( media , 0 ) ;
229+ // Skip past any initial trim region(s) before recording starts to avoid
230+ // capturing trimmed audio during the first rAF frames of playback.
231+ // Loops to handle back-to-back or overlapping trims at t=0.
232+ const effectiveEnd = validatedDurationSec ;
233+ let startPosition = 0 ;
234+ for ( let i = 0 ; i <= trimRegions . length ; i ++ ) {
235+ const activeTrim = this . findActiveTrimRegion ( startPosition * 1000 , trimRegions ) ;
236+ if ( ! activeTrim ) break ;
237+ startPosition = activeTrim . endMs / 1000 ;
238+ if ( startPosition >= effectiveEnd ) break ;
239+ }
240+
241+ if ( startPosition >= effectiveEnd ) {
242+ // All content is trimmed — return silent blob
243+ return new Blob ( [ ] , { type : "audio/webm" } ) ;
244+ }
245+
246+ await this . seekTo ( media , startPosition ) ;
247+
248+ // Set initial playback rate for the starting position
249+ const initialSpeedRegion = this . findActiveSpeedRegion ( startPosition * 1000 , speedRegions ) ;
250+ if ( initialSpeedRegion ) {
251+ media . playbackRate = initialSpeedRegion . speed ;
252+ }
253+
254+ // Start recording only AFTER seeking past trims
255+ const recording = this . startAudioRecording ( destinationNode . stream ) ;
256+ recorder = recording . recorder ;
257+ recordedBlobPromise = recording . recordedBlobPromise ;
223258 await media . play ( ) ;
224259
225260 await new Promise < void > ( ( resolve , reject ) => {
@@ -249,24 +284,66 @@ export class AudioProcessor {
249284 return ;
250285 }
251286
287+ // Stop playback at validated duration — browser's media.duration
288+ // may be inflated from bad container metadata.
289+ if ( media . currentTime >= validatedDurationSec ) {
290+ media . pause ( ) ;
291+ cleanup ( ) ;
292+ resolve ( ) ;
293+ return ;
294+ }
295+
252296 const currentTimeMs = media . currentTime * 1000 ;
253297 const activeTrimRegion = this . findActiveTrimRegion ( currentTimeMs , trimRegions ) ;
254298
255299 if ( activeTrimRegion && ! media . paused && ! media . ended ) {
256300 const skipToTime = activeTrimRegion . endMs / 1000 ;
257- if ( skipToTime >= media . duration ) {
301+ if ( skipToTime >= media . duration || skipToTime >= validatedDurationSec ) {
258302 media . pause ( ) ;
259303 cleanup ( ) ;
260304 resolve ( ) ;
261305 return ;
262306 }
307+ // Pause recording during trim seek to prevent capturing
308+ // silence/noise as the audio element seeks.
309+ media . pause ( ) ;
310+ if ( recorder ?. state === "recording" ) recorder . pause ( ) ;
311+ const onSeeked = ( ) => {
312+ clearTimeout ( seekTimer ) ;
313+ if ( this . cancelled ) {
314+ cleanup ( ) ;
315+ resolve ( ) ;
316+ return ;
317+ }
318+ if ( recorder ?. state === "paused" ) recorder . resume ( ) ;
319+ media
320+ . play ( )
321+ . then ( ( ) => {
322+ if ( ! this . cancelled ) rafId = requestAnimationFrame ( tick ) ;
323+ } )
324+ . catch ( ( err ) => {
325+ cleanup ( ) ;
326+ reject (
327+ new Error (
328+ `Failed to resume playback after trim seek: ${ err instanceof Error ? err . message : String ( err ) } ` ,
329+ ) ,
330+ ) ;
331+ } ) ;
332+ } ;
333+ const seekTimer = window . setTimeout ( ( ) => {
334+ media . removeEventListener ( "seeked" , onSeeked ) ;
335+ cleanup ( ) ;
336+ reject ( new Error ( "Audio seek timed out while skipping trim region" ) ) ;
337+ } , SEEK_TIMEOUT_MS ) ;
338+ media . addEventListener ( "seeked" , onSeeked , { once : true } ) ;
263339 media . currentTime = skipToTime ;
264- } else {
265- const activeSpeedRegion = this . findActiveSpeedRegion ( currentTimeMs , speedRegions ) ;
266- const playbackRate = activeSpeedRegion ? activeSpeedRegion . speed : 1 ;
267- if ( Math . abs ( media . playbackRate - playbackRate ) > 0.0001 ) {
268- media . playbackRate = playbackRate ;
269- }
340+ return ;
341+ }
342+
343+ const activeSpeedRegion = this . findActiveSpeedRegion ( currentTimeMs , speedRegions ) ;
344+ const playbackRate = activeSpeedRegion ? activeSpeedRegion . speed : 1 ;
345+ if ( Math . abs ( media . playbackRate - playbackRate ) > 0.0001 ) {
346+ media . playbackRate = playbackRate ;
270347 }
271348
272349 if ( ! media . paused && ! media . ended ) {
@@ -286,7 +363,7 @@ export class AudioProcessor {
286363 cancelAnimationFrame ( rafId ) ;
287364 }
288365 media . pause ( ) ;
289- if ( recorder . state !== "inactive" ) {
366+ if ( recorder && recorder . state !== "inactive" ) {
290367 recorder . stop ( ) ;
291368 }
292369 destinationNode . stream . getTracks ( ) . forEach ( ( track ) => track . stop ( ) ) ;
@@ -297,6 +374,12 @@ export class AudioProcessor {
297374 media . load ( ) ;
298375 }
299376
377+ if ( ! recordedBlobPromise ) {
378+ // Invariant: either an early return above fires, or startAudioRecording ran and
379+ // populated recordedBlobPromise before the playback Promise resolved. Reaching
380+ // here means that contract was broken — fail loud instead of returning silence.
381+ throw new Error ( "Audio recorder finished without assigning recordedBlobPromise" ) ;
382+ }
300383 const recordedBlob = await recordedBlobPromise ;
301384 if ( this . cancelled ) {
302385 throw new Error ( "Export cancelled" ) ;
@@ -314,8 +397,8 @@ export class AudioProcessor {
314397
315398 try {
316399 await demuxer . load ( file ) ;
317- const audioConfig = ( await demuxer . getDecoderConfig ( "audio" ) ) as AudioDecoderConfig ;
318- const reader = ( demuxer . read ( "audio" ) as ReadableStream < EncodedAudioChunk > ) . getReader ( ) ;
400+ const audioConfig = await demuxer . getDecoderConfig ( "audio" ) ;
401+ const reader = demuxer . read ( "audio" ) . getReader ( ) ;
319402 let isFirstChunk = true ;
320403
321404 try {
0 commit comments