import { execSync } from 'node:child_process' import fsSync from 'node:fs' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { createLogger } from '@sim/logger' import ffmpegStatic from 'ffmpeg-static' import ffmpeg from 'fluent-ffmpeg' import type { AudioExtractionOptions, AudioExtractionResult, AudioMetadata, } from '@/lib/audio/types' const logger = createLogger('AudioExtractor') let ffmpegInitialized = false let ffmpegPath: string | null = null /** * Lazy initialization of FFmpeg - only runs when needed, not at module load */ function ensureFfmpeg(): void { if (ffmpegInitialized) { if (!ffmpegPath) { throw new Error( 'FFmpeg not found. Install: brew install ffmpeg (macOS) / apk add ffmpeg (Alpine) / apt-get install ffmpeg (Ubuntu)' ) } return } ffmpegInitialized = true // Try ffmpeg-static binary if (ffmpegStatic && typeof ffmpegStatic === 'string') { try { fsSync.accessSync(ffmpegStatic, fsSync.constants.X_OK) ffmpegPath = ffmpegStatic ffmpeg.setFfmpegPath(ffmpegPath) logger.info('[FFmpeg] Using ffmpeg-static:', ffmpegPath) return } catch { // Binary doesn't exist or not executable } } // Try system ffmpeg (cross-platform) try { const cmd = process.platform === 'win32' ? 'where ffmpeg' : 'which ffmpeg' const result = execSync(cmd, { encoding: 'utf-8' }).trim() // On Windows, 'where' returns multiple paths - take first ffmpegPath = result.split('\n')[0] ffmpeg.setFfmpegPath(ffmpegPath) logger.info('[FFmpeg] Using system ffmpeg:', ffmpegPath) return } catch { // System ffmpeg not found } // No FFmpeg found - set flag but don't throw yet // Error will be thrown when user tries to use video extraction logger.warn('[FFmpeg] No FFmpeg binary found at module load time') } /** * Extract audio from video or convert audio format using FFmpeg */ export async function extractAudioFromVideo( inputBuffer: Buffer, mimeType: string, options: AudioExtractionOptions = {} ): Promise { // Initialize FFmpeg on first use ensureFfmpeg() const isVideo = mimeType.startsWith('video/') const isAudio = mimeType.startsWith('audio/') // If it's already audio and no conversion needed, get metadata and return if (isAudio && !options.outputFormat) { try { const metadata = await getAudioMetadata(inputBuffer, mimeType) return { buffer: inputBuffer, format: mimeType.split('/')[1] || 'unknown', duration: metadata.duration || 0, size: inputBuffer.length, } } catch (error) { // If metadata extraction fails, still return the buffer return { buffer: inputBuffer, format: mimeType.split('/')[1] || 'unknown', duration: 0, size: inputBuffer.length, } } } // For video or audio conversion, use ffmpeg if (isVideo || options.outputFormat) { return await convertAudioWithFFmpeg(inputBuffer, mimeType, options) } // Fallback return { buffer: inputBuffer, format: options.outputFormat || mimeType.split('/')[1] || 'unknown', duration: 0, size: inputBuffer.length, } } /** * Convert audio/video using FFmpeg */ async function convertAudioWithFFmpeg( inputBuffer: Buffer, mimeType: string, options: AudioExtractionOptions ): Promise { // Create temporary files const tempDir = os.tmpdir() const inputExt = getExtensionFromMimeType(mimeType) const outputFormat = options.outputFormat || 'mp3' const inputFile = path.join(tempDir, `ffmpeg-input-${Date.now()}.${inputExt}`) const outputFile = path.join(tempDir, `ffmpeg-output-${Date.now()}.${outputFormat}`) try { // Write input buffer to temporary file await fs.writeFile(inputFile, inputBuffer) // Get metadata for duration let duration = 0 try { const metadata = await getAudioMetadataFromFile(inputFile) duration = metadata.duration || 0 } catch (error) { // Metadata extraction failed, continue without duration logger.warn('Failed to extract metadata:', error) } // Convert using FFmpeg await new Promise((resolve, reject) => { let command = ffmpeg(inputFile).toFormat(outputFormat).audioCodec(getAudioCodec(outputFormat)) // Apply audio options if (options.channels) { command = command.audioChannels(options.channels) } if (options.sampleRate) { command = command.audioFrequency(options.sampleRate) } if (options.bitrate) { command = command.audioBitrate(options.bitrate) } command .on('end', () => resolve()) .on('error', (err) => reject(new Error(`FFmpeg error: ${err.message}`))) .save(outputFile) }) // Read output file const outputBuffer = await fs.readFile(outputFile) return { buffer: outputBuffer, format: outputFormat, duration, size: outputBuffer.length, } } finally { // Clean up temporary files try { await fs.unlink(inputFile).catch(() => {}) await fs.unlink(outputFile).catch(() => {}) } catch (error) { // Ignore cleanup errors } } } /** * Get audio metadata using ffprobe */ export async function getAudioMetadata(buffer: Buffer, mimeType: string): Promise { ensureFfmpeg() // Initialize FFmpeg/ffprobe const tempDir = os.tmpdir() const inputExt = getExtensionFromMimeType(mimeType) const inputFile = path.join(tempDir, `ffprobe-input-${Date.now()}.${inputExt}`) try { // Write buffer to temporary file await fs.writeFile(inputFile, buffer) // Get metadata using ffprobe return await getAudioMetadataFromFile(inputFile) } finally { // Clean up temporary file try { await fs.unlink(inputFile).catch(() => {}) } catch (error) { // Ignore cleanup errors } } } /** * Get audio metadata from a file path using ffprobe */ async function getAudioMetadataFromFile(filePath: string): Promise { ensureFfmpeg() // Initialize FFmpeg/ffprobe return new Promise((resolve, reject) => { ffmpeg.ffprobe(filePath, (err, metadata) => { if (err) { reject(new Error(`FFprobe error: ${err.message}`)) return } const audioStream = metadata.streams.find((s) => s.codec_type === 'audio') const format = metadata.format resolve({ duration: format.duration || 0, format: format.format_name || 'unknown', codec: audioStream?.codec_name, sampleRate: audioStream?.sample_rate, channels: audioStream?.channels, bitrate: format.bit_rate ? Number(format.bit_rate) : undefined, }) }) }) } /** * Get file extension from MIME type */ function getExtensionFromMimeType(mimeType: string): string { const mimeToExt: Record = { // Video 'video/mp4': 'mp4', 'video/quicktime': 'mov', 'video/x-msvideo': 'avi', 'video/x-matroska': 'mkv', 'video/webm': 'webm', // Audio 'audio/mpeg': 'mp3', 'audio/mp4': 'm4a', 'audio/wav': 'wav', 'audio/webm': 'webm', 'audio/ogg': 'ogg', 'audio/flac': 'flac', 'audio/aac': 'aac', 'audio/opus': 'opus', } return mimeToExt[mimeType] || mimeType.split('/')[1] || 'dat' } /** * Get appropriate audio codec for output format */ function getAudioCodec(format: string): string { const codecMap: Record = { mp3: 'libmp3lame', wav: 'pcm_s16le', flac: 'flac', m4a: 'aac', aac: 'aac', ogg: 'libvorbis', opus: 'libopus', } return codecMap[format] || 'libmp3lame' } /** * Check if a file is a video file */ export function isVideoFile(mimeType: string): boolean { return mimeType.startsWith('video/') } /** * Check if a file is an audio file */ export function isAudioFile(mimeType: string): boolean { return mimeType.startsWith('audio/') } /** * Get optimal audio format for STT provider */ export function getOptimalFormat(provider: 'whisper' | 'deepgram' | 'elevenlabs'): { format: 'mp3' | 'wav' | 'flac' sampleRate: number channels: 1 | 2 } { switch (provider) { case 'whisper': // Whisper prefers 16kHz mono return { format: 'mp3', sampleRate: 16000, channels: 1, } case 'deepgram': // Deepgram works well with various formats return { format: 'mp3', sampleRate: 16000, channels: 1, } case 'elevenlabs': // ElevenLabs format preferences return { format: 'mp3', sampleRate: 16000, channels: 1, } default: return { format: 'mp3', sampleRate: 16000, channels: 1, } } }