/**
* `audioconv` is an abbreviation for Audio Converter, this module
* provides a function to convert audios to any supported format utilizing the
* `fluent-ffmpeg` module and `ffmpeg` library on the system.
*
* To convert the audio file, this module needs `ffmpeg` to be installed.
* You can download it from [FFmpeg official site](https://ffmpeg.org/).
* Or if you want to use CLI:
*
* #### Using `apt` (for Linux)
* ```bash
* $ sudo apt install ffmpeg
* ```
*
* #### Using Chocolatey (for Windows)
* <small>_*Make sure to **Run as Administrator**_</small>
* ```pwsh
* PS > choco install ffmpeg-full -y
* ```
*
* @module audioconv
* @requires utils
* @author Ryuu Mitsuki (https://github.com/mitsuki31)
* @license MIT
* @since 0.2.0
*/
'use strict';
const fs = require('fs');
const path = require('path');
const childProcess = require('node:child_process');
const { EOL } = require('node:os');
const ffmpeg = require('fluent-ffmpeg');
const {
logger: log,
LOGDIR,
createDirIfNotExistSync,
createLogFile,
dropNullAndUndefined,
isNullOrUndefined,
isPlainObject
} = require('./utils');
/**
* Options for configuring the audio conversion.
*
* @typedef {Object} ConvertAudioOptions
* @property {string[]} [inputOptions=[]] - The input options for the conversion.
* @property {string[]} [outputOptions=[]] - The output options for the conversion.
* @property {string} [format='mp3'] - The desired output format (e.g., `'mp3'`, `'aac'`).
* @property {string | number} [bitrate=128] - The audio bitrate (e.g., `'128k'`),
* it may be a number or a string with an optional `k` suffix.
* @property {number} [frequency=44100] - The audio sampling frequency in Hz.
* @property {string} [codec='libmp3lame'] - The audio codec to use (e.g., `'libmp3lame'`).
* @property {number} [channels=2] - The number of audio channels (`2` for stereo).
* @property {boolean} [deleteOld=false] - Whether to delete the original file after conversion.
* @property {boolean} [quiet=false] - Whether to suppress the conversion progress and error message or not.
*
* @global
* @since 1.0.0
* @see {@link module:audioconv~defaultOptions defaultOptions}
*/
/**
* The resolved {@link module:audioconv~ConvertAudioOptions ConvertAudioOptions} options.
*
* @typedef {Object} ResolvedConvertAudioOptions
* @property {string[]} inputOptions - The input options for the conversion.
* @property {string[]} outputOptions - The output options for the conversion.
* @property {string} format - The desired output format (e.g., `'mp3'`, `'aac'`).
* @property {string | number} bitrate - The audio bitrate (e.g., `'128k'`),
* it may be a number or a string with an optional `k` suffix.
* @property {number} frequency - The audio sampling frequency in Hz.
* @property {string} codec - The audio codec to use (e.g., `'libmp3lame'`).
* @property {number} channels - The number of audio channels (`2` for stereo).
* @property {boolean} deleteOld - Whether to delete the original file after conversion.
* @property {boolean} quiet - Whether to suppress the conversion progress and error message or not.
*
* @package
* @since 1.0.0
* @see {@link module:audioconv~defaultOptions defaultOptions}
*/
/**
* An object representing the information data when FFmpeg emits the `'progress'` event.
*
* @typedef {Object} FFmpegInfo
* @property {number} frames - Total processed frame count.
* @property {number} currentFps - Framerate at which FFmpeg is currently processing.
* @property {number} currentKbps - Throughput at which FFmpeg is currently processing.
* @property {number} targetSize - Current size of the target file in kilobytes.
* @property {number} timemark - The timestamp of the current frame in seconds.
* @property {number} percent - An estimation of the progress percentage, may be (very) inaccurate.
*
* @package
* @since 1.1.0
* @see ['progress' event]{@linkplain https://github.com/fluent-ffmpeg/node-fluent-ffmpeg#progress-transcoding-progress-information}
*/
/**
* Default options of audio converter options.
*
* This default options will convert the audio to the MP3 format with bitrate of 128 kbps,
* frequency of 44100 Hz (Hertz), stereo channel and use the default MP3 codec.
*
* If you want to delete the old audio file after conversion, set the
* `deleteOld` option to `true`.
*
* @public
* @readonly
* @type {Readonly<module:audioconv~ResolvedConvertAudioOptions>}
* @since 0.2.0
*/
const defaultOptions = Object.freeze({
inputOptions: [],
outputOptions: [],
format: 'mp3',
bitrate: 128, // optional `k` suffix
frequency: 44100,
codec: 'libmp3lame',
channels: 2,
deleteOld: false,
quiet: false
});
// region Utilities
/**
* Splits and resolves FFmpeg options from a string or array format into an array of individual options.
*
* This function handles both single string input, where options are space-separated, and array input.
* It correctly pairs options with their respective values and avoids accidental concatenation with subsequent options.
*
* @example
* const optionsStr = '-f -vcodec libx264 -preset slow';
* const result1 = splitOptions(optionsStr);
* // Output: ['-f', '-vcodec libx264', '-preset slow']
*
* @param {string | string[]} options - The options to split, either as a string or an array.
* @returns {string[]} The resolved options as an array of individual options.
*
* @package
* @since 1.0.0
*/
function splitOptions(options) {
if (typeof options === 'string') {
const optionsList = options.trim().split(' ');
const resolvedOptions = [];
// Iterate over the options and resolve the option
optionsList.forEach((option, index) => {
let valueIndex = -1; // To indicate if the option has a value, -1 means no value
// Only resolve the option that starts with a hyphen ('-')
if (option.startsWith('-')) {
valueIndex = index + 1;
if (valueIndex < optionsList.length) {
// Check if the next argument is not an option that starts with hyphen
if (!optionsList[valueIndex].startsWith('-')) {
resolvedOptions.push(`${option} ${optionsList[valueIndex]}`);
// Otherwise, only push the option without the next argument as value
} else {
resolvedOptions.push(option);
}
} else {
resolvedOptions.push(option);
}
} else {
// If the option doesn't start with a hyphen, it's a value
// of the previous option and won't add it to the list
// and also this option may an invalid or unknown option for FFmpeg
if (valueIndex > -1) resolvedOptions.push(option);
}
});
// Drop null and undefined values and convert to an array
return Object.values(dropNullAndUndefined(
resolvedOptions.map(option => option.trim())
));
} else if (Array.isArray(options)) {
return options;
}
return [];
}
/**
* Resolves the given {@link ConvertAudioOptions} options.
*
* @package
* @param {ConvertAudioOptions} options - The unresolved audio converter options.
* @return {module:audioconv~ResolvedConvertAudioOptions} The resolved options.
* @since 1.0.0
*/
function resolveOptions(options, useDefault=false) {
if (!isPlainObject(options)) return defaultOptions;
return {
inputOptions: (
Array.isArray(options?.inputOptions)
? options.inputOptions
: (typeof options?.inputOptions === 'string')
? splitOptions(options.inputOptions)
: (useDefault ? defaultOptions.inputOptions : undefined)
),
outputOptions: (
Array.isArray(options?.outputOptions)
? options.outputOptions
: (typeof options?.outputOptions === 'string')
? splitOptions(options.outputOptions)
: (useDefault ? defaultOptions.outputOptions : undefined)
),
format: (typeof options?.format === 'string')
? options.format
: (useDefault ? defaultOptions.format : undefined),
bitrate: ['string', 'number'].includes(typeof options?.bitrate)
? options.bitrate
: (useDefault ? defaultOptions.bitrate : undefined),
frequency: (typeof options?.frequency === 'number')
? options.frequency
: (useDefault ? defaultOptions.frequency : undefined),
codec: (typeof options?.codec === 'string')
? options.codec
: (useDefault ? defaultOptions.codec : undefined),
channels: (typeof options?.channels === 'number')
? options.channels
: (useDefault ? defaultOptions.channels : undefined),
deleteOld: (typeof options?.deleteOld === 'boolean')
? options.deleteOld
: (useDefault ? defaultOptions.deleteOld : undefined),
quiet: (typeof options?.quiet === 'boolean')
? options.quiet
: (useDefault ? defaultOptions.quiet : undefined)
};
}
/**
* Checks whether the `ffmpeg` binary is installed on system or not.
*
* First, it checks if the `FFMPEG_PATH` environment variable is set. If it is set, it returns `true`.
* Otherwise, if not set, it checks if the `ffmpeg` binary is installed on system by directly executing it.
*
* @param {boolean} verbose - Whether to log verbose messages or not.
* @returns {Promise<boolean>} `true` if the `ffmpeg` binary installed on system; otherwise, `false`.
*
* @async
* @public
* @since 1.0.0
*/
async function checkFfmpeg(verbose=false) {
verbose && log.debug('Checking `ffmpeg` binary...');
if (!isNullOrUndefined(process.env.FFMPEG_PATH) && process.env.FFMPEG_PATH !== '') {
if ((await fs.promises.stat(process.env.FFMPEG_PATH)).isDirectory()) {
const msg = '[EISDIR] Please set the FFMPEG_PATH environment variable '
+ 'to the path of the `ffmpeg` binary';
verbose && log.warn(msg);
throw new Error(msg);
}
verbose && log.debug('`ffmpeg` installed on system');
return true;
}
const { status } = childProcess.spawnSync('ffmpeg', ['-version'], {
shell: true, // For Windows, would cause error if this set to false
windowsHide: true
});
if (status === 0) {
verbose && log.debug('`ffmpeg` installed on system');
return true;
}
verbose && log.error('`ffmpeg` not installed on system');
return false;
}
/**
* Writes error details and associated video information to a log file.
*
* The error message is written to the log file in the following format:
*
* ```txt
* [ERROR]<ACONV> <error message>
* Input Audio: <input audio name>
* Output Audio: <output audio name>
* File Size: <input audio size> MiB
* ---------------------------------------------
* ```
*
* Generated log file will be saved in {@link module:utils~LOGDIR `LOGDIR`} directory
* with file name typically prefixed with `'audioConvError'`.
*
* @param {string} logFile - The name of the log file where the error details should be written.
* @param {Object} data - An object containing information about the audio associated with the error.
* @param {Error} [error] - The error object, optional. If not provided,
* an error message will be `'Unknown error'`.
* @returns {Promise<void>}
*
* @async
* @package
* @since 1.0.0
*/
async function writeErrorLog(logFile, data, error) {
// Return immediately if given log file is not a string type
if (isNullOrUndefined(logFile) || typeof logFile !== 'string') return;
logFile = path.join(LOGDIR, path.basename(logFile));
createDirIfNotExistSync(LOGDIR);
return new Promise((resolve, reject) => {
const logStream = fs.createWriteStream(logFile, { flags: 'a+', flush: true });
logStream.write(`[ERROR]<ACONV> ${error?.message || 'Unknown error'}${EOL}`);
logStream.write(` Input Audio: ${data?.inputAudio || 'Unknown'}${EOL}`);
logStream.write(` Output Audio: ${data?.outputAudio || 'Unknown'}${EOL}`);
logStream.write(` File Size: ${data?.inputSize / (1024 * 1024) || '0.0'} MiB${EOL}`);
logStream.write(`---------------------------------------------${EOL}`);
logStream.end(EOL);
logStream.on('finish', () => resolve());
logStream.on('error', (err) => {
if (!logStream.destroyed) logStream.destroy();
reject(err);
});
});
}
// region Audio Conversion
/**
* Creates a string representing the progress bar for audio conversion progress.
*
* @param {FFmpegInfo} info - The progress data from FFmpeg.
* @param {string[]} extnames - A list of extension names of both input and output files.
* @returns {string} A formatted string representing the progress bar with percentage.
*
* @package
* @since 1.0.0
*/
function createConversionProgress(info, extnames) {
const percentage = Math.max(0, Math.round(info.percent || 0));
const currentKbps = Math.max(0, info.currentKbps || 0);
const targetSize = (Math.max(0, info.targetSize || 0) / 1024).toFixed(2);
return `
\x1b[K${(percentage < 100 ? '\x1b[1;93m[...]\x1b[0m' : log.DONE_PREFIX)
} (${extnames[0].toUpperCase()} >> ${extnames[1].toUpperCase()}) | \x1b[95m${
currentKbps} kbps // ${targetSize} MB\x1b[0m ${
percentage < 100 ? '\x1b[93m' : '\x1b[92m'}[${percentage}%]\x1b[0m
`.trim() + '\r';
}
/**
* Converts an audio file to a specified format using the given options.
*
* Before performing audio conversion, it first checks the `ffmpeg` binary by
* searching on the `FFMPEG_PATH` environment variable, if set. Otherwise, it
* force check by calling the `ffmpeg` command itself on child process.
*
* If the `ffmpeg` is not installed on the system, this function will aborts
* immediately and rejects with an error.
*
* @param {string} inFile - The input file path of the audio file to be converted.
* @param {ConvertAudioOptions} [options=defaultOptions] - Options object for configuring
* the conversion process.
*
* @throws {Error} If the input audio file is not exist or if there is an error
* occurred during audio conversion.
*
* @example
* convertAudio('path/to/audio.wav', { format: 'mp3', bitrate: '192k' })
* .then(() => console.log('Conversion complete'))
* .catch(err => console.error('Conversion failed:', err));
*
* @async
* @public
* @since 0.2.0
* @see {@link module:audioconv~defaultOptions defaultOptions}
* @see {@link module:audioconv~checkFfmpeg checkFfmpeg}
*/
async function convertAudio(inFile, options = defaultOptions) {
inFile = path.resolve(inFile);
options = dropNullAndUndefined(options); // Drop all nullable properties
/**
* @ignore
* @type {ResolvedConvertAudioOptions}
*/
options = resolveOptions(options);
const { quiet } = options; // Extract the 'quiet' field
// Check whether the given audio file is exist
if (!fs.existsSync(inFile)) {
throw new Error('File not exists: ' + inFile);
}
// Create the output file name and change the file extension
let outFile = path.join(
path.dirname(inFile),
`${path.basename(inFile).replace(/\.[^/.]+$/, '')}.${(options.format
|| (() => {
let index = 0;
if (Array.isArray(options.outputOptions) && options.outputOptions.length > 0) {
options.outputOptions.forEach((opt, idx) => {
if (opt.match(/^-(acodec|c:a)/)) index = idx;
});
return options.outputOptions[index].split(' ')[1];
}
return null;
})()
|| path.extname(inFile).replace(/^\./, ''))
}`
);
// Store the file names only without their path directories
const ioBaseFile = [
path.basename(inFile).replace(/\.[^/.]+$/, ''),
path.basename(outFile).replace(/\.[^/.]+$/, '')
];
const extnames = [
path.extname(inFile).replace('.', ''),
path.extname(outFile).replace('.', '')
];
// Logic to prevent crash due to write the same file in-place
if (inFile === outFile) {
ioBaseFile[1] = ioBaseFile[1] + ' (copy)';
outFile = path.join(path.dirname(outFile), `${ioBaseFile[1]}.${extnames[1]}`);
}
quiet || log.info(`Processing audio for \x1b[93m${ioBaseFile[0]}\x1b[0m ...`);
// Check whether the `ffmpeg` binary is installed
if (!(await checkFfmpeg())) {
if (!quiet) {
log.error('Cannot find `ffmpeg` on your system');
log.error('Audio conversion aborted');
}
throw new Error('Cannot find `ffmpeg` binary on your system');
}
await new Promise((resolve, reject) => {
// Perform audio conversion using ffmpeg
const ffmpegChain = ffmpeg()
.addInput(inFile) // IN
.output(outFile); // OUT
// Only add non-custom options (e.g., bitrate, codec) if the `inputOptions`
// and `outputOptions` are not set or they are an empty array, this make the
// custom options are more prioritized
if (
(
isNullOrUndefined(options.inputOptions)
|| (Array.isArray(options.inputOptions) && options.inputOptions.length === 0)
) && (
isNullOrUndefined(options.outputOptions)
|| (Array.isArray(options.outputOptions) && options.outputOptions.length === 0)
)
) {
// Add each option only if present and specified
// By using this logic, now user can convert an audio file with only specifying
// the output audio format
if (options.bitrate) ffmpegChain.audioBitrate(options.bitrate);
if (options.codec) ffmpegChain.audioCodec(options.codec);
if (options.channels) ffmpegChain.audioChannels(options.channels);
if (options.frequency) ffmpegChain.audioFrequency(options.frequency);
// Mandatory for output format
// Note: Specifying the same output format as input may copy the input file
// with the same codec and format
ffmpegChain.outputFormat(options.format);
} else {
if (Array.isArray(options.inputOptions)) {
ffmpegChain.inputOptions(options.inputOptions);
}
if (Array.isArray(options.outputOptions)) {
ffmpegChain.outputOptions(options.outputOptions);
}
}
// Handlers
ffmpegChain
.on('error', (err) => {
quiet || process.stdout.write('\n');
// Safely get the input file size and prevent any error
// if the input file has been deleted unexpectedly
let inputSize = NaN;
try {
inputSize = fs.statSync(inFile).size;
// eslint-disable-next-line no-unused-vars,no-empty
} catch (err) {}
writeErrorLog(createLogFile('audioConvError'), {
inputAudio: inFile,
outputAudio: outFile,
inputSize
}, err).then(() => {
if (!quiet) {
log.error('Failed to convert the audio file');
console.error('Caused by:', err.message);
}
reject(err);
}).catch((errLog) => reject(new Error(errLog.message, { cause: err })));
})
.on('progress', (info) => {
// Write the progress information to the console
quiet || process.stdout.write(createConversionProgress(info, extnames));
})
.on('end', () => {
quiet || process.stdout.write('\n');
// Remove the old audio file if `deleteOld` option is true
if (options.deleteOld) {
fs.rmSync(inFile);
}
resolve();
});
ffmpegChain.run();
});
}
module.exports = Object.freeze({
defaultOptions,
splitOptions,
writeErrorLog,
createConversionProgress,
resolveOptions,
checkFfmpeg,
convertAudio
});