# Decibels, Then Colour

*Task 4 of 5 · [Spectrograms](https://gpu.rocks/learn/spectrograms-9ecd2295.md) · GPU.js Learn*

The numbers in a spectrogram span orders of magnitude. In this one the loudest cell
is about `0.126` and the median cell about `0.00014` — a factor of
nearly 900. Hand that straight to a colour ramp and the median cell gets a tenth of one
percent of the range: you get a black rectangle with a few bright streaks, and the obvious
conclusion — "my kernel is broken" — is wrong. Your kernel is fine. Your scaling is
wrong.

The fix is what ears already do: work in **decibels**.
`db = 20 · log₁₀(mag / peak)` puts the loudest cell at 0 and every halving 6 dB
below it, so a 60 dB display range covers a factor of 1,000 in one legible ramp and
everything quieter clamps to black. That single transform is the difference between a
picture and a black rectangle.

Then colour. Loudness rides on **value** — the part that has to be monotone,
or the picture lies about which cell is louder — and the hue sweep is there for legibility,
which is the case Colour Spaces argues at length. The ramp below is written for you: black →
indigo → magenta → orange → cream. What is yours is the two lines above it, and the canvas
is 256×256, so each spectrogram cell paints a 4×2 block.

## Goal

**Goal:** finish the paint kernel — magnitude to decibels, decibels to a
0…1 value — and render the result.

## Requirements

- Convert against the peak: `db = 20 * Math.log10(mag / peak + 1e-9)`
- Map the top `this.constants.range` decibels onto 0…1 and clamp: `Math.max(0, Math.min(1, 1 + db / this.constants.range))`
- Leave the 4×2 zoom and the ramp alone — the canvas stays `256×256`
- Keep the `render(paint.canvas)` call

## Hint 1 — log base 10

`Math.log10` is on gpu.js's whitelist, so write it directly — no need
for `Math.log(x) / Math.LN10`. `Math.log` is the *natural*
logarithm and gives answers 2.3 times too far down the scale.

## Hint 2 — the two lines

```js
const db = 20 * Math.log10(mag / peak + 1e-9);
const v = Math.max(0, Math.min(1, 1 + db / this.constants.range));
```

## Hint 3 — why the 1e-9

`log₁₀(0)` is `-Infinity`, and `-Infinity / 60`
stays infinite: one silent cell would paint `NaN`, which lands on screen as
whatever the driver felt like. The tiny floor costs nothing (it is 180 dB down) and
makes the silent case land on plain black instead.

## Same idea elsewhere

Amplitude-to-decibels is the universal display transform for audio —
`librosa.amplitude_to_db`, every DAW meter, every analyser you have ever looked
at — and the wider lesson generalises well past sound: a GPU can compute a perfectly correct
answer and a bad transfer function will still show you nothing. HDR tone mapping, log-scale
depth buffers and gamma correction are the same move made for the same reason. On any
platform this is a fragment shader reading a storage texture; the arithmetic does not
change.

## Starter code

```js
// The spectrogram from task 2, already built. What is missing is the part
// that makes it legible. 64 frames × 128 bins onto a 256×256 canvas.
const gpu = new GPU({ mode });

const WIN = 256;
const HOP = 64;
const BINS = 128;
const FRAMES = Math.floor((signal.length - WIN) / HOP) + 1;

const spectrogram = gpu.createKernel(function (signal) {
  const frame = this.thread.x;
  const bin = this.thread.y;
  const start = frame * this.constants.hop;
  let re = 0;
  let im = 0;
  for (let t = 0; t < this.constants.win; t++) {
    const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * t) / (this.constants.win - 1));
    const s = signal[start + t] * w;
    const angle = (-2 * Math.PI * bin * t) / this.constants.win;
    re += s * Math.cos(angle);
    im += s * Math.sin(angle);
  }
  return Math.sqrt(re * re + im * im) / this.constants.win;
}, {
  output: [FRAMES, BINS],
  constants: { win: WIN, hop: HOP },
});

const spec = spectrogram(signal);

// How wide is the range you are about to map?
let peak = 0;
const all = [];
for (let b = 0; b < BINS; b++) {
  for (let f = 0; f < FRAMES; f++) {
    all.push(spec[b][f]);
    if (spec[b][f] > peak) peak = spec[b][f];
  }
}
all.sort((a, b) => a - b);
console.log('loudest cell:', peak);
console.log('median cell: ', all[all.length >> 1]);
console.log('ratio:       ', peak / all[all.length >> 1]);

const paint = gpu.createKernel(function (spec, peak) {
  const frame = Math.floor(this.thread.x / 4);
  const bin = Math.floor(this.thread.y / 2);
  const mag = spec[bin][frame];

  // TODO: magnitude → decibels → 0…1.
  //   db = 20 * Math.log10(mag / peak + 1e-9)
  //   v  = 1 + db / this.constants.range, clamped to 0…1
  const v = mag; // ← linear. Run it and look at what that gets you.

  // Already written: the ramp. Black → indigo → magenta → orange → cream.
  const r = Math.min(1, 1.43 * v);
  const g = 0.95 * Math.max(0, Math.min(1, (v - 0.45) / 0.55));
  const b = 0.55 * Math.min(1, v / 0.32)
          - 0.37 * Math.max(0, Math.min(1, (v - 0.32) / 0.4))
          + 0.54 * Math.max(0, Math.min(1, (v - 0.72) / 0.28));
  this.color(r, g, b, 1);
}, {
  output: [256, 256],
  graphical: true,
  constants: { range: 60 },
});

paint(spec, peak);
render(paint.canvas);
```

---

Interactive version: https://gpu.rocks/learn/spectrograms-9ecd2295/4

[Previous task](https://gpu.rocks/learn/spectrograms-9ecd2295/3.md) · [Next task](https://gpu.rocks/learn/spectrograms-9ecd2295/5.md)
