.improve audio quality analyzer

This commit is contained in:
afkarxyz
2026-03-25 20:10:05 +07:00
parent 386c541658
commit f8ef1180f6
6 changed files with 582 additions and 53 deletions
+17 -1
View File
@@ -50,12 +50,28 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
func SelectFileDialog(ctx context.Context) (string, error) { func SelectFileDialog(ctx context.Context) (string, error) {
options := wailsRuntime.OpenDialogOptions{ options := wailsRuntime.OpenDialogOptions{
Title: "Select FLAC File for Analysis", Title: "Select Audio File for Analysis",
Filters: []wailsRuntime.FileFilter{ Filters: []wailsRuntime.FileFilter{
{
DisplayName: "Audio Files (*.flac;*.mp3;*.m4a;*.aac)",
Pattern: "*.flac;*.mp3;*.m4a;*.aac",
},
{ {
DisplayName: "FLAC Audio Files (*.flac)", DisplayName: "FLAC Audio Files (*.flac)",
Pattern: "*.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 (*.*)", DisplayName: "All Files (*.*)",
Pattern: "*.*", Pattern: "*.*",
+50 -4
View File
@@ -35,7 +35,7 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<p className="font-medium">Audio Quality Analysis</p> <p className="font-medium">Audio Quality Analysis</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Verify the true lossless quality of downloaded files Inspect spectral content and effective quality of FLAC, MP3, M4A, and AAC files
</p> </p>
</div> </div>
{onAnalyze && ( {onAnalyze && (
@@ -73,6 +73,14 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
}; };
const nyquistFreq = result.sample_rate / 2; 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 ( return (
<Card className="gap-2"> <Card className="gap-2">
@@ -83,10 +91,16 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className={`grid grid-cols-1 gap-6 md:grid-cols-2 ${hasCodecMeta ? "lg:grid-cols-4" : "lg:grid-cols-3"}`}>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Format</p> <p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Format</p>
<ul className="text-sm space-y-1"> <ul className="text-sm space-y-1">
{result.file_type && (
<li className="flex justify-between">
<span className="text-muted-foreground">Type:</span>
<span className="font-medium font-mono">{result.file_type}</span>
</li>
)}
<li className="flex justify-between"> <li className="flex justify-between">
<span className="text-muted-foreground">Sample Rate:</span> <span className="text-muted-foreground">Sample Rate:</span>
<span className="font-medium font-mono">{(result.sample_rate / 1000).toFixed(1)} kHz</span> <span className="font-medium font-mono">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
@@ -133,11 +147,43 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
</li> </li>
<li className="flex justify-between"> <li className="flex justify-between">
<span className="text-muted-foreground">Total Samples:</span> <span className="text-muted-foreground">Total Samples:</span>
<span className="font-medium font-mono">{result.total_samples.toLocaleString()}</span> <span className="font-medium font-mono">{totalSamplesText}</span>
</li> </li>
</ul> </ul>
</div> </div>
{hasCodecMeta && (
<div className="space-y-2">
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">MP3 Meta</p>
<ul className="text-sm space-y-1">
{result.codec_mode && (
<li className="flex justify-between">
<span className="text-muted-foreground">Mode:</span>
<span className="font-medium font-mono">{result.codec_mode}</span>
</li>
)}
{typeof result.bitrate_kbps === "number" && (
<li className="flex justify-between">
<span className="text-muted-foreground">Bitrate:</span>
<span className="font-medium font-mono">{result.bitrate_kbps} kbps</span>
</li>
)}
{typeof result.total_frames === "number" && result.total_frames > 0 && (
<li className="flex justify-between">
<span className="text-muted-foreground">Frames:</span>
<span className="font-medium font-mono">{result.total_frames.toLocaleString()}</span>
</li>
)}
{result.codec_version && (
<li className="flex justify-between">
<span className="text-muted-foreground">Version:</span>
<span className="font-medium font-mono">{result.codec_version}</span>
</li>
)}
</ul>
</div>
)}
{result.spectrum && (() => { {result.spectrum && (() => {
const frames = result.spectrum.time_slices.length; const frames = result.spectrum.time_slices.length;
const fftSize = (result.spectrum.freq_bins - 1) * 2; const fftSize = (result.spectrum.freq_bins - 1) * 2;
@@ -156,7 +202,7 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
<span className="font-medium font-mono">{fftSize.toLocaleString()}</span> <span className="font-medium font-mono">{fftSize.toLocaleString()}</span>
</li> </li>
<li className="flex justify-between"> <li className="flex justify-between">
<span className="text-muted-foreground">Freq Resolution:</span> <span className="text-muted-foreground">{freqResolutionLabel}</span>
<span className="font-medium font-mono">{freqRes.toFixed(2)} Hz/bin</span> <span className="font-medium font-mono">{freqRes.toFixed(2)} Hz/bin</span>
</li> </li>
</ul> </ul>
+43 -15
View File
@@ -13,16 +13,41 @@ interface AudioAnalysisPageProps {
onBack?: () => void; onBack?: () => void;
} }
function isFlacPath(filePath: string): boolean { const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"];
return filePath.toLowerCase().endsWith(".flac"); 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 { function isSupportedAudioFile(file: File): boolean {
const name = file.name.toLowerCase(); const normalizedName = file.name.toLowerCase();
const normalizedType = file.type.toLowerCase();
return ( return (
name.endsWith(".flac") || SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalizedName.endsWith(ext)) ||
file.type === "audio/flac" || normalizedType === "audio/flac" ||
file.type === "audio/x-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 spectrumRef = useRef<{ getCanvasDataURL: () => string | null; }>(null);
const analyzeSelectedPath = useCallback(async (filePath: string) => { const analyzeSelectedPath = useCallback(async (filePath: string) => {
if (!isFlacPath(filePath)) { if (!isSupportedAudioPath(filePath)) {
toast.error("Invalid File Type", { toast.error("Invalid File Type", {
description: "Please select a FLAC file for analysis", description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`,
}); });
return; return;
} }
@@ -65,9 +90,9 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
}, [analyzeFilePath]); }, [analyzeFilePath]);
const analyzeSelectedFile = useCallback(async (file: File) => { const analyzeSelectedFile = useCallback(async (file: File) => {
if (!isFlacFile(file)) { if (!isSupportedAudioFile(file)) {
toast.error("Invalid File Type", { toast.error("Invalid File Type", {
description: "Please select a FLAC file for analysis", description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`,
}); });
return; return;
} }
@@ -165,7 +190,7 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept=".flac,audio/flac,audio/x-flac" accept={SUPPORTED_AUDIO_ACCEPT}
className="hidden" className="hidden"
onChange={handleInputChange} onChange={handleInputChange}
/> />
@@ -221,13 +246,16 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
</div> </div>
<p className="text-sm text-muted-foreground mb-4 text-center"> <p className="text-sm text-muted-foreground mb-4 text-center">
{isDragging {isDragging
? "Drop your FLAC file here" ? "Drop your audio file here"
: "Drag and drop a FLAC file here, or click the button below to select"} : "Drag and drop an audio file here, or click the button below to select"}
</p> </p>
<Button onClick={handleSelectFile} size="lg"> <Button onClick={handleSelectFile} size="lg">
<Upload className="h-5 w-5" /> <Upload className="h-5 w-5" />
Select FLAC File Select Audio File
</Button> </Button>
<p className="text-xs text-muted-foreground mt-4 text-center">
Supported formats: FLAC, MP3, M4A, AAC
</p>
</div> </div>
)} )}
+4 -4
View File
@@ -3,8 +3,8 @@ import type { AnalysisResult } from "@/types/api";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { import {
analyzeFlacArrayBuffer, analyzeAudioArrayBuffer,
analyzeFlacFile, analyzeAudioFile,
analyzeSpectrumFromSamples, analyzeSpectrumFromSamples,
type AnalysisProgress, type AnalysisProgress,
} from "@/lib/flac-analysis"; } from "@/lib/flac-analysis";
@@ -159,7 +159,7 @@ export function useAudioAnalysis() {
logger.info(`Analyzing audio file (frontend): ${file.name}`); logger.info(`Analyzing audio file (frontend): ${file.name}`);
const start = Date.now(); const start = Date.now();
const prefs = loadAudioAnalysisPreferences(); const prefs = loadAudioAnalysisPreferences();
const payload = await analyzeFlacFile(file, { const payload = await analyzeAudioFile(file, {
fftSize: prefs.fftSize, fftSize: prefs.fftSize,
windowFunction: prefs.windowFunction, windowFunction: prefs.windowFunction,
}, (progress) => { }, (progress) => {
@@ -248,7 +248,7 @@ export function useAudioAnalysis() {
message: "Preparing audio buffer...", message: "Preparing audio buffer...",
}); });
const fileName = fileNameFromPath(filePath); const fileName = fileNameFromPath(filePath);
const payload = await analyzeFlacArrayBuffer( const payload = await analyzeAudioArrayBuffer(
{ {
fileName, fileName,
fileSize: arrayBuffer.byteLength, fileSize: arrayBuffer.byteLength,
+463 -29
View File
@@ -12,13 +12,36 @@ const DEFAULT_PARAMS: SpectrumParams = {
const MAX_SPECTRUM_FRAMES = 2200; const MAX_SPECTRUM_FRAMES = 2200;
const METRICS_CHUNK_SIZE = 262144; 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; sampleRate: number;
channels: number; channels: number;
bitsPerSample: number; bitsPerSample: number;
totalSamples: number; totalSamples: number;
duration: number; duration: number;
codecMode?: string;
bitrateKbps?: number;
totalFrames?: number;
codecVersion?: string;
}
interface Mp4BoxInfo {
offset: number;
size: number;
headerSize: number;
type: string;
} }
export interface FrontendAnalysisPayload { export interface FrontendAnalysisPayload {
@@ -26,7 +49,7 @@ export interface FrontendAnalysisPayload {
samples: Float32Array; samples: Float32Array;
} }
export interface FlacArrayBufferInput { export interface AudioArrayBufferInput {
fileName: string; fileName: string;
fileSize: number; fileSize: number;
arrayBuffer: ArrayBuffer; arrayBuffer: ArrayBuffer;
@@ -50,10 +73,9 @@ function reportProgress(
message: string, message: string,
): void { ): void {
if (!callback) return; if (!callback) return;
const clamped = Math.max(0, Math.min(100, percent));
callback({ callback({
phase, phase,
percent: clamped, percent: Math.max(0, Math.min(100, percent)),
message, message,
}); });
} }
@@ -75,7 +97,66 @@ function nextTick(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0)); 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); const data = new Uint8Array(buffer);
if (data.length < 4 || data[0] !== 0x66 || data[1] !== 0x4c || data[2] !== 0x61 || data[3] !== 0x43) { if (data.length < 4 || data[0] !== 0x66 || data[1] !== 0x4c || data[2] !== 0x61 || data[3] !== 0x43) {
throw new Error("Invalid FLAC file"); 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]; const blockLength = (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
offset += 4; offset += 4;
if (offset + blockLength > data.length) { if (offset + blockLength > data.length) break;
break;
}
if (blockType === 0 && blockLength >= 18) { if (blockType === 0 && blockLength >= 18) {
const streamInfo = data.subarray(offset, offset + blockLength); const streamInfo = data.subarray(offset, offset + blockLength);
const sampleRate = const sampleRate = (streamInfo[10] << 12) | (streamInfo[11] << 4) | (streamInfo[12] >> 4);
(streamInfo[10] << 12) |
(streamInfo[11] << 4) |
(streamInfo[12] >> 4);
const channels = ((streamInfo[12] >> 1) & 0x07) + 1; const channels = ((streamInfo[12] >> 1) & 0x07) + 1;
const bitsPerSample = (((streamInfo[12] & 0x01) << 4) | (streamInfo[13] >> 4)) + 1; const bitsPerSample = (((streamInfo[12] & 0x01) << 4) | (streamInfo[13] >> 4)) + 1;
const totalSamplesBig = const totalSamplesBig =
@@ -110,6 +186,7 @@ function parseFlacStreamInfo(buffer: ArrayBuffer): FlacStreamInfo {
const duration = sampleRate > 0 && totalSamples > 0 ? totalSamples / sampleRate : 0; const duration = sampleRate > 0 && totalSamples > 0 ? totalSamples / sampleRate : 0;
return { return {
fileType: "FLAC",
sampleRate, sampleRate,
channels, channels,
bitsPerSample, bitsPerSample,
@@ -124,6 +201,344 @@ function parseFlacStreamInfo(buffer: ArrayBuffer): FlacStreamInfo {
throw new Error("FLAC STREAMINFO metadata not found"); 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<number, Record<number, number[]>> = {
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 { function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["windowFunction"]): Float32Array {
const coeffs = new Float32Array(size); const coeffs = new Float32Array(size);
if (size <= 1) { 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, file: File,
params: SpectrumParams = DEFAULT_PARAMS, params: SpectrumParams = DEFAULT_PARAMS,
onProgress?: AnalysisProgressCallback, onProgress?: AnalysisProgressCallback,
@@ -320,7 +746,7 @@ export async function analyzeFlacFile(
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
throwIfCancelled(shouldCancel); throwIfCancelled(shouldCancel);
reportProgress(onProgress, "read", 10, "File loaded"); reportProgress(onProgress, "read", 10, "File loaded");
return analyzeFlacArrayBuffer( return analyzeAudioArrayBuffer(
{ {
fileName: file.name, fileName: file.name,
fileSize: file.size, fileSize: file.size,
@@ -335,18 +761,18 @@ export async function analyzeFlacFile(
); );
} }
export async function analyzeFlacArrayBuffer( export async function analyzeAudioArrayBuffer(
input: FlacArrayBufferInput, input: AudioArrayBufferInput,
params: SpectrumParams = DEFAULT_PARAMS, params: SpectrumParams = DEFAULT_PARAMS,
onProgress?: AnalysisProgressCallback, onProgress?: AnalysisProgressCallback,
shouldCancel?: AnalysisCancelCheck, shouldCancel?: AnalysisCancelCheck,
): Promise<FrontendAnalysisPayload> { ): Promise<FrontendAnalysisPayload> {
throwIfCancelled(shouldCancel); throwIfCancelled(shouldCancel);
reportProgress(onProgress, "parse", 5, "Parsing FLAC metadata..."); reportProgress(onProgress, "parse", 5, "Parsing audio metadata...");
const streamInfo = parseFlacStreamInfo(input.arrayBuffer); const metadata = parseAudioMetadata(input);
throwIfCancelled(shouldCancel); throwIfCancelled(shouldCancel);
reportProgress(onProgress, "decode", 15, "Decoding audio stream..."); reportProgress(onProgress, "decode", 15, "Decoding audio stream...");
const audioContext = new AudioContext({ sampleRate: streamInfo.sampleRate }); const audioContext = createAnalysisAudioContext(metadata.sampleRate);
try { try {
const audioBuffer = await audioContext.decodeAudioData(input.arrayBuffer.slice(0)); const audioBuffer = await audioContext.decodeAudioData(input.arrayBuffer.slice(0));
@@ -362,8 +788,7 @@ export async function analyzeFlacArrayBuffer(
throwIfCancelled(shouldCancel); throwIfCancelled(shouldCancel);
const sample = samples[i]; const sample = samples[i];
const absSample = Math.abs(sample); const absSample = Math.abs(sample);
if (absSample > peak) if (absSample > peak) peak = absSample;
peak = absSample;
sumSquares += sample * sample; sumSquares += sample * sample;
if ((i + 1) % METRICS_CHUNK_SIZE === 0 || i === samples.length - 1) { 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 rms = samples.length > 0 ? Math.sqrt(sumSquares / samples.length) : 0;
const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120; const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120;
const dynamicRange = peakDB - rmsDB; 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"); reportProgress(onProgress, "metrics", 50, "Signal metrics complete");
const spectrum = await analyzeSpectrumFromSamples( const spectrum = await analyzeSpectrumFromSamples(
samples, samples,
streamInfo.sampleRate, metadata.sampleRate,
params, params,
(progress) => { (progress) => {
const mappedPercent = 50 + (progress.percent * 0.45); const mappedPercent = 50 + (progress.percent * 0.45);
@@ -395,9 +824,6 @@ export async function analyzeFlacArrayBuffer(
}, },
shouldCancel, 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..."); reportProgress(onProgress, "finalize", 97, "Finalizing result...");
@@ -405,15 +831,20 @@ export async function analyzeFlacArrayBuffer(
result: { result: {
file_path: input.fileName, file_path: input.fileName,
file_size: input.fileSize, file_size: input.fileSize,
sample_rate: streamInfo.sampleRate, file_type: metadata.fileType,
channels: streamInfo.channels, sample_rate: metadata.sampleRate,
bits_per_sample: streamInfo.bitsPerSample, channels: metadata.channels || audioBuffer.numberOfChannels,
bits_per_sample: metadata.bitsPerSample,
total_samples: totalSamples, total_samples: totalSamples,
duration, duration,
bit_depth: `${streamInfo.bitsPerSample}-bit`, bit_depth: `${metadata.bitsPerSample}-bit`,
dynamic_range: dynamicRange, dynamic_range: dynamicRange,
peak_amplitude: peakDB, peak_amplitude: peakDB,
rms_level: rmsDB, rms_level: rmsDB,
codec_mode: metadata.codecMode,
bitrate_kbps: metadata.bitrateKbps,
total_frames: metadata.totalFrames,
codec_version: metadata.codecVersion,
spectrum, spectrum,
}, },
samples, samples,
@@ -425,3 +856,6 @@ export async function analyzeFlacArrayBuffer(
await audioContext.close(); await audioContext.close();
} }
} }
export const analyzeFlacFile = analyzeAudioFile;
export const analyzeFlacArrayBuffer = analyzeAudioArrayBuffer;
+5
View File
@@ -167,6 +167,7 @@ export interface SpectrumData {
export interface AnalysisResult { export interface AnalysisResult {
file_path: string; file_path: string;
file_size: number; file_size: number;
file_type?: "FLAC" | "MP3" | "M4A" | "AAC";
sample_rate: number; sample_rate: number;
channels: number; channels: number;
bits_per_sample: number; bits_per_sample: number;
@@ -176,6 +177,10 @@ export interface AnalysisResult {
dynamic_range: number; dynamic_range: number;
peak_amplitude: number; peak_amplitude: number;
rms_level: number; rms_level: number;
codec_mode?: string;
bitrate_kbps?: number;
total_frames?: number;
codec_version?: string;
spectrum?: SpectrumData; spectrum?: SpectrumData;
} }
export interface LyricsDownloadRequest { export interface LyricsDownloadRequest {