Skip to main content

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

A small JavaScript layer for pitch-ratio calculations, signed fine-tuning corrections, and the browser playback-speed trap.

Updated 5 min readMarkdown

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 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:

totalCents = 100 * s + c

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

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:

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:

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.

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:

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 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, 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 describes the released extension, while the public engine listening page 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.

No comments yet. Be the first to comment.