diff --git a/backend/folder.go b/backend/folder.go index f7934f2..db33d2a 100644 --- a/backend/folder.go +++ b/backend/folder.go @@ -50,12 +50,28 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) func SelectFileDialog(ctx context.Context) (string, error) { options := wailsRuntime.OpenDialogOptions{ - Title: "Select FLAC File for Analysis", + Title: "Select Audio File for Analysis", Filters: []wailsRuntime.FileFilter{ + { + DisplayName: "Audio Files (*.flac;*.mp3;*.m4a;*.aac)", + Pattern: "*.flac;*.mp3;*.m4a;*.aac", + }, { DisplayName: "FLAC Audio Files (*.flac)", Pattern: "*.flac", }, + { + DisplayName: "MP3 Audio Files (*.mp3)", + Pattern: "*.mp3", + }, + { + DisplayName: "M4A Audio Files (*.m4a)", + Pattern: "*.m4a", + }, + { + DisplayName: "AAC Audio Files (*.aac)", + Pattern: "*.aac", + }, { DisplayName: "All Files (*.*)", Pattern: "*.*", diff --git a/frontend/src/components/AudioAnalysis.tsx b/frontend/src/components/AudioAnalysis.tsx index 4a8052f..bfbd27d 100644 --- a/frontend/src/components/AudioAnalysis.tsx +++ b/frontend/src/components/AudioAnalysis.tsx @@ -35,7 +35,7 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton

Audio Quality Analysis

- Verify the true lossless quality of downloaded files + Inspect spectral content and effective quality of FLAC, MP3, M4A, and AAC files

{onAnalyze && ( @@ -73,6 +73,14 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton }; const nyquistFreq = result.sample_rate / 2; + const totalSamplesText = result.total_samples > 0 ? result.total_samples.toLocaleString() : "N/A"; + const freqResolutionLabel = result.file_type === "MP3" ? "Freq Res.:" : "Freq Resolution:"; + const hasCodecMeta = result.file_type === "MP3" && ( + Boolean(result.codec_mode) || + typeof result.bitrate_kbps === "number" || + typeof result.total_frames === "number" || + Boolean(result.codec_version) + ); return ( @@ -83,10 +91,16 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton -
+

Format

    + {result.file_type && ( +
  • + Type: + {result.file_type} +
  • + )}
  • Sample Rate: {(result.sample_rate / 1000).toFixed(1)} kHz @@ -133,11 +147,43 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
  • Total Samples: - {result.total_samples.toLocaleString()} + {totalSamplesText}
+ {hasCodecMeta && ( +
+

MP3 Meta

+
    + {result.codec_mode && ( +
  • + Mode: + {result.codec_mode} +
  • + )} + {typeof result.bitrate_kbps === "number" && ( +
  • + Bitrate: + {result.bitrate_kbps} kbps +
  • + )} + {typeof result.total_frames === "number" && result.total_frames > 0 && ( +
  • + Frames: + {result.total_frames.toLocaleString()} +
  • + )} + {result.codec_version && ( +
  • + Version: + {result.codec_version} +
  • + )} +
+
+ )} + {result.spectrum && (() => { const frames = result.spectrum.time_slices.length; const fftSize = (result.spectrum.freq_bins - 1) * 2; @@ -156,7 +202,7 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton {fftSize.toLocaleString()}
  • - Freq Resolution: + {freqResolutionLabel} {freqRes.toFixed(2)} Hz/bin
  • diff --git a/frontend/src/components/AudioAnalysisPage.tsx b/frontend/src/components/AudioAnalysisPage.tsx index fd28169..752e970 100644 --- a/frontend/src/components/AudioAnalysisPage.tsx +++ b/frontend/src/components/AudioAnalysisPage.tsx @@ -13,16 +13,41 @@ interface AudioAnalysisPageProps { onBack?: () => void; } -function isFlacPath(filePath: string): boolean { - return filePath.toLowerCase().endsWith(".flac"); +const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"]; +const SUPPORTED_AUDIO_ACCEPT = [ + ".flac", + ".mp3", + ".m4a", + ".aac", + "audio/flac", + "audio/x-flac", + "audio/mpeg", + "audio/mp3", + "audio/mp4", + "audio/x-m4a", + "audio/aac", + "audio/aacp", +].join(","); +const SUPPORTED_AUDIO_LABEL = "FLAC, MP3, M4A, or AAC"; + +function isSupportedAudioPath(filePath: string): boolean { + const normalized = filePath.toLowerCase(); + return SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalized.endsWith(ext)); } -function isFlacFile(file: File): boolean { - const name = file.name.toLowerCase(); +function isSupportedAudioFile(file: File): boolean { + const normalizedName = file.name.toLowerCase(); + const normalizedType = file.type.toLowerCase(); return ( - name.endsWith(".flac") || - file.type === "audio/flac" || - file.type === "audio/x-flac" + SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalizedName.endsWith(ext)) || + normalizedType === "audio/flac" || + normalizedType === "audio/x-flac" || + normalizedType === "audio/mpeg" || + normalizedType === "audio/mp3" || + normalizedType === "audio/mp4" || + normalizedType === "audio/x-m4a" || + normalizedType === "audio/aac" || + normalizedType === "audio/aacp" ); } @@ -55,9 +80,9 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { const spectrumRef = useRef<{ getCanvasDataURL: () => string | null; }>(null); const analyzeSelectedPath = useCallback(async (filePath: string) => { - if (!isFlacPath(filePath)) { + if (!isSupportedAudioPath(filePath)) { toast.error("Invalid File Type", { - description: "Please select a FLAC file for analysis", + description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`, }); return; } @@ -65,9 +90,9 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { }, [analyzeFilePath]); const analyzeSelectedFile = useCallback(async (file: File) => { - if (!isFlacFile(file)) { + if (!isSupportedAudioFile(file)) { toast.error("Invalid File Type", { - description: "Please select a FLAC file for analysis", + description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`, }); return; } @@ -165,7 +190,7 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { @@ -221,13 +246,16 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {

    {isDragging - ? "Drop your FLAC file here" - : "Drag and drop a FLAC file here, or click the button below to select"} + ? "Drop your audio file here" + : "Drag and drop an audio file here, or click the button below to select"}

    +

    + Supported formats: FLAC, MP3, M4A, AAC +

    )} diff --git a/frontend/src/hooks/useAudioAnalysis.ts b/frontend/src/hooks/useAudioAnalysis.ts index 9823be1..10b41a0 100644 --- a/frontend/src/hooks/useAudioAnalysis.ts +++ b/frontend/src/hooks/useAudioAnalysis.ts @@ -3,8 +3,8 @@ import type { AnalysisResult } from "@/types/api"; import { logger } from "@/lib/logger"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { - analyzeFlacArrayBuffer, - analyzeFlacFile, + analyzeAudioArrayBuffer, + analyzeAudioFile, analyzeSpectrumFromSamples, type AnalysisProgress, } from "@/lib/flac-analysis"; @@ -159,7 +159,7 @@ export function useAudioAnalysis() { logger.info(`Analyzing audio file (frontend): ${file.name}`); const start = Date.now(); const prefs = loadAudioAnalysisPreferences(); - const payload = await analyzeFlacFile(file, { + const payload = await analyzeAudioFile(file, { fftSize: prefs.fftSize, windowFunction: prefs.windowFunction, }, (progress) => { @@ -248,7 +248,7 @@ export function useAudioAnalysis() { message: "Preparing audio buffer...", }); const fileName = fileNameFromPath(filePath); - const payload = await analyzeFlacArrayBuffer( + const payload = await analyzeAudioArrayBuffer( { fileName, fileSize: arrayBuffer.byteLength, diff --git a/frontend/src/lib/flac-analysis.ts b/frontend/src/lib/flac-analysis.ts index 6f2d06a..b66c80d 100644 --- a/frontend/src/lib/flac-analysis.ts +++ b/frontend/src/lib/flac-analysis.ts @@ -12,13 +12,36 @@ const DEFAULT_PARAMS: SpectrumParams = { const MAX_SPECTRUM_FRAMES = 2200; const METRICS_CHUNK_SIZE = 262144; +const AAC_SAMPLE_RATES = [ + 96000, 88200, 64000, 48000, 44100, 32000, 24000, + 22050, 16000, 12000, 11025, 8000, 7350, +] as const; -interface FlacStreamInfo { +const MP4_CONTAINER_TYPES = new Set([ + "moov", "trak", "mdia", "minf", "stbl", "edts", "dinf", + "udta", "ilst", "meta", "stsd", "wave", +]); + +type SupportedAudioFileType = "FLAC" | "MP3" | "M4A" | "AAC"; + +interface ParsedAudioMetadata { + fileType: SupportedAudioFileType; sampleRate: number; channels: number; bitsPerSample: number; totalSamples: number; duration: number; + codecMode?: string; + bitrateKbps?: number; + totalFrames?: number; + codecVersion?: string; +} + +interface Mp4BoxInfo { + offset: number; + size: number; + headerSize: number; + type: string; } export interface FrontendAnalysisPayload { @@ -26,7 +49,7 @@ export interface FrontendAnalysisPayload { samples: Float32Array; } -export interface FlacArrayBufferInput { +export interface AudioArrayBufferInput { fileName: string; fileSize: number; arrayBuffer: ArrayBuffer; @@ -50,10 +73,9 @@ function reportProgress( message: string, ): void { if (!callback) return; - const clamped = Math.max(0, Math.min(100, percent)); callback({ phase, - percent: clamped, + percent: Math.max(0, Math.min(100, percent)), message, }); } @@ -75,7 +97,66 @@ function nextTick(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } -function parseFlacStreamInfo(buffer: ArrayBuffer): FlacStreamInfo { +function readFourCC(view: DataView, offset: number): string { + return String.fromCharCode( + view.getUint8(offset), + view.getUint8(offset + 1), + view.getUint8(offset + 2), + view.getUint8(offset + 3), + ); +} + +function fileExtension(fileName: string): string { + const normalized = fileName.toLowerCase(); + const dotIndex = normalized.lastIndexOf("."); + return dotIndex >= 0 ? normalized.slice(dotIndex) : ""; +} + +function detectAudioFileType(buffer: ArrayBuffer, fileName = ""): SupportedAudioFileType { + const view = new DataView(buffer); + + if (view.byteLength >= 4 && view.getUint32(0, false) === 0x664c6143) { + return "FLAC"; + } + + if (view.byteLength >= 3 && + view.getUint8(0) === 0x49 && + view.getUint8(1) === 0x44 && + view.getUint8(2) === 0x33) { + return "MP3"; + } + + if (view.byteLength >= 8 && readFourCC(view, 4) === "ftyp") { + return "M4A"; + } + + if (view.byteLength >= 2 && view.getUint8(0) === 0xff && (view.getUint8(1) & 0xf6) === 0xf0) { + return "AAC"; + } + + for (let offset = 0; offset < Math.min(4096, view.byteLength - 4); offset++) { + const header = view.getUint32(offset, false); + if ((header >>> 21) === 0x7ff) { + const version = (header >>> 19) & 0x03; + const layer = (header >>> 17) & 0x03; + const sampleRateIndex = (header >>> 10) & 0x03; + if (version !== 1 && layer !== 0 && sampleRateIndex !== 3) { + return "MP3"; + } + } + } + + switch (fileExtension(fileName)) { + case ".flac": return "FLAC"; + case ".mp3": return "MP3"; + case ".m4a": + case ".mp4": return "M4A"; + case ".aac": return "AAC"; + default: throw new Error(`Unsupported audio format: ${fileName || "unknown"}`); + } +} + +function parseFlacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { const data = new Uint8Array(buffer); if (data.length < 4 || data[0] !== 0x66 || data[1] !== 0x4c || data[2] !== 0x61 || data[3] !== 0x43) { throw new Error("Invalid FLAC file"); @@ -88,16 +169,11 @@ function parseFlacStreamInfo(buffer: ArrayBuffer): FlacStreamInfo { const blockLength = (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]; offset += 4; - if (offset + blockLength > data.length) { - break; - } + if (offset + blockLength > data.length) break; if (blockType === 0 && blockLength >= 18) { const streamInfo = data.subarray(offset, offset + blockLength); - const sampleRate = - (streamInfo[10] << 12) | - (streamInfo[11] << 4) | - (streamInfo[12] >> 4); + const sampleRate = (streamInfo[10] << 12) | (streamInfo[11] << 4) | (streamInfo[12] >> 4); const channels = ((streamInfo[12] >> 1) & 0x07) + 1; const bitsPerSample = (((streamInfo[12] & 0x01) << 4) | (streamInfo[13] >> 4)) + 1; const totalSamplesBig = @@ -110,6 +186,7 @@ function parseFlacStreamInfo(buffer: ArrayBuffer): FlacStreamInfo { const duration = sampleRate > 0 && totalSamples > 0 ? totalSamples / sampleRate : 0; return { + fileType: "FLAC", sampleRate, channels, bitsPerSample, @@ -124,6 +201,344 @@ function parseFlacStreamInfo(buffer: ArrayBuffer): FlacStreamInfo { throw new Error("FLAC STREAMINFO metadata not found"); } +function skipId3v2Tag(view: DataView): number { + if (view.byteLength < 10 || + view.getUint8(0) !== 0x49 || + view.getUint8(1) !== 0x44 || + view.getUint8(2) !== 0x33) { + return 0; + } + + const size = + ((view.getUint8(6) & 0x7f) << 21) | + ((view.getUint8(7) & 0x7f) << 14) | + ((view.getUint8(8) & 0x7f) << 7) | + (view.getUint8(9) & 0x7f); + + let offset = 10 + size; + if ((view.getUint8(5) & 0x10) !== 0) { + offset += 10; + } + return offset < view.byteLength ? offset : 0; +} + +function getMp3Bitrate(version: number, layer: number, bitrateIndex: number): number { + const tables: Record> = { + 1: { + 1: [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0], + 2: [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0], + 3: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0], + }, + 2: { + 1: [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0], + 2: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0], + 3: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0], + }, + }; + + const normalizedVersion = version === 2.5 ? 2 : version; + return tables[normalizedVersion]?.[layer]?.[bitrateIndex] ?? 0; +} + +function getMp3SamplesPerFrame(version: number, layer: number): number { + if (layer === 1) return 384; + if (version === 1) return 1152; + return 576; +} + +interface Mp3FrameInfo { + version: number; + versionName: string; + layer: number; + sampleRate: number; + bitrate: number; + channels: number; + frameSize: number; + samplesPerFrame: number; +} + +function parseMp3FrameHeader(header: number): Mp3FrameInfo | null { + if (((header >>> 21) & 0x7ff) !== 0x7ff) return null; + + const versionBits = (header >>> 19) & 0x03; + const layerBits = (header >>> 17) & 0x03; + const bitrateIndex = (header >>> 12) & 0x0f; + const sampleRateIndex = (header >>> 10) & 0x03; + const padding = (header >>> 9) & 0x01; + const channelMode = (header >>> 6) & 0x03; + + const versions = [2.5, null, 2, 1] as const; + const layers = [null, 3, 2, 1] as const; + const version = versions[versionBits]; + const layer = layers[layerBits]; + if (version === null || layer === null || sampleRateIndex === 3) return null; + + const sampleRateTables: Record<1 | 2 | 25, [number, number, number]> = { + 1: [44100, 48000, 32000], + 2: [22050, 24000, 16000], + 25: [11025, 12000, 8000], + }; + const sampleRateKey = version === 2.5 ? 25 : (version as 1 | 2); + const sampleRate = sampleRateTables[sampleRateKey][sampleRateIndex]; + const bitrate = getMp3Bitrate(version, layer, bitrateIndex); + const samplesPerFrame = getMp3SamplesPerFrame(version, layer); + if (!sampleRate || !bitrate || !samplesPerFrame) return null; + + return { + version, + versionName: `MPEG-${version === 1 ? "1" : version === 2 ? "2" : "2.5"}`, + layer, + sampleRate, + bitrate, + channels: channelMode === 3 ? 1 : 2, + frameSize: Math.floor((samplesPerFrame / 8 * bitrate * 1000) / sampleRate) + padding, + samplesPerFrame, + }; +} + +function getMp3SideInfoSize(frameInfo: Mp3FrameInfo): number { + if (frameInfo.version === 1) { + return frameInfo.channels === 1 ? 17 : 32; + } + return frameInfo.channels === 1 ? 9 : 17; +} + +function parseMp3XingHeader(view: DataView, offset: number, frameInfo: Mp3FrameInfo) { + if (offset + 16 > view.byteLength) return null; + const flags = view.getUint32(offset + 4, false); + let pos = offset + 8; + let totalFrames = 0; + let totalBytes = 0; + + if ((flags & 0x01) !== 0 && pos + 4 <= view.byteLength) { + totalFrames = view.getUint32(pos, false); + pos += 4; + } + if ((flags & 0x02) !== 0 && pos + 4 <= view.byteLength) { + totalBytes = view.getUint32(pos, false); + } + + const duration = totalFrames > 0 ? (totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate : 0; + const avgBitrate = duration > 0 && totalBytes > 0 ? Math.round((totalBytes * 8) / duration / 1000) : frameInfo.bitrate; + + return { + codecMode: "VBR (Xing)", + totalFrames, + duration, + bitrateKbps: avgBitrate, + }; +} + +function parseMp3VbriHeader(view: DataView, offset: number, frameInfo: Mp3FrameInfo) { + if (offset + 18 > view.byteLength) return null; + const totalBytes = view.getUint32(offset + 10, false); + const totalFrames = view.getUint32(offset + 14, false); + const duration = totalFrames > 0 ? (totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate : 0; + const bitrateKbps = duration > 0 && totalBytes > 0 ? Math.round((totalBytes * 8) / duration / 1000) : frameInfo.bitrate; + return { + codecMode: "VBR (VBRI)", + totalFrames, + duration, + bitrateKbps, + }; +} + +function parseMp3VbrInfo(view: DataView, frameOffset: number, frameInfo: Mp3FrameInfo) { + const sideInfoSize = getMp3SideInfoSize(frameInfo); + const xingOffset = frameOffset + 4 + sideInfoSize; + + if (xingOffset + 4 <= view.byteLength) { + const xingTag = String.fromCharCode( + view.getUint8(xingOffset), + view.getUint8(xingOffset + 1), + view.getUint8(xingOffset + 2), + view.getUint8(xingOffset + 3), + ); + if (xingTag === "Xing" || xingTag === "Info") { + return parseMp3XingHeader(view, xingOffset, frameInfo); + } + } + + const vbriOffset = frameOffset + 36; + if (vbriOffset + 4 <= view.byteLength) { + const vbriTag = String.fromCharCode( + view.getUint8(vbriOffset), + view.getUint8(vbriOffset + 1), + view.getUint8(vbriOffset + 2), + view.getUint8(vbriOffset + 3), + ); + if (vbriTag === "VBRI") { + return parseMp3VbriHeader(view, vbriOffset, frameInfo); + } + } + + return null; +} + +function parseMp3Metadata(buffer: ArrayBuffer): ParsedAudioMetadata { + const view = new DataView(buffer); + const startOffset = skipId3v2Tag(view); + + for (let offset = startOffset; offset <= view.byteLength - 4; offset++) { + const header = view.getUint32(offset, false); + const frameInfo = parseMp3FrameHeader(header); + if (frameInfo) { + const vbrInfo = parseMp3VbrInfo(view, offset, frameInfo); + const estimatedAudioDataSize = Math.max(0, view.byteLength - offset); + const estimatedFrameSize = frameInfo.frameSize > 0 ? frameInfo.frameSize : 1; + const totalFrames = vbrInfo?.totalFrames ?? Math.floor(estimatedAudioDataSize / estimatedFrameSize); + const duration = vbrInfo?.duration ?? ((totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate); + const bitrateKbps = vbrInfo?.bitrateKbps ?? frameInfo.bitrate; + + return { + fileType: "MP3", + sampleRate: frameInfo.sampleRate, + channels: frameInfo.channels, + bitsPerSample: 16, + totalSamples: duration > 0 ? Math.floor(duration * frameInfo.sampleRate) : 0, + duration, + codecMode: vbrInfo?.codecMode ?? "CBR", + bitrateKbps, + totalFrames, + codecVersion: frameInfo.versionName, + }; + } + } + + throw new Error("No valid MP3 frame found"); +} + +function parseAacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { + const data = new Uint8Array(buffer); + + for (let offset = 0; offset <= data.length - 7; offset++) { + if (data[offset] !== 0xff || (data[offset + 1] & 0xf6) !== 0xf0) continue; + + const sampleRateIndex = (data[offset + 2] >> 2) & 0x0f; + const sampleRate = AAC_SAMPLE_RATES[sampleRateIndex]; + const channels = ((data[offset + 2] & 0x01) << 2) | ((data[offset + 3] >> 6) & 0x03); + if (!sampleRate) continue; + + return { + fileType: "AAC", + sampleRate, + channels: channels || 2, + bitsPerSample: 16, + totalSamples: 0, + duration: 0, + }; + } + + throw new Error("No valid AAC ADTS header found"); +} + +function readMp4Box(view: DataView, offset: number, limit: number): Mp4BoxInfo | null { + if (offset + 8 > limit) return null; + + let size = view.getUint32(offset, false); + const type = readFourCC(view, offset + 4); + let headerSize = 8; + + if (size === 1) { + if (offset + 16 > limit) return null; + const high = view.getUint32(offset + 8, false); + const low = view.getUint32(offset + 12, false); + size = high * 4294967296 + low; + headerSize = 16; + } else if (size === 0) { + size = limit - offset; + } + + if (size < headerSize || offset + size > limit) return null; + + return { offset, size, headerSize, type }; +} + +function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { + const view = new DataView(buffer); + let sampleRate = 0; + let channels = 0; + let bitsPerSample = 0; + let duration = 0; + + const scanBoxes = (start: number, end: number): void => { + let offset = start; + while (offset + 8 <= end) { + const box = readMp4Box(view, offset, end); + if (!box) break; + + const boxEnd = box.offset + box.size; + const contentStart = box.offset + box.headerSize; + + if (box.type === "mdhd" && contentStart + 24 <= boxEnd) { + const version = view.getUint8(contentStart); + if (version === 0 && contentStart + 24 <= boxEnd) { + const timeScale = view.getUint32(contentStart + 12, false); + const durationValue = view.getUint32(contentStart + 16, false); + if (timeScale > 0) { + sampleRate = timeScale; + duration = durationValue / timeScale; + } + } else if (version === 1 && contentStart + 36 <= boxEnd) { + const timeScale = view.getUint32(contentStart + 20, false); + const durationHigh = view.getUint32(contentStart + 24, false); + const durationLow = view.getUint32(contentStart + 28, false); + const durationValue = durationHigh * 4294967296 + durationLow; + if (timeScale > 0) { + sampleRate = timeScale; + duration = durationValue / timeScale; + } + } + } else if ((box.type === "mp4a" || box.type === "aac ") && box.offset + 36 <= boxEnd) { + channels = view.getUint16(box.offset + 24, false) || channels; + bitsPerSample = view.getUint16(box.offset + 26, false) || bitsPerSample; + if (!sampleRate) { + const fixedPointSampleRate = view.getUint32(box.offset + 32, false); + if (fixedPointSampleRate > 0) { + sampleRate = Math.floor(fixedPointSampleRate / 65536); + } + } + } + + if (MP4_CONTAINER_TYPES.has(box.type)) { + let childStart = contentStart; + if (box.type === "meta") childStart = Math.min(boxEnd, contentStart + 4); + else if (box.type === "stsd") childStart = Math.min(boxEnd, contentStart + 8); + if (childStart < boxEnd) scanBoxes(childStart, boxEnd); + } + + offset = boxEnd; + } + }; + + scanBoxes(0, view.byteLength); + + if (sampleRate <= 0) sampleRate = 44100; + if (channels <= 0) channels = 2; + if (bitsPerSample <= 0) bitsPerSample = 16; + + return { + fileType: "M4A", + sampleRate, + channels, + bitsPerSample, + totalSamples: duration > 0 ? Math.floor(duration * sampleRate) : 0, + duration, + }; +} + +function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata { + const fileType = detectAudioFileType(input.arrayBuffer, input.fileName); + + switch (fileType) { + case "FLAC": return parseFlacMetadata(input.arrayBuffer); + case "MP3": return parseMp3Metadata(input.arrayBuffer); + case "M4A": return parseM4aMetadata(input.arrayBuffer); + case "AAC": return parseAacMetadata(input.arrayBuffer); + default: throw new Error(`Unsupported audio format: ${input.fileName || "unknown"}`); + } +} + function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["windowFunction"]): Float32Array { const coeffs = new Float32Array(size); if (size <= 1) { @@ -309,7 +724,18 @@ export async function analyzeSpectrumFromSamples( }; } -export async function analyzeFlacFile( +function createAnalysisAudioContext(sampleRate: number): AudioContext { + if (sampleRate > 0) { + try { + return new AudioContext({ sampleRate }); + } catch { + return new AudioContext(); + } + } + return new AudioContext(); +} + +export async function analyzeAudioFile( file: File, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, @@ -320,7 +746,7 @@ export async function analyzeFlacFile( const arrayBuffer = await file.arrayBuffer(); throwIfCancelled(shouldCancel); reportProgress(onProgress, "read", 10, "File loaded"); - return analyzeFlacArrayBuffer( + return analyzeAudioArrayBuffer( { fileName: file.name, fileSize: file.size, @@ -335,18 +761,18 @@ export async function analyzeFlacFile( ); } -export async function analyzeFlacArrayBuffer( - input: FlacArrayBufferInput, +export async function analyzeAudioArrayBuffer( + input: AudioArrayBufferInput, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck, ): Promise { throwIfCancelled(shouldCancel); - reportProgress(onProgress, "parse", 5, "Parsing FLAC metadata..."); - const streamInfo = parseFlacStreamInfo(input.arrayBuffer); + reportProgress(onProgress, "parse", 5, "Parsing audio metadata..."); + const metadata = parseAudioMetadata(input); throwIfCancelled(shouldCancel); reportProgress(onProgress, "decode", 15, "Decoding audio stream..."); - const audioContext = new AudioContext({ sampleRate: streamInfo.sampleRate }); + const audioContext = createAnalysisAudioContext(metadata.sampleRate); try { const audioBuffer = await audioContext.decodeAudioData(input.arrayBuffer.slice(0)); @@ -362,8 +788,7 @@ export async function analyzeFlacArrayBuffer( throwIfCancelled(shouldCancel); const sample = samples[i]; const absSample = Math.abs(sample); - if (absSample > peak) - peak = absSample; + if (absSample > peak) peak = absSample; sumSquares += sample * sample; if ((i + 1) % METRICS_CHUNK_SIZE === 0 || i === samples.length - 1) { @@ -383,11 +808,15 @@ export async function analyzeFlacArrayBuffer( const rms = samples.length > 0 ? Math.sqrt(sumSquares / samples.length) : 0; const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120; const dynamicRange = peakDB - rmsDB; + const duration = audioBuffer.duration > 0 ? audioBuffer.duration : metadata.duration; + const totalSamples = metadata.totalSamples > 0 + ? metadata.totalSamples + : Math.floor(duration * metadata.sampleRate); reportProgress(onProgress, "metrics", 50, "Signal metrics complete"); const spectrum = await analyzeSpectrumFromSamples( samples, - streamInfo.sampleRate, + metadata.sampleRate, params, (progress) => { const mappedPercent = 50 + (progress.percent * 0.45); @@ -395,9 +824,6 @@ export async function analyzeFlacArrayBuffer( }, shouldCancel, ); - const durationFromBuffer = audioBuffer.duration; - const duration = durationFromBuffer > 0 ? durationFromBuffer : streamInfo.duration; - const totalSamples = streamInfo.totalSamples > 0 ? streamInfo.totalSamples : Math.floor(duration * streamInfo.sampleRate); reportProgress(onProgress, "finalize", 97, "Finalizing result..."); @@ -405,15 +831,20 @@ export async function analyzeFlacArrayBuffer( result: { file_path: input.fileName, file_size: input.fileSize, - sample_rate: streamInfo.sampleRate, - channels: streamInfo.channels, - bits_per_sample: streamInfo.bitsPerSample, + file_type: metadata.fileType, + sample_rate: metadata.sampleRate, + channels: metadata.channels || audioBuffer.numberOfChannels, + bits_per_sample: metadata.bitsPerSample, total_samples: totalSamples, duration, - bit_depth: `${streamInfo.bitsPerSample}-bit`, + bit_depth: `${metadata.bitsPerSample}-bit`, dynamic_range: dynamicRange, peak_amplitude: peakDB, rms_level: rmsDB, + codec_mode: metadata.codecMode, + bitrate_kbps: metadata.bitrateKbps, + total_frames: metadata.totalFrames, + codec_version: metadata.codecVersion, spectrum, }, samples, @@ -425,3 +856,6 @@ export async function analyzeFlacArrayBuffer( await audioContext.close(); } } + +export const analyzeFlacFile = analyzeAudioFile; +export const analyzeFlacArrayBuffer = analyzeAudioArrayBuffer; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 85bad41..03cd42d 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -167,6 +167,7 @@ export interface SpectrumData { export interface AnalysisResult { file_path: string; file_size: number; + file_type?: "FLAC" | "MP3" | "M4A" | "AAC"; sample_rate: number; channels: number; bits_per_sample: number; @@ -176,6 +177,10 @@ export interface AnalysisResult { dynamic_range: number; peak_amplitude: number; rms_level: number; + codec_mode?: string; + bitrate_kbps?: number; + total_frames?: number; + codec_version?: string; spectrum?: SpectrumData; } export interface LyricsDownloadRequest {