This commit is contained in:
Armin 2026-07-05 00:44:08 +02:00
commit c985d235a3
8 changed files with 2666 additions and 0 deletions

431
js/analyzer.js Normal file
View file

@ -0,0 +1,431 @@
const Analyzer = {
detectBPM(pcmData, sampleRate, channels) {
const frameSize = 2048;
const hopSize = 256;
const maxSecs = 60;
const totalSamples = pcmData.length / channels;
const limitSamples = Math.min(totalSamples, Math.floor(sampleRate * maxSecs));
if (limitSamples < sampleRate * 3) return 0;
const numFrames = Math.max(1, Math.floor((limitSamples - frameSize) / hopSize) + 1);
const onset = new Float64Array(numFrames);
for (let f = 0; f < numFrames; f++) {
let energy = 0;
const start = f * hopSize * channels;
const end = Math.min(start + frameSize * channels, limitSamples * channels);
for (let i = start; i < end; i += channels) {
const s = pcmData[i];
energy += s * s;
}
onset[f] = energy;
}
const odf = new Float64Array(numFrames);
for (let f = 1; f < numFrames; f++) {
const d = onset[f] - onset[f - 1];
odf[f] = d > 0 ? d : 0;
}
let maxOdf = 0;
for (let i = 0; i < numFrames; i++) if (odf[i] > maxOdf) maxOdf = odf[i];
if (maxOdf > 0) for (let i = 0; i < numFrames; i++) odf[i] /= maxOdf;
let meanOdf = 0;
for (let i = 0; i < numFrames; i++) meanOdf += odf[i];
meanOdf /= numFrames;
for (let i = 0; i < numFrames; i++) odf[i] -= meanOdf;
const minBPM = 30;
const maxBPM = 300;
const secsPerHop = hopSize / sampleRate;
const minLag = Math.ceil(60 / (maxBPM * secsPerHop));
const maxLag = Math.floor(60 / (minBPM * secsPerHop));
if (minLag >= numFrames || maxLag < minLag) return 0;
const acLen = maxLag - minLag + 1;
const ac = new Float64Array(acLen);
for (let lag = minLag; lag <= maxLag; lag++) {
let s = 0;
const n = numFrames - lag;
for (let i = 0; i < n; i++) s += odf[i] * odf[i + lag];
ac[lag - minLag] = n > 0 ? s / n : 0;
}
const interpAC = (lag) => {
const idx = lag - minLag;
const i = Math.floor(idx);
const f = idx - i;
if (i < 0 || i + 1 >= acLen) return 0;
return ac[i] + f * (ac[i + 1] - ac[i]);
};
const peaks = [];
for (let i = 1; i < acLen - 1; i++) {
if (ac[i] > ac[i - 1] && ac[i] >= ac[i + 1]) {
const a = ac[i - 1];
const b = ac[i];
const c = ac[i + 1];
const denom = a - 2 * b + c;
if (Math.abs(denom) < 1e-12) continue;
const p = 0.5 * (a - c) / denom;
const peakLag = (i + minLag) + p;
if (peakLag <= 0) continue;
const bpm = 60 / (peakLag * secsPerHop);
if (bpm >= minBPM && bpm <= maxBPM) {
const interpVal = b + 0.25 * (a - c) * p;
peaks.push({ bpm, lag: peakLag, score: interpVal });
}
}
}
if (peaks.length === 0) return 0;
peaks.sort((a, b) => b.score - a.score);
const fastBPM = (lag) => 60 / (lag * secsPerHop);
let bestBPM = peaks[0].bpm;
let bestScore = peaks[0].score;
for (const pk of peaks) {
const lag = pk.lag;
const acBase = pk.score;
let hScore = acBase;
for (let div = 2; div <= 8; div *= 2) {
const fl = lag / div;
if (fl >= minLag && fl <= maxLag) {
hScore += interpAC(fl) * (1.0 / div);
}
}
if (hScore > bestScore) {
bestScore = hScore;
bestBPM = pk.bpm;
}
}
for (const pk of peaks) {
const lag = pk.lag;
for (let div = 2; div <= 8; div *= 2) {
const fl = lag / div;
if (fl >= minLag && fl <= maxLag) {
const acDiv = interpAC(fl);
if (acDiv > pk.score * 0.35) {
const candBPM = fastBPM(fl);
if (candBPM >= minBPM && candBPM <= maxBPM) {
bestBPM = candBPM;
return bestBPM;
}
}
}
}
}
return bestBPM;
},
applyHann(buf) {
const n = buf.length;
for (let i = 0; i < n; i++)
buf[i] *= 0.5 * (1.0 - Math.cos(2.0 * Math.PI * i / (n - 1)));
},
fftRadix2(re, im, n, inv) {
for (let i = 1, j = 0; i < n; i++) {
let bit = n >> 1;
for (; j & bit; bit >>= 1)
j ^= bit;
j ^= bit;
if (i < j) {
let tr = re[i]; re[i] = re[j]; re[j] = tr;
let ti = im[i]; im[i] = im[j]; im[j] = ti;
}
}
for (let len = 2; len <= n; len <<= 1) {
const ang = 2.0 * Math.PI / len * (inv ? -1 : 1);
const wr = Math.cos(ang), wi = Math.sin(ang);
for (let i = 0; i < n; i += len) {
let cr = 1.0, ci = 0.0;
for (let j = 0; j < len / 2; j++) {
const a = i + j, b = a + len / 2;
const tr = cr * re[b] - ci * im[b];
const ti = cr * im[b] + ci * re[b];
re[b] = re[a] - tr; im[b] = im[a] - ti;
re[a] += tr; im[a] += ti;
const ncr = cr * wr - ci * wi;
const nci = cr * wi + ci * wr;
cr = ncr; ci = nci;
}
}
}
if (inv)
for (let i = 0; i < n; i++) { re[i] /= n; im[i] /= n; }
},
analyzeAudio(pcmData, sampleRate, channels, fftSize) {
const power = new Float64Array(fftSize / 2);
let count = 0;
const frame = pcmData;
const buf = new Float64Array(fftSize);
const re = new Float64Array(fftSize);
const im = new Float64Array(fftSize);
const overlap = 2;
const step = fftSize / overlap;
for (let ch = 0; ch < channels; ch++) {
for (let pos = 0; pos + fftSize <= frame.length / channels; pos += step) {
for (let i = 0; i < fftSize; i++)
buf[i] = frame[(pos + i) * channels + ch];
this.applyHann(buf);
re.set(buf);
im.fill(0);
this.fftRadix2(re, im, fftSize, 0);
for (let i = 0; i < fftSize / 2; i++)
power[i] += re[i] * re[i] + im[i] * im[i];
count++;
}
}
if (count === 0) return null;
return { power, count, sampleRate, channels, fftSize };
},
detectCutoff(a, thresholdDb, sensitivity) {
const n = a.fftSize / 2;
const sr = a.sampleRate;
const mag = new Float64Array(n);
let peak = 0.0;
for (let i = 0; i < n; i++) {
mag[i] = Math.sqrt(a.power[i] / a.count);
if (mag[i] > peak) peak = mag[i];
}
if (peak < 1e-12) return { score: 0, cutoff: 0 };
const threshold = peak * Math.pow(10.0, thresholdDb / 20.0);
let cutoffHz = 0;
for (let i = n - 1; i >= 0; i--) {
if (mag[i] >= threshold) { cutoffHz = i * sr / a.fftSize; break; }
}
const lowThresh60 = peak * 0.001;
let cutoff60Hz = 0;
let cutoff60Bin = n - 1;
for (let i = n - 1; i >= 0; i--) {
if (mag[i] >= lowThresh60) { cutoff60Hz = i * sr / a.fftSize; cutoff60Bin = i; break; }
}
const highThresh = peak * 0.1;
let cutoffHighHz = 0;
const startBin = cutoff60Bin > 0 ? cutoff60Bin : n - 1;
for (let i = startBin; i >= 0; i--) {
if (mag[i] >= highThresh) { cutoffHighHz = i * sr / a.fftSize; break; }
}
let transitionBw = (cutoff60Hz > 0) ? cutoff60Hz - cutoffHighHz : 0;
if (transitionBw < 0) transitionBw = 0;
const steepness = transitionBw;
let noiseSum = 0, noiseCount = 0;
for (let i = n * 3 / 4; i < n; i++) {
if (mag[i] > 0) { noiseSum += mag[i]; noiseCount++; }
}
const noiseFloor = (noiseCount > 0) ? (noiseSum / noiseCount) : 1e-12;
const noiseDb = 20.0 * Math.log10(noiseFloor / peak);
let extDb = noiseDb + 6.0;
const minExtDb = thresholdDb - 30.0;
if (extDb < minExtDb) extDb = minExtDb;
const extPeak = peak * Math.pow(10.0, extDb / 20.0);
let extCutoff = 0;
for (let i = n - 1; i >= 0; i--) {
if (mag[i] >= extPeak) { extCutoff = i * sr / a.fftSize; break; }
}
const extendedCutoff = extCutoff;
let roughCutoff = cutoffHz;
if (noiseDb > thresholdDb) {
const adjDb = noiseDb + 10.0;
const adjThresh = peak * Math.pow(10.0, adjDb / 20.0);
let adjCutoff = 0;
for (let i = n - 1; i >= 0; i--) {
if (mag[i] >= adjThresh) { adjCutoff = i * sr / a.fftSize; break; }
}
if (adjCutoff > 0) roughCutoff = adjCutoff;
}
let roughness = 0.0;
const cutoffIdx = roughCutoff * a.fftSize / sr;
let lo = Math.floor(cutoffIdx * 0.60);
let hi = Math.floor(cutoffIdx * 0.95);
if (hi >= n) hi = n - 1;
if (lo < 1) lo = 1;
const noiseMag = Math.pow(10.0, noiseDb / 20.0) * peak;
const regionTotal = hi - lo + 1;
let aboveNoise = 0;
for (let i = lo; i <= hi; i++) {
if (mag[i] > noiseMag * 2.0) aboveNoise++;
}
if (aboveNoise / regionTotal < 0.30) {
roughness = 0.01;
} else if (hi > lo) {
let sum = 0;
for (let i = lo; i <= hi; i++) sum += mag[i];
const mean = sum / (hi - lo + 1);
if (mean > 1e-12) {
let varSum = 0;
for (let i = lo; i <= hi; i++) {
const dev = (mag[i] - mean) / mean;
varSum += dev * dev;
}
roughness = Math.sqrt(varSum / (hi - lo));
}
}
if (roughness < 0.01) roughness = 0.01;
let energyLow = 0, energyHigh = 0;
let elCount = 0, ehCount = 0;
for (let i = 0; i < n; i++) {
const f = i * sr / a.fftSize;
if (f >= 12000 && f < 16000) { energyLow += mag[i]; elCount++; }
if (f >= 16000 && f < 20000) { energyHigh += mag[i]; ehCount++; }
}
const bandRatio = (elCount > 0 && ehCount > 0)
? (energyHigh / ehCount) / (energyLow / elCount + 1e-12)
: 0.5;
const nyquist = sr / 2.0;
let decisionCutoff = cutoffHz;
if (noiseDb > thresholdDb) {
const dcDb = noiseDb + 10.0;
const dcThresh = peak * Math.pow(10.0, dcDb / 20.0);
let dc = 0;
for (let i = n - 1; i >= 0; i--) {
if (mag[i] >= dcThresh) { dc = i * sr / a.fftSize; break; }
}
if (dc > 0) decisionCutoff = dc;
}
const cutoffRatio = decisionCutoff / nyquist;
const effCutoffRatio = (extendedCutoff > cutoffHz && noiseDb >= -100.0)
? extendedCutoff / nyquist : cutoffRatio;
const bwFactor = Math.max(0.25, 2.0 * (1.0 - sensitivity));
const r1 = 0.40 * (1.0 + (0.5 - sensitivity) * 1.5);
const r2 = 0.30 * (1.0 + (0.5 - sensitivity) * 1.5);
const r3 = 0.20 * (1.0 + (0.5 - sensitivity) * 1.5);
const b1 = 0.90 - (0.5 - sensitivity) * 0.10;
const b2 = 0.85 - (0.5 - sensitivity) * 0.10;
const bypass = 0.99 - (0.5 - sensitivity) * 0.02;
let score = 0;
if (decisionCutoff <= 0 || cutoffRatio >= bypass) {
if (roughness > r1) score = 1;
else if (roughness > r2 && bandRatio < b1) score = 1;
else if (roughness > r3 && bandRatio < b2) score = 1;
return { score, cutoff: cutoffHz, steepness, noiseDb, roughness, bandRatio, extendedCutoff,
cutoffRatio, bypassed: true, mag, peak };
}
let maxBw;
if (cutoffRatio < 0.50) maxBw = 4000.0 * bwFactor;
else if (cutoffRatio < 0.70) maxBw = 3000.0 * bwFactor;
else if (cutoffRatio < 0.80) maxBw = 2000.0 * bwFactor;
else if (cutoffRatio < 0.90) maxBw = 1200.0 * bwFactor;
else maxBw = 500.0 * bwFactor;
if (transitionBw < maxBw) score = 1;
if (!score && cutoffRatio > 0.80) {
if (roughness > r1) score = 1;
else if (roughness > r2 && bandRatio < b1) score = 1;
else if (roughness > r3 && bandRatio < b2) score = 1;
}
return { score, cutoff: cutoffHz, steepness, noiseDb, roughness, bandRatio, extendedCutoff,
cutoffRatio, maxBw, transitionBw, bypassed: false, mag, peak };
},
computeConfidence(cutoffRatio, steepness, roughness, bandRatio, isNativeLossy) {
let conf = 0;
let count = 0;
if (isNativeLossy) {
if (cutoffRatio < 0.70) {
const margin = Math.min(1, (0.70 - cutoffRatio) / 0.70);
conf += 0.50 + 0.50 * margin;
count++;
} else if (cutoffRatio < 0.80) {
const margin = Math.min(1, (0.80 - cutoffRatio) / 0.80);
conf += 0.40 + 0.60 * margin;
count++;
} else if (cutoffRatio < 0.85) {
const margin = Math.min(1, (0.85 - cutoffRatio) / 0.85);
conf += 0.20 + 0.60 * margin;
count++;
} else {
conf += 0.70;
count++;
}
} else {
if (cutoffRatio < 0.50) {
const margin = Math.min(1, (0.50 - cutoffRatio) / 0.50);
const sMargin = Math.max(0, Math.min(1, (4000.0 - steepness) / 4000.0));
conf += 0.50 + 0.50 * (margin * 0.5 + sMargin * 0.5);
count++;
} else if (cutoffRatio < 0.70) {
const rMargin = Math.min(1, (0.70 - cutoffRatio) / 0.70);
const sMargin = Math.max(0, Math.min(1, (3000.0 - steepness) / 3000.0));
conf += 0.30 + 0.70 * (rMargin * 0.4 + sMargin * 0.6);
count++;
} else if (cutoffRatio < 0.80) {
const rMargin = Math.min(1, (0.80 - cutoffRatio) / 0.80);
const sMargin = Math.max(0, Math.min(1, (2000.0 - steepness) / 2000.0));
conf += 0.20 + 0.80 * (rMargin * 0.4 + sMargin * 0.6);
count++;
} else if (cutoffRatio < 0.90) {
const rMargin = Math.min(1, (0.90 - cutoffRatio) / 0.90);
const sMargin = Math.max(0, Math.min(1, (1200.0 - steepness) / 1200.0));
conf += 0.10 + 0.90 * (rMargin * 0.4 + sMargin * 0.6);
count++;
} else {
const sMargin = Math.max(0, Math.min(1, (500.0 - steepness) / 500.0));
conf += 0.20 + 0.80 * sMargin;
count++;
}
if (cutoffRatio > 0.80) {
if (roughness > 0.40) {
const margin = Math.min(1, (roughness - 0.40) / 0.40);
conf += 0.40 + 0.60 * margin;
count++;
} else if (roughness > 0.30 && bandRatio < 0.90) {
const rMargin = Math.min(1, (roughness - 0.30) / 0.10);
const bMargin = Math.min(1, (0.90 - bandRatio) / 0.90);
conf += 0.20 + 0.80 * (rMargin * 0.5 + bMargin * 0.5);
count++;
} else if (roughness > 0.20 && bandRatio < 0.85) {
const rMargin = Math.min(1, (roughness - 0.20) / 0.10);
const bMargin = Math.min(1, (0.85 - bandRatio) / 0.85);
conf += 0.10 + 0.90 * (rMargin * 0.5 + bMargin * 0.5);
count++;
}
}
}
if (count === 0) return 0;
return Math.max(0, Math.min(100, conf / count * 100.0));
}
};

1209
js/app.js Normal file

File diff suppressed because it is too large Load diff

318
js/metadata.js Normal file
View file

@ -0,0 +1,318 @@
const Metadata = {
parse(arrayBuffer, fileName) {
const dv = new DataView(arrayBuffer);
const size = arrayBuffer.byteLength;
const tags = [];
if (size >= 10 && this._str(dv, 0, 3) === 'ID3')
this._id3v2(dv, size, tags);
if (size >= 4 && this._str(dv, 0, 4) === 'fLaC')
this._flac(dv, size, tags);
if (size >= 32 && this._str(dv, 0, 8) === 'APETAGEX')
this._ape(dv, size, 0, tags);
if (size >= 128) {
const off = size - 128;
if (this._str(dv, off, 3) === 'TAG')
this._id3v1(dv, off, tags);
}
return tags;
},
_str(dv, off, len) {
const b = new Uint8Array(dv.buffer, off, len);
const end = b.indexOf(0);
return new TextDecoder('latin1').decode(end < 0 ? b : b.subarray(0, end));
},
_utf16(dv, off, max) {
if (max < 2) return '';
const bom = dv.getUint16(off, false);
const le = bom !== 0xFEFF;
let s = '', i = off + 2;
while (i + 1 < off + max) {
const c = dv.getUint16(i, le);
if (c === 0) break;
if (c >= 0xD800 && c <= 0xDFFF && i + 3 < off + max) {
const c2 = dv.getUint16(i + 2, le);
s += String.fromCodePoint(0x10000 + ((c - 0xD800) << 10) + (c2 - 0xDC00));
i += 4;
} else {
s += String.fromCharCode(c);
i += 2;
}
}
return s;
},
_latin1(dv, off, max) {
const b = new Uint8Array(dv.buffer, off, max);
const end = b.indexOf(0);
return new TextDecoder('latin1').decode(end < 0 ? b : b.subarray(0, end));
},
_ssint(dv, off) {
return (dv.getUint8(off) << 21) | (dv.getUint8(off + 1) << 14) |
(dv.getUint8(off + 2) << 7) | dv.getUint8(off + 3);
},
_fid(id) {
const m = {
TIT2: 'Title', TPE1: 'Artist', TPE2: 'Album Artist', TALB: 'Album',
TYER: 'Year', TDRC: 'Year', TDRL: 'Release Time', TRCK: 'Track',
TPOS: 'Disc', TCON: 'Genre', COMM: 'Comment', TCOP: 'Copyright',
TPUB: 'Publisher', TENC: 'Encoded By', TSSE: 'Encoder',
TCOM: 'Composer', TEXT: 'Lyricist', TLAN: 'Language', TBPM: 'BPM',
TKEY: 'Initial Key', USLT: 'Lyrics',
TT2: 'Title', TP1: 'Artist', TP2: 'Album Artist', TAL: 'Album',
TYE: 'Year', TRK: 'Track', TPA: 'Disc', TCO: 'Genre',
COM: 'Comment', TCR: 'Copyright', TPB: 'Publisher',
TEN: 'Encoded By', TSS: 'Encoder', TCM: 'Composer',
};
return m[id] || id;
},
_rdFrm(dv, off, id, sz) {
const enc = dv.getUint8(off);
if (id === 'COMM' || id === 'COM') {
let p = off + 4;
if (enc === 0 || enc === 3) {
while (p < off + sz && dv.getUint8(p) !== 0) p++;
p++;
return enc === 0
? this._latin1(dv, p, off + sz - p)
: this._str(dv, p, off + sz - p);
}
while (p + 1 < off + sz) {
if (dv.getUint16(p, enc === 2) === 0) { p += 2; break; }
p += 2;
}
return this._utf16(dv, p, off + sz - p);
}
if (id === 'WXXX') {
let p = off + 1;
if (enc === 0 || enc === 3) {
while (p < off + sz && dv.getUint8(p) !== 0) p++;
return this._str(dv, p + 1, off + sz - p - 1);
}
while (p + 1 < off + sz) {
if (dv.getUint16(p, enc === 2) === 0) { p += 2; break; }
p += 2;
}
return this._str(dv, p, off + sz - p);
}
if (id.startsWith('T') || (id.length === 3 && id.startsWith('T'))) {
if (enc === 0) return this._latin1(dv, off + 1, sz - 1);
if (enc === 3) return this._str(dv, off + 1, sz - 1);
return this._utf16(dv, off + 1, sz - 1);
}
if (id === 'APIC' || id === 'PIC') return null;
return '';
},
_id3v2(dv, size, tags) {
try {
const ver = dv.getUint8(3);
const fl = dv.getUint8(5);
const tagSz = this._ssint(dv, 6);
let off = 10, end = off + tagSz;
if (end > size) return;
const vs = `ID3v2.${ver}.${dv.getUint8(4)}`;
if (fl & 0x40) {
off += ver >= 4 ? this._ssint(dv, off) : dv.getUint32(off, false);
}
while (off + (ver === 2 ? 6 : 10) <= end) {
const fid = this._str(dv, off, ver === 2 ? 3 : 4);
if (fid.length < (ver === 2 ? 3 : 4) || fid.indexOf('\x00') >= 0) break;
let fsz;
if (ver === 2) {
fsz = (dv.getUint8(off + 3) << 16) | (dv.getUint8(off + 4) << 8) | dv.getUint8(off + 5);
off += 6;
} else {
fsz = ver >= 4 ? this._ssint(dv, off + 4) : dv.getUint32(off + 4, false);
off += 10;
}
if (fsz === 0 || off + fsz > end) break;
const val = this._rdFrm(dv, off, fid, fsz);
if (val) tags.push({ source: vs, key: this._fid(fid), value: val });
off += fsz;
}
} catch (_) {}
},
_id3v1(dv, off, tags) {
try {
const src = 'ID3v1';
const title = this._str(dv, off + 3, 30).trim();
const artist = this._str(dv, off + 33, 30).trim();
const album = this._str(dv, off + 63, 30).trim();
const year = this._str(dv, off + 93, 4).trim();
const comment = this._str(dv, off + 97, 30).trim();
const genre = dv.getUint8(off + 127);
const genres = [
'Blues','Classic Rock','Country','Dance','Disco','Funk','Grunge','Hip-Hop',
'Jazz','Metal','New Age','Oldies','Other','Pop','R&B','Rap','Reggae','Rock',
'Techno','Industrial','Alternative','Ska','Death Metal','Pranks','Soundtrack',
'Euro-Techno','Ambient','Trip-Hop','Vocal','Jazz+Funk','Fusion','Trance',
'Classical','Instrumental','Acid','House','Game','Sound Clip','Gospel',
'Noise','Alt Rock','Bass','Soul','Punk','Space','Meditative','Instrumental Pop',
'Instrumental Rock','Ethnic','Gothic','Darkwave','Techno-Industrial','Electronic',
'Pop-Folk','Eurodance','Dream','Southern Rock','Comedy','Cult','Gangsta Rap',
'Top 40','Christian Rap','Pop/Funk','Jungle','Native American','Cabaret',
'New Wave','Psychedelic','Rave','Showtunes','Trailer','Lo-Fi','Tribal',
'Acid Punk','Acid Jazz','Polka','Retro','Musical','Rock & Roll','Hard Rock',
'Folk','Folk/Rock','National Folk','Swing','Fast-Fusion','Bebob','Latin',
'Revival','Celtic','Bluegrass','Avantgarde','Gothic Rock','Progressive Rock',
'Psychedelic Rock','Symphonic Rock','Slow Rock','Big Band','Chorus',
'Easy Listening','Acoustic','Humour','Speech','Chanson','Opera','Chamber Music',
'Sonata','Symphony','Booty Bass','Primus','Porn Groove','Satire','Slow Jam',
'Club','Tango','Samba','Folklore','Ballad','Power Ballad','Rhythmic Soul',
'Freestyle','Duet','Punk Rock','Drum Solo','A Cappella','Euro-House','Dance Hall',
'Goa','Drum & Bass','Club-House','Hardcore','Terror','Indie','BritPop',
'Negerpunk','Polsk Punk','Beat','Christian Gangsta Rap','Heavy Metal','Black Metal',
'Crossover','Contemporary Christian','Christian Rock','Merengue','Salsa',
'Thrash Metal','Anime','JPop','Synthpop'
];
if (title) tags.push({ source: src, key: 'Title', value: title });
if (artist) tags.push({ source: src, key: 'Artist', value: artist });
if (album) tags.push({ source: src, key: 'Album', value: album });
if (year) tags.push({ source: src, key: 'Year', value: year });
if (comment) tags.push({ source: src, key: 'Comment', value: comment });
if (genre >= 0 && genre < genres.length)
tags.push({ source: src, key: 'Genre', value: genres[genre] });
} catch (_) {}
},
_flac(dv, size, tags) {
try {
let off = 4;
let last = 0;
while (!last && off + 4 <= size) {
last = dv.getUint8(off) >> 7;
const type = dv.getUint8(off) & 0x7F;
const blen = (dv.getUint8(off + 1) << 16) | (dv.getUint8(off + 2) << 8) | dv.getUint8(off + 3);
off += 4;
if (off + blen > size) break;
if (type === 0) {
const bps = ((dv.getUint16(off + 13) & 0xFF) >> 4) + 1;
const sr = (dv.getUint16(off + 10) & 0xFFFF) | ((dv.getUint16(off + 9) & 0x0F) << 16);
const ch = ((dv.getUint16(off + 12) & 0x0E) >> 1) + 1;
tags.push({ source: 'FLAC', key: 'Sample Rate', value: `${sr} Hz` });
tags.push({ source: 'FLAC', key: 'Channels', value: `${ch}` });
tags.push({ source: 'FLAC', key: 'Bit Depth', value: `${bps}` });
}
if (type === 4) {
const vlen = dv.getUint32(off, true);
off += 4 + vlen;
const ntags = dv.getUint32(off, true);
off += 4;
for (let i = 0; i < ntags && off < size; i++) {
const tlen = dv.getUint32(off, true);
off += 4;
if (tlen === 0 || off + tlen > size) break;
const raw = this._str(dv, off, tlen);
const eq = raw.indexOf('=');
if (eq > 0) {
const k = raw.substring(0, eq);
const v = raw.substring(eq + 1);
tags.push({ source: 'FLAC', key: k, value: v });
}
off += tlen;
}
} else {
off += blen;
}
}
} catch (_) {}
},
_ape(dv, size, off, tags) {
try {
const ver = dv.getUint32(off + 8, false);
const tagSz = dv.getUint32(off + 12, false);
const nItems = dv.getUint32(off + 16, false);
const flags = dv.getUint32(off + 20, false);
let p = off + (ver >= 2000 ? 32 : 26);
const src = `APE${ver}`;
for (let i = 0; i < nItems && p + 8 <= size; i++) {
const valSz = dv.getUint32(p, true);
const itemFlags = dv.getUint32(p + 4, false);
p += 8;
if (valSz === 0 || p + valSz > size) break;
const keyEnd = p;
let kp = keyEnd;
while (kp < size && dv.getUint8(kp) !== 0) kp++;
const key = this._str(dv, keyEnd, kp - keyEnd);
if (!key) break;
kp++;
const val = this._str(dv, kp, valSz);
tags.push({ source: src, key, value: val });
p = kp + valSz;
}
} catch (_) {}
},
_mp3Sync(dv, off, size) {
while (off + 4 <= size) {
if (dv.getUint8(off) === 0xFF && (dv.getUint8(off + 1) & 0xE0) === 0xE0) return off;
off++;
}
return -1;
},
getFormatInfo(arrayBuffer, codecName) {
const info = { bitrateMode: null, stereoMode: null };
if (codecName !== 'MP3') return info;
try {
const dv = new DataView(arrayBuffer);
const size = arrayBuffer.byteLength;
let off = 0;
const tagEnd = this._id3v2Size(dv, size);
if (tagEnd > 0) off = tagEnd;
const sync = this._mp3Sync(dv, off, size);
if (sync < 0) return info;
const h = dv.getUint16(sync + 2, false);
const chMode = (h >> 6) & 3;
const chNames = ['Stereo', 'Joint Stereo', 'Dual Channel', 'Mono'];
info.stereoMode = chNames[chMode] || null;
const scanEnd = Math.min(sync + 200, size - 4);
for (let p = sync + 4; p < scanEnd; p++) {
const tag = this._str(dv, p, 4);
if (tag === 'Xing' || tag === 'Info') {
const flags = dv.getUint32(p + 4, false);
info.bitrateMode = (flags & 1) ? 'VBR' : 'CBR';
return info;
}
}
info.bitrateMode = 'CBR';
} catch (_) {}
return info;
},
_id3v2Size(dv, size) {
if (size < 10 || this._str(dv, 0, 3) !== 'ID3') return 0;
return 10 + this._ssint(dv, 6);
},
};