# Fine Pitch in JavaScript: Semitones, Cents, and Playback Rate

Suppose you already know that a passage is uniformly 20 cents sharp relative to the reference you want. The correction is -20 cents. Adding +20 would move it farther away. That sign looks obvious once written down, but fine-pitch code can mix semitones, cents, frequency ratios, and playback speed in ways that make small mistakes easy.

## From cents to a frequency ratio

In 12-tone equal temperament, one semitone is 100 cents and one octave is 1200 cents. A cent is a logarithmic pitch unit, not a fixed number of hertz. The [Korg DT-1 manual](https://cdn.korg.com/us/support/download/files/3024604ca0d3577bf5ee9db81330319d.pdf) is a practical example of cent offsets around a reference pitch, with its calibration expressed in hertz.

The ratio follows directly from octave doubling. Moving up one octave multiplies frequency by 2. If a shift is `s` semitones plus `c` cents, its size in cents is:

```text
totalCents = 100 * s + c
```

That is `totalCents / 1200` octaves, so the frequency ratio is:

```text
ratio = 2 ** (totalCents / 1200)
```

Positive values raise frequency and negative values lower it. The formula works for combined controls too. For example, `s = -1` and `c = +20` is a net shift of -80 cents, not two unrelated operations that need separate pitch math.

Using 440 Hz as a base, these are calculated values from the formula, not measurements from an audio engine:

```text
Shift       Calculated frequency
-100 cents  415.305 Hz
-20 cents   434.946 Hz
0 cents     440.000 Hz
+20 cents   445.113 Hz
+100 cents  466.164 Hz
```

The inverse is just as useful when you already know two positive frequencies. The correction in cents from `sourceHz` to `targetHz` is:

```text
1200 * log2(targetHz / sourceHz)
```

So 440 Hz to 442 Hz is about +7.851 cents. Reversing the direction, from 442 Hz to 440 Hz, gives about -7.851 cents. This is only arithmetic on known frequencies. It is not a pitch detector, and a single global offset cannot fix a recording where different notes or instruments need different corrections.

## Two small JavaScript helpers

For a UI control, I would keep the conversion layer boring and explicit. The functions below reject non-finite control values and reject zero or negative frequencies before taking a logarithm.

```js
function pitchRatio(semitones, cents = 0) {
  if (!Number.isFinite(semitones) || !Number.isFinite(cents)) {
    throw new TypeError("semitones and cents must be finite numbers");
  }

  return 2 ** ((100 * semitones + cents) / 1200);
}

function centsBetween(sourceHz, targetHz) {
  if (
    !Number.isFinite(sourceHz) ||
    !Number.isFinite(targetHz) ||
    sourceHz <= 0 ||
    targetHz <= 0
  ) {
    throw new RangeError("frequencies must be positive finite numbers");
  }

  return 1200 * Math.log2(targetHz / sourceHz);
}
```

The first helper returns a dimensionless ratio. The second returns the signed correction needed to move from one known frequency to another. That makes the direction visible at the call site: `centsBetween(observedHz, desiredHz)`.

For this article, I ran the conversion checks with plain Node. This compact check can be pasted after the two helpers and run without dependencies or network access:

```js
function assertClose(actual, expected, epsilon = 1e-9) {
  if (Math.abs(actual - expected) > epsilon) {
    throw new Error(`expected ${expected}, got ${actual}`);
  }
}

assertClose(pitchRatio(12), 2);
assertClose(pitchRatio(-1, 20), pitchRatio(0, -80), 1e-12);
assertClose(centsBetween(440, 442), 7.851415040126504, 1e-12);

for (const fn of [
  () => centsBetween(0, 440),
  () => pitchRatio(Number.NaN),
]) {
  let threw = false;
  try {
    fn();
  } catch {
    threw = true;
  }
  if (!threw) {
    throw new Error("expected invalid input to throw");
  }
}

console.log("pitch checks passed");
```

The broader diagnostic I ran for the article also checked octave ratios in both directions, positive and negative semitones, an inverse round trip, all seven 440 Hz cases used for verification, the combined -80 cent inverse, and 12 invalid inputs. Those checks cover the conversion logic only, not audio processing or browser-extension runtime.

## Why this ratio is not playbackRate

A browser implementation mistake is to calculate a pitch ratio and assign it directly to `audio.playbackRate`. Those numbers look compatible because both are ratios, but they represent different controls.

The current [MDN documentation for `HTMLMediaElement.playbackRate`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/playbackRate) says that `1.0` is normal playback speed, values below 1 are slower, and values above 1 are faster. It also states that browser audio pitch is corrected by default, with `preservesPitch` available to disable that correction.

So a pitch ratio such as `2 ** (20 / 1200)` is not a drop-in `playbackRate` value when the requirement is "raise pitch by 20 cents while keeping tempo unchanged." Independent pitch and time control requires suitable pitch-shifting processing separate from the media element's playback-rate control.

## A browser product example

I built my own WebAssembly pitch-shifting engine after Chrome transposers I tried made upward vocals sound cartoonish to me and downward shifts heavy or distorted. Its first released application is [Key Changer](https://keychanger.app/), a desktop Chrome extension that shifts already-playing browser audio on supported sites including YouTube, Spotify Web, and SoundCloud. It provides -12 to +12 semitones and -50 to +50 cents in 1-cent steps while keeping tempo unchanged; Keep vocals natural is intended to help preserve timbre. A 1-cent input step is control resolution, not a claim of measured 1-cent DSP accuracy. The [Chrome Web Store listing](https://chromewebstore.google.com/detail/key-changer/fhomhcphchfjgidmefjjbhnkdoblgcpj) describes the released extension, while the [public engine listening page](https://misheno.com/key-changer-engine/#listen) uses selected fixed 20-second prerecorded excerpts with an unshifted Original and processed choices. Those examples are for listening comparison, not live-input execution or latency proof.

The same math remains useful without that product. If a known source is uniformly off by a small amount, calculate the signed cents correction, keep the frequency conversion separate from playback speed, and test the helper at the boundaries before wiring it into an audio control.
