Zum Inhalt
Anmelden

/insights · MixLab

MixLab Analyzer bauen: BS.1770-4 LUFS im Browser

Der Praxis-Rundgang eines Engineers, wie MixLab Analyzer im Browser funktioniert: K-Weighting-Biquads, FFT, True Peak, Stereokorrelation und die Designentscheidungen, die alles client-seitig halten.

2. Juni 2026 18 min read mixlabwebaudiolufsbs.1770build logdsp

MixLab Analyzer begann als Demo, um einen Punkt zu beweisen: Die meisten „AI-Mastering”-Tools sind eine Black Box, und man braucht keine Black Box, um Creators eine nüchterne Einschätzung ihres Mixes zu geben. Man braucht einen Meter, ein Spektrum, eine Stereokorrelation und die Disziplin, das Ergebnis in Klartext zu formulieren.

Dies ist der Praxis-Rundgang eines Engineers, wie die aktuelle Version gebaut ist. Das Ziel ist keine erschöpfende DSP-Theorie (dafür gibt es Lehrbücher), sondern die Designentscheidungen und die Kompromisse zu zeigen, die wir gemacht haben, um das Ganze client-seitig, schnell und ehrlich zu halten.

Die Pipeline auf einen Blick

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

In diesem Graphen steckt kein Modell. Es gibt keinen API-Aufruf. Es gibt ein paar hundert Zeilen TypeScript, die die Float32-Arrays lesen, die dein Browser bereits dekodiert hat.

Schritt 1: Dekodieren ohne Uploads

Der mit Abstand größte UX-Gewinn für einen Audio-Analyser ist „deine Datei verlässt nie den Browser”. WebAudio liefert dir das kostenlos:

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

decodeAudioData verarbeitet WAV, MP3, M4A, OGG, FLAC und Opus über moderne Browser hinweg. Sobald die Funktion zurückkehrt, hast du einen AudioBuffer mit numberOfChannels, sampleRate und einem Float32Array pro Kanal. Ab hier ist alles JavaScript-Arithmetik.

Das .slice(0) ist wichtig: Manche Browser verbrauchen den zugrunde liegenden Buffer beim Dekodieren, was nachfolgende Lesezugriffe zerstört. Slicing erzeugt eine defensive Kopie.

Schritt 2: BS.1770-4 K-Weighting

Was die meisten „Loudness-Meter” falsch machen: Sie geben RMS in dBFS aus. Das ist Peak/RMS-Metering, keine Loudness. Loudness braucht perzeptive Gewichtung.

ITU-R BS.1770-4 (worauf EBU R128 aufbaut) spezifiziert eine zweistufige Filterkaskade:

  1. Ein Pre-Filter: ein High-Shelf bei ~1681 Hz, das die Head-Related Transfer Function annähert.
  2. Ein RLB-Filter: ein High-Pass bei ~38 Hz, das den Tieffrequenz-Rolloff des Ohrs annähert.

Beide sind einfache Biquads. Für 48 kHz veröffentlicht die Spezifikation die Koeffizienten direkt. Für andere Sample-Raten leitet man sie neu her, mit einem High-Shelf und High-Pass im RBJ-Stil bei denselben Prototyp-Frequenzen und Q-Werten:

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
}

Das Anwenden eines Biquads ist eine Direct-Form-I mit 5 Multiplikationen pro 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;
}

Das ist das vollständige K-Weighting-Filter. Lass es über links und rechts laufen (oder nur über links bei Mono) und halte beide getrennt.

Schritt 3: Gating und Integration

LUFS ist nicht einfach die mittlere Energie des K-gewichteten Signals. Die Spezifikation verlangt Gating:

  1. Teile das K-gewichtete Signal in Fenster von 400 ms mit 75% Überlappung (Hop von 100 ms).
  2. Berechne das mittlere Quadrat (Mean Square) pro Block.
  3. Berechne die Block-Loudness: L_k = -0.691 + 10·log10(mean_square).
  4. Verwirf jeden Block mit L_k < -70 LUFS (absolutes Gate).
  5. Berechne den Mittelwert der Mean-Squares der verbleibenden Blöcke und leite den relativen Schwellenwert ab: L_rel = -0.691 + 10·log10(mean) - 10.
  6. Verwirf jeden Block mit L_k < L_rel (relatives Gate).
  7. Berechne den Mittelwert der Mean-Squares dieser Blöcke. Das ist die Integrated Loudness.

Der Offset von -0.691 ist nicht willkürlich: es ist der Punkt, an dem das über die Kanäle summierte Mean-Square mit Referenzpegeln übereinstimmt. Bei Stereo summierst du die Mean-Squares von L und R, bevor du den Logarithmus nimmst.

Dies in 30 Zeilen TypeScript zu implementieren ist die gesamte Codebasis für LUFS-Metering. Keine Library, kein Modell.

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

Für Loudness Range (LRA, nach EBU 3342) nimmst du die Short-Term-Loudness-Reihe (gleitendes 3-Sekunden-Fenster), gatest bei -70 absolut und integrated - 20, sortierst die verbleibenden Werte und nimmst das 95. Perzentil minus das 10. Das ist deine LRA.

Schritt 4: True Peak via 4×-Oversampling

Der Peak im Sample-Bereich verfehlt Intersample-Peaks: der tatsächliche analoge Peak nach der Rekonstruktion kann jedes einzelne Sample überschreiten. Streaming-Codecs verstärken das Problem.

Die Standardlösung ist, vor der Peak-Messung mit 4× zu oversamplen. Eine frühe Version von MixLab nutzte dafür Catmull-Rom-Kubikinterpolation. Das ist nicht, was ausgeliefert wird: Die Engine baut eine 4-phasige Polyphase-Filterbank aus Blackman-gefensterten Sincs, 16 Taps pro Seite, auf Einheitsverstärkung normiert:

for (let k = 0; k < TP_FACTOR; k++) {          // TP_FACTOR = 4
  const t = k / TP_FACTOR;
  for (let j = 0; j < 2 * TP_TAPS; j++) {      // TP_TAPS = 16
    const d = j - TP_TAPS + 1 - t;
    const sinc = d === 0 ? 1 : Math.sin(Math.PI * d) / (Math.PI * d);
    const w = 0.42 - 0.5 * Math.cos(2 * Math.PI * (j + 0.5) / len)
                   + 0.08 * Math.cos(4 * Math.PI * (j + 0.5) / len);
    h[j] = sinc * w;
  }
}

Das ist schneller als ein Windowed-Sinc und kommt bei typischem Material bis auf ~0,3 dB an einen zertifizierten Meter heran. Für Compliance auf Mastering-Niveau würdest du einen Polyphase-FIR einsetzen, aber für „ist das laut genug, um mich zu alarmieren?” reicht Kubik.

Gemessen wird der gesamte Puffer, nicht nur der Anfang. Ein gefensterter Sinc ist teurer, deshalb läuft die Bank nur dort, wo es zählen kann: zuerst der Sample-Peak, dann nur die Samples innerhalb von 6 dB davon. (Eine frühere Fassung dieses Artikels nannte eine 10-Sekunden-Grenze; die gibt es, sie gilt aber für den Spektrumdurchlauf, nicht für True Peak.)

Schritt 5: Mid/Side und Stereobreite

Dieser Teil ist fast zu einfach:

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) ergibt ein Breitenverhältnis. Skaliere und klemme es für die Anzeige auf 0..1. Wir berechnen außerdem die Pearson-Korrelation zwischen den rohen linken und rechten Kanälen:

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

Eine Korrelation unter -0,05 ist das Signal „dein Mix verliert Elemente in Mono”. Es fängt den phasengedrehten Kanal und das überbreite Stereobild ab.

Schritt 6: Spektrumanalyse

Für die tonale Balance brauchen wir ein Magnitudenspektrum. Standard-Rezept:

  1. Fenstere 4096 Samples mit einem Hann-Fenster.
  2. Lass eine Radix-2-Cooley-Tukey-FFT laufen.
  3. Nimm |X[k]| für die untere Hälfte.
  4. Overlap-add das nächste Frame (50% Überlappung).
  5. Mittele über die Frames.

Die FFT selbst sind ~30 Zeilen Vanilla-JS. Eine Library machen wir uns nicht zur Mühe:

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

Für eine 4096-Punkte-FFT über 10 Sekunden 48-kHz-Audio verarbeitest du ~234 Frames. Auf einer modernen Maschine liegt das deutlich unter 50 ms.

Aus dem gemittelten Magnitudenspektrum leiten wir ab:

  • Spektraler Schwerpunkt (Centroid): die nach Magnitude gewichtete mittlere Frequenz. Korrespondiert mit der wahrgenommenen Brillanz.
  • Spektraler Rolloff (85%): die Frequenz, unterhalb derer 85% der Gesamtenergie liegen. Eine zweite Sicht auf die Brillanz.
  • Spektrale Flachheit (Flatness): geometrisches Mittel über arithmetisches Mittel. Nahe 1 = rauschartig, nahe 0 = tonal.
  • Band-Energien: integrierte Magnitude in 20–60, 60–200, 200–500, 500–2k, 2k–4k, 4k–8k, 8k–16k.

Aus den Band-Energien ist der Harshness-Score presence_band / mean(neighbour_bands). Alles deutlich über 1 bedeutet, dass das 2–4-kHz-Band heißer ist als seine Nachbarn: der Biss, der Hörer ermüdet.

Schritt 7: Die Einschätzung in Klartext

Hier hören die meisten Analyse-Tools auf. Du bekommst Zahlen und sollst wissen, was du damit anfangen sollst.

MixLabs Feedback-Schicht ist im besten Sinne dumm: ein Switch über jeden Metrik-Bereich mit einem handgeschriebenen Absatz für jedes 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.`,
  });
}

Die rechte Seite von > ist eine Zahl, die du vom Bildschirm abliest. Der Body ist die Art von Feedback, die ein praktizierender Engineer in eine Slack-DM tippen würde. Es gibt kein Modell, keine Template-Engine, keine AI, nur ein paar Dutzend sorgfältig geschriebene Branches.

Was wir bewusst nicht gemacht haben

  • Kein „Enhance”-Button. Der Analyser benennt Probleme. Er behebt sie nicht. Das ist ein anderes Produkt.
  • Kein Modell im Loop. Alles läuft auf Signalebene. Die Klartext-Schicht besteht aus Wörterbuch-Lookups, nicht aus Generierung.
  • Kein Upload. Der ganze Grund, warum das für die Privatsphäre von Creators funktioniert, ist, dass nie Audio über das Netzwerk geht.
  • Kein „Score”. Eine einzelne Zahl über alle Metriken hinweg wäre jedes Mal falsch. Einschätzungen pro Metrik sind ehrlich.

Was als Nächstes kommt

Die Pipeline läuft neben dem Main Thread, in einem Web Worker (src/workers/audio-analysis.worker.ts), sodass eine lange Datei die UI während des LUFS-Durchlaufs nicht ruckeln lässt. Eine frühere Fassung dieses Artikels beschrieb das Main-Thread-Design davor.

Für Compliance auf Mastering-Niveau (Broadcast-Einreichungen, Festival-Deliverables) läuft der Polyphase-Upsampler bereits; was einem zertifizierten Modus noch fehlt, ist eine externe Stelle, die ihn prüft. Die gibt es nicht. Für jetzt ist MixLab das richtige Tool für „ist dieser Mix in der richtigen Form?”, aber das falsche Tool für „ist das technisch R128-konform für eine Einreichung bei BBC Radio?”

Verwandt