@@ -15,7 +15,67 @@ import { unwrapTemplate } from "../utils/htmlTemplate.js";
1515import { resolveProjectRelativeSrc } from "./videoFrameExtractor.js" ;
1616import type { AudioElement , AudioTrack , MixResult } from "./audioMixer.types.js" ;
1717
18- export type { AudioElement , AudioTrack , MixResult } from "./audioMixer.types.js" ;
18+ export type { AudioElement , MixResult } from "./audioMixer.types.js" ;
19+
20+ function clampVolume ( volume : number ) : number {
21+ if ( ! Number . isFinite ( volume ) ) return 1 ;
22+ return Math . max ( 0 , Math . min ( 1 , volume ) ) ;
23+ }
24+
25+ function formatFilterNumber ( value : number ) : string {
26+ return Number ( value . toFixed ( 6 ) ) . toString ( ) ;
27+ }
28+
29+ function escapeExpressionCommas ( expression : string ) : string {
30+ return expression . replace ( / , / g, "\\," ) ;
31+ }
32+
33+ function buildVolumeExpression ( track : AudioTrack ) : string {
34+ const trimDuration = track . end - track . start ;
35+ const staticVolume = clampVolume ( track . volume ) ;
36+ const keyframes = ( track . volumeKeyframes ?? [ ] )
37+ . filter ( ( keyframe ) => Number . isFinite ( keyframe . time ) && Number . isFinite ( keyframe . volume ) )
38+ . map ( ( keyframe ) => ( {
39+ time : Math . max ( 0 , Math . min ( trimDuration , keyframe . time - track . start ) ) ,
40+ volume : clampVolume ( keyframe . volume ) ,
41+ } ) )
42+ . sort ( ( a , b ) => a . time - b . time ) ;
43+
44+ if ( keyframes . length === 0 ) return `volume=${ formatFilterNumber ( staticVolume ) } ` ;
45+
46+ if ( keyframes [ 0 ] ! . time > 0 ) {
47+ keyframes . unshift ( { time : 0 , volume : staticVolume } ) ;
48+ }
49+
50+ const deduped : typeof keyframes = [ ] ;
51+ for ( const keyframe of keyframes ) {
52+ const previous = deduped . at ( - 1 ) ;
53+ if ( previous && Math . abs ( previous . time - keyframe . time ) < 0.000001 ) {
54+ previous . volume = keyframe . volume ;
55+ } else {
56+ deduped . push ( keyframe ) ;
57+ }
58+ }
59+
60+ if ( deduped . length === 1 ) {
61+ return `volume=${ formatFilterNumber ( deduped [ 0 ] ! . volume ) } ` ;
62+ }
63+
64+ let expression = formatFilterNumber ( deduped . at ( - 1 ) ! . volume ) ;
65+ for ( let i = deduped . length - 2 ; i >= 0 ; i -= 1 ) {
66+ const current = deduped [ i ] ! ;
67+ const next = deduped [ i + 1 ] ! ;
68+ const currentTime = formatFilterNumber ( current . time ) ;
69+ const nextTime = formatFilterNumber ( next . time ) ;
70+ const currentVolume = formatFilterNumber ( current . volume ) ;
71+ const span = Math . max ( 0.000001 , next . time - current . time ) ;
72+ const slope = formatFilterNumber ( ( next . volume - current . volume ) / span ) ;
73+ const segment = `${ currentVolume } +(${ slope } )*(t-${ currentTime } )` ;
74+ expression = `if(lt(t,${ nextTime } ),${ segment } ,${ expression } )` ;
75+ }
76+
77+ return `volume=${ escapeExpressionCommas ( expression ) } :eval=frame` ;
78+ }
1979
2080interface ExtractResult {
2181 success : boolean ;
@@ -246,8 +306,9 @@ async function mixAudioTracks(
246306 inputs . push ( "-i" , track . srcPath ) ;
247307 const delayMs = Math . round ( track . start * 1000 ) ;
248308 const trimDuration = track . end - track . start ;
309+ const volumeFilter = buildVolumeExpression ( track ) ;
249310 filterParts . push (
250- `[${ i } :a]atrim=0:${ trimDuration } ,volume= ${ track . volume } ,adelay=${ delayMs } |${ delayMs } ,apad=whole_dur=${ totalDuration } [a${ i } ]` ,
311+ `[${ i } :a]atrim=0:${ trimDuration } ,${ volumeFilter } ,adelay=${ delayMs } |${ delayMs } ,apad=whole_dur=${ totalDuration } [a${ i } ]` ,
251312 ) ;
252313 } ) ;
253314
@@ -399,6 +460,7 @@ export async function processCompositionAudio(
399460 mediaStart : element . mediaStart ,
400461 duration : element . end - element . start ,
401462 volume : element . volume ?? 1.0 ,
463+ volumeKeyframes : element . volumeKeyframes ,
402464 } ) ;
403465 } catch ( err : unknown ) {
404466 errors . push ( `Error: ${ element . id } — ${ err instanceof Error ? err . message : String ( err ) } ` ) ;
0 commit comments