Naar inhoud
Inloggen

/insights · MixLab

MixLab Analyzer bouwen: BS.1770-4 LUFS in de browser

De doorloop van een werkende engineer over hoe MixLab Analyzer in de browser werkt. Behandelt K-weging-biquads, FFT, true peak, stereocorrelatie, en de ontwerpkeuzes achter het volledig client-side houden van alles.

2 juni 2026 18 min read mixlabwebaudiolufsbs.1770build logdsp

MixLab Analyzer begon als een demo om een punt te bewijzen: de meeste “AI-mastering”-tools zijn een black box, en je hebt geen black box nodig om makers een nuchtere lezing van hun mix te geven. Je hebt een meter nodig, een spectrum, een stereocorrelatie, en de discipline om het resultaat in gewone taal op te schrijven.

Dit is de doorloop van een werkende engineer over hoe de huidige versie is gebouwd. Het doel is geen uitputtende DSP-theorie (daar bestaan studieboeken voor), maar het tonen van de ontwerpkeuzes en de afwegingen die we maakten om het geheel client-side, snel en eerlijk te houden.

De pijplijn, in een oogopslag

user file
  ↓ decodeAudioData (WebAudio)
AudioBuffer (Float32 PCM, sample rate, channels)

  ├─ peak / RMS / crest                       → levels
  ├─ K-weighting (BS.1770 pre + RLB biquads)  → LUFS / LRA
  ├─ true peak (4× polyphase upsample)        → dBTP
  ├─ Mid/Side decomposition                   → width
  ├─ Pearson correlation                      → mono safety
  ├─ FFT (radix-2, Hann-windowed)             → spectrum
  ├─ band integrals                           → tonal balance
  └─ heuristics on band ratios                → harshness / muddiness

            AnalysisResult

        plain-language feedback

Er zit geen model in deze graaf. Er is geen API-aanroep. Er zijn een paar honderd regels TypeScript die de Float32-arrays lezen die je browser al heeft gedecodeerd.

Stap 1: Decoderen zonder uploads

De allergrootste UX-winst voor een audio-analyser is “je bestand verlaat nooit de browser”. WebAudio geeft je dit gratis:

const ctx = new AudioContext();
const arrayBuffer = await file.arrayBuffer();
const buffer = await ctx.decodeAudioData(arrayBuffer.slice(0));

decodeAudioData verwerkt WAV, MP3, M4A, OGG, FLAC en Opus in moderne browsers. Zodra het terugkeert, heb je een AudioBuffer met numberOfChannels, sampleRate en een Float32Array per kanaal. Vanaf hier is alles JavaScript-rekenwerk.

De .slice(0) doet ertoe: sommige browsers consumeren de onderliggende buffer tijdens het decoderen, wat latere reads breekt. Slicen maakt een defensieve kopie.

Stap 2: BS.1770-4 K-weging

Wat de meeste “loudness-meters” verkeerd doen, is dat ze RMS in dBFS rapporteren. Dat is peak/RMS-metering, geen loudness. Loudness vereist perceptuele weging.

ITU-R BS.1770-4 (waar EBU R128 op voortbouwt) specificeert een filtercascade in twee fasen:

  1. Een pre-filter: een high-shelf rond ~1681 Hz die de head-related transfer function benadert.
  2. Een RLB-filter: een high-pass rond ~38 Hz die de laagfrequente rolloff van het oor benadert.

Beide zijn eenvoudige biquads. Voor 48 kHz publiceert de spec de coëfficiënten direct. Voor andere sample rates leid je ze opnieuw af met een high-shelf en high-pass in RBJ-stijl met dezelfde prototypefrequenties en Q-waarden:

function preFilterCoeffs(sr: number): Biquad {
  if (sr === 48000) {
    return { b0: 1.53512485958697, b1: -2.69169618940638, b2: 1.19839281085285,
             a1: -1.69065929318241, a2: 0.73248077421585 };
  }
  // RBJ high-shelf derivation as substitute
  const f0 = 1681.974450955533;
  const G = 3.999843853973347; // dB
  const Q = 0.7071752369554196;
  // ... compute b0..b2, a1..a2
}

Een biquad toepassen is een direct-form-I met 5 vermenigvuldigingen per sample:

function applyBiquad(data: Float32Array, c: Biquad): Float32Array {
  const out = new Float32Array(data.length);
  let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
  for (let i = 0; i < data.length; i++) {
    const x0 = data[i];
    const y0 = c.b0*x0 + c.b1*x1 + c.b2*x2 - c.a1*y1 - c.a2*y2;
    out[i] = y0;
    x2 = x1; x1 = x0; y2 = y1; y1 = y0;
  }
  return out;
}

Dat is het volledige K-wegingsfilter. Draai het op links en rechts (of alleen links voor mono) en houd ze gescheiden.

Stap 3: Gating en integratie

LUFS is niet zomaar de gemiddelde energie van het K-gewogen signaal. De spec eist gating:

  1. Verdeel het K-gewogen signaal in vensters van 400 ms met 75% overlap (hop van 100 ms).
  2. Bereken het kwadratisch gemiddelde (mean square) per blok.
  3. Bereken de blok-loudness: L_k = -0.691 + 10·log10(mean_square).
  4. Verwijder elk blok met L_k < -70 LUFS (absolute gate).
  5. Bereken het gemiddelde van de mean-squares van de overgebleven blokken en leid de relatieve drempel af: L_rel = -0.691 + 10·log10(mean) - 10.
  6. Verwijder elk blok met L_k < L_rel (relatieve gate).
  7. Bereken het gemiddelde van de mean-squares van die blokken. Dat is de integrated loudness.

De offset van -0.691 is niet willekeurig; het is waar de over kanalen gesommeerde mean-square uitlijnt met referentieniveaus. Voor stereo sommeer je de mean-squares van L en R voordat je de log neemt.

Dit in 30 regels TypeScript implementeren is de volledige codebase voor LUFS-metering. Geen library, geen model.

// per block
const ms = (sumL + sumR) / blockSize;
blockMeanSquares.push(ms);
blockLoudness.push(-0.691 + 10 * Math.log10(ms + 1e-12));

Voor Loudness Range (LRA, per EBU 3342) neem je de short-term loudness-reeks (glijdend venster van 3 seconden), gate je op -70 absoluut en integrated - 20, sorteer je de overgebleven waarden, en neem je het 95e percentiel minus het 10e. Dat is je LRA.

Stap 4: True peak via 4× oversampling

Peak in het sample-domein mist intersample-pieken: de werkelijke analoge piek na reconstructie kan elke afzonderlijke sample overschrijden. Streaming-codecs versterken het probleem.

De standaardoplossing is om 4× te oversamplen voordat je pieken meet. Voor MixLabs schatting op previewkwaliteit gebruiken we Catmull-Rom kubieke interpolatie:

for (let k = 0; k < 4; k++) {
  const t = k / 4;
  const a0 = -0.5 * y0 + 1.5 * y1 - 1.5 * y2 + 0.5 * y3;
  const a1 = y0 - 2.5 * y1 + 2 * y2 - 0.5 * y3;
  const a2 = -0.5 * y0 + 0.5 * y2;
  const a3 = y1;
  const v = ((a0 * t + a1) * t + a2) * t + a3;
  if (Math.abs(v) > peak) peak = Math.abs(v);
}

Dit is sneller dan een windowed sinc en komt tot binnen ~0,3 dB van een gecertificeerde meter op typisch materiaal. Voor compliance op masteringniveau zou je een polyphase FIR inruilen, maar voor “is dit luid genoeg om me te alarmeren?” volstaat kubiek.

We beperken de true-peak-meting tot de eerste 10 seconden van het bestand om de latency redelijk te houden bij lange bestanden.

Stap 5: Mid/Side en stereobreedte

Dit deel is bijna te simpel:

for (let i = 0; i < n; i++) {
  mid[i]  = (left[i] + right[i]) * 0.5;
  side[i] = (left[i] - right[i]) * 0.5;
}

rms(side) / rms(mid) geeft je een breedteverhouding. Schaal en klem die naar 0..1 voor de weergave. We berekenen ook de Pearson-correlatie tussen de ruwe linker- en rechterkanalen:

const correlation = (sumProduct - n * meanL * meanR) / Math.sqrt(denL * denR);

Een correlatie onder -0,05 is het signaal “je mix verliest elementen in mono”. Het is wat het in fase omgekeerde kanaal en het te ver verbrede beeld opvangt.

Stap 6: Spectrumanalyse

Voor tonale balans hebben we een magnitudespectrum nodig. Standaardrecept:

  1. Vensters van 4096 samples met een Hann-venster.
  2. Draai een radix-2 Cooley-Tukey FFT.
  3. Neem |X[k]| voor de onderste helft.
  4. Overlap-add het volgende frame (50% overlap).
  5. Middel over de frames.

De FFT zelf is ~30 regels vanilla JS. Een library nemen we niet de moeite voor:

function fft(real: Float32Array, imag: Float32Array): void {
  const n = real.length;
  // bit reversal permutation
  // butterfly stages
}

Voor een 4096-punts FFT op 10 seconden 48 kHz-audio verwerk je ~234 frames. Op een moderne machine is dit ruim onder de 50 ms.

Uit het gemiddelde magnitudespectrum leiden we af:

  • Spectraal centroïde: de naar magnitude gewogen gemiddelde frequentie. Correspondeert met waargenomen helderheid.
  • Spectrale rolloff (85%): de frequentie waaronder 85% van de totale energie ligt. Een tweede kijk op helderheid.
  • Spectrale vlakheid: geometrisch gemiddelde over rekenkundig gemiddelde. Dicht bij 1 = ruisachtig, dicht bij 0 = tonaal.
  • Band-energieën: geïntegreerde magnitude in 20–60, 60–200, 200–500, 500–2k, 2k–4k, 4k–8k, 8k–16k.

Uit de band-energieën is de harshness-score presence_band / mean(neighbour_bands). Alles ruim boven 1 betekent dat de 2–4 kHz-band heter is dan zijn buren: de bite die luisteraars vermoeit.

Stap 7: De lezing in gewone taal

Hier stoppen de meeste analysetools. Je krijgt getallen en wordt geacht te weten wat je ermee moet doen.

MixLabs feedbacklaag is dom op de beste manier: een switch op elk metriekbereik met een handgeschreven alinea voor elke band:

if (r.integratedLufs > -10) {
  cards.push({
    level: 'warn',
    title: 'Loud — likely over-limited',
    body: `${r.integratedLufs.toFixed(1)} LUFS sits well above streaming targets.`,
  });
}

De rechterkant van > is een getal dat je van het scherm afleest. De body is het soort feedback dat een werkende engineer in een Slack-DM zou typen. Er is geen model, geen template-engine, geen AI, alleen een paar dozijn zorgvuldig geschreven branches.

Wat we bewust niet hebben gedaan

  • Geen “enhance”-knop. De analyser benoemt problemen. Hij lost ze niet op. Dat is een ander product.
  • Geen model in de loop. Alles is op signaalniveau. De gewone-taallaag bestaat uit woordenboekopzoekingen, geen generatie.
  • Geen upload. De hele reden dat dit werkt voor de privacy van makers is dat er nooit audio over het netwerk gaat.
  • Geen “score”. Eén enkel getal over alle metrieken heen zou elke keer fout zijn. Lezingen per metriek zijn eerlijk.

Wat er nu volgt

De huidige pijplijn draait op de main thread. Bij lange bestanden (10+ minuten) hapert dat zichtbaar in de UI tijdens de LUFS-doorloop. De volgende iteratie verplaatst het zware werk naar een AudioWorklet zodat de browser de hele tijd responsief blijft. Dezelfde algoritmes, een andere runtime.

Voor compliance op masteringniveau (broadcast-inzendingen, festival-deliverables) voegen we een gecertificeerde-metermodus toe met een polyphase upsampler en de geverifieerde coëfficiënten bij alle standaard sample rates. Voor nu is MixLab de juiste tool voor “is deze mix in de goede vorm?”, en de verkeerde tool voor “is dit technisch R128-compliant voor inzending bij BBC Radio?”

Gerelateerd