Last active
August 26, 2025 11:53
-
-
Save remorses/4d32dbce64147a1dda28d08e7dec8a8e to your computer and use it in GitHub Desktop.
ffmpeg concatenating audio clips the good way. no frozen frames, no gaps
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| interface InputFile { | |
| path: string | |
| start?: number // Start time for trimming (in seconds) | |
| end?: number // End time for trimming (in seconds) | |
| fadeIn?: number // Fade in duration in seconds | |
| fadeOut?: number // Fade out duration in seconds | |
| } | |
| interface ConcatenateOptions { | |
| inputFiles: InputFile[] | |
| outputFile: string | |
| signal?: AbortSignal | |
| } | |
| export async function concatenateAudio( | |
| options: ConcatenateOptions, | |
| ): Promise<void> { | |
| const { inputFiles, outputFile, signal } = options | |
| if (!inputFiles?.length || !outputFile) { | |
| throw new Error('Missing required parameters') | |
| } | |
| // Generate stable ID for timing | |
| const timerId = `concat-${inputFiles.length}-videos-${outputFile.split('/').pop()}` | |
| console.time(timerId) | |
| // Build input file arguments for FFmpeg | |
| const inputArgs = inputFiles.map((file) => `-i "${file.path}"`).join(' ') | |
| // Build the filter_complex string | |
| const filterComplexParts: string[] = [] | |
| const streamParts: string[] = [] | |
| inputFiles.forEach((file, index) => { | |
| let processedAudio = `[${index}:a]` | |
| // Add trimming if start and/or end are defined | |
| if (file.start !== undefined || file.end !== undefined) { | |
| const start = file.start ?? 0 | |
| const end = file.end ? `end=${file.end}` : '' | |
| filterComplexParts.push( | |
| `[${index}:a]atrim=start=${start}:${end},asetpts=PTS-STARTPTS[trim${index}]`, | |
| ) | |
| processedAudio = `[trim${index}]` | |
| } | |
| // Add fade in/out if defined | |
| if (file.fadeIn || file.fadeOut) { | |
| if (file.fadeIn) { | |
| filterComplexParts.push( | |
| `${processedAudio}afade=t=in:st=0:d=${file.fadeIn}[fadein${index}]`, | |
| ) | |
| processedAudio = `[fadein${index}]` | |
| } | |
| if (file.fadeOut && file.end) { | |
| const fadeStart = file.end - file.fadeOut | |
| filterComplexParts.push( | |
| `${processedAudio}afade=t=out:st=${fadeStart}:d=${file.fadeOut}[fadeout${index}]`, | |
| ) | |
| processedAudio = `[fadeout${index}]` | |
| } | |
| } | |
| streamParts.push(processedAudio) | |
| }) | |
| // Concatenate audio streams | |
| filterComplexParts.push( | |
| `${streamParts.join('')}concat=n=${inputFiles.length}:v=0:a=1[a_out]`, | |
| ) | |
| const filterComplex = filterComplexParts.join(';') | |
| // Build the FFmpeg command | |
| const command = `ffmpeg -y ${inputArgs} -filter_complex "${filterComplex}" -map "[a_out]" "${outputFile}"` | |
| // Execute the command | |
| console.log('Running FFmpeg command:', command) | |
| try { | |
| await new Promise((resolve, reject) => { | |
| const childProcess = spawn(command, { | |
| shell: true, | |
| env: process.env, | |
| // stdio: 'inherit', | |
| }) | |
| childProcess.on('exit', (code) => { | |
| if (code !== 0) { | |
| reject(new Error(`FFmpeg exited with code ${code}`)) | |
| } else { | |
| resolve(null) | |
| } | |
| }) | |
| if (signal) { | |
| signal.addEventListener('abort', () => { | |
| childProcess.kill() | |
| reject(new Error('Operation aborted')) | |
| }) | |
| } | |
| }) | |
| } finally { | |
| console.timeEnd(timerId) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment