Created
October 29, 2019 08:37
-
-
Save hos/fcd9728396776f520cc524251e37209c to your computer and use it in GitHub Desktop.
Control audio volume with ffmpeg
This file contains 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
const pathExtra = require('path-extra') | |
const { exec } = require('child_process') | |
const DIRECTIONS = { | |
UP: 'UP', | |
DOWN: 'DOWN' | |
} | |
const _isInstructionClose = (before, current) => { | |
const isSameEndTime = before.end >= current.start | |
const isSameVolume = before.volume === current.volume | |
return isSameEndTime && isSameVolume | |
} | |
/** | |
* @param {Array<{start,volume,end,fadeDuration}>} instructions | |
* Volume manipulation list. | |
* @returns {Array<{start,volume,end,fadeDuration}>} | |
* Instructions with merged close instructions. | |
* @description Create new array with merged close instructions. | |
*/ | |
const _mergeCloseInstructions = (instructions) => { | |
const flattenList = [] | |
for (const index in instructions) { | |
const before = flattenList[flattenList.length - 1] | |
const current = instructions[index] | |
if (current.volume === 1) { | |
continue | |
} | |
if (!before) { | |
flattenList.push(current) | |
continue | |
} | |
if (before && _isInstructionClose(before, current)) { | |
continue | |
} else { | |
flattenList.push(current) | |
} | |
} | |
return flattenList | |
} | |
/** | |
* @param {string} cmd Command to execute asynchronously. | |
* @returns {Promise<string>} Output stdout and stderr concatenated. | |
*/ | |
const logExecAsync = (cmd) => new Promise((resolve, reject) => { | |
console.info(cmd) | |
const cb = (err, stdout, stderr) => { | |
if (err) { | |
return reject(err) | |
} | |
resolve(stdout + stderr + '') | |
} | |
exec(cmd, {}, cb) | |
}) | |
const logEffects = (instructions) => { | |
instructions.forEach((instruction) => { | |
console.info( | |
`${instruction.start}` + | |
` to volume ${instruction.volume} with fade duration ${instruction.transitionDuration}` | |
) | |
}) | |
} | |
/** | |
* @param {number} volumeA | |
* @param {number} volumeB | |
* @returns {string} Direction 'UP', 'DOWN' or null if | |
* one of the volumes is undefined. | |
* @description Get direction from volumeA to volumeB. | |
*/ | |
const _getDirection = (volumeA, volumeB) => { | |
const diff = volumeA - volumeB | |
if (diff > 0) { | |
return DIRECTIONS.DOWN | |
} else if (diff < 0) { | |
return DIRECTIONS.UP | |
} else { | |
return null | |
} | |
} | |
/** | |
* @param {Object} params | |
* @param {number} params.start Change volume range start second. | |
* @param {number} params.end Change volume range end second. | |
* @param {number} params.volumeFrom Volume in range 0 to 1. | |
* @param {number} [params.volumeTo] Fade duration in seconds. | |
* @returns {string} FFmpeg volume control options. | |
* @description Generate ffmpeg volume control options, | |
* from instructions. NOTE: FFmpeg will fail, | |
* if command will be too large as described here https://bit.ly/2RiWALs. | |
*/ | |
const _instructionToArg = ({ | |
start, | |
end, | |
volumeFrom, | |
volumeTo | |
}) => { | |
const enable = end > 0 | |
? `volume=enable='between(t,${start},${end})'` | |
: `volume=enable='gte(t,${start})` | |
const args = [enable] | |
const direction = _getDirection(volumeFrom, volumeTo) | |
const mathOp = volumeFrom > volumeTo ? 'max' : 'min' | |
if (direction === DIRECTIONS.UP) { | |
args.push(`volume='${mathOp}(${volumeFrom}+(t-${start}),${volumeTo})'`) | |
} else if (direction === DIRECTIONS.DOWN) { | |
args.push(`volume='${mathOp}(${volumeFrom}-(t-${start}),${volumeTo})'`) | |
} else { | |
args.push(`volume='${volumeTo}'`) | |
} | |
args.push(`eval=frame`) | |
return args.join(':') | |
} | |
/** | |
* @param {Array<Object>} [commands] Default is empty array. | |
* @param {Object} current Instruction. | |
* @param {number} index | |
* @param {Array<Object>} all | |
* @returns {Array<Object>} Array of ffmpeg '-af' arguments. | |
*/ | |
const _reduceFilters = (commands = [], current, index, all) => { | |
const previous = all[index - 1] | |
const next = all[index + 1] | |
const volumeFrom = previous ? previous.volume : 1 | |
if (current.transitionDuration > 0) { | |
commands.push( | |
_instructionToArg({ | |
start: current.start, | |
end: current.start + current.transitionDuration, | |
volumeTo: current.volume, | |
volumeFrom | |
}) | |
) | |
} | |
const start = current.transitionDuration > 0 | |
? current.start + current.transitionDuration | |
: current.start | |
commands.push( | |
_instructionToArg({ | |
start, | |
end: next && next.start, | |
volumeTo: current.volume | |
}) | |
) | |
return commands | |
} | |
/** | |
* @param {string} input Input file absolute path. | |
* @param {string} options Command to execute. | |
* @returns {string} Output path. | |
* @description Execute ffmpeg command provided as argument on | |
* audio file and return. | |
*/ | |
const _applyFilters = async (input, args) => { | |
const output = pathExtra.fileNameWithPostfix(input, '-modified') | |
await logExecAsync(`ffmpeg -i ${input} ${args} -y ${output}`) | |
return output | |
} | |
const _execApplyVolume = async (filePath, audioFilters) => { | |
const args = audioFilters.length > 0 ? `-af "${audioFilters.join()}"` : '' | |
return _applyFilters( | |
filePath, | |
args | |
) | |
} | |
/** | |
* https://ffmpeg.org/ffmpeg-filters.html#toc-afade-1 | |
* @param {string} filePath Array of screens, | |
* @param {Array} instructions Array of instructions. | |
*/ | |
const applyVolume = async (filePath, instructions) => { | |
const commands = _mergeCloseInstructions(instructions) | |
.sort((a, b) => a.start - b.start) | |
.reduce(_reduceFilters, []) | |
logEffects(instructions) | |
return _execApplyVolume(filePath, commands) | |
} | |
module.exports = { | |
applyVolume | |
} | |
const instructions = [{ | |
start: 10, | |
volume: 0.2, | |
transitionDuration: 2 | |
}, { | |
start: 20, | |
volume: 0.8, | |
transitionDuration: 3 | |
}] | |
applyVolume('/path/to/file', instructions) | |
.then(console.log) | |
.catch(console.error) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment