utils/music_theory/DOCS-article-walkthrough.md
Krzysztof kuhy Rudnicki d5b3f2cff3 music_theory: music theory from first principles, as code you can hear
New subproject porting Luke Haas's "Music Theory for Programmers" section
by section into typed Python: pitch (MIDI <-> Hz), tuning (harmonic
series, the Pythagorean comma, brute-forced "why twelve"), scales as step
arrays with modes as rotations, chords stacked in thirds with Roman
numerals and the V7 -> I resolution, named progressions, a numpy synth
(waveforms, ADSR, low-pass), a bar-exact sequencer, and a seeded Recipe ->
WAV composer. `degrade` is the same piece ruined by a 0..1 amount, so an
ending can be the opening destroyed on the same seed; filtering runs LAST
because filtering first measured brighter than the clean render.

17 demos (`python -m music_theory demo all`) each print the article's
numbers -- the comma is 1.013643, the tempered fifth is 0.113 % flat,
twelve is the first acceptable division -- and the FFT of the render, so
the claims are tested rather than asserted. 157 tests, 100 % branch
coverage, ruff ALL, mypy strict, pylint 10.

Consumed next by roadside-assistance for its three world-state beds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VC17zymxkeiUcHrMvt8W5W
2026-09-11 18:29:13 +02:00

4.5 KiB
Raw Blame History

The article, section by section, as code

Each row: the article's claim, where it lives here, and the demo that lets you hear it. Run python -m music_theory demo all --out /tmp/mt once and follow along with aplay.

1. Sound is a number that changes over time

A note is a frequency in Hz. Pitch perception is multiplicative: "one semitone up" means "multiply by 2^(1/12)", never "add N Hz". pitch.midi_to_hz is that exponent counted from A4 = 440 Hz. Demo tone; analysis.dominant_frequency confirms the FFT peak is 440.

2. Envelopes and timbre

Volume over time (attack, decay, sustain, release) is what makes a sound a note. A 10 ms attack reads as struck, 500 ms as bowed; the release is what stops a click at the end. synth.Envelope + synth.adsr. Timbre is the recipe of overtones at 2×, 3×, 4× the fundamental; sine, triangle, square and saw are four such recipes (synth.oscillator). Demos envelope, waveforms; the printed spectral centroid rises from sine to saw.

3. Octaves and consonance

Doubling the frequency gives the "same" note (octaves). Two notes sound consonant when their ratio is simple: for p/q the combined wave repeats after q cycles of the lower note (tuning.repeat_cycles), so 3/2 locks in after 2 cycles, 16/15 after 15, and √2 (the tritone) never. Demo consonance.

4. The Pythagorean comma

Stack twelve pure fifths and you overshoot seven octaves by (3/2)¹² / 2⁷ = 1.013643, about 23.5 cents. The proof is one line: 3ⁿ = 2ᵐ has no integer solution because both are prime. "The system we want does not exist and never has." tuning.pythagorean_comma; demo comma plays A4 against A4 × comma and you hear it beat six times a second.

5. Equal temperament, and why twelve

Split the octave into twelve equal multiplicative steps. Every interval except the octave is slightly wrong, but wrong by the same amount in every key. The fifth becomes 2^(7/12) = 1.498307, 0.113 % flat. tuning.best_divisions brute-forces every division up to 25: twelve is the smallest whose fifth is within 0.2 % of 3/2 (first_acceptable_division). Demos temperament, divisions.

6. Scales

A scale is a subset of the twelve notes with UNEVEN gaps, so the ear can tell where it is. Encode it as the step array: major [2,2,1,2,2,2,1], minor [2,1,2,2,1,2,2]. scales.build(60, major) is the article's reduce → [60,62,64,65,67,69,71,72]. All seven modes are the same array rotated (scales.mode); aeolian is rotation 5 and equals natural minor. Demos scale, modes.

7. Chords

Stack every other scale degree (chords.stacked_thirds). Major triad [0,4,7], minor [0,3,7]: one array element is the entire happy/sad difference. Sevenths add one more third. Demo chords.

8. Keys and Roman numerals

Build a triad on each degree and you get the diatonic chords of the key: C major → C Dm Em F G Am B° (chords.diatonic_chords). Roman numerals are relative addressing, so progressions.transpose is adding one constant to every MIDI number. Demos diatonic, transpose.

9. Tension and resolution

V7 contains the leading tone (B in C) and a tritone (B–F). Both resolve by a single semitone into the tonic: B→C, F→E, contrary motion, the tritone collapsing into a major third. chords.resolution returns exactly those two moves. Common loops (I V vi IV, ii V I, vi IV I V, I vi IV V) are in progressions.NAMED. Demos resolution, progressions.

10. Notation as a serialization format

The staff is diatonic, not chromatic; sharps and flats are the escape hatch; durations are powers of two and a dot multiplies by 1.5; tempo turns beats into seconds. Here that is sequencer.NoteEvent (pitch, start beat, length in beats) and sequencer.beat_seconds. No demo; the sequencer is what every later demo renders through.

11. The complete composition

Pick a key and a progression, play the root as bass, the chord tones as an arpeggio, and a melody drawn from the scale. Constraining every voice to the scale is what makes any melody fit. compose.Recipe + compose.compose, seeded so the same recipe always renders the same bytes. Demo piece.

Beyond the article: degrade

If the piece is data, "the ending is the opening, destroyed" is a transform on that data. degrade darkens the mode past 0.5, drags the tempo, thins and detunes the melody, then bit-crushes, hisses and low-passes the render. Order matters: filtering LAST is what makes the result measurably darker (spectral centroid 514 Hz against 2207 Hz clean); filtering first left it brighter than the original. Demo bracket.