.improve audio quality analyzer
This commit is contained in:
+17
-1
@@ -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: "*.*",
|
||||
|
||||
@@ -35,7 +35,7 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
|
||||
<div className="text-center space-y-2">
|
||||
<p className="font-medium">Audio Quality Analysis</p>
|
||||
<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>
|
||||
</div>
|
||||
{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 (
|
||||
<Card className="gap-2">
|
||||
@@ -83,10 +91,16 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
|
||||
</CardHeader>
|
||||
|
||||
<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">
|
||||
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Format</p>
|
||||
<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">
|
||||
<span className="text-muted-foreground">Sample Rate:</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 className="flex justify-between">
|
||||
<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>
|
||||
</ul>
|
||||
</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 && (() => {
|
||||
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
|
||||
<span className="font-medium font-mono">{fftSize.toLocaleString()}</span>
|
||||
</li>
|
||||
<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>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -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) {
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".flac,audio/flac,audio/x-flac"
|
||||
accept={SUPPORTED_AUDIO_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
@@ -221,13 +246,16 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{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"}
|
||||
</p>
|
||||
<Button onClick={handleSelectFile} size="lg">
|
||||
<Upload className="h-5 w-5" />
|
||||
Select FLAC File
|
||||
Select Audio File
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||
Supported formats: FLAC, MP3, M4A, AAC
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<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 {
|
||||
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<FrontendAnalysisPayload> {
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user