Run the web UI entirely in the browser
The FastAPI backend kept one analysis session shared by every visitor and spooled each upload to the server's /tmp, and the browser copied audio into persistent OPFS storage. None of that fits a tool for unreleased masters, so the web UI is now a static site that decodes and analyses in the tab: - web/decode.js: WAV/AIFF PCM and float read directly with libsndfile's scaling (bit-identical to librosa.load; Chromium's WAV decoder scales positive int16 by 1/32767), other formats via decodeAudioData on an OfflineAudioContext at the header-sniffed native rate. ID3 song names. - web/dsp.js: ports of every Metric.compute with the same numerics (pyloudnorm K-weighting via lfilter's DF2T recurrence, BS.1770 gating, EBU LRA, resample_poly's Kaiser filter for true peak, librosa.stft and amplitude_to_db), streaming passes with no signal-length intermediates. - web/metrics.js: build_spec ports producing the existing plot payload. - web/analysis-worker.js + app.js: per-tab track list and result cache, a worker job queue where the visible metric jumps background prefetch, and the renderer, which now also draws the metrics' own reference lines. Deployment is nginx-unprivileged serving web/ read-only with Content-Security-Policy connect-src 'none', so the page cannot make network requests at all, and no access log. No secure context is needed, so plain http on a LAN works. The Python web backend, its smoke test, the web extra and the OPFS store are removed; the desktop app is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
This commit is contained in:
+435
@@ -0,0 +1,435 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Decode a dropped audio file entirely inside this tab.
|
||||
//
|
||||
// This is the browser counterpart of `master_core.AudioFile.__init__`: tags ->
|
||||
// song name, `librosa.load(sr=None)` -> native-rate samples, `librosa.to_mono`
|
||||
// -> the mono signal every metric reads. Nothing is uploaded or stored: the File
|
||||
// is read into memory, decoded, downmixed, and the compressed bytes and
|
||||
// per-channel buffers are dropped as soon as the mono signal exists.
|
||||
//
|
||||
// Two decode paths:
|
||||
// * WAV and AIFF PCM/float are read directly with libsndfile's sample scaling,
|
||||
// so the signal is bit-identical to what librosa hands the desktop metrics
|
||||
// (Chromium's WAV decoder scales positive int16 by 1/32767, a small but
|
||||
// systematic gain skew; browsers don't decode AIFF at all).
|
||||
// * Everything else goes through decodeAudioData on an OfflineAudioContext
|
||||
// running at the rate sniffed from the container header, so nothing is
|
||||
// resampled — the same "analyse the full band" guarantee as `sr=None`.
|
||||
|
||||
const MIN_CONTEXT_RATE = 3000; // OfflineAudioContext's legal range
|
||||
const MAX_CONTEXT_RATE = 768000;
|
||||
const FALLBACK_RATE = 48000;
|
||||
|
||||
/**
|
||||
* Decode `file` to a mono Float32Array at its native sample rate.
|
||||
*
|
||||
* Returns {name, songName, format, sampleRate, nativeRateKnown, channels,
|
||||
* duration, mono, maxAmplitude, avgAmplitude, size}.
|
||||
*/
|
||||
export async function decodeFile(file) {
|
||||
const bytes = await file.arrayBuffer();
|
||||
const view = new DataView(bytes);
|
||||
|
||||
// Everything that reads the raw bytes must run before decodeAudioData, which
|
||||
// detaches the ArrayBuffer.
|
||||
const format = sniffFormat(view);
|
||||
const tags = format === 'mp3' ? readId3(view) : null;
|
||||
|
||||
let decoded = format === 'wav' || format === 'aiff' ? readPcm(view, format) : null;
|
||||
let nativeRateKnown = true;
|
||||
if (!decoded) {
|
||||
const sniffed = sniffSampleRate(view, format);
|
||||
nativeRateKnown = sniffed !== null && sniffed >= MIN_CONTEXT_RATE && sniffed <= MAX_CONTEXT_RATE;
|
||||
const sampleRate = nativeRateKnown ? sniffed : FALLBACK_RATE;
|
||||
const ctx = new OfflineAudioContext({ numberOfChannels: 1, length: 1, sampleRate });
|
||||
let buffer;
|
||||
try {
|
||||
buffer = await ctx.decodeAudioData(bytes);
|
||||
} catch (err) {
|
||||
throw new Error(`this browser cannot decode ${format || 'this file'} (${err.message || err})`);
|
||||
}
|
||||
decoded = { sampleRate: buffer.sampleRate, channels: buffer.numberOfChannels, mono: bufferToMono(buffer) };
|
||||
}
|
||||
|
||||
const { mono, sampleRate, channels } = decoded;
|
||||
let maxAmplitude = 0;
|
||||
let absSum = 0;
|
||||
for (let i = 0; i < mono.length; i++) {
|
||||
const a = Math.abs(mono[i]);
|
||||
if (a > maxAmplitude) maxAmplitude = a;
|
||||
absSum += a;
|
||||
}
|
||||
|
||||
// Same naming rule as AudioFile: "artist - title" when both tags exist.
|
||||
const songName = tags && tags.artist && tags.title
|
||||
? `${tags.artist} - ${tags.title}`
|
||||
: file.name;
|
||||
|
||||
return {
|
||||
name: file.name,
|
||||
songName,
|
||||
format,
|
||||
sampleRate,
|
||||
nativeRateKnown,
|
||||
channels,
|
||||
duration: mono.length / sampleRate,
|
||||
mono,
|
||||
maxAmplitude,
|
||||
avgAmplitude: mono.length ? absSum / mono.length : 0,
|
||||
size: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `librosa.to_mono`: the float32 mean across channels.
|
||||
*
|
||||
* Accumulating in a Float32Array rounds each partial sum to float32 exactly as
|
||||
* numpy's float32 `mean(axis=0)` does, so downmixes match bit for bit.
|
||||
*/
|
||||
function bufferToMono(buffer) {
|
||||
const channels = buffer.numberOfChannels;
|
||||
if (channels === 1) return new Float32Array(buffer.getChannelData(0));
|
||||
const n = buffer.length;
|
||||
const mono = new Float32Array(n);
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const data = buffer.getChannelData(c);
|
||||
for (let i = 0; i < n; i++) mono[i] += data[i];
|
||||
}
|
||||
for (let i = 0; i < n; i++) mono[i] /= channels;
|
||||
return mono;
|
||||
}
|
||||
|
||||
// ---- direct PCM (WAV / AIFF) ------------------------------------------------
|
||||
|
||||
function ascii(view, offset, length) {
|
||||
if (offset < 0 || offset + length > view.byteLength) return '';
|
||||
let s = '';
|
||||
for (let i = 0; i < length; i++) s += String.fromCharCode(view.getUint8(offset + i));
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reader for one sample as libsndfile's sf_read_float returns it, or null
|
||||
* for encodings left to the browser decoder.
|
||||
*/
|
||||
function sampleReader(view, encoding, bytesPerSample, littleEndian) {
|
||||
if (encoding === 'int') {
|
||||
switch (bytesPerSample) {
|
||||
case 1: return null; // handled by the caller: WAV is unsigned, AIFF signed
|
||||
case 2: return (p) => view.getInt16(p, littleEndian) / 0x8000;
|
||||
case 3: return littleEndian
|
||||
? (p) => (((view.getUint8(p) | (view.getUint8(p + 1) << 8) | (view.getUint8(p + 2) << 16)) << 8) >> 8) / 0x800000
|
||||
: (p) => (((view.getUint8(p + 2) | (view.getUint8(p + 1) << 8) | (view.getUint8(p) << 16)) << 8) >> 8) / 0x800000;
|
||||
case 4: return (p) => Math.fround(view.getInt32(p, littleEndian)) / 0x80000000;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
if (encoding === 'float') {
|
||||
if (bytesPerSample === 4) return (p) => view.getFloat32(p, littleEndian);
|
||||
if (bytesPerSample === 8) return (p) => Math.fround(view.getFloat64(p, littleEndian));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Interleaved frames -> float32 mono, channel sums in channel order. */
|
||||
function interleavedToMono(view, dataOffset, frames, channels, bytesPerSample, read) {
|
||||
const mono = new Float32Array(frames);
|
||||
const scratch = new Float32Array(1);
|
||||
for (let f = 0; f < frames; f++) {
|
||||
const base = dataOffset + f * channels * bytesPerSample;
|
||||
scratch[0] = 0;
|
||||
for (let c = 0; c < channels; c++) scratch[0] += read(base + c * bytesPerSample);
|
||||
mono[f] = channels === 1 ? scratch[0] : scratch[0] / channels;
|
||||
}
|
||||
return mono;
|
||||
}
|
||||
|
||||
function readPcm(view, format) {
|
||||
return format === 'wav' ? readWav(view) : readAiff(view);
|
||||
}
|
||||
|
||||
function readWav(view) {
|
||||
let fmt = null;
|
||||
let dataOffset = -1;
|
||||
let dataSize = 0;
|
||||
let p = 12;
|
||||
while (p + 8 <= view.byteLength) {
|
||||
const id = ascii(view, p, 4);
|
||||
const size = view.getUint32(p + 4, true);
|
||||
if (id === 'fmt ' && p + 24 <= view.byteLength) {
|
||||
let code = view.getUint16(p + 8, true);
|
||||
// WAVE_FORMAT_EXTENSIBLE: the real format code leads the SubFormat GUID.
|
||||
if (code === 0xfffe && size >= 40 && p + 34 <= view.byteLength) code = view.getUint16(p + 32, true);
|
||||
fmt = {
|
||||
code,
|
||||
channels: view.getUint16(p + 10, true),
|
||||
sampleRate: view.getUint32(p + 12, true),
|
||||
blockAlign: view.getUint16(p + 20, true),
|
||||
};
|
||||
} else if (id === 'data') {
|
||||
dataOffset = p + 8;
|
||||
// Streamed/RF64 files may carry a placeholder size; trust the file length.
|
||||
dataSize = size === 0xffffffff || dataOffset + size > view.byteLength ? view.byteLength - dataOffset : size;
|
||||
break;
|
||||
}
|
||||
p += 8 + size + (size & 1);
|
||||
}
|
||||
if (!fmt || dataOffset < 0 || !fmt.channels || !fmt.blockAlign) return null;
|
||||
|
||||
const bytesPerSample = fmt.blockAlign / fmt.channels;
|
||||
const frames = Math.floor(dataSize / fmt.blockAlign);
|
||||
let read;
|
||||
if (fmt.code === 1 && bytesPerSample === 1) read = (q) => (view.getUint8(q) - 128) / 0x80;
|
||||
else if (fmt.code === 1) read = sampleReader(view, 'int', bytesPerSample, true);
|
||||
else if (fmt.code === 3) read = sampleReader(view, 'float', bytesPerSample, true);
|
||||
if (!read) return null;
|
||||
return {
|
||||
sampleRate: fmt.sampleRate,
|
||||
channels: fmt.channels,
|
||||
mono: interleavedToMono(view, dataOffset, frames, fmt.channels, bytesPerSample, read),
|
||||
};
|
||||
}
|
||||
|
||||
function readAiff(view) {
|
||||
const aifc = ascii(view, 8, 4) === 'AIFC';
|
||||
let comm = null;
|
||||
let dataOffset = -1;
|
||||
let dataSize = 0;
|
||||
let p = 12;
|
||||
while (p + 8 <= view.byteLength) {
|
||||
const id = ascii(view, p, 4);
|
||||
const size = view.getUint32(p + 4, false);
|
||||
if (id === 'COMM' && p + 26 <= view.byteLength) {
|
||||
comm = {
|
||||
channels: view.getUint16(p + 8, false),
|
||||
frames: view.getUint32(p + 10, false),
|
||||
bits: view.getUint16(p + 14, false),
|
||||
sampleRate: aiffRate(view, p + 16),
|
||||
compression: aifc && p + 30 <= view.byteLength ? ascii(view, p + 26, 4) : 'NONE',
|
||||
};
|
||||
} else if (id === 'SSND' && p + 16 <= view.byteLength) {
|
||||
dataOffset = p + 16 + view.getUint32(p + 8, false);
|
||||
dataSize = Math.min(size - 8, view.byteLength - dataOffset);
|
||||
}
|
||||
p += 8 + size + (size & 1);
|
||||
}
|
||||
if (!comm || dataOffset < 0 || !comm.channels) return null;
|
||||
|
||||
const bytesPerSample = Math.ceil(comm.bits / 8);
|
||||
let read = null;
|
||||
const c = comm.compression;
|
||||
if ((c === 'NONE' || c === 'twos') && bytesPerSample === 1) read = (q) => view.getInt8(q) / 0x80;
|
||||
else if (c === 'NONE' || c === 'twos') read = sampleReader(view, 'int', bytesPerSample, false);
|
||||
else if (c === 'sowt') read = sampleReader(view, 'int', bytesPerSample, true);
|
||||
else if (c === 'fl32' || c === 'FL32') read = sampleReader(view, 'float', 4, false);
|
||||
else if (c === 'fl64' || c === 'FL64') read = sampleReader(view, 'float', 8, false);
|
||||
if (!read) return null;
|
||||
|
||||
const width = c.toLowerCase() === 'fl32' ? 4 : c.toLowerCase() === 'fl64' ? 8 : bytesPerSample;
|
||||
const frames = Math.min(comm.frames, Math.floor(dataSize / (width * comm.channels)));
|
||||
return {
|
||||
sampleRate: comm.sampleRate,
|
||||
channels: comm.channels,
|
||||
mono: interleavedToMono(view, dataOffset, frames, comm.channels, width, read),
|
||||
};
|
||||
}
|
||||
|
||||
/** 80-bit IEEE 754 extended float (AIFF COMM sample rate). */
|
||||
function aiffRate(view, q) {
|
||||
const exponent = view.getUint16(q, false) & 0x7fff;
|
||||
const mantissa = view.getUint32(q + 2, false) * 2 ** 32 + view.getUint32(q + 6, false);
|
||||
return Math.round(mantissa * 2 ** (exponent - 16383 - 63));
|
||||
}
|
||||
|
||||
// ---- container sniffing (browser-decoded formats) ---------------------------
|
||||
|
||||
function syncsafe(view, offset) {
|
||||
return ((view.getUint8(offset) & 0x7f) << 21) | ((view.getUint8(offset + 1) & 0x7f) << 14)
|
||||
| ((view.getUint8(offset + 2) & 0x7f) << 7) | (view.getUint8(offset + 3) & 0x7f);
|
||||
}
|
||||
|
||||
/** Byte offset just past a leading ID3v2 tag (0 when there is none). */
|
||||
function skipId3(view) {
|
||||
if (ascii(view, 0, 3) !== 'ID3' || view.byteLength < 10) return 0;
|
||||
const footer = (view.getUint8(5) & 0x10) ? 10 : 0;
|
||||
return 10 + syncsafe(view, 6) + footer;
|
||||
}
|
||||
|
||||
export function sniffFormat(view) {
|
||||
const riff = ascii(view, 0, 4);
|
||||
if ((riff === 'RIFF' || riff === 'RF64') && ascii(view, 8, 4) === 'WAVE') return 'wav';
|
||||
if (riff === 'FORM' && /^AIF[FC]$/.test(ascii(view, 8, 4))) return 'aiff';
|
||||
if (riff === 'OggS') return 'ogg';
|
||||
if (ascii(view, 4, 4) === 'ftyp') return 'mp4';
|
||||
const start = skipId3(view);
|
||||
if (ascii(view, start, 4) === 'fLaC') return 'flac';
|
||||
if (findMpegFrame(view, start) !== null) return 'mp3';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sniffSampleRate(view, format) {
|
||||
switch (format) {
|
||||
case 'wav': return wavRate(view);
|
||||
case 'aiff': return aiffCommRate(view);
|
||||
case 'flac': return flacRate(view, skipId3(view));
|
||||
case 'mp3': {
|
||||
const frame = findMpegFrame(view, skipId3(view));
|
||||
return frame ? frame.sampleRate : null;
|
||||
}
|
||||
case 'ogg': return oggRate(view);
|
||||
case 'mp4': return mp4Rate(view);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function wavRate(view) {
|
||||
let p = 12;
|
||||
while (p + 8 <= view.byteLength) {
|
||||
const id = ascii(view, p, 4);
|
||||
const size = view.getUint32(p + 4, true);
|
||||
if (id === 'fmt ' && p + 16 <= view.byteLength) return view.getUint32(p + 12, true);
|
||||
p += 8 + size + (size & 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function aiffCommRate(view) {
|
||||
let p = 12;
|
||||
while (p + 8 <= view.byteLength) {
|
||||
const size = view.getUint32(p + 4, false);
|
||||
if (ascii(view, p, 4) === 'COMM' && p + 26 <= view.byteLength) return aiffRate(view, p + 16);
|
||||
p += 8 + size + (size & 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function flacRate(view, start) {
|
||||
// STREAMINFO is always the first metadata block: 4-byte marker, 4-byte block
|
||||
// header, then 10 bytes of block/frame sizes before the 20-bit sample rate.
|
||||
const q = start + 8 + 10;
|
||||
if (q + 3 > view.byteLength) return null;
|
||||
return (view.getUint8(q) << 12) | (view.getUint8(q + 1) << 4) | (view.getUint8(q + 2) >> 4);
|
||||
}
|
||||
|
||||
const MPEG_RATES = { 3: [44100, 48000, 32000], 2: [22050, 24000, 16000], 0: [11025, 12000, 8000] };
|
||||
const L3_KBPS = {
|
||||
v1: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
|
||||
v2: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
|
||||
};
|
||||
|
||||
function parseMpegHeader(view, p) {
|
||||
if (p + 4 > view.byteLength) return null;
|
||||
const b1 = view.getUint8(p + 1), b2 = view.getUint8(p + 2);
|
||||
if (view.getUint8(p) !== 0xff || (b1 & 0xe0) !== 0xe0) return null;
|
||||
const version = (b1 >> 3) & 3;
|
||||
const layer = (b1 >> 1) & 3;
|
||||
const bitrateIndex = (b2 >> 4) & 15;
|
||||
const rateIndex = (b2 >> 2) & 3;
|
||||
if (version === 1 || layer === 0 || bitrateIndex === 15 || rateIndex === 3) return null;
|
||||
const sampleRate = MPEG_RATES[version][rateIndex];
|
||||
let frameLength = null;
|
||||
if (layer === 1 && bitrateIndex !== 0) { // Layer III
|
||||
const kbps = (version === 3 ? L3_KBPS.v1 : L3_KBPS.v2)[bitrateIndex];
|
||||
const coeff = version === 3 ? 144 : 72;
|
||||
frameLength = Math.floor((coeff * kbps * 1000) / sampleRate) + ((b2 >> 1) & 1);
|
||||
}
|
||||
return { sampleRate, frameLength };
|
||||
}
|
||||
|
||||
/**
|
||||
* First plausible MPEG audio frame at or after `start`.
|
||||
*
|
||||
* A lone 0xFFE sync pattern is common in arbitrary bytes, so a Layer III frame
|
||||
* is only accepted when another header with the same rate follows at its
|
||||
* computed length.
|
||||
*/
|
||||
function findMpegFrame(view, start) {
|
||||
const limit = Math.min(view.byteLength - 4, start + 64 * 1024);
|
||||
for (let p = start; p < limit; p++) {
|
||||
const head = parseMpegHeader(view, p);
|
||||
if (!head) continue;
|
||||
if (head.frameLength === null) return head;
|
||||
const next = parseMpegHeader(view, p + head.frameLength);
|
||||
if (next && next.sampleRate === head.sampleRate) return head;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function oggRate(view) {
|
||||
const segments = view.byteLength > 26 ? view.getUint8(26) : 0;
|
||||
const packet = 27 + segments;
|
||||
if (ascii(view, packet, 7) === '\x01vorbis' && packet + 16 <= view.byteLength) {
|
||||
return view.getUint32(packet + 12, true);
|
||||
}
|
||||
// Opus always decodes at 48 kHz; its header's "input rate" is informational.
|
||||
if (ascii(view, packet, 8) === 'OpusHead') return 48000;
|
||||
return null;
|
||||
}
|
||||
|
||||
function mp4Rate(view) {
|
||||
// Audio sample entries (mp4a/alac/Opus/fLaC) carry a 16.16 fixed-point rate
|
||||
// 32 bytes into the box. moov may sit at the end of the file, so scan it all.
|
||||
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
||||
const types = ['mp4a', 'alac', 'Opus', 'fLaC'].map((t) => [...t].map((c) => c.charCodeAt(0)));
|
||||
for (let i = 4; i + 30 < bytes.length; i++) {
|
||||
for (const t of types) {
|
||||
if (bytes[i] === t[0] && bytes[i + 1] === t[1] && bytes[i + 2] === t[2] && bytes[i + 3] === t[3]) {
|
||||
const rate = view.getUint16(i - 4 + 32, false);
|
||||
if (rate >= MIN_CONTEXT_RATE) return rate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- ID3 tags ---------------------------------------------------------------
|
||||
|
||||
/** {artist, title} from an ID3v2.2/2.3/2.4 tag (EasyID3's TPE1/TIT2), or null. */
|
||||
export function readId3(view) {
|
||||
if (ascii(view, 0, 3) !== 'ID3' || view.byteLength < 10) return null;
|
||||
const major = view.getUint8(3);
|
||||
const flags = view.getUint8(5);
|
||||
const end = Math.min(10 + syncsafe(view, 6), view.byteLength);
|
||||
let p = 10;
|
||||
if (flags & 0x40) p += major === 4 ? syncsafe(view, 10) : view.getUint32(10, false) + 4;
|
||||
|
||||
const v22 = major === 2;
|
||||
const idLen = v22 ? 3 : 4;
|
||||
const headerLen = v22 ? 6 : 10;
|
||||
const wanted = v22 ? { TT2: 'title', TP1: 'artist' } : { TIT2: 'title', TPE1: 'artist' };
|
||||
const out = {};
|
||||
|
||||
while (p + headerLen <= end) {
|
||||
const id = ascii(view, p, idLen);
|
||||
if (!/^[A-Z0-9]+$/.test(id)) break; // reached padding
|
||||
let size;
|
||||
if (v22) size = (view.getUint8(p + 3) << 16) | (view.getUint8(p + 4) << 8) | view.getUint8(p + 5);
|
||||
else if (major === 4) size = syncsafe(view, p + 4);
|
||||
else size = view.getUint32(p + 4, false);
|
||||
const body = p + headerLen;
|
||||
if (size < 0 || body + size > end) break;
|
||||
if (wanted[id] && size > 1) {
|
||||
const text = decodeId3Text(new Uint8Array(view.buffer, view.byteOffset + body, size));
|
||||
if (text) out[wanted[id]] = text;
|
||||
}
|
||||
p = body + size;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeId3Text(bytes) {
|
||||
const encoding = bytes[0];
|
||||
let data = bytes.subarray(1);
|
||||
let label = 'latin1';
|
||||
if (encoding === 1) {
|
||||
if (data[0] === 0xfe && data[1] === 0xff) { label = 'utf-16be'; data = data.subarray(2); }
|
||||
else { label = 'utf-16le'; if (data[0] === 0xff && data[1] === 0xfe) data = data.subarray(2); }
|
||||
} else if (encoding === 2) {
|
||||
label = 'utf-16be';
|
||||
} else if (encoding === 3) {
|
||||
label = 'utf-8';
|
||||
}
|
||||
// v2.4 separates multiple values with NUL; EasyID3 reports the first.
|
||||
return new TextDecoder(label).decode(data).split('\0')[0].trim();
|
||||
}
|
||||
Reference in New Issue
Block a user