@@ -103,10 +103,10 @@ function simplifyVolumeKeyframes(
103103 return sampled ;
104104}
105105
106- function buildVolumeExpression ( track : AudioTrack ) : string {
106+ function buildVolumeExpression ( track : AudioTrack , ignoreKeyframes = false ) : string {
107107 const trimDuration = track . end - track . start ;
108108 const staticVolume = clampVolume ( track . volume ) ;
109- const keyframes = ( track . volumeKeyframes ?? [ ] )
109+ const keyframes = ( ignoreKeyframes ? [ ] : ( track . volumeKeyframes ?? [ ] ) )
110110 . filter ( ( keyframe ) => Number . isFinite ( keyframe . time ) && Number . isFinite ( keyframe . volume ) )
111111 . map ( ( keyframe ) => ( {
112112 time : Math . max ( 0 , Math . min ( trimDuration , keyframe . time - track . start ) ) ,
@@ -377,42 +377,58 @@ async function mixAudioTracks(
377377 const outputDir = dirname ( outputPath ) ;
378378 if ( ! existsSync ( outputDir ) ) mkdirSync ( outputDir , { recursive : true } ) ;
379379
380- const inputs : string [ ] = [ ] ;
381- const filterParts : string [ ] = [ ] ;
382-
383- tracks . forEach ( ( track , i ) => {
384- inputs . push ( "-i" , track . srcPath ) ;
385- const delayMs = Math . round ( track . start * 1000 ) ;
386- const trimDuration = track . end - track . start ;
387- const volumeFilter = buildVolumeExpression ( track ) ;
388- filterParts . push (
389- `[${ i } :a]atrim=0:${ trimDuration } ,${ volumeFilter } ,adelay=${ delayMs } |${ delayMs } ,apad=whole_dur=${ totalDuration } [a${ i } ]` ,
390- ) ;
391- } ) ;
392-
393- const mixInputs = tracks . map ( ( _ , i ) => `[a${ i } ]` ) . join ( "" ) ;
394- const weights = tracks . map ( ( ) => "1" ) . join ( " " ) ;
395- const mixFilter = `${ mixInputs } amix=inputs=${ tracks . length } :duration=longest:dropout_transition=0:normalize=0:weights='${ weights } '[mixed]` ;
396- const postMixGainFilter = `[mixed]volume=${ masterOutputGain } [out]` ;
397- const fullFilter = [ ...filterParts , mixFilter , postMixGainFilter ] . join ( ";" ) ;
380+ const buildArgs = ( ignoreAutomation : boolean ) : string [ ] => {
381+ const inputs : string [ ] = [ ] ;
382+ const filterParts : string [ ] = [ ] ;
383+ tracks . forEach ( ( track , i ) => {
384+ inputs . push ( "-i" , track . srcPath ) ;
385+ const delayMs = Math . round ( track . start * 1000 ) ;
386+ const trimDuration = track . end - track . start ;
387+ const volumeFilter = buildVolumeExpression ( track , ignoreAutomation ) ;
388+ filterParts . push (
389+ `[${ i } :a]atrim=0:${ trimDuration } ,${ volumeFilter } ,adelay=${ delayMs } |${ delayMs } ,apad=whole_dur=${ totalDuration } [a${ i } ]` ,
390+ ) ;
391+ } ) ;
398392
399- const args = [
400- ...inputs ,
401- "-filter_complex" ,
402- fullFilter ,
403- "-map" ,
404- "[out]" ,
405- "-acodec" ,
406- "aac" ,
407- "-b:a" ,
408- "192k" ,
409- "-t" ,
410- String ( totalDuration ) ,
411- "-y" ,
412- outputPath ,
413- ] ;
393+ const mixInputs = tracks . map ( ( _ , i ) => `[a${ i } ]` ) . join ( "" ) ;
394+ const weights = tracks . map ( ( ) => "1" ) . join ( " " ) ;
395+ const mixFilter = `${ mixInputs } amix=inputs=${ tracks . length } :duration=longest:dropout_transition=0:normalize=0:weights='${ weights } '[mixed]` ;
396+ const postMixGainFilter = `[mixed]volume=${ masterOutputGain } [out]` ;
397+ const fullFilter = [ ...filterParts , mixFilter , postMixGainFilter ] . join ( ";" ) ;
398+
399+ return [
400+ ...inputs ,
401+ "-filter_complex" ,
402+ fullFilter ,
403+ "-map" ,
404+ "[out]" ,
405+ "-acodec" ,
406+ "aac" ,
407+ "-b:a" ,
408+ "192k" ,
409+ "-t" ,
410+ String ( totalDuration ) ,
411+ "-y" ,
412+ outputPath ,
413+ ] ;
414+ } ;
414415
415- const result = await runFfmpeg ( args , { signal, timeout : ffmpegProcessTimeout } ) ;
416+ let result = await runFfmpeg ( buildArgs ( false ) , { signal, timeout : ffmpegProcessTimeout } ) ;
417+
418+ // Defense in depth: volume automation is folded into an FFmpeg `volume`
419+ // expression whose evaluator limits are build-dependent (see
420+ // MAX_VOLUME_SEGMENTS). If that ever fails the mix, retry once without the
421+ // automation so the track renders at its base volume rather than being
422+ // dropped from the output entirely — a missing fade beats missing audio.
423+ let degradedAutomation = false ;
424+ const hasAutomation = tracks . some ( ( track ) => ( track . volumeKeyframes ?. length ?? 0 ) > 0 ) ;
425+ if ( ! result . success && ! signal ?. aborted && hasAutomation ) {
426+ const retry = await runFfmpeg ( buildArgs ( true ) , { signal, timeout : ffmpegProcessTimeout } ) ;
427+ if ( retry . success ) {
428+ result = retry ;
429+ degradedAutomation = true ;
430+ }
431+ }
416432
417433 if ( signal ?. aborted ) {
418434 return {
@@ -438,6 +454,9 @@ async function mixAudioTracks(
438454 outputPath,
439455 durationMs : result . durationMs ,
440456 tracksProcessed : tracks . length ,
457+ error : degradedAutomation
458+ ? "Volume automation exceeded this ffmpeg build's expression limits; rendered at base volume"
459+ : undefined ,
441460 } ;
442461}
443462
0 commit comments