Skip to content

Instantly share code, notes, and snippets.

@hos
Created October 29, 2019 08:37
Show Gist options
  • Save hos/fcd9728396776f520cc524251e37209c to your computer and use it in GitHub Desktop.
Save hos/fcd9728396776f520cc524251e37209c to your computer and use it in GitHub Desktop.
Control audio volume with ffmpeg
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