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'
};
}
}