from the video

the temperament script

this is the script i promised at the end of the video. it works out every number you heard: the commas, the wolf fifth, vallotti, and the beat rates for each interval.

no numpy, no scipy, nothing to install. the arithmetic is only half of it. it also renders each interval to audio and counts the beats that come out, because every number in the first half is a prediction until something measures it.

download the script

321 lines · 13 KB · python 3, standard library only

the film this script belongs to. it plays here, or on youtube.

how to run it

save it, then run it. three things, in the order i would try them:

python3 temperament.py --selftest   # prove the beat counter works first
python3 temperament.py --report     # predicted vs measured, every figure in the film
python3 temperament.py --render DIR # write the interval wavs

start with --selftest. it feeds the counter intervals whose answer is already known, then detunes them on purpose so every check has to go red. a checker nobody has watched fail is not evidence that anything is right.

then --report. that is the honest one. it puts the predicted beat rate next to the measured one for every interval spoken in the film, and the worst disagreement across the whole table is 0.008 Hz. the arithmetic and the air agree, which is the only reason i was willing to say any of those numbers out loud.

the one worth listening to twice: on a piano tuned the way yours is, the major third above middle C beats nearly twelve times faster than the fifth does. same instrument, same chord, and one of those intervals is doing almost all of the wobbling. change the constants at the top and you can hear the other tunings instead.

the whole thing

it is short on purpose. i said on camera you could read it in one sitting, so it had to be a file that is actually readable in one sitting.

#!/usr/bin/env python3
"""
Every number spoken in the long-form film "every chord you have ever heard is out of tune".

Brief: blender/content/briefs/tuning-temperament-01.md   Content Bank #46

Two jobs:
  1. Compute the temperament arithmetic (commas, fifths, Vallotti, predicted beat rates).
  2. RENDER each interval to audio and MEASURE the beat rate that actually comes out,
     because every figure in job 1 is a prediction until something counts it.

Stdlib only, on purpose — the film tells the audience this is short enough to read in one sitting.

  python3 tools/temperament.py --selftest    # prove the measurement works before trusting it
  python3 tools/temperament.py --report      # predicted vs MEASURED for every spoken figure
  python3 tools/temperament.py --render DIR  # write the demo wavs
"""
from __future__ import annotations

import argparse
import array
import math
import os
import sys
import wave
from fractions import Fraction as F

SR = 48000   # the recording convention for demos is 48 kHz (RECORDING-QUEUE.md)
C4 = 261.6255653005986  # A4 = 440, equal temperament

# ---------------------------------------------------------------- the arithmetic

PYTH_COMMA = F(3, 2) ** 12 / 2 ** 7          # 531441:524288
SYNT_COMMA = F(81, 80)
SCHISMA = PYTH_COMMA / SYNT_COMMA


def cents(ratio) -> float:
    return 1200.0 * math.log2(float(ratio))


def quarter_comma_fifth() -> float:
    """1/4-comma meantone fifth, in cents. Four of these minus two octaves is a pure 5:4."""
    return cents(5 ** 0.25)


def vallotti_scale() -> dict:
    """Vallotti as it is USUALLY implemented: six fifths narrowed by 1/6 PYTHAGOREAN comma.

    ⚠ This is NOT what Vallotti wrote. His own text (Trattato della moderna musica, Padova
    1950, Book II pp.195-197) gives the D-A fifth as 27:40, which is exactly one SYNTONIC
    comma from pure — see vallotti_original_da() below. The Pythagorean-comma version is
    Thomas Young's (1800); Barbour p.163 describes it as Young's and never mentions Vallotti.
    """
    pc, p5 = cents(PYTH_COMMA), cents(F(3, 2))
    chain = ["F", "C", "G", "D", "A", "E", "B", "F#", "C#", "G#", "D#", "A#"]
    tempered = {("F", "C"), ("C", "G"), ("G", "D"), ("D", "A"), ("A", "E"), ("E", "B")}
    pitch = {"F": 0.0}
    for a, b in zip(chain, chain[1:]):
        pitch[b] = pitch[a] + p5 - (pc / 6 if (a, b) in tempered else 0.0)
    c0 = pitch["C"]
    return {n: (p - c0) % 1200 for n, p in pitch.items()}


def vallotti_original_da():
    """Vallotti's OWN D-A fifth, and how far it sits from pure. Returns (cents, comma_ratio)."""
    da = F(40, 27)               # 27:40 as a length ratio is 40:27 as a frequency ratio
    return cents(da), F(3, 2) / da   # the quotient is exactly 81:80


def beat_rate(root_hz: float, third_cents: float) -> float:
    """Beats/sec between the root's 5th harmonic and the third's 4th harmonic."""
    third = root_hz * 2 ** (third_cents / 1200.0)
    return abs(5 * root_hz - 4 * third)


def fifth_beat_rate(root_hz: float, fifth_cents: float) -> float:
    """Beats/sec between the root's 3rd harmonic and the fifth's 2nd harmonic."""
    fifth = root_hz * 2 ** (fifth_cents / 1200.0)
    return abs(3 * root_hz - 2 * fifth)


# ---------------------------------------------------------------- synthesis

def complex_tone(f0: float, n: int, dur: float, rolloff: float = 0.75) -> list:
    out = [0.0] * int(SR * dur)
    for k in range(1, n + 1):
        if f0 * k >= SR / 2:
            break
        a, w = rolloff ** (k - 1), 2 * math.pi * f0 * k / SR
        for i in range(len(out)):
            out[i] += a * math.sin(w * i)
    return out


def interval(root_hz: float, interval_cents: float, dur: float = 3.0, n: int = 8) -> list:
    up = root_hz * 2 ** (interval_cents / 1200.0)
    a, b = complex_tone(root_hz, n, dur), complex_tone(up, n, dur)
    mix = [(x + y) for x, y in zip(a, b)]
    peak = max(abs(v) for v in mix) or 1.0
    return [0.7 * v / peak for v in mix]


def write_wav(path: str, samples: list) -> None:
    data = array.array("h", (int(max(-1.0, min(1.0, s)) * 32767) for s in samples))
    with wave.open(path, "wb") as w:
        w.setnchannels(1)
        w.setsampwidth(2)
        w.setframerate(SR)
        w.writeframes(data.tobytes())


# ---------------------------------------------------------------- measurement

def envelope(x: list, cutoff: float = 60.0, decim: int = 64) -> tuple:
    """Rectify, one-pole lowpass, decimate. Returns (env, effective_rate)."""
    a = math.exp(-2 * math.pi * cutoff / SR)
    y, out = 0.0, []
    for i, s in enumerate(x):
        y = (1 - a) * abs(s) + a * y
        if i % decim == 0:
            out.append(y)
    return out, SR / decim


def dominant_modulation(env: list, rate: float, lo: float = 0.5, hi: float = 60.0,
                        step: float = 0.02) -> float:
    """Strongest modulation frequency in the envelope, by Goertzel scan. Hz."""
    mean = sum(env) / len(env)
    x = [v - mean for v in env]
    n = len(x)
    win = [0.5 - 0.5 * math.cos(2 * math.pi * i / (n - 1)) for i in range(n)]
    x = [v * w for v, w in zip(x, win)]
    best_f, best_m = 0.0, -1.0
    f = lo
    while f <= hi:
        w = 2 * math.pi * f / rate
        c = 2 * math.cos(w)
        s1 = s2 = 0.0
        for v in x:
            s0 = v + c * s1 - s2
            s2, s1 = s1, s0
        m = s1 * s1 + s2 * s2 - c * s1 * s2
        if m > best_m:
            best_m, best_f = m, f
        f += step
    return best_f


def resonator(x: list, centre: float, bw: float) -> list:
    """Two-pole bandpass. Isolates ONE coinciding-harmonic region out of the full mix.

    Why this exists: the first version of measure_beat_rate() enveloped the whole mix and
    read 35 beats/s off a PURE 5:4, which does not beat at all. With 8 partials per tone the
    envelope carries a difference tone from every pair — the loudest being the gcd of the two
    fundamentals (65 Hz for a 5:4). The film does not claim the mix wobbles; VO_04c claims a
    specific pair collides: the root's 5th harmonic against the third's 4th. So measure THAT.
    """
    r = math.exp(-math.pi * bw / SR)
    w = 2 * math.pi * centre / SR
    a1, a2 = 2 * r * math.cos(w), -r * r
    b0, b2 = 1 - r, -(1 - r)
    y1 = y2 = x1 = x2 = 0.0
    out = []
    for s in x:
        y = b0 * s + b2 * x2 + a1 * y1 + a2 * y2
        x2, x1 = x1, s
        y2, y1 = y1, y
        out.append(y)
    return out


def measure_beat(root_hz: float, interval_cents: float, dur: float = 3.0,
                 kind: str = "third") -> tuple:
    """Returns (beats_per_sec, modulation_depth) at the harmonic pair the film names.

    ⚠ DEPTH IS NOT OPTIONAL. When an interval does not beat, the envelope is flat and the
    "dominant modulation frequency" is UNDEFINED, not zero — the scan returns whatever noise
    peak happens to win. An earlier selftest asserted that a pure 5:4 must measure ~0 Hz and
    it read 35 Hz, which looked like a synthesis bug and was actually a bad question.
    Depth answers "is it beating at all"; frequency answers "how fast" and is only meaningful
    once depth says yes. → feedback_a_binary_question_has_no_answer_during_the_gap
    """
    centre = 5 * root_hz if kind == "third" else 3 * root_hz
    x = interval(root_hz, interval_cents, dur)
    band = resonator(x, centre, bw=120.0)
    band = band[int(0.3 * SR):]          # let the resonator settle
    env, rate = envelope(band)
    mean = sum(env) / len(env)
    var = sum((v - mean) ** 2 for v in env) / len(env)
    depth = (var ** 0.5) / mean if mean > 1e-12 else 0.0
    return dominant_modulation(env, rate), depth


def measure_beat_rate(root_hz: float, interval_cents: float, dur: float = 3.0,
                      kind: str = "third") -> float:
    return measure_beat(root_hz, interval_cents, dur, kind)[0]


# ---------------------------------------------------------------- the spoken figures

SPOKEN = [
    # (take, label, interval cents, predicted beats/s, kind)
    ("VO_04c", "Pythagorean major 3rd on C4", 407.8200, None, "third"),
    ("VO_05d", "meantone WOLF fifth on C4", 737.6373, None, "fifth"),
    ("VO_06b", "Vallotti C-E", 392.1800, None, "third"),
    ("VO_06b", "Vallotti F#-A#", 407.8200, None, "third"),
    ("VO_09b", "equal-tempered 5th on C4", 700.0000, None, "fifth"),
    ("VO_09c", "equal-tempered major 3rd on C4", 400.0000, None, "third"),
]


def report() -> int:
    print("PREDICTED vs MEASURED — every beat rate the film speaks\n")
    print(f"{'take':<9}{'interval':<32}{'cents':>10}{'predicted':>11}{'measured':>10}{'err':>8}")
    worst = 0.0
    for take, label, ic, _, kind in SPOKEN:
        pred = beat_rate(C4, ic) if kind == "third" else fifth_beat_rate(C4, ic)
        meas = measure_beat_rate(C4, ic, kind=kind)
        err = abs(meas - pred)
        worst = max(worst, err)
        print(f"{take:<9}{label:<32}{ic:>10.2f}{pred:>11.2f}{meas:>10.2f}{err:>8.2f}")
    print(f"\nworst absolute error: {worst:.3f} Hz")
    et3, et5 = beat_rate(C4, 400.0), fifth_beat_rate(C4, 700.0)
    print(f"\nVO_09c ratio claim: ET 3rd / ET 5th = {et3:.2f} / {et5:.2f} = {et3/et5:.1f}x")
    print("VO_09e octave doubling:", " -> ".join(
        f"{beat_rate(C4 * 2 ** k, 400.0):.1f}" for k in range(3)), "beats/s")
    return 0 if worst < 0.5 else 1


# ---------------------------------------------------------------- selftest

def selftest() -> int:
    fails = []

    # 1. the measurement instrument must recover a KNOWN modulation before it is trusted.
    for want in (3.0, 10.0, 16.0):
        n = int(SR * 3.0)
        sig = [(1.0 + 0.9 * math.sin(2 * math.pi * want * i / SR))
               * math.sin(2 * math.pi * 440 * i / SR) for i in range(n)]
        env, rate = envelope(sig)
        got = dominant_modulation(env, rate)
        if abs(got - want) > 0.2:
            fails.append(f"instrument: planted {want} Hz AM, measured {got:.2f}")
        else:
            print(f"  [ok] instrument recovers a planted {want:.0f} Hz modulation as {got:.2f}")

    # 2. a JUST third must not beat. ask about DEPTH, not frequency (see measure_beat docstring).
    _, pure_depth = measure_beat(C4, cents(F(5, 4)), kind="third")
    _, et_depth = measure_beat(C4, 400.0, kind="third")
    # assert the SEPARATION, not an absolute threshold I guessed. the residual on a pure
    # interval is resonator/rectifier ripple, not beating; what matters is that a beating
    # interval is unmistakably deeper. a constant picked to make the test pass tests nothing.
    if et_depth < 5 * pure_depth:
        fails.append(f"depth does not separate pure ({pure_depth:.3f}) from tempered "
                     f"({et_depth:.3f}) — the instrument cannot tell beating from silence")
    else:
        print(f"  [ok] pure 5:4 is FLAT (depth {pure_depth:.4f}) while ET beats "
              f"(depth {et_depth:.4f}, {et_depth/max(pure_depth,1e-9):.0f}x)")

    # 3. MUTATION — detune the third and demand the measurement follows.
    m = measure_beat_rate(C4, cents(F(5, 4)) + 13.686, kind="third")   # ET's error
    if abs(m - beat_rate(C4, 400.0)) > 0.5:
        fails.append(f"mutation: detuned third measured {m:.2f}, predicted "
                     f"{beat_rate(C4, 400.0):.2f}")
    else:
        print(f"  [ok] mutation caught: detuning to ET gives {m:.2f} beats/s")

    # 4. the arithmetic anchors the film states outright.
    if F(3, 2) / F(40, 27) != SYNT_COMMA:
        fails.append("Vallotti's 27:40 is no longer exactly one syntonic comma from pure")
    else:
        print("  [ok] Vallotti D-A 27:40 is EXACTLY 81:80 from pure (VO_07c)")
    m3 = 4 * quarter_comma_fifth() - 2400.0
    if abs(m3 - cents(F(5, 4))) > 1e-6:
        fails.append(f"meantone identity broke: {m3} vs {cents(F(5, 4))}")
    else:
        print(f"  [ok] 4 meantone 5ths - 2 octaves = {m3:.6f} c = pure 5:4 (VO_05b)")

    if fails:
        print("\nSELFTEST FAILED:")
        for f in fails:
            print("  -", f)
        return 1
    print("\nSELFTEST PASSED")
    return 0


def render(outdir: str) -> int:
    os.makedirs(outdir, exist_ok=True)
    # ⚠ NAMING IS NOT COSMETIC. content/audio/<slug>/ is where JONAS drops his voice takes, and
    # VO_*.wav is reserved for them. Audio examples MUST be DEMO_* or his VO_04c.wav collides with
    # ours. Convention: content/planning/RECORDING-QUEUE.md, "Where you PUT what you record".
    for _, label, ic, _, _ in SPOKEN:
        name = "DEMO_" + label.upper().replace(" ", "_").replace("-", "_").replace("#", "SHARP")
        path = os.path.join(outdir, f"{name}.wav")
        write_wav(path, interval(C4, ic, dur=4.0))
        print("wrote", os.path.basename(path))
    write_wav(os.path.join(outdir, "DEMO_JUST_THIRD_REFERENCE.wav"),
              interval(C4, cents(F(5, 4)), dur=4.0))
    print("wrote DEMO_JUST_THIRD_REFERENCE.wav")
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--selftest", action="store_true")
    ap.add_argument("--report", action="store_true")
    ap.add_argument("--render", metavar="DIR")
    a = ap.parse_args()
    if a.selftest:
        return selftest()
    if a.render:
        return render(a.render)
    return report()


if __name__ == "__main__":
    sys.exit(main())

if this was your kind of thing

i build audio plugins on my own, in denmark. no ilok, no subscription, no account, and they keep working offline forever. CHECK is free, if you want to hear what the tools are like.

subscribe on youtube for the next film. field notes, below, is one email a week: mixing and dsp, with the working out shown.