Code Lab: Tuning Systems Calculator

Western music has never settled on a single way to tune its twelve notes. Each tuning system makes a different trade-off between pure intervals and the freedom to change key. In this lab you will build a Python calculator that computes the frequencies for four historically important tuning systems -- equal temperament, Pythagorean, just intonation, and quarter-comma meantone -- then measure the cent differences between them and visualize the deviations on a single chart.

Prerequisites: numpy, matplotlib. Install with pip install numpy matplotlib.

The Cent: A Universal Unit for Pitch Comparison

Before we begin, recall that a cent is 1/1200 of an octave. The formula to convert a frequency ratio to cents is:

$$\text{cents} = 1200 \times \log_2\!\left(\frac{f}{f_{\text{ref}}}\right)$$

Equal temperament spaces its semitones exactly 100 cents apart, so any deviation from 100-cent multiples tells us how a tuning system differs from the modern piano.

import numpy as np
import matplotlib.pyplot as plt

def ratio_to_cents(ratio):
    """Convert a frequency ratio to cents (1200 cents = one octave)."""
    return 1200.0 * np.log2(ratio)

NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F",
              "F#", "G", "G#", "A", "A#", "B"]

Equal Temperament (12-TET)

The modern standard divides the octave into twelve geometrically equal steps. Every semitone has the ratio 2^(1/12). This system makes every key equally (im)pure, which is why it dominates keyboard instruments.

def equal_temperament(reference_freq=261.63):
    """
    Compute 12-tone equal temperament frequencies.
    Default reference: C4 = 261.63 Hz (derived from A4 = 440 Hz).
    """
    ratios = [2 ** (n / 12.0) for n in range(12)]
    freqs = [reference_freq * r for r in ratios]
    cents = [ratio_to_cents(r) for r in ratios]
    return dict(zip(NOTE_NAMES, zip(freqs, ratios, cents)))

et = equal_temperament()
print("Equal Temperament (reference C4 = 261.63 Hz)")
print(f"{'Note':<5} {'Freq (Hz)':>10} {'Ratio':>10} {'Cents':>8}")
for name in NOTE_NAMES:
    freq, ratio, cent = et[name]
    print(f"{name:<5} {freq:>10.2f} {ratio:>10.6f} {cent:>8.1f}")

Pythagorean Tuning

Pythagorean tuning builds every interval from stacked perfect fifths (ratio 3:2). Starting from C, each new note is a fifth above the previous one, transposed down by octaves as needed. The resulting fifths are acoustically pure, but the major thirds are noticeably sharp (408 cents instead of the pure 386 cents).

def pythagorean(reference_freq=261.63):
    """
    Compute Pythagorean tuning by stacking pure fifths (3:2).
    Circle of fifths order: F-C-G-D-A-E-B-F#-C#-G#-D#-A#
    """
    # Number of fifths above C for each note in the circle
    fifth_counts = {
        "C": 0, "G": 1, "D": 2, "A": 3, "E": 4, "B": 5,
        "F#": 6, "C#": 7, "G#": 8, "D#": 9, "A#": 10, "F": -1,
    }
    results = {}
    for name in NOTE_NAMES:
        n_fifths = fifth_counts[name]
        # Ratio = (3/2)^n_fifths, reduced into a single octave
        raw_ratio = (3.0 / 2.0) ** n_fifths
        # Bring into the range [1, 2) by dividing by powers of 2
        ratio = raw_ratio / (2 ** int(np.log2(raw_ratio))) if raw_ratio >= 2 else raw_ratio
        if ratio < 1.0:
            ratio *= 2.0
        freq = reference_freq * ratio
        cents = ratio_to_cents(ratio)
        results[name] = (freq, ratio, cents)
    return results

pyth = pythagorean()
print("\nPythagorean Tuning")
print(f"{'Note':<5} {'Freq (Hz)':>10} {'Ratio':>10} {'Cents':>8}")
for name in NOTE_NAMES:
    freq, ratio, cent = pyth[name]
    print(f"{name:<5} {freq:>10.2f} {ratio:>10.6f} {cent:>8.1f}")

Just Intonation (5-limit)

Just intonation tunes each interval to the simplest possible whole-number ratio involving the primes 2, 3, and 5. Major thirds become a pure 5:4 and minor thirds a pure 6:5. The trade-off: the tuning is locked to one key. Modulating to a distant key produces "wolf" intervals that sound painfully out of tune.

def just_intonation(reference_freq=261.63):
    """
    5-limit just intonation ratios relative to the tonic (C major).
    These are the standard textbook ratios for a major-mode JI scale.
    """
    ji_ratios = {
        "C":  1/1,
        "C#": 16/15,
        "D":  9/8,
        "D#": 6/5,
        "E":  5/4,
        "F":  4/3,
        "F#": 45/32,
        "G":  3/2,
        "G#": 8/5,
        "A":  5/3,
        "A#": 9/5,
        "B":  15/8,
    }
    results = {}
    for name in NOTE_NAMES:
        ratio = ji_ratios[name]
        freq = reference_freq * ratio
        cents = ratio_to_cents(ratio)
        results[name] = (freq, ratio, cents)
    return results

ji = just_intonation()
print("\nJust Intonation (5-limit, C major)")
print(f"{'Note':<5} {'Freq (Hz)':>10} {'Ratio':>12} {'Cents':>8}")
for name in NOTE_NAMES:
    freq, ratio, cent = ji[name]
    print(f"{name:<5} {freq:>10.2f} {ratio:>12.6f} {cent:>8.1f}")

Quarter-Comma Meantone

Meantone temperament, dominant in European music from roughly 1500 to 1750, narrows each fifth by a quarter of the syntonic comma so that major thirds come out pure (5:4). The fifths are slightly flat (696.6 cents instead of 702), but the thirds are beautiful. The system breaks down at the "wolf fifth" (G# to D#), which absorbs all the accumulated error.

def quarter_comma_meantone(reference_freq=261.63):
    """
    Quarter-comma meantone: each fifth is narrowed by 1/4 of the
    syntonic comma (81/80).  Fifth ratio = 5^(1/4) ≈ 1.49535.
    """
    meantone_fifth = 5 ** 0.25  # ≈ 1.49535 (compared to pure 1.5)
    # Same circle-of-fifths construction as Pythagorean
    fifth_counts = {
        "C": 0, "G": 1, "D": 2, "A": 3, "E": 4, "B": 5,
        "F#": 6, "C#": 7, "G#": 8, "D#": 9, "A#": 10, "F": -1,
    }
    results = {}
    for name in NOTE_NAMES:
        n = fifth_counts[name]
        raw_ratio = meantone_fifth ** n
        # Reduce to one octave
        while raw_ratio >= 2.0:
            raw_ratio /= 2.0
        while raw_ratio < 1.0:
            raw_ratio *= 2.0
        freq = reference_freq * raw_ratio
        cents = ratio_to_cents(raw_ratio)
        results[name] = (freq, raw_ratio, cents)
    return results

mt = quarter_comma_meantone()
print("\nQuarter-Comma Meantone")
print(f"{'Note':<5} {'Freq (Hz)':>10} {'Ratio':>10} {'Cents':>8}")
for name in NOTE_NAMES:
    freq, ratio, cent = mt[name]
    print(f"{name:<5} {freq:>10.2f} {ratio:>10.6f} {cent:>8.1f}")

Comparing the Four Systems: Cent Deviations from Equal Temperament

The most revealing comparison uses equal temperament as the zero baseline and plots how many cents each system deviates at every chromatic pitch.

def deviation_from_et(system, et_system):
    """Return cent deviations of *system* relative to *et_system*."""
    devs = {}
    for name in NOTE_NAMES:
        et_cents = et_system[name][2]
        sys_cents = system[name][2]
        devs[name] = sys_cents - et_cents
    return devs

dev_pyth = deviation_from_et(pyth, et)
dev_ji   = deviation_from_et(ji, et)
dev_mt   = deviation_from_et(mt, et)

# ---- Visualization ----
x = np.arange(len(NOTE_NAMES))
width = 0.25

fig, ax = plt.subplots(figsize=(13, 6))

bars1 = ax.bar(x - width, [dev_pyth[n] for n in NOTE_NAMES],
               width, label="Pythagorean", color="#2196F3", alpha=0.85)
bars2 = ax.bar(x,         [dev_ji[n] for n in NOTE_NAMES],
               width, label="Just Intonation", color="#FF5722", alpha=0.85)
bars3 = ax.bar(x + width, [dev_mt[n] for n in NOTE_NAMES],
               width, label="Quarter-Comma Meantone", color="#4CAF50", alpha=0.85)

ax.axhline(0, color="black", linewidth=0.8, linestyle="-")
ax.set_xticks(x)
ax.set_xticklabels(NOTE_NAMES, fontsize=11)
ax.set_xlabel("Note", fontsize=12)
ax.set_ylabel("Deviation from Equal Temperament (cents)", fontsize=12)
ax.set_title("Tuning System Deviations from 12-TET\n"
             "Positive = sharper than ET; Negative = flatter than ET",
             fontsize=14, weight="bold")
ax.legend(fontsize=10)
ax.grid(True, axis="y", alpha=0.3)

plt.tight_layout()
plt.show()

What to notice:

  • Pythagorean tuning is very close to equal temperament for fifths and fourths (G, D, A, F) but diverges sharply at the thirds (E, G#, B).
  • Just intonation nails the intervals that matter most in C major (E is 14 cents flat of ET, giving a pure 5:4 major third) but the remote notes (F#, C#) drift considerably.
  • Quarter-comma meantone compromises: its thirds are pure and its fifths are only 3.4 cents flat, but the accumulated error at G# and D# creates the notorious wolf interval.

Printing a Summary Table

print(f"\n{'Note':<5} {'ET':>8} {'Pyth':>8} {'JI':>8} {'MT':>8}  "
      f"{'Pyth-ET':>8} {'JI-ET':>8} {'MT-ET':>8}")
print("-" * 72)
for name in NOTE_NAMES:
    et_c   = et[name][2]
    pyth_c = pyth[name][2]
    ji_c   = ji[name][2]
    mt_c   = mt[name][2]
    print(f"{name:<5} {et_c:>8.1f} {pyth_c:>8.1f} {ji_c:>8.1f} {mt_c:>8.1f}  "
          f"{dev_pyth[name]:>+8.1f} {dev_ji[name]:>+8.1f} {dev_mt[name]:>+8.1f}")

Try It Yourself

  1. The Pythagorean comma. If you stack twelve pure fifths (3:2) you should return to the same note seven octaves higher, but you overshoot by the Pythagorean comma. Calculate (3/2)**12 / 2**7 and convert the result to cents. How large is this comma, and why does it make Pythagorean tuning impossible to use in all twelve keys?

  2. Just intonation in a different key. Rewrite the just_intonation function so that the pure ratios are centered on G major instead of C major (hint: the G-major JI scale uses the same ratio set but shifted so that G = 1/1). Plot the cent deviations against equal temperament and compare with the C-major version. Which notes move the most?

  3. Historical listening test. Using the frequency values from each tuning system, generate a C-major triad (C-E-G) as a sum of three sine waves at 0.5 seconds each. Use numpy to create the audio arrays and matplotlib to plot the combined waveforms. Compare the beat patterns: the just-intonation triad should show a smooth, steady envelope (no beats), while equal temperament should exhibit slow amplitude fluctuations caused by the slightly mistuned third. Calculate the expected beat rate from the frequency difference between the ET and JI versions of E.