UNPKG

@profullstack/transcoder

Version:

A server-side module for transcoding videos, audio, and images using FFmpeg with smart presets and optimizations

307 lines (257 loc) 10.3 kB
/** * @profullstack/transcoder - Audio Module * Contains functionality for transcoding audio files */ import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; import { TranscodeEmitter, DEFAULT_AUDIO_OPTIONS, checkFfmpeg, parseProgress } from './core.js'; import { getVideoMetadata, validatePaths, ensureOutputDirectory } from './utils.js'; import { getPreset, PRESETS } from '../presets.js'; /** * Transcodes an audio file to another format * * @param {string} inputPath - Path to the input audio file * @param {string} outputPath - Path where the transcoded audio will be saved * @param {Object} [options={}] - Transcoding options * @returns {Promise<Object>} - Promise that resolves with the output path and emitter */ export async function transcodeAudio(inputPath, outputPath, options = {}) { // Create an emitter for progress events const emitter = new TranscodeEmitter(); // Handle platform-specific presets let mergedOptions = { ...options }; // If a preset name is provided, get the preset configuration if (options.preset && typeof options.preset === 'string' && PRESETS[options.preset.toLowerCase()]) { const presetConfig = getPreset(options.preset); if (presetConfig) { // Merge preset with user options (user options take precedence over preset) mergedOptions = { ...presetConfig, ...options }; } } // Merge default options with user options (including preset if applicable) const settings = { ...DEFAULT_AUDIO_OPTIONS, ...mergedOptions }; // Validate input and output paths await validatePaths(inputPath, outputPath, settings.overwrite); // Check if ffmpeg is installed try { await checkFfmpeg(); } catch (error) { throw error; } // Check if input has audio streams before proceeding try { const metadata = await getVideoMetadata(inputPath); if (!metadata.audio || Object.keys(metadata.audio).length === 0) { throw new Error('Input file does not contain any audio streams'); } } catch (error) { throw error; } // Build ffmpeg arguments const ffmpegArgs = []; // Add input file ffmpegArgs.push('-i', inputPath); // Check if we're only applying audio enhancements const isEnhancementOnly = !settings.audioCodec && ( settings.normalize || settings.fadeIn > 0 || settings.fadeOut > 0 || settings.noiseReduction > 0 || (settings.audio && Object.keys(settings.audio).length > 0) // Check for audio enhancement options from the new API ); // For unsupported formats, skip audio enhancement if (isEnhancementOnly) { // Get the file extension const ext = path.extname(inputPath).toLowerCase(); // Check if the format is supported for audio enhancement // WAV and AAC are fully supported if (ext === '.wav' || ext === '.aac') { // These formats are fully supported, continue processing } // MP3, FLAC, and OGG may have issues with audio enhancement else if (ext === '.mp3' || ext === '.flac' || ext === '.ogg') { // Create a custom error object with a special property const error = new Error(`Audio enhancement for ${ext} files may not work correctly. Use a codec parameter or convert to WAV first.`); error.message = `Audio enhancement for ${ext} files may not work correctly. Use a codec parameter or convert to WAV first.`; error.isFormatWarning = true; throw error; } } // For audio enhancement only, we'll use a simpler approach that works for all formats if (isEnhancementOnly) { // Use the codec specified by the user, or default to AAC ffmpegArgs.push('-c:a', settings.audioCodec || 'aac'); } else { // For regular transcoding, use the specified codec ffmpegArgs.push('-c:a', settings.audioCodec); } // Add audio bitrate if specified if (settings.audioBitrate) { ffmpegArgs.push('-b:a', settings.audioBitrate); } // Add sample rate if specified if (settings.audioSampleRate) { ffmpegArgs.push('-ar', settings.audioSampleRate.toString()); } // Add channels if specified if (settings.audioChannels) { ffmpegArgs.push('-ac', settings.audioChannels.toString()); } // Prepare audio filters let audioFilters = []; // Add normalization filter if specified if (settings.normalize) { audioFilters.push('loudnorm=I=-16:TP=-1.5:LRA=11'); } // Add fade in filter if specified if (settings.fadeIn > 0) { audioFilters.push(`afade=t=in:st=0:d=${settings.fadeIn}`); } // Add fade out filter if specified if (settings.fadeOut > 0) { // Get audio duration to calculate fade out start time try { const metadata = await getVideoMetadata(inputPath); const duration = metadata.format.duration || 0; if (duration > 0) { const fadeOutStart = Math.max(0, duration - settings.fadeOut); audioFilters.push(`afade=t=out:st=${fadeOutStart}:d=${settings.fadeOut}`); } } catch (error) { console.warn(`Warning: Could not determine audio duration for fade out: ${error.message}`); // If we can't get the duration, add a fade out without a specific start time audioFilters.push(`afade=t=out:d=${settings.fadeOut}`); } } // Add noise reduction filter if specified if (settings.noiseReduction > 0) { // Ensure the value is between 0 and 1 const nrValue = Math.min(1, Math.max(0, settings.noiseReduction)); // Convert to a value between 0.01 and 0.97 for the FFmpeg filter const nrAmount = 0.01 + (nrValue * 0.96); // Use a valid noise floor value (in dB, between -80 and -20) const noiseFloor = -60; // A reasonable default value audioFilters.push(`afftdn=nr=${nrAmount}:nf=${noiseFloor}`); } // Apply audio filters if any if (audioFilters.length > 0) { ffmpegArgs.push('-af', audioFilters.join(',')); } // Disable video if input has video streams ffmpegArgs.push('-vn'); // Add progress output ffmpegArgs.push('-progress', 'pipe:1'); // Add overwrite flag if needed if (settings.overwrite) { ffmpegArgs.push('-y'); } else { ffmpegArgs.push('-n'); } // No need to specify output format explicitly, FFmpeg will determine it from the output file extension // Add custom ffmpeg arguments if specified if (settings.ffmpegArgs) { // Split the custom arguments string into an array of arguments // This handles arguments with spaces correctly by respecting quotes const customArgs = settings.ffmpegArgs.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; // Remove any quotes from the arguments const processedArgs = customArgs.map(arg => arg.replace(/^['"]|['"]$/g, '')); if (settings.verbose) { console.log(`Adding custom ffmpeg arguments: ${processedArgs.join(' ')}`); } // Add the custom arguments to the ffmpeg command ffmpegArgs.push(...processedArgs); } // Add output file ffmpegArgs.push(outputPath); // Store the complete FFmpeg command for logging const ffmpegCommand = `ffmpeg ${ffmpegArgs.join(' ')}`; return new Promise((resolve, reject) => { // Spawn ffmpeg process const ffmpegProcess = spawn('ffmpeg', ffmpegArgs); let errorOutput = ''; // Handle stdout (progress information) ffmpegProcess.stdout.on('data', (data) => { const dataStr = data.toString(); const progress = parseProgress(dataStr); if (progress) { emitter.emit('progress', progress); } }); // Handle stderr (log information) ffmpegProcess.stderr.on('data', (data) => { const dataStr = data.toString(); errorOutput += dataStr; // FFmpeg outputs progress information to stderr as well const progress = parseProgress(dataStr); if (progress) { emitter.emit('progress', progress); } // Emit log event emitter.emit('log', dataStr); }); // Handle process exit ffmpegProcess.on('close', async (code) => { if (code === 0) { // Check if output file was created if (!fs.existsSync(outputPath)) { return reject(new Error('Transcoding failed: Output file was not created')); } // Extract metadata from the input audio try { const metadata = await getVideoMetadata(inputPath); resolve({ outputPath, emitter, ffmpegCommand, metadata }); } catch (metadataError) { console.warn(`Warning: Failed to extract metadata: ${metadataError.message}`); resolve({ outputPath, emitter, ffmpegCommand }); } } else { reject(new Error(`FFmpeg transcoding failed with code ${code}: ${errorOutput}`)); } }); // Handle process error ffmpegProcess.on('error', (err) => { reject(new Error(`Failed to start FFmpeg process: ${err.message}`)); }); // Emit start event emitter.emit('start', { command: 'ffmpeg', args: ffmpegArgs }); }); } /** * Batch transcodes multiple audio files * * @param {Array<Object>} items - Array of objects with input and output paths and optional settings * @param {Object} [globalOptions={}] - Global options to apply to all items * @returns {Promise<Object>} - Promise that resolves with results for all items */ export async function transcodeAudioBatch(items, globalOptions = {}) { if (!Array.isArray(items) || items.length === 0) { throw new Error('Items array is required and must not be empty'); } const results = { successful: [], failed: [] }; for (const item of items) { try { // Merge global options with item-specific options const options = { ...globalOptions, ...item.options }; // Transcode the audio const result = await transcodeAudio(item.input, item.output, options); // Add to successful results results.successful.push({ input: item.input, output: result.outputPath, metadata: result.metadata }); } catch (error) { // Add to failed results results.failed.push({ input: item.input, output: item.output, error: error.message }); } } return results; }