From 946179c230950865abaf53c223284ad1cb59da03 Mon Sep 17 00:00:00 2001 From: JaysonAlbert Date: Sun, 18 Oct 2026 08:48:12 +0800 Subject: [PATCH] fix: preserve clip gain in timeline audio export --- src/core/editor/render-project-timeline.ts | 4 +-- src/core/editor/timeline-audio-filter.test.ts | 36 +++++++++++++++++++ src/core/editor/timeline-audio-filter.ts | 10 ++++++ 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/core/editor/timeline-audio-filter.test.ts create mode 100644 src/core/editor/timeline-audio-filter.ts diff --git a/src/core/editor/render-project-timeline.ts b/src/core/editor/render-project-timeline.ts index b3524fc..930c326 100644 --- a/src/core/editor/render-project-timeline.ts +++ b/src/core/editor/render-project-timeline.ts @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { buildTimelineAudioMixFilter } from './timeline-audio-filter'; import { getResolvedCaptionStyle } from './captions'; import { @@ -363,8 +364,7 @@ async function buildFfmpegArgs({ } if (audioLabels.length > 0) { filters.push( - `${audioLabels.join('')}amix=inputs=${audioLabels.length}:duration=longest:dropout_transition=0,` + - `atrim=duration=${time(document.duration)},apad=whole_dur=${time(document.duration)}[aout]` + `${audioLabels.join('')}${buildTimelineAudioMixFilter(audioLabels.length, document.duration)}[aout]` ); } diff --git a/src/core/editor/timeline-audio-filter.test.ts b/src/core/editor/timeline-audio-filter.test.ts new file mode 100644 index 0000000..08565be --- /dev/null +++ b/src/core/editor/timeline-audio-filter.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { buildTimelineAudioMixFilter } from './timeline-audio-filter'; + +const ffmpeg = process.env.BEATDESIGN_FFMPEG?.trim() || 'ffmpeg'; +const available = spawnSync(ffmpeg,['-version'],{stdio:'ignore'}).status === 0; +function render(count:number, overlap=false) { + const args=['-hide_banner','-loglevel','error']; + for(let i=0;i`[${i}:a]volume=${overlap?10:1},adelay=${overlap?0:i*1000}:all=1[a${i}]`); + filters.push(Array.from({length:count},(_,i)=>`[a${i}]`).join('')+buildTimelineAudioMixFilter(count,overlap?1:count)+'[out]'); + args.push('-filter_complex',filters.join(';'),'-map','[out]','-ac','1','-c:a','pcm_f32le','-f','f32le','pipe:1'); + const result=spawnSync(ffmpeg,args,{maxBuffer:4*1024*1024}); + assert.equal(result.status,0,result.stderr?.toString()); + const samples=new Float32Array(result.stdout.buffer.slice(result.stdout.byteOffset,result.stdout.byteOffset+result.stdout.byteLength)); + return samples; +} +function rms(samples:Float32Array,start:number,end:number) { + let sum=0;for(let i=start;i{ + const one=render(1),three=render(3),expected=rms(one,4800,43200); + assert.ok(expected>0.08); + for(let i=0;i<3;i++)assert.ok(Math.abs(rms(three,48000*i+4800,48000*i+43200)-expected)<0.0001); + assert.equal(three.length,3*48000); +}); + +test('overlapping loud clips are limited without automatic makeup or timeline delay',{skip:!available},()=>{ + const samples=render(2,true); + let peak=0;for(const sample of samples)peak=Math.max(peak,Math.abs(sample)); + assert.ok(peak<=0.951 && peak>0.90,`Peak was ${peak}`); + assert.equal(samples.length,48000); +}); diff --git a/src/core/editor/timeline-audio-filter.ts b/src/core/editor/timeline-audio-filter.ts new file mode 100644 index 0000000..f866e08 --- /dev/null +++ b/src/core/editor/timeline-audio-filter.ts @@ -0,0 +1,10 @@ +/** Preserve clip gains independently of silent/delayed timeline inputs. */ +export function buildTimelineAudioMixFilter(inputCount: number, duration: number) { + if (!Number.isSafeInteger(inputCount) || inputCount < 1 || !Number.isFinite(duration) || duration <= 0) + throw new Error('Audio mixing requires inputs and a positive timeline duration.'); + const seconds = duration.toFixed(3); + return `amix=inputs=${inputCount}:duration=longest:dropout_transition=0:normalize=0,` + + // Disable the limiter's automatic makeup gain; compensate its lookahead. + 'alimiter=limit=0.95:level=0:latency=1,' + + `atrim=duration=${seconds},apad=whole_dur=${seconds}`; +}