Skip to main content
Glama
inspector.js10.7 kB
import { parseFile } from 'music-metadata'; import { promises as fs } from 'fs'; import path from 'path'; import { spawn } from 'child_process'; import { promisify } from 'util'; export class AudioInspector { constructor() { this.supportedFormats = [ '.mp3', '.wav', '.flac', '.ogg', '.m4a', '.aac', '.wma', '.aiff', '.au', '.webm', '.opus', '.ape', '.mp4' ]; } /** * Analyze a single audio file * @param {string} filePath - Path to the audio file * @param {boolean} includeGameAnalysis - Whether to include game-specific analysis * @returns {Object} Audio metadata and analysis */ async analyzeFile(filePath, includeGameAnalysis = true) { try { // Check if file exists const stats = await fs.stat(filePath); if (!stats.isFile()) { throw new Error(`Path is not a file: ${filePath}`); } // Get file info const fileInfo = { path: path.resolve(filePath), name: path.basename(filePath), size: stats.size, modified: stats.mtime.toISOString() }; // Try music-metadata first let metadata = null; let useFFprobe = false; try { metadata = await parseFile(filePath); } catch (error) { console.warn(`music-metadata failed for ${filePath}, trying FFprobe:`, error.message); useFFprobe = true; } // Fallback to FFprobe if music-metadata fails if (useFFprobe || !metadata) { metadata = await this.analyzeWithFFprobe(filePath); } // Build standardized result const result = { file: fileInfo, format: this.extractFormatInfo(metadata), tags: this.extractTags(metadata), source: useFFprobe ? 'ffprobe' : 'music-metadata' }; // Add game-specific analysis if requested if (includeGameAnalysis) { result.gameAudio = await this.analyzeForGameAudio(result, filePath); } return result; } catch (error) { return { file: { path: path.resolve(filePath), name: path.basename(filePath), error: error.message }, error: true, message: error.message }; } } /** * Analyze multiple audio files in a directory * @param {string} directoryPath - Path to directory * @param {boolean} recursive - Search recursively * @param {boolean} includeGameAnalysis - Include game-specific analysis * @returns {Object} Batch analysis results */ async analyzeBatch(directoryPath, recursive = false, includeGameAnalysis = true) { try { const files = await this.findAudioFiles(directoryPath, recursive); const results = []; const summary = { totalFiles: files.length, successful: 0, failed: 0, formats: {}, totalSize: 0, totalDuration: 0 }; for (const file of files) { const result = await this.analyzeFile(file, includeGameAnalysis); results.push(result); if (result.error) { summary.failed++; } else { summary.successful++; summary.totalSize += result.file.size || 0; summary.totalDuration += result.format.duration || 0; const format = result.format.container || 'unknown'; summary.formats[format] = (summary.formats[format] || 0) + 1; } } return { summary, results, timestamp: new Date().toISOString() }; } catch (error) { throw new Error(`Batch analysis failed: ${error.message}`); } } /** * Find audio files in directory * @param {string} directoryPath - Directory to search * @param {boolean} recursive - Search recursively * @returns {Array} Array of file paths */ async findAudioFiles(directoryPath, recursive = false) { const files = []; async function scanDirectory(dir) { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isFile()) { const ext = path.extname(entry.name).toLowerCase(); if (this.supportedFormats.includes(ext)) { files.push(fullPath); } } else if (entry.isDirectory() && recursive) { await scanDirectory.call(this, fullPath); } } } await scanDirectory.call(this, directoryPath); return files; } /** * Extract format information from metadata * @param {Object} metadata - Raw metadata from parser * @returns {Object} Standardized format info */ extractFormatInfo(metadata) { const format = metadata.format || {}; return { container: format.container || this.getContainerFromCodec(format.codec), codec: format.codec || format.codecProfile || 'unknown', lossless: format.lossless || false, duration: format.duration || 0, bitrate: format.bitrate || 0, sampleRate: format.sampleRate || 0, channels: format.numberOfChannels || 0, bitsPerSample: format.bitsPerSample || 0 }; } /** * Extract tag information from metadata * @param {Object} metadata - Raw metadata from parser * @returns {Object} Standardized tags */ extractTags(metadata) { const common = metadata.common || {}; return { title: common.title || '', artist: common.artist || '', album: common.album || '', year: common.year || null, genre: Array.isArray(common.genre) ? common.genre.join(', ') : (common.genre || ''), track: common.track?.no || null, comment: common.comment ? common.comment.join(' ') : '' }; } /** * Analyze audio for game development purposes * @param {Object} basicInfo - Basic audio info * @param {string} filePath - File path for additional analysis * @returns {Object} Game-specific analysis */ async analyzeForGameAudio(basicInfo, filePath) { const format = basicInfo.format; const fileSize = basicInfo.file.size; // Calculate memory usage estimate (uncompressed) const estimatedMemoryUsage = Math.round( (format.sampleRate || 44100) * (format.channels || 2) * (format.bitsPerSample || 16) / 8 * (format.duration || 0) ); // Determine if suitable for looping (check for silence at beginning/end) const suitableForLoop = this.checkLoopSuitability(format); // Recommend compression format based on content const recommendedFormat = this.recommendCompressionFormat(format, fileSize); // Platform optimizations const platformOptimizations = this.getPlatformOptimizations(format); return { suitableForLoop, recommendedCompressionFormat: recommendedFormat, estimatedMemoryUsage, platformOptimizations, compressionRatio: fileSize / (estimatedMemoryUsage || 1), gameDevNotes: this.getGameDevNotes(format, fileSize) }; } /** * Analyze with FFprobe fallback * @param {string} filePath - File to analyze * @returns {Object} Metadata from FFprobe */ async analyzeWithFFprobe(filePath) { return new Promise((resolve, reject) => { const ffprobe = spawn('ffprobe', [ '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath ]); let output = ''; let error = ''; ffprobe.stdout.on('data', (data) => { output += data.toString(); }); ffprobe.stderr.on('data', (data) => { error += data.toString(); }); ffprobe.on('close', (code) => { if (code !== 0) { reject(new Error(`FFprobe failed: ${error}`)); return; } try { const data = JSON.parse(output); const stream = data.streams?.find(s => s.codec_type === 'audio'); const format = data.format; resolve({ format: { container: format?.format_name?.split(',')[0] || 'unknown', codec: stream?.codec_name || 'unknown', duration: parseFloat(format?.duration) || 0, bitrate: parseInt(format?.bit_rate) || 0, sampleRate: parseInt(stream?.sample_rate) || 0, numberOfChannels: parseInt(stream?.channels) || 0, bitsPerSample: parseInt(stream?.bits_per_sample) || 0 }, common: { title: format?.tags?.title || '', artist: format?.tags?.artist || '', album: format?.tags?.album || '', year: format?.tags?.date ? parseInt(format.tags.date) : null, genre: format?.tags?.genre || '' } }); } catch (parseError) { reject(new Error(`Failed to parse FFprobe output: ${parseError.message}`)); } }); }); } /** * Helper methods for game audio analysis */ checkLoopSuitability(format) { // Simple heuristic: files under 30 seconds might be suitable for looping return format.duration && format.duration < 30; } recommendCompressionFormat(format, fileSize) { if (format.lossless || format.bitsPerSample >= 24) { return 'OGG Vorbis (High Quality)'; } else if (format.duration < 10) { return 'Uncompressed (Short Sound)'; } else { return 'OGG Vorbis (Standard)'; } } getPlatformOptimizations(format) { return { mobile: format.sampleRate > 22050 ? 'Consider downsampling to 22kHz' : 'Optimized', desktop: 'Use original quality', console: format.channels > 2 ? 'Consider stereo downmix' : 'Optimized' }; } getGameDevNotes(format, fileSize) { const notes = []; if (format.sampleRate > 48000) { notes.push('High sample rate detected - consider 48kHz for games'); } if (format.bitsPerSample > 16) { notes.push('High bit depth - 16-bit usually sufficient for games'); } if (fileSize > 10 * 1024 * 1024) { // 10MB notes.push('Large file size - consider compression'); } return notes.join('; '); } getContainerFromCodec(codec) { const codecMap = { 'mp3': 'MP3', 'wav': 'WAV', 'flac': 'FLAC', 'vorbis': 'OGG', 'aac': 'M4A', 'opus': 'OPUS' }; return codecMap[codec?.toLowerCase()] || 'unknown'; } /** * Get supported formats * @returns {Object} Supported formats info */ getSupportedFormats() { return { primary: this.supportedFormats, fallback: 'FFprobe supports additional formats', note: 'Primary formats use music-metadata library, exotic formats fallback to FFprobe' }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DeveloperZo/mcp-audio-inspector'

If you have feedback or need assistance with the MCP directory API, please join our Discord server