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
This commit is contained in:
Krzysztof Rudnicki 2026-09-11 18:29:13 +02:00
parent 94407268f0
commit d5b3f2cff3
41 changed files with 3349 additions and 0 deletions

View File

@ -0,0 +1,31 @@
name: music_theory tests
on:
push:
branches: [main]
# The workflow's own file counts as music_theory: editing only this file used
# to trigger nothing, so a fix to the job itself could never prove itself.
paths: ['music_theory/**', '.github/workflows/music-theory-tests.yml']
pull_request:
branches: [main]
paths: ['music_theory/**', '.github/workflows/music-theory-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: music_theory
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests with coverage
run: python -m pytest -v

View File

@ -72,6 +72,20 @@ published to PyPI). Consumed via pip by `screen-locker`, `diet-guard`, and
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.1.0#subdirectory=gatelock
```
## music_theory/
Music theory from first principles as typed Python (package: `music_theory`,
not published to PyPI): pitch, tuning, scales, chords, progressions, a numpy
synth and a `Recipe` → WAV composer with a `degrade` transform. Follows Luke
Haas's "Music Theory for Programmers" section by section; `python -m
music_theory demo all --out DIR` renders one WAV per section. Consumed by
`roadside-assistance` for its three world-state music beds.
Consume via:
```
music_theory @ git+https://github.com/kuhyx/utils@music-theory-v0.1.0#subdirectory=music_theory
```
## unified-design-system/
Not a package — the frozen design tokens (colors, spacing, type, radius,

View File

@ -0,0 +1,150 @@
# Pre-commit Configuration for music_theory
# Install: pre-commit install && pre-commit install --hook-type pre-push
# Run: pre-commit run --all-files
# Update: pre-commit autoupdate
# NOTE: this subproject lives inside the ~/utils monorepo, whose git root is
# one level up. pre-commit always runs hooks from the git root, so every path
# below is written relative to THAT, not to this directory, and the top-level
# `files` key keeps these hooks off the sibling subprojects. CI invokes this
# config exactly the same way:
# pre-commit run --config music_theory/.pre-commit-config.yaml --all-files
files: ^music_theory/
default_language_version:
python: python3
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-added-large-files
args: [--maxkb=2000]
- id: check-merge-conflict
- id: detect-private-key
- id: debug-statements
- id: name-tests-test
args: [--pytest-test-first]
- id: check-ast
- id: mixed-line-ending
args: [--fix=lf]
- id: requirements-txt-fixer
- repo: local
hooks:
- id: file-length-cap
name: file length <= 250 lines
entry: bash scripts/check_file_length.sh
language: system
pass_filenames: true
- id: subproject-scope
name: subproject scope regex still matches
entry: scripts/check_subproject_scope.sh music_theory
language: script
# always_run so the top-level `files` filter cannot skip the one hook
# whose job is to notice that filter has stopped matching anything.
always_run: true
pass_filenames: false
- id: no-noqa
name: Block noqa comments
entry: '(?i)#\s*(noqa|type:\s*ignore)'
language: pygrep
types: [python]
- id: no-ruff-noqa
name: Block ruff noqa file-level comments
entry: '(?i)#\s*ruff:\s*noqa'
language: pygrep
types: [python]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.2
hooks:
- id: ruff
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
types_or: [python, pyi]
- id: ruff-format
types_or: [python, pyi]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
additional_dependencies:
# Without numpy the NDArray alias is an unresolved variable and
# every signature using it fails valid-type.
- numpy==2.5.3
args:
- --ignore-missing-imports
- --no-error-summary
- --disable-error-code=no-untyped-def
- --disable-error-code=no-untyped-call
- --disable-error-code=var-annotated
- --disable-error-code=no-any-unimported
- --disable-error-code=type-arg
- --disable-error-code=no-any-return
- --disable-error-code=misc
- --disable-error-code=unused-ignore
- --disable-error-code=unreachable
- --disable-error-code=assignment
- --disable-error-code=no-redef
- --disable-error-code=attr-defined
- --disable-error-code=arg-type
- --disable-error-code=union-attr
- --disable-error-code=call-overload
- --disable-error-code=return-value
- --disable-error-code=redundant-cast
- --disable-error-code=empty-body
- --disable-error-code=list-item
- repo: https://github.com/pylint-dev/pylint
rev: v3.3.2
hooks:
- id: pylint
args:
- --rcfile=music_theory/pyproject.toml
- --fail-under=8.0
- --jobs=4
additional_dependencies:
- pytest
# numpy is a real runtime dependency; without it every module is an
# import-error and the score collapses below the gate.
- numpy==2.5.3
- repo: https://github.com/PyCQA/bandit
rev: 1.9.4
hooks:
- id: bandit
args:
- -c
- music_theory/pyproject.toml
- --severity-level=high
- --confidence-level=medium
- --skip=B113
additional_dependencies: ["bandit[toml]"]
exclude: ^(music_theory/music_theory/tests/|.*test.*\.py$)
- repo: local
hooks:
- id: pytest-coverage
name: pytest with coverage enforcement
entry: scripts/run_subproject_tests.sh music_theory
language: script
types: [python]
pass_filenames: false
require_serial: true
stages: [pre-push]
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
hooks:
- id: codespell
args:
- --skip=*.json,*.lock,.git,__pycache__,.venv

View File

@ -0,0 +1,101 @@
# 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`.

98
music_theory/README.md Normal file
View File

@ -0,0 +1,98 @@
# music_theory
Music theory from first principles, as typed Python you can hear. Follows
Luke Haas's ["Music Theory for Programmers"](https://runjs.app/blog/music-theory-for-programmers)
section by section: a sound is a number changing over time, pitch is
multiplicative, the tuning everyone wants cannot exist (3ⁿ ≠ 2ᵐ), so equal
temperament trades purity for a system that works in every key. Scales,
chords and progressions are then plain integer arrays on top of that, and a
small numpy synth turns them into WAV files.
Section-by-section notes, with what to listen for, live in
[`DOCS-article-walkthrough.md`](DOCS-article-walkthrough.md).
## Install
```
pip install "music_theory @ git+https://github.com/kuhyx/utils@music-theory-v0.1.0#subdirectory=music_theory"
```
Only dependency: numpy. Playback is left to `aplay` (or anything that opens
a 16-bit mono WAV); the package never touches an audio device itself.
## Hear the article
```
python -m music_theory list # every demo and its section
python -m music_theory explain comma # just the numbers
python -m music_theory demo all --out /tmp/mt # WAV per section + facts
aplay -q /tmp/mt/06_comma.wav
```
| demo | article section | what you hear |
|---|---|---|
| `tone` | 1 sound | A4, 440 Hz; the FFT peak is printed |
| `envelope` | 2 envelopes | same sine with a 10 ms then a 500 ms attack |
| `waveforms` | 2 timbre | sine, triangle, square, saw at 220 Hz |
| `octaves` | 3 octaves | A1 to A5, each double the last |
| `consonance` | 3 consonance | 2:1, 3:2, 4:3, 5:4, 16:15, then √2 |
| `comma` | 4 the comma | A4 against A4 × 1.0136, beating 6 times a second |
| `temperament` | 5 equal steps | twelve steps of 2^(1/12) |
| `divisions` | 5 why twelve | pure fifth vs the 5-, 7-, 12-, 19-, 24-TET fifth |
| `scale` | 6 scales | C major, then A minor |
| `modes` | 6 modes | seven rotations of the same array |
| `chords` | 7 qualities | major, minor, dim, aug, and three sevenths on C |
| `diatonic` | 8 keys | I ii iii IV V vi vii° of C |
| `transpose` | 8 transposition | I V vi IV in C, then +4 in E |
| `resolution` | 9 tension | V7 then I; the tritone collapses |
| `progressions` | 9 loops | pop, jazz, axis, fifties |
| `piece` | 11 the piece | bass + arpeggio + seeded melody, A minor |
| `bracket` | (beyond) | the same piece clean, then degraded |
## Render a recipe
A composition is data. `render` turns a recipe JSON into a loop-length WAV
and records its provenance (recipe, package version, SHA-256) in a manifest:
```json
{"key": "A3", "mode": "major", "tempo_bpm": 112, "progression": [1, 6, 3, 7],
"bars": 16, "seed": 20260911, "waveform": "saw", "degrade": 0.0}
```
```
python -m music_theory render --recipe city.json --out city.wav --manifest MANIFEST.json
python -m music_theory render --recipe city.json --out ruin.wav --degrade 1.0 --manifest MANIFEST.json
```
`degrade` (0..1) is the same piece, ruined: past 0.5 the mode flips to its
darker relative, the tempo drags, melody notes drop out and drift off pitch,
and the signal is bit-crushed, hissed and low-passed. `degrade: 0` is byte-
identical to the original, so "the ending is the opening, destroyed" is a
one-flag transform on the same seed.
## Modules
| module | article | contents |
|---|---|---|
| `pitch` | 1, 5 | MIDI ↔ Hz (440·2^((n−69)/12)), cents, note names |
| `tuning` | 3, 4, 5 | harmonic series, just ratios, the comma, `best_divisions` |
| `scales` | 6 | step patterns, modes as rotations, `pitch_at_degree` |
| `chords` | 7, 9 | qualities, stacked thirds, Roman numerals, V7 → I |
| `progressions` | 8, 9 | named loops, degree → chord, transpose |
| `synth` | 1, 2 | waveforms, ADSR, low-pass, mix, normalise |
| `sequencer` | 10 | beat grid, `NoteEvent`, bar-exact render |
| `compose` | 11 | `Recipe` → bass, arpeggio, melody |
| `degrade` | — | the ruin transform |
| `analysis` | — | FFT peak and spectral centroid, so claims are testable |
| `wav`, `manifest` | — | 16-bit mono PCM out, SHA-256 provenance |
## Develop
```
pip install -r requirements.txt
python -m pytest # 100 % branch coverage is enforced
pre-commit run --config music_theory/.pre-commit-config.yaml --all-files # from the utils root
```
Consumed by `~/roadside-assistance` (`tools/render_music_beds.sh`) for its
three world-state music beds.

View File

@ -0,0 +1,17 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Music theory from first principles, as code.
Follows Luke Haas's "Music Theory for Programmers"
(https://runjs.app/blog/music-theory-for-programmers) section by section:
a sound is a number changing over time, pitch is multiplicative, the tuning
everyone wants cannot exist, so equal temperament trades purity for a
system that works in every key. Scales, chords and progressions are then
plain arrays of integers on top of that.
Every module is pure and typed; ``synth``/``sequencer``/``compose`` turn
the integers into audio with numpy so each idea can be heard, not just read.
"""
from __future__ import annotations
__version__ = "0.1.0"

View File

@ -0,0 +1,11 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""``python -m music_theory`` entry point."""
from __future__ import annotations
import sys
from music_theory.cli import main
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,41 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Analysis: measure a rendered signal so a claim about it can be checked.
Not in the article, but every demo here makes a claim ("this is 440 Hz",
"this is darker") and a claim about audio that only a listener can verify
is one the test suite cannot hold. These two measurements let it.
"""
from __future__ import annotations
import numpy as np
from music_theory.synth import SAMPLE_RATE, Signal
def dominant_frequency(signal: Signal, sample_rate: int = SAMPLE_RATE) -> float:
"""Frequency of the strongest FFT bin, in Hz (0.0 for silence)."""
if not signal.size:
return 0.0
spectrum = np.abs(np.fft.rfft(signal))
if not np.any(spectrum > 0.0):
return 0.0
bins = np.fft.rfftfreq(len(signal), d=1.0 / sample_rate)
return float(bins[int(np.argmax(spectrum))])
def spectral_centroid(signal: Signal, sample_rate: int = SAMPLE_RATE) -> float:
"""Amplitude-weighted mean frequency: lower means darker."""
if not signal.size:
return 0.0
spectrum = np.abs(np.fft.rfft(signal))
total = float(np.sum(spectrum))
if not total:
return 0.0
bins = np.fft.rfftfreq(len(signal), d=1.0 / sample_rate)
return float(np.sum(bins * spectrum) / total)
def peak(signal: Signal) -> float:
"""Loudest absolute sample value."""
return float(np.max(np.abs(signal))) if len(signal) else 0.0

View File

@ -0,0 +1,139 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Chords: scale notes stacked in thirds, and why V wants to become I.
Article sections 7 and 9. A chord is every other note of the scale from a
starting degree, which stacks harmonics that already share overtones. The
whole difference between a major and a minor triad is one array element
([0, 4, 7] against [0, 3, 7]). The dominant seventh holds both the leading
tone and a tritone, and both resolve by a single semitone into the tonic.
"""
from __future__ import annotations
from dataclasses import dataclass
from music_theory import scales
from music_theory.pitch import SEMITONES_PER_OCTAVE
Intervals = tuple[int, ...]
## Semitone offsets of each chord quality from its root.
QUALITIES: dict[str, Intervals] = {
"major": (0, 4, 7),
"minor": (0, 3, 7),
"diminished": (0, 3, 6),
"augmented": (0, 4, 8),
"major7": (0, 4, 7, 11),
"minor7": (0, 3, 7, 10),
"dominant7": (0, 4, 7, 10),
"half-diminished7": (0, 3, 6, 10),
}
## Roman numerals per scale degree; case and the ° come from the quality.
_NUMERALS: tuple[str, ...] = ("I", "II", "III", "IV", "V", "VI", "VII")
## Interval sizes the ear cares about here.
TRITONE = 6
SEMITONE = 1
_FIFTH_DEGREE = 4
_TRIAD_SIZE = 3
@dataclass(frozen=True)
class DiatonicChord:
"""One chord built on a scale degree, with its Roman numeral address."""
degree: int
numeral: str
quality: str
notes: tuple[int, ...]
def chord(root_midi: int, quality: str) -> list[int]:
"""MIDI notes of a chord of the given quality on a root."""
if quality not in QUALITIES:
msg = f"unknown chord quality {quality!r}"
raise ValueError(msg)
return [root_midi + offset for offset in QUALITIES[quality]]
def quality_of(notes: list[int]) -> str | None:
"""Name of the quality whose intervals match these notes, if any."""
if not notes:
return None
intervals = tuple(note - notes[0] for note in notes)
for name, shape in QUALITIES.items():
if shape == intervals:
return name
return None
def stacked_thirds(
root_midi: int, pattern: scales.StepPattern, degree: int, size: int = _TRIAD_SIZE
) -> list[int]:
"""Every other scale note from ``degree``: the chord on that degree."""
return [
scales.pitch_at_degree(root_midi, pattern, degree + 2 * i) for i in range(size)
]
def roman(degree: int, quality: str) -> str:
"""Roman numeral for a 0-based degree: upper case major, lower minor, ° dim."""
numeral = _NUMERALS[degree % len(_NUMERALS)]
if quality in {"minor", "minor7"}:
return numeral.lower()
if quality in {"diminished", "half-diminished7"}:
return numeral.lower() + "°"
if quality == "augmented":
return numeral + "+"
return numeral
def diatonic_chords(
root_midi: int, pattern: scales.StepPattern, size: int = _TRIAD_SIZE
) -> list[DiatonicChord]:
"""The chord on every degree of the scale (C major: C Dm Em F G Am B°)."""
result = []
for degree in range(len(pattern)):
notes = stacked_thirds(root_midi, pattern, degree, size)
quality = quality_of(notes) or "other"
result.append(
DiatonicChord(degree, roman(degree, quality), quality, tuple(notes))
)
return result
def dominant_seventh(key_root: int, pattern: scales.StepPattern) -> list[int]:
"""The V7 chord of a key: the fifth degree with a minor seventh on top."""
fifth = scales.pitch_at_degree(key_root, pattern, _FIFTH_DEGREE)
return chord(fifth, "dominant7")
def tritone_pair(notes: list[int]) -> tuple[int, int] | None:
"""The two notes six semitones apart, if the chord contains a tritone."""
for i, low in enumerate(notes):
for high in notes[i + 1 :]:
if (high - low) % SEMITONES_PER_OCTAVE == TRITONE:
return (low, high)
return None
def resolution(key_root: int, pattern: scales.StepPattern) -> list[tuple[int, int]]:
"""How each tense note of V7 moves into I, as (from, to) pairs.
In C: B (the leading tone) rises to C and F (the seventh) falls to E.
Together they are the tritone collapsing by contrary semitones into a
stable major third -- the strongest pull in Western harmony.
"""
seventh = dominant_seventh(key_root, pattern)
tonic = stacked_thirds(key_root, pattern, 0)
# The tonic in every nearby octave, so B4 finds C5 and F5 finds E5.
targets = [
t + o for t in tonic for o in (-SEMITONES_PER_OCTAVE, 0, SEMITONES_PER_OCTAVE)
]
moves = []
for note in seventh:
close = [t for t in targets if abs(t - note) == SEMITONE]
if close:
moves.append((note, close[0]))
return moves

View File

@ -0,0 +1,107 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Command line: hear a section of the article, or render a recipe to a file.
Subcommands: ``list``, ``explain <demo|all>``, ``demo <demo|all> --out DIR``
and ``render --recipe recipe.json --out bed.wav [--manifest m.json]``.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
from typing import TYPE_CHECKING
from music_theory import compose, demos, manifest, wav
if TYPE_CHECKING:
from music_theory.demo_support import Demo
def _emit(line: str) -> None:
sys.stdout.write(line + "\n")
def build_parser() -> argparse.ArgumentParser:
"""The argument parser, separate from ``main`` so it can be tested."""
parser = argparse.ArgumentParser(prog="music_theory", description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("list", help="names of every demo")
explain = sub.add_parser("explain", help="print a demo's facts without rendering")
explain.add_argument("name", help="demo name or 'all'")
demo = sub.add_parser("demo", help="render a demo to WAV and print its facts")
demo.add_argument("name", help="demo name or 'all'")
demo.add_argument("--out", type=Path, required=True, help="directory for the WAVs")
render = sub.add_parser("render", help="render a recipe JSON to a WAV")
render.add_argument("--recipe", type=Path, required=True, help="recipe JSON file")
render.add_argument("--out", type=Path, required=True, help="WAV to write")
render.add_argument("--manifest", type=Path, help="manifest JSON to update")
render.add_argument(
"--degrade", type=float, help="override the recipe's degrade amount"
)
return parser
def cmd_list() -> int:
"""Print every demo name with its article section."""
for name, builder in demos.REGISTRY.items():
_emit(f"{name:12s} {builder.__doc__ or ''}")
return 0
def cmd_explain(name: str) -> int:
"""Print the facts of one or all demos."""
for demo_name in demos.selected(name):
_print_demo(demos.run(demo_name))
return 0
def cmd_demo(name: str, out_dir: Path) -> int:
"""Render one or all demos into ``out_dir`` and print how to hear them."""
for index, demo_name in enumerate(demos.selected(name)):
demo = demos.run(demo_name)
path = wav.write(out_dir / f"{index + 1:02d}_{demo_name}.wav", demo.signal)
_print_demo(demo)
_emit(f" hear it: {wav.play_command(path)}")
return 0
def cmd_render(
recipe_path: Path, out: Path, manifest_path: Path | None, degrade: float | None
) -> int:
"""Render a recipe file to a WAV and record it in the manifest."""
data = json.loads(recipe_path.read_text())
if not isinstance(data, dict):
_emit(f"error: {recipe_path} does not hold a recipe object")
return 1
recipe = compose.Recipe.from_dict(data)
if degrade is not None:
recipe = compose.with_degrade(recipe, degrade)
signal = compose.compose(recipe)
wav.write(out, signal, recipe.sample_rate)
record = manifest.entry(recipe, out, signal)
if manifest_path is not None:
entries = manifest.read(manifest_path) if manifest_path.exists() else {}
entries[out.name] = record
manifest.write(manifest_path, entries)
_emit(f"{out}: {record['seconds']:.2f} s, sha256 {record['sha256']}")
return 0
def _print_demo(demo: Demo) -> None:
_emit(f"[{demo.section}] {demo.name}")
for fact in demo.facts:
_emit(f" {fact}")
def main(argv: list[str] | None = None) -> int:
"""Entry point; returns the process exit code."""
args = build_parser().parse_args(argv)
if args.command == "list":
return cmd_list()
if args.command == "explain":
return cmd_explain(args.name)
if args.command == "demo":
return cmd_demo(args.name, args.out)
return cmd_render(args.recipe, args.out, args.manifest, args.degrade)

View File

@ -0,0 +1,233 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Compose: the article's closing example, generalised into a recipe.
Article section 11: pick a key, pick a progression, play a bass note, an
arpeggio and a melody drawn from the scale. Constraining every voice to
the scale is what makes any melody "fit". Everything is derived from one
frozen ``Recipe`` plus a seed, so the same recipe always renders the same
bytes -- the property a game asset manifest needs.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, fields, replace
import numpy as np
from music_theory import degrade, pitch, progressions, scales, sequencer, synth
from music_theory.pitch import SEMITONES_PER_OCTAVE
## Voice levels, chosen so the bass carries and the melody sits on top.
BASS_VELOCITY = 0.9
ARPEGGIO_VELOCITY = 0.35
MELODY_VELOCITY = 0.5
## Eighth-note arpeggio: two chord tones per beat.
ARPEGGIO_NOTES_PER_BEAT = 2
_ARPEGGIO_ORDER = (0, 1, 2, 1)
## The melody wanders inside two octaves above the root, one step at a time.
MELODY_RANGE_DEGREES = 14
_MELODY_STEPS = (-2, -1, -1, 0, 1, 1, 2)
BASS_ENVELOPE = synth.Envelope(0.02, 0.1, 0.7, 0.2)
ARPEGGIO_ENVELOPE = synth.Envelope(0.005, 0.05, 0.5, 0.05)
MELODY_ENVELOPE = synth.Envelope(0.02, 0.08, 0.6, 0.1)
@dataclass(frozen=True)
class Recipe:
"""Everything that decides what a piece sounds like."""
key: str = "A3"
mode: str = "minor"
tempo_bpm: float = 100.0
progression: tuple[int, ...] = (1, 6, 3, 7)
bars: int = 8
seed: int = 0
waveform: synth.Waveform = "saw"
degrade: float = 0.0
sample_rate: int = synth.SAMPLE_RATE
def to_dict(self) -> dict[str, object]:
"""JSON-ready form (tuples become lists)."""
data = asdict(self)
data["progression"] = list(self.progression)
return data
@classmethod
def from_dict(cls, data: dict[str, object]) -> Recipe:
"""Inverse of ``to_dict``; unknown keys and wrong types are errors."""
unknown = set(data) - {field.name for field in fields(cls)}
if unknown:
msg = f"unknown recipe keys: {sorted(unknown)}"
raise ValueError(msg)
base = cls()
return cls(
key=_as_str(data.get("key", base.key)),
mode=_as_str(data.get("mode", base.mode)),
tempo_bpm=_as_float(data.get("tempo_bpm", base.tempo_bpm)),
progression=_as_ints(data.get("progression", base.progression)),
bars=_as_int(data.get("bars", base.bars)),
seed=_as_int(data.get("seed", base.seed)),
waveform=_as_waveform(data.get("waveform", base.waveform)),
degrade=_as_float(data.get("degrade", base.degrade)),
sample_rate=_as_int(data.get("sample_rate", base.sample_rate)),
)
def _as_str(value: object) -> str:
if not isinstance(value, str):
msg = f"expected a string, got {value!r}"
raise TypeError(msg)
return value
def _as_float(value: object) -> float:
# bool is an int subclass; a recipe with tempo `true` is a typo, not 1 bpm.
if isinstance(value, bool) or not isinstance(value, (int, float)):
msg = f"expected a number, got {value!r}"
raise TypeError(msg)
return float(value)
def _as_int(value: object) -> int:
if isinstance(value, bool) or not isinstance(value, int):
msg = f"expected an integer, got {value!r}"
raise TypeError(msg)
return value
def _as_ints(value: object) -> tuple[int, ...]:
if not isinstance(value, (list, tuple)):
msg = f"expected a list of integers, got {value!r}"
raise TypeError(msg)
return tuple(_as_int(item) for item in value)
def _as_waveform(value: object) -> synth.Waveform:
name = _as_str(value)
for waveform in synth.WAVEFORMS:
if waveform == name:
return waveform
msg = f"unknown waveform {name!r}; choose one of {synth.WAVEFORMS}"
raise ValueError(msg)
def effective(recipe: Recipe) -> Recipe:
"""The recipe after its own ``degrade`` amount has darkened it."""
return degrade.apply_to_recipe(recipe)
def _chords(recipe: Recipe) -> list[progressions.ProgressionChord]:
root = pitch.parse_note(recipe.key)
pattern = scales.pattern_named(recipe.mode)
return progressions.chords_for(root, pattern, recipe.progression)
def bass_line(recipe: Recipe) -> list[sequencer.NoteEvent]:
"""One whole-bar root note per bar, an octave below the chord."""
chords = _chords(recipe)
beats = sequencer.BEATS_PER_BAR
return [
sequencer.NoteEvent(
chords[bar_index % len(chords)].notes[0] - SEMITONES_PER_OCTAVE,
bar_index * beats,
beats,
BASS_VELOCITY,
"triangle",
BASS_ENVELOPE,
)
for bar_index in range(recipe.bars)
]
def arpeggio(recipe: Recipe) -> list[sequencer.NoteEvent]:
"""Chord tones cycled in eighth notes, low-high-low, every bar."""
chords = _chords(recipe)
per_bar = sequencer.BEATS_PER_BAR * ARPEGGIO_NOTES_PER_BEAT
length = 1.0 / ARPEGGIO_NOTES_PER_BEAT
result = []
for bar_index in range(recipe.bars):
notes = chords[bar_index % len(chords)].notes
for slot in range(per_bar):
midi = notes[_ARPEGGIO_ORDER[slot % len(_ARPEGGIO_ORDER)] % len(notes)]
start = bar_index * sequencer.BEATS_PER_BAR + slot * length
result.append(
sequencer.NoteEvent(
midi,
start,
length,
ARPEGGIO_VELOCITY,
recipe.waveform,
ARPEGGIO_ENVELOPE,
)
)
return result
def melody(recipe: Recipe) -> list[sequencer.NoteEvent]:
"""A seeded random walk over scale degrees, one quarter note per beat.
Beat one of every bar snaps to a chord tone so the line agrees with the
harmony under it; the other beats just stay in the scale, which is
enough for them to sound intentional.
"""
rng = np.random.default_rng(recipe.seed)
root = pitch.parse_note(recipe.key) + SEMITONES_PER_OCTAVE
pattern = scales.pattern_named(recipe.mode)
chords = _chords(recipe)
rest_chance = degrade.rest_probability(recipe.degrade)
degree = len(pattern)
result = []
for beat in range(recipe.bars * sequencer.BEATS_PER_BAR):
bar_index, position = divmod(beat, sequencer.BEATS_PER_BAR)
step = int(rng.choice(_MELODY_STEPS))
degree = int(np.clip(degree + step, 0, MELODY_RANGE_DEGREES))
midi = scales.pitch_at_degree(root, pattern, degree)
if not position:
midi = _nearest_chord_tone(midi, chords[bar_index % len(chords)].notes)
if rng.random() < rest_chance:
continue
cents = degrade.detune_cents(recipe.degrade, rng)
result.append(
sequencer.NoteEvent(
midi, beat, 1.0, MELODY_VELOCITY, "triangle", MELODY_ENVELOPE, cents
)
)
return result
def _nearest_chord_tone(midi: int, chord: tuple[int, ...]) -> int:
candidates = [
n + o for n in chord for o in (-SEMITONES_PER_OCTAVE, 0, SEMITONES_PER_OCTAVE)
]
return min(candidates, key=lambda n: (abs(n - midi), n))
def events(recipe: Recipe) -> list[sequencer.NoteEvent]:
"""Every note of the piece, from the degrade-adjusted recipe."""
adjusted = effective(recipe)
return bass_line(adjusted) + arpeggio(adjusted) + melody(adjusted)
def length_beats(recipe: Recipe) -> float:
"""Total length in beats: bars times four."""
return float(recipe.bars * sequencer.BEATS_PER_BAR)
def compose(recipe: Recipe) -> synth.Signal:
"""Render the piece to a loop-length signal, degraded and normalised."""
adjusted = effective(recipe)
signal = sequencer.render(
events(recipe), adjusted.tempo_bpm, length_beats(adjusted), adjusted.sample_rate
)
signal = degrade.process(
signal, adjusted.degrade, adjusted.sample_rate, adjusted.seed
)
return synth.normalize(signal)
def with_degrade(recipe: Recipe, amount: float) -> Recipe:
"""The same recipe at another degrade amount: the compositional bracket."""
return replace(recipe, degrade=amount)

View File

@ -0,0 +1,112 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Degrade: the same piece, ruined by a controllable amount.
Not in the article, but built from its parts. If a composition is data,
"the ending is the opening, destroyed" is a transform on that data: the
mode darkens, the tempo drags, the melody thins out and drifts off pitch,
and the signal itself loses its top end and its resolution. Amount 0 is
the original, byte for byte; amount 1 is as far as this goes.
"""
from __future__ import annotations
from dataclasses import replace
from typing import TYPE_CHECKING
import numpy as np
from music_theory import synth
if TYPE_CHECKING:
from music_theory.compose import Recipe
## Above this much degradation the mode flips to its darker relative.
MODE_THRESHOLD = 0.5
## Full degradation slows the tempo by this fraction.
TEMPO_DROP = 0.35
## Full degradation drops this fraction of melody notes.
MAX_REST_PROBABILITY = 0.5
## Full degradation lets each melody note drift up to this many cents.
MAX_DETUNE_CENTS = 30.0
## Low-pass cutoff sweeps from bright to muffled.
BRIGHT_CUTOFF_HZ = 9000.0
DARK_CUTOFF_HZ = 700.0
## Bit depth sweeps from clean 16-bit to a crunchy 8-bit.
CLEAN_BITS = 16.0
CRUSHED_BITS = 8.0
## Noise floor at full degradation, relative to full scale.
MAX_NOISE = 0.02
## Every bright mode's darker relative.
DARKER_MODE: dict[str, str] = {
"major": "minor",
"ionian": "aeolian",
"lydian": "dorian",
"mixolydian": "aeolian",
"dorian": "phrygian",
"major pentatonic": "minor pentatonic",
"minor": "harmonic minor",
"aeolian": "harmonic minor",
}
def clamp(amount: float) -> float:
"""Degrade amounts live in 0..1."""
return min(max(amount, 0.0), 1.0)
def darker_mode(mode: str) -> str:
"""The mode one shade darker, or the same mode if there is none."""
return DARKER_MODE.get(mode.lower(), mode)
def apply_to_recipe(recipe: Recipe) -> Recipe:
"""Darken the mode past the threshold and drag the tempo proportionally."""
amount = clamp(recipe.degrade)
mode = darker_mode(recipe.mode) if amount >= MODE_THRESHOLD else recipe.mode
tempo = recipe.tempo_bpm * (1.0 - TEMPO_DROP * amount)
return replace(recipe, mode=mode, tempo_bpm=tempo, degrade=amount)
def rest_probability(amount: float) -> float:
"""Chance a melody note is dropped."""
return MAX_REST_PROBABILITY * clamp(amount)
def detune_cents(amount: float, rng: np.random.Generator) -> float:
"""A per-note pitch drift drawn from the seeded generator."""
if amount <= 0.0:
return 0.0
return float(rng.uniform(-MAX_DETUNE_CENTS, MAX_DETUNE_CENTS) * clamp(amount))
def bitcrush(signal: synth.Signal, bits: float) -> synth.Signal:
"""Quantise to 2**bits levels."""
levels = 2.0**bits
return np.asarray(np.round(signal * levels) / levels, dtype=np.float64)
def process(
signal: synth.Signal, amount: float, sample_rate: int, seed: int
) -> synth.Signal:
"""Low-pass, bit-crush and add noise in proportion to ``amount``."""
amount = clamp(amount)
if not amount:
return signal.copy()
cutoff = BRIGHT_CUTOFF_HZ + (DARK_CUTOFF_HZ - BRIGHT_CUTOFF_HZ) * amount
bits = CLEAN_BITS + (CRUSHED_BITS - CLEAN_BITS) * amount
rng = np.random.default_rng(seed)
out = bitcrush(signal, bits)
out = out + rng.standard_normal(len(out)) * MAX_NOISE * amount
# FILTER LAST, TWICE. Crushing and noise both add broadband hiss; if the
# low-pass ran first the result measured BRIGHTER than the original
# (spectral centroid 2624 Hz against 2207 Hz on the first render). Two
# one-pole passes give a real 12 dB/octave muffle.
out = synth.lowpass(out, cutoff, sample_rate)
return synth.lowpass(out, cutoff, sample_rate)

View File

@ -0,0 +1,71 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Shared plumbing for the demos: a result type and a few signal builders."""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from music_theory import pitch, sequencer, synth
## Demo notes get a short envelope so consecutive tones never click.
NOTE_ENVELOPE = synth.Envelope(0.01, 0.02, 0.9, 0.05)
## Silence between tones so the ear resets.
GAP_SECONDS = 0.08
## Tempo for the note sequences: quick enough to hear as a line.
DEMO_TEMPO = 150.0
@dataclass(frozen=True)
class Demo:
"""A rendered demo: what it demonstrates, the facts, and the audio."""
name: str
section: str
facts: tuple[str, ...]
signal: synth.Signal
def tone(hz: float, seconds: float, waveform: synth.Waveform = "sine") -> synth.Signal:
"""One enveloped tone at a frequency."""
return synth.oscillator(hz, seconds, waveform) * synth.adsr(NOTE_ENVELOPE, seconds)
def tones(
frequencies: list[float], seconds: float, waveform: synth.Waveform = "sine"
) -> synth.Signal:
"""Tones one after another with a short gap between them."""
gap = np.zeros(int(GAP_SECONDS * synth.SAMPLE_RATE))
parts = [tone(hz, seconds, waveform) for hz in frequencies]
return np.concatenate([np.concatenate((part, gap)) for part in parts])
def pair(hz_low: float, hz_high: float, seconds: float) -> synth.Signal:
"""Two tones sounded together, at equal level."""
return synth.normalize(
tone(hz_low, seconds) + tone(hz_high, seconds), synth.DEFAULT_PEAK
)
def note_line(midis: list[int], beats_each: float = 0.5) -> synth.Signal:
"""A melodic line of MIDI notes, rendered through the sequencer."""
events = sequencer.sequence(midis, beats_each, "triangle", NOTE_ENVELOPE)
return sequencer.render(events, DEMO_TEMPO, beats_each * len(midis))
def chord_line(chords: list[list[int]], beats_each: float = 2.0) -> synth.Signal:
"""Chords one after another, each held for ``beats_each``."""
events: list[sequencer.NoteEvent] = []
for index, notes in enumerate(chords):
events += sequencer.stack(
notes, index * beats_each, beats_each, "triangle", NOTE_ENVELOPE
)
return sequencer.render(events, DEMO_TEMPO, beats_each * len(chords))
def names(midis: list[int]) -> str:
"""Note names of a list of MIDI numbers, space separated."""
return " ".join(pitch.note_name(m) for m in midis)

View File

@ -0,0 +1,55 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The demo registry: one entry per article section, in reading order."""
from __future__ import annotations
from dataclasses import replace
from typing import TYPE_CHECKING
from music_theory import demos_a, demos_b, synth
if TYPE_CHECKING:
from collections.abc import Callable
from music_theory.demo_support import Demo
REGISTRY: dict[str, Callable[[], Demo]] = {
"tone": demos_a.tone_demo,
"envelope": demos_a.envelope_demo,
"waveforms": demos_a.waveforms_demo,
"octaves": demos_a.octaves_demo,
"consonance": demos_a.consonance_demo,
"comma": demos_a.comma_demo,
"temperament": demos_a.temperament_demo,
"divisions": demos_a.divisions_demo,
"scale": demos_b.scale_demo,
"modes": demos_b.modes_demo,
"chords": demos_b.chords_demo,
"diatonic": demos_b.diatonic_demo,
"transpose": demos_b.transpose_demo,
"resolution": demos_b.resolution_demo,
"progressions": demos_b.progressions_demo,
"piece": demos_b.piece_demo,
"bracket": demos_b.bracket_demo,
}
def run(name: str) -> Demo:
"""Build one demo by name."""
if name not in REGISTRY:
msg = f"unknown demo {name!r}; choose one of {tuple(REGISTRY)}"
raise ValueError(msg)
demo = REGISTRY[name]()
# Every demo leaves the same headroom, so switching between files is not
# a volume jump and no render sits exactly on full scale.
return replace(demo, signal=synth.normalize(demo.signal))
def selected(name: str) -> list[str]:
"""Names to run for a CLI argument: one name, or "all"."""
if name == "all":
return list(REGISTRY)
run_check = run # validates the name eagerly for a clear error
if name not in REGISTRY:
run_check(name)
return [name]

View File

@ -0,0 +1,173 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Demos for article sections 1-5: sound, timbre, octaves, the comma, temperament."""
from __future__ import annotations
import numpy as np
from music_theory import analysis, pitch, synth, tuning
from music_theory.demo_support import Demo, pair, tone, tones
_A4 = pitch.A4_HZ
_A3 = 220.0
_LONG = 1.0
_SHORT = 0.5
_OCTAVES = (55.0, 110.0, 220.0, 440.0, 880.0)
_MAX_DIVISIONS = 25
_SHOWN_DIVISIONS = (5, 7, 12, 19, 24)
_STRUCK = synth.Envelope(0.01, 0.1, 0.6, 0.2)
_BOWED = synth.Envelope(0.5, 0.1, 0.8, 0.2)
def tone_demo() -> Demo:
"""Section 1: a sound is a number that changes over time."""
signal = tone(_A4, _LONG)
return Demo(
"tone",
"1 sound as numerical change",
(
f"A4 = {_A4:.0f} Hz = MIDI {pitch.A4_MIDI}",
f"one semitone up multiplies by {pitch.semitone_ratio(1):.6f}",
f"FFT peak of the render: {analysis.dominant_frequency(signal):.1f} Hz",
),
signal,
)
def envelope_demo() -> Demo:
"""Section 2: a 10 ms attack sounds struck, a 500 ms attack sounds bowed."""
seconds = 1.2
struck = synth.oscillator(_A3, seconds) * synth.adsr(_STRUCK, seconds)
bowed = synth.oscillator(_A3, seconds) * synth.adsr(_BOWED, seconds)
signal = synth.mix(struck, _shift(bowed, seconds + 0.3))
return Demo(
"envelope",
"2 envelopes",
(
(
f"same {_A3:.0f} Hz sine twice: "
f"attack {_STRUCK.attack * 1000:.0f} ms then "
f"{_BOWED.attack * 1000:.0f} ms"
),
"the release fades to zero so neither note ends in a click",
),
signal,
)
def waveforms_demo() -> Demo:
"""Section 2: four overtone recipes at the same pitch."""
parts = [tone(_A3, _SHORT, w) for w in synth.WAVEFORMS]
facts = tuple(
f"{w}: spectral centroid {analysis.spectral_centroid(p):.0f} Hz"
for w, p in zip(synth.WAVEFORMS, parts, strict=True)
)
gap = np.zeros(int(0.2 * synth.SAMPLE_RATE))
signal = np.concatenate([np.concatenate((part, gap)) for part in parts])
return Demo(
"waveforms",
"2 timbre",
(*facts, "same pitch, only the overtones differ"),
signal,
)
def octaves_demo() -> Demo:
"""Section 3: doubling the frequency gives the same note."""
return Demo(
"octaves",
"3 octaves",
tuple(
f"{hz:.0f} Hz = {pitch.note_name(round(pitch.hz_to_midi(hz)))}"
for hz in _OCTAVES
),
tones(list(_OCTAVES), _SHORT),
)
def consonance_demo() -> Demo:
"""Section 3: simple ratios lock in fast; the tritone never does."""
pairs = [
("octave", 2.0),
("perfect fifth", 1.5),
("perfect fourth", 4.0 / 3.0),
("major third", 1.25),
("minor second", 16.0 / 15.0),
("tritone", tuning.TRITONE_RATIO),
]
facts = []
for name, _ in pairs:
if name == "tritone":
facts.append("tritone (sqrt 2): the pattern never repeats")
else:
facts.append(
f"{name}: repeats after "
f"{tuning.repeat_cycles(tuning.JUST_RATIOS[name])} cycles"
)
signal = synth.mix(
*[_shift(pair(_A3, _A3 * r, 0.8), i * 1.0) for i, (_, r) in enumerate(pairs)]
)
return Demo("consonance", "3 consonance", tuple(facts), signal)
def comma_demo() -> Demo:
"""Section 4: twelve fifths overshoot seven octaves, audibly."""
comma = tuning.pythagorean_comma()
beats = tuning.beat_frequency(_A4, _A4 * comma)
seconds = 2.0
return Demo(
"comma",
"4 the Pythagorean comma",
(
f"(3/2)^12 / 2^7 = {comma:.6f} ({pitch.cents(comma):.2f} cents)",
"3^n can never equal 2^m: both are prime, so no n and m close the loop",
f"A4 against A4 x comma beats {beats:.1f} times/s",
),
pair(_A4, _A4 * comma, seconds),
)
def temperament_demo() -> Demo:
"""Section 5: twelve equal steps, and what the fifth costs."""
steps = [
_A3 * pitch.semitone_ratio(n) for n in range(pitch.SEMITONES_PER_OCTAVE + 1)
]
fifth = tuning.fifth_error(pitch.SEMITONES_PER_OCTAVE)
return Demo(
"temperament",
"5 equal temperament",
(
f"step = 2^(1/12) = {pitch.semitone_ratio(1):.6f}",
(
f"tempered fifth = 2^(7/12) = {fifth.ratio:.6f}, "
f"{fifth.error * 100:.3f} % flat of 3/2"
),
f"MIDI 69 -> {pitch.midi_to_hz(69):.1f} Hz",
),
tones(steps, 0.25),
)
def divisions_demo() -> Demo:
"""Section 5: brute-force why the octave has twelve notes."""
first = tuning.first_acceptable_division(_MAX_DIVISIONS)
facts = [
f"{s.divisions:2d}-TET: fifth = step {s.step:2d} = {s.ratio:.5f}, "
f"error {s.error * 100:.3f} %"
for s in sorted(
tuning.best_divisions(_MAX_DIVISIONS), key=lambda s: s.divisions
)
if s.divisions in _SHOWN_DIVISIONS
]
facts.append(
f"first division within {tuning.ACCEPTABLE_FIFTH_ERROR * 100:.1f} %: {first}"
)
frequencies = []
for n in _SHOWN_DIVISIONS:
frequencies += [_A3, _A3 * 1.5, _A3 * tuning.fifth_error(n).ratio]
return Demo("divisions", "5 why twelve", tuple(facts), tones(frequencies, 0.3))
def _shift(signal: synth.Signal, seconds: float) -> synth.Signal:
"""The signal preceded by ``seconds`` of silence."""
return np.concatenate((np.zeros(int(seconds * synth.SAMPLE_RATE)), signal))

View File

@ -0,0 +1,184 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Demos for article sections 6-11: scales, chords, keys, resolution, the piece."""
from __future__ import annotations
import numpy as np
from music_theory import (
analysis,
chords,
compose,
pitch,
progressions,
scales,
synth,
wav,
)
from music_theory.demo_support import Demo, chord_line, names, note_line
_C4 = 60
_E_MAJOR_SHIFT = 4
_TRIAD_QUALITIES = ("major", "minor", "diminished", "augmented")
_SEVENTH_QUALITIES = ("major7", "minor7", "dominant7")
_HALF_SECOND_FRAMES = synth.SAMPLE_RATE // 2
_THIRD_INDEX = 1
_SEVENTH_INDEX = 3
def scale_demo() -> Demo:
"""Section 6: a scale is a step array folded from a root."""
major = scales.build(_C4, scales.STEP_PATTERNS["major"])
minor = scales.build(pitch.parse_note("A3"), scales.STEP_PATTERNS["minor"])
return Demo(
"scale",
"6 scales",
(
f"major steps {scales.STEP_PATTERNS['major']} from C4 -> {major}",
f"that is {names(major)}",
f"minor steps {scales.STEP_PATTERNS['minor']} from A3 -> {names(minor)}",
),
note_line(major + minor),
)
def modes_demo() -> Demo:
"""Section 6: all seven modes are the same array rotated."""
lines: list[int] = []
facts = []
for index, name in enumerate(scales.MODE_NAMES):
pattern = scales.mode(name)
facts.append(f"{name:10s} = major rotated {index}: {pattern}")
lines += scales.build(_C4, pattern)
facts.append("ionian is the major scale, aeolian (rotation 5) the natural minor")
return Demo("modes", "6 modes", tuple(facts), note_line(lines, 0.25))
def chords_demo() -> Demo:
"""Section 7: one array element is the whole major/minor difference."""
qualities = (*_TRIAD_QUALITIES, *_SEVENTH_QUALITIES)
stacks = [chords.chord(_C4, q) for q in qualities]
facts = tuple(
f"{q:11s} {chords.QUALITIES[q]} -> {names(s)}"
for q, s in zip(qualities, stacks, strict=True)
)
return Demo("chords", "7 chord qualities", facts, chord_line(stacks, 1.5))
def diatonic_demo() -> Demo:
"""Section 8: the seven chords that live inside one key."""
table = chords.diatonic_chords(_C4, scales.STEP_PATTERNS["major"])
facts = tuple(
f"{c.numeral:4s} {c.quality:10s} {names(list(c.notes))}" for c in table
)
return Demo(
"diatonic", "8 keys", facts, chord_line([list(c.notes) for c in table], 1.0)
)
def transpose_demo() -> Demo:
"""Section 8: the same numerals in two keys differ by one constant."""
pattern = scales.STEP_PATTERNS["major"]
in_c = progressions.chords_for(_C4, pattern, progressions.named("pop"))
in_e = progressions.transpose(in_c, _E_MAJOR_SHIFT)
both = [list(c.notes) for c in in_c] + [list(c.notes) for c in in_e]
return Demo(
"transpose",
"8 transposition",
(
f"{progressions.numerals(in_c)} in C: {_names_of(in_c)}",
f"add {_E_MAJOR_SHIFT} to every note -> in E: {_names_of(in_e)}",
),
chord_line(both, 1.0),
)
def _names_of(prog: list[progressions.ProgressionChord]) -> list[str]:
return [names(list(c.notes)) for c in prog]
def resolution_demo() -> Demo:
"""Section 9: V7 pulls into I through a leading tone and a tritone."""
pattern = scales.STEP_PATTERNS["major"]
seventh = chords.dominant_seventh(_C4, pattern)
tonic = chords.stacked_thirds(_C4, pattern, 0)
# The third and the seventh of any dominant seventh are a tritone apart.
tritone = [seventh[_THIRD_INDEX], seventh[_SEVENTH_INDEX]]
moves = chords.resolution(_C4, pattern)
facts = [f"V7 in C = {names(seventh)}"]
facts.append(f"tritone inside it: {names(tritone)} (six semitones apart)")
facts += [
f"{pitch.note_name(a)} -> {pitch.note_name(b)} by one semitone"
for a, b in moves
]
return Demo(
"resolution",
"9 tension and resolution",
tuple(facts),
chord_line([seventh, tonic], 2.0),
)
def progressions_demo() -> Demo:
"""Section 9: the loops everyone has heard."""
pattern = scales.STEP_PATTERNS["major"]
facts = []
lines: list[list[int]] = []
for name in ("pop", "jazz", "axis", "fifties"):
prog = progressions.chords_for(_C4, pattern, progressions.named(name))
facts.append(f"{name:8s} {progressions.numerals(prog)}")
lines += [list(c.notes) for c in prog]
return Demo(
"progressions", "9 common progressions", tuple(facts), chord_line(lines, 1.0)
)
def piece_demo() -> Demo:
"""Section 11: the whole article in one recipe."""
recipe = compose.Recipe(bars=4)
signal = compose.compose(recipe)
return Demo(
"piece",
"11 a complete composition",
(
(
f"key {recipe.key} {recipe.mode}, {recipe.tempo_bpm:.0f} bpm, "
f"degrees {recipe.progression}"
),
(
f"bass + arpeggio + seeded melody, "
f"{wav.duration_seconds(signal):.1f} s, loops on the bar"
),
),
signal,
)
def bracket_demo() -> Demo:
"""Beyond the article: the same piece, then the same piece ruined."""
clean = compose.Recipe(bars=4, mode="major", tempo_bpm=112.0)
ruined = compose.with_degrade(clean, 1.0)
a = compose.compose(clean)
b = compose.compose(ruined)
return Demo(
"bracket",
"12 degrade (not in the article)",
(
(
f"clean: {compose.effective(clean).mode}, "
f"centroid {analysis.spectral_centroid(a):.0f} Hz"
),
(
f"ruined: {compose.effective(ruined).mode}, "
f"{compose.effective(ruined).tempo_bpm:.0f} bpm, "
f"centroid {analysis.spectral_centroid(b):.0f} Hz"
),
"same seed, same chords: the ending is the opening, degraded",
),
_join(a, b),
)
def _join(a: synth.Signal, b: synth.Signal) -> synth.Signal:
"""Two signals with half a second of silence between them."""
return np.concatenate((a, np.zeros(_HALF_SECOND_FRAMES), b))

View File

@ -0,0 +1,52 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Manifest: the recipe of every rendered file, so it can be rebuilt.
A committed audio file whose recipe lives only in a chat transcript is
unreproducible. An entry here holds the full recipe, the package version
that rendered it and the SHA-256 of the output, so a re-render can prove
it reproduced the shipped bytes -- or show exactly what changed.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from music_theory import __version__, wav
if TYPE_CHECKING:
from pathlib import Path
from music_theory.compose import Recipe
from music_theory.synth import Signal
Entry = dict[str, object]
def entry(recipe: Recipe, path: Path, signal: Signal) -> Entry:
"""Provenance record for one rendered file."""
return {
"file": path.name,
"recipe": recipe.to_dict(),
"music_theory_version": __version__,
"sample_rate": recipe.sample_rate,
"frames": len(signal),
"seconds": wav.duration_seconds(signal, recipe.sample_rate),
"sha256": wav.sha256(path),
}
def write(path: Path, entries: dict[str, Entry]) -> Path:
"""Write entries as sorted, indented JSON with a trailing newline."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(entries, indent=2, sort_keys=True) + "\n")
return path
def read(path: Path) -> dict[str, Entry]:
"""Load a manifest written by ``write``."""
data = json.loads(path.read_text())
if not isinstance(data, dict):
msg = f"{path} does not hold a manifest object"
raise TypeError(msg)
return data

View File

@ -0,0 +1,104 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Pitch: the arithmetic between a MIDI number, a note name and a frequency.
Article section 1 and 5. Pitch perception is MULTIPLICATIVE -- going up one
semitone always multiplies the frequency by the same factor, never adds the
same number of hertz -- so every conversion here is an exponent, and the
MIDI number is simply that exponent counted in twelfths from A4 = 440 Hz.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
## The reference every other pitch is derived from.
A4_HZ = 440.0
A4_MIDI = 69
## Twelve equal steps per octave (why twelve: see ``tuning.best_divisions``).
SEMITONES_PER_OCTAVE = 12
## Cents subdivide a semitone into 100, so an octave is 1200.
CENTS_PER_OCTAVE = 1200.0
NOTE_NAMES: tuple[str, ...] = (
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
)
## Flat spellings map onto the sharp above the note below them.
_FLAT_TO_SHARP = {"Db": "C#", "Eb": "D#", "Gb": "F#", "Ab": "G#", "Bb": "A#"}
## MIDI octave numbering puts C4 (middle C) at 60, so octave 0 starts at 12.
_OCTAVE_OFFSET = 1
def semitone_ratio(semitones: float) -> float:
"""Frequency multiplier for a distance in semitones (2 ** (n / 12))."""
return float(2.0 ** (semitones / SEMITONES_PER_OCTAVE))
def midi_to_hz(midi: float) -> float:
"""Frequency of a MIDI note: 440 * 2 ** ((n - 69) / 12)."""
return A4_HZ * semitone_ratio(midi - A4_MIDI)
def hz_to_midi(hz: float) -> float:
"""Fractional MIDI number of a frequency (the inverse of ``midi_to_hz``)."""
if hz <= 0.0:
msg = f"frequency must be positive, got {hz}"
raise ValueError(msg)
return A4_MIDI + SEMITONES_PER_OCTAVE * math.log2(hz / A4_HZ)
def cents(ratio: float) -> float:
"""Size of a frequency ratio in cents (1200 per octave)."""
if ratio <= 0.0:
msg = f"ratio must be positive, got {ratio}"
raise ValueError(msg)
return CENTS_PER_OCTAVE * math.log2(ratio)
def note_name(midi: int) -> str:
"""Scientific pitch name of a MIDI note, e.g. 69 -> "A4"."""
octave, index = divmod(midi, SEMITONES_PER_OCTAVE)
return f"{NOTE_NAMES[index]}{octave - _OCTAVE_OFFSET}"
def parse_note(name: str) -> int:
"""MIDI number of a note name such as "A4", "C#3" or "Bb2"."""
letters = name.rstrip("-0123456789")
digits = name[len(letters) :]
if not letters or not digits:
msg = f"not a note name: {name!r}"
raise ValueError(msg)
letters = _FLAT_TO_SHARP.get(letters, letters)
if letters not in NOTE_NAMES:
msg = f"unknown pitch class in {name!r}"
raise ValueError(msg)
octave = int(digits)
return (octave + _OCTAVE_OFFSET) * SEMITONES_PER_OCTAVE + NOTE_NAMES.index(letters)
def transpose(midis: Sequence[int], semitones: int) -> list[int]:
"""Shift every note by a constant (article section 8: transposition)."""
return [midi + semitones for midi in midis]
def pitch_class(midi: int) -> int:
"""The note within the octave, 0 (C) to 11 (B)."""
return midi % SEMITONES_PER_OCTAVE

View File

@ -0,0 +1,72 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Progressions: chord sequences addressed by scale degree, not by note.
Article section 8. Roman numerals are RELATIVE addressing: "I V vi IV" is
the same progression in every key, and transposing is adding one constant to
every MIDI number. Degrees here are 1-based as musicians write them.
"""
from __future__ import annotations
from dataclasses import dataclass
from music_theory import chords, scales
Degrees = tuple[int, ...]
## Progressions the article names, 1-based degrees.
NAMED: dict[str, Degrees] = {
"pop": (1, 5, 6, 4),
"jazz": (2, 5, 1),
"axis": (6, 4, 1, 5),
"fifties": (1, 6, 4, 5),
"article": (1, 6, 3, 7),
}
@dataclass(frozen=True)
class ProgressionChord:
"""A chord of a progression: its 1-based degree, numeral and notes."""
degree: int
numeral: str
notes: tuple[int, ...]
def named(name: str) -> Degrees:
"""Degrees of a named progression, e.g. "pop" -> (1, 5, 6, 4)."""
key = name.lower()
if key not in NAMED:
msg = f"unknown progression {name!r}; choose one of {tuple(NAMED)}"
raise ValueError(msg)
return NAMED[key]
def chords_for(
root_midi: int, pattern: scales.StepPattern, degrees: Degrees
) -> list[ProgressionChord]:
"""Resolve 1-based degrees into the diatonic chords of a key."""
table = chords.diatonic_chords(root_midi, pattern)
result = []
for degree in degrees:
if not 1 <= degree <= len(table):
msg = f"degree {degree} is outside 1..{len(table)}"
raise ValueError(msg)
entry = table[degree - 1]
result.append(ProgressionChord(degree, entry.numeral, entry.notes))
return result
def transpose(
progression: list[ProgressionChord], semitones: int
) -> list[ProgressionChord]:
"""The same progression in another key: add a constant to every note."""
return [
ProgressionChord(c.degree, c.numeral, tuple(n + semitones for n in c.notes))
for c in progression
]
def numerals(progression: list[ProgressionChord]) -> str:
"""Human-readable numeral string, e.g. "I V vi IV"."""
return " ".join(c.numeral for c in progression)

View File

@ -0,0 +1,120 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Scales: a subset of the twelve notes, encoded as the gaps between them.
Article section 6. A scale is an array of step sizes that sums to twelve;
the gaps are UNEVEN on purpose, so the ear can tell where in the octave it
is. All seven modes are the same major-scale array rotated, which is why
they are built here by rotation rather than stored as seven tables.
"""
from __future__ import annotations
from music_theory.pitch import SEMITONES_PER_OCTAVE
StepPattern = tuple[int, ...]
## Named step patterns. Every one sums to twelve.
STEP_PATTERNS: dict[str, StepPattern] = {
"major": (2, 2, 1, 2, 2, 2, 1),
"minor": (2, 1, 2, 2, 1, 2, 2),
"harmonic minor": (2, 1, 2, 2, 1, 3, 1),
"major pentatonic": (2, 2, 3, 2, 3),
"minor pentatonic": (3, 2, 2, 3, 2),
"blues": (3, 2, 1, 1, 3, 2),
"whole tone": (2, 2, 2, 2, 2, 2),
}
## The seven modes are rotations of the major scale, in this order.
MODE_NAMES: tuple[str, ...] = (
"ionian",
"dorian",
"phrygian",
"lydian",
"mixolydian",
"aeolian",
"locrian",
)
## Rotation indices worth naming: major is rotation 0, natural minor is 5.
MAJOR_ROTATION = 0
MINOR_ROTATION = 5
def validate(pattern: StepPattern) -> StepPattern:
"""Return the pattern if it spans exactly one octave, else raise."""
if sum(pattern) != SEMITONES_PER_OCTAVE:
msg = (
f"step pattern {pattern} sums to {sum(pattern)}, not {SEMITONES_PER_OCTAVE}"
)
raise ValueError(msg)
if any(step <= 0 for step in pattern):
msg = f"step pattern {pattern} has a non-positive step"
raise ValueError(msg)
return pattern
def rotate(pattern: StepPattern, count: int) -> StepPattern:
"""The same pattern started ``count`` steps later (a mode of it)."""
if not pattern:
return pattern
shift = count % len(pattern)
return pattern[shift:] + pattern[:shift]
def mode(name: str) -> StepPattern:
"""Step pattern of a named mode, derived by rotating the major scale."""
key = name.lower()
if key not in MODE_NAMES:
msg = f"unknown mode {name!r}; choose one of {MODE_NAMES}"
raise ValueError(msg)
return rotate(STEP_PATTERNS["major"], MODE_NAMES.index(key))
def pattern_named(name: str) -> StepPattern:
"""Look a pattern up by scale name OR mode name ("minor", "dorian", ...)."""
key = name.lower()
if key in STEP_PATTERNS:
return STEP_PATTERNS[key]
if key in MODE_NAMES:
return mode(key)
msg = f"unknown scale {name!r}"
raise ValueError(msg)
def offsets(pattern: StepPattern) -> list[int]:
"""Semitone offset of each degree from the root, root included, octave excluded."""
validate(pattern)
result = [0]
for step in pattern[:-1]:
result.append(result[-1] + step)
return result
def build(root_midi: int, pattern: StepPattern) -> list[int]:
"""MIDI notes of one octave of the scale, root through to the octave above.
This is the article's ``reduce`` over the step array: C major from 60
gives [60, 62, 64, 65, 67, 69, 71, 72].
"""
validate(pattern)
notes = [root_midi]
for step in pattern:
notes.append(notes[-1] + step)
return notes
def pitch_at_degree(root_midi: int, pattern: StepPattern, degree: int) -> int:
"""MIDI note of a 0-based scale degree, wrapping across octaves.
Degree 7 of a seven-note scale is the root an octave up; degree -1 is
the note just below the root. This is what lets a melody be written as
degree numbers and moved up or down without leaving the scale.
"""
steps = offsets(pattern)
octave, index = divmod(degree, len(steps))
return root_midi + octave * SEMITONES_PER_OCTAVE + steps[index]
def is_in_scale(root_midi: int, pattern: StepPattern, midi: int) -> bool:
"""Whether a note belongs to the scale (in any octave)."""
return (midi - root_midi) % SEMITONES_PER_OCTAVE in offsets(pattern)

View File

@ -0,0 +1,110 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Sequencer: put notes on a beat grid and render them to one buffer.
Article section 10 covers notation as a serialization format for exactly
this: a note is a pitch plus a start and a length measured in beats, and
tempo is what turns beats into seconds. Rendering to a FIXED length in
beats is what makes a loop close on a bar boundary.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from music_theory import pitch, synth
## A 4/4 bar, the article's default and the only meter used here.
BEATS_PER_BAR = 4
SECONDS_PER_MINUTE = 60.0
@dataclass(frozen=True)
class NoteEvent:
"""One note: MIDI pitch, start and length in beats, 0..1 velocity."""
midi: int
start_beat: float
length_beats: float
velocity: float = 1.0
waveform: synth.Waveform = "sine"
envelope: synth.Envelope = synth.DEFAULT_ENVELOPE
## Detune in cents, applied at render time; the pitch stays an integer
## so the note is still addressable by scale degree.
cents: float = 0.0
def beat_seconds(tempo_bpm: float) -> float:
"""Length of one beat at a tempo."""
if tempo_bpm <= 0.0:
msg = f"tempo must be positive, got {tempo_bpm}"
raise ValueError(msg)
return SECONDS_PER_MINUTE / tempo_bpm
def loop_frames(
tempo_bpm: float, beats: float, sample_rate: int = synth.SAMPLE_RATE
) -> int:
"""Frames in ``beats`` at a tempo, floored so a loop never overruns its bar."""
return int(beats * beat_seconds(tempo_bpm) * sample_rate)
def render_note(event: NoteEvent, tempo_bpm: float, sample_rate: int) -> synth.Signal:
"""Samples of one note: oscillator times envelope times velocity."""
seconds = event.length_beats * beat_seconds(tempo_bpm)
hz = synth.detune(pitch.midi_to_hz(event.midi), event.cents)
tone = synth.oscillator(hz, seconds, event.waveform, sample_rate)
return tone * synth.adsr(event.envelope, seconds, sample_rate) * event.velocity
def render(
events: list[NoteEvent],
tempo_bpm: float,
length_beats: float,
sample_rate: int = synth.SAMPLE_RATE,
) -> synth.Signal:
"""Mix every note into a buffer exactly ``length_beats`` long.
Notes that run past the end are cut, not wrapped: a loop that must end
on the bar is more important than a note's tail.
"""
total = np.zeros(
loop_frames(tempo_bpm, length_beats, sample_rate), dtype=np.float64
)
for event in events:
start = loop_frames(tempo_bpm, event.start_beat, sample_rate)
if start >= len(total):
continue
note = render_note(event, tempo_bpm, sample_rate)
end = min(start + len(note), len(total))
total[start:end] += note[: end - start]
return total
def sequence(
midis: list[int],
beats_each: float,
waveform: synth.Waveform = "sine",
envelope: synth.Envelope = synth.DEFAULT_ENVELOPE,
) -> list[NoteEvent]:
"""Notes played one after another, each ``beats_each`` long."""
return [
NoteEvent(midi, index * beats_each, beats_each, 1.0, waveform, envelope)
for index, midi in enumerate(midis)
]
def stack(
midis: list[int],
start_beat: float,
beats: float,
waveform: synth.Waveform = "sine",
envelope: synth.Envelope = synth.DEFAULT_ENVELOPE,
) -> list[NoteEvent]:
"""Notes played together (a chord), all starting at ``start_beat``."""
velocity = 1.0 / max(len(midis), 1)
return [
NoteEvent(midi, start_beat, beats, velocity, waveform, envelope)
for midi in midis
]

View File

@ -0,0 +1,155 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Synth: turn frequencies into samples, with envelopes and timbre.
Article sections 1 and 2. A sound is a number changing over time; an
envelope shapes its volume so a note has a beginning and an end (and no
click), and timbre is the recipe of overtones -- sine, triangle, square and
saw are four such recipes at the same pitch.
"""
from __future__ import annotations
from dataclasses import dataclass
import math
from typing import Literal
import numpy as np
import numpy.typing as npt
type Signal = npt.NDArray[np.float64]
type Waveform = Literal["sine", "triangle", "square", "saw"]
## The game's engine bands are 22050 Hz mono; keeping the same rate means a
## bed and the engine never need resampling against each other.
SAMPLE_RATE = 22050
## Headroom so summed voices and the game's other sounds have room to breathe.
DEFAULT_PEAK = 0.8
WAVEFORMS: tuple[Waveform, ...] = ("sine", "triangle", "square", "saw")
_INT16_FULL_SCALE = 32767.0
## A square wave is high for the first half of each cycle.
_HALF_CYCLE = 0.5
@dataclass(frozen=True)
class Envelope:
"""ADSR volume curve. Times in seconds, sustain as a 0..1 level.
A 10 ms attack reads as struck, 500 ms as bowed; the release is what
stops a note ending in a click.
"""
attack: float = 0.01
decay: float = 0.05
sustain: float = 0.8
release: float = 0.05
## Shared default so dataclass fields and argument defaults never call a
## constructor at import time (ruff B008/RUF009).
DEFAULT_ENVELOPE = Envelope()
def time_axis(seconds: float, sample_rate: int = SAMPLE_RATE) -> Signal:
"""Sample times for a duration, starting at 0."""
frames = max(int(seconds * sample_rate), 0)
return np.arange(frames, dtype=np.float64) / sample_rate
def oscillator(
hz: float,
seconds: float,
waveform: Waveform = "sine",
sample_rate: int = SAMPLE_RATE,
) -> Signal:
"""One waveform at one frequency, amplitude 1, no envelope."""
if waveform not in WAVEFORMS:
msg = f"unknown waveform {waveform!r}; choose one of {WAVEFORMS}"
raise ValueError(msg)
phase = (time_axis(seconds, sample_rate) * hz) % 1.0
if waveform == "sine":
return np.sin(2.0 * math.pi * phase)
saw = 2.0 * phase - 1.0
if waveform == "saw":
return saw
if waveform == "triangle":
return 2.0 * np.abs(saw) - 1.0
return np.where(phase < _HALF_CYCLE, 1.0, -1.0).astype(np.float64)
def additive(
hz: float, seconds: float, amplitudes: list[float], sample_rate: int = SAMPLE_RATE
) -> Signal:
"""Sum of harmonics: amplitudes[k] scales the (k+1)th multiple of ``hz``."""
total = np.zeros(int(seconds * sample_rate), dtype=np.float64)
for index, amplitude in enumerate(amplitudes):
total += amplitude * oscillator(hz * (index + 1), seconds, "sine", sample_rate)
return total
def adsr(envelope: Envelope, seconds: float, sample_rate: int = SAMPLE_RATE) -> Signal:
"""Gain curve of an ADSR envelope over ``seconds``.
The release is carved out of the END of the duration rather than added
after it, so a note of N seconds is exactly N seconds long and a
sequencer can lay notes end to end without them overlapping.
"""
frames = max(int(seconds * sample_rate), 0)
gain = np.zeros(frames, dtype=np.float64)
attack = min(int(envelope.attack * sample_rate), frames)
decay = min(int(envelope.decay * sample_rate), frames - attack)
release = min(int(envelope.release * sample_rate), frames - attack - decay)
sustain_start = attack + decay
sustain_end = frames - release
gain[:attack] = np.linspace(0.0, 1.0, attack, endpoint=False)
gain[attack:sustain_start] = np.linspace(
1.0, envelope.sustain, decay, endpoint=False
)
gain[sustain_start:sustain_end] = envelope.sustain
gain[sustain_end:] = np.linspace(envelope.sustain, 0.0, release, endpoint=False)
return gain
def lowpass(signal: Signal, cutoff_hz: float, sample_rate: int = SAMPLE_RATE) -> Signal:
"""One-pole low-pass: the cheapest way to make a bright sound darker."""
if cutoff_hz <= 0.0:
return np.zeros_like(signal)
alpha = 1.0 - math.exp(-2.0 * math.pi * cutoff_hz / sample_rate)
out = np.empty_like(signal)
state = 0.0
for index, sample in enumerate(signal):
state += alpha * (sample - state)
out[index] = state
return out
def detune(hz: float, cents: float) -> float:
"""Frequency shifted by a number of cents (100 per semitone)."""
return float(hz * 2.0 ** (cents / 1200.0))
def mix(*signals: Signal) -> Signal:
"""Sum signals of any lengths, padding the shorter ones with silence."""
if not signals:
return np.zeros(0, dtype=np.float64)
length = max(len(s) for s in signals)
total = np.zeros(length, dtype=np.float64)
for signal in signals:
total[: len(signal)] += signal
return total
def normalize(signal: Signal, peak: float = DEFAULT_PEAK) -> Signal:
"""Scale so the loudest sample sits at ``peak``; silence stays silence."""
loudest = float(np.max(np.abs(signal))) if len(signal) else 0.0
if not loudest:
return signal.copy()
return signal * (peak / loudest)
def to_int16(signal: Signal) -> npt.NDArray[np.int16]:
"""Clip to [-1, 1] and scale to 16-bit PCM."""
return (np.clip(signal, -1.0, 1.0) * _INT16_FULL_SCALE).astype(np.int16)

View File

@ -0,0 +1,2 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for music_theory."""

View File

@ -0,0 +1,32 @@
# Copyright (c) 2026 Krzysztof Rudnicki
import numpy as np
import pytest
from music_theory import analysis, synth
def test_dominant_frequency_of_a_sine() -> None:
assert analysis.dominant_frequency(synth.oscillator(1000.0, 1.0)) == pytest.approx(
1000.0, abs=1.0
)
def test_dominant_frequency_of_silence_and_empty() -> None:
assert analysis.dominant_frequency(np.zeros(100)) == 0.0
assert analysis.dominant_frequency(np.zeros(0)) == 0.0
def test_spectral_centroid_orders_timbres() -> None:
sine = analysis.spectral_centroid(synth.oscillator(220.0, 0.5, "sine"))
saw = analysis.spectral_centroid(synth.oscillator(220.0, 0.5, "saw"))
assert sine < saw
def test_spectral_centroid_of_silence_and_empty() -> None:
assert analysis.spectral_centroid(np.zeros(100)) == 0.0
assert analysis.spectral_centroid(np.zeros(0)) == 0.0
def test_peak() -> None:
assert analysis.peak(np.array([0.1, -0.7, 0.3])) == pytest.approx(0.7)
assert analysis.peak(np.zeros(0)) == 0.0

View File

@ -0,0 +1,71 @@
# Copyright (c) 2026 Krzysztof Rudnicki
import pytest
from music_theory import chords, scales
MAJOR = scales.STEP_PATTERNS["major"]
def test_major_and_minor_differ_by_one_element() -> None:
assert chords.chord(60, "major") == [60, 64, 67]
assert chords.chord(60, "minor") == [60, 63, 67]
def test_chord_rejects_unknown_quality() -> None:
with pytest.raises(ValueError, match="quality"):
chords.chord(60, "sus-whatever")
def test_quality_of_inverts_chord() -> None:
for name in chords.QUALITIES:
assert chords.quality_of(chords.chord(60, name)) == name
assert chords.quality_of([]) is None
assert chords.quality_of([60, 61, 62]) is None
def test_stacked_thirds_builds_the_triad() -> None:
assert chords.stacked_thirds(60, MAJOR, 0) == [60, 64, 67]
assert chords.stacked_thirds(60, MAJOR, 4, 4) == [67, 71, 74, 77]
@pytest.mark.parametrize(
("degree", "quality", "numeral"),
[
(0, "major", "I"),
(1, "minor", "ii"),
(6, "diminished", "vii°"),
(2, "augmented", "III+"),
(4, "dominant7", "V"),
(1, "half-diminished7", "ii°"),
(5, "minor7", "vi"),
],
)
def test_roman(degree: int, quality: str, numeral: str) -> None:
assert chords.roman(degree, quality) == numeral
def test_diatonic_chords_of_c_major() -> None:
table = chords.diatonic_chords(60, MAJOR)
assert [c.numeral for c in table] == ["I", "ii", "iii", "IV", "V", "vi", "vii°"]
assert table[0].notes == (60, 64, 67)
assert table[6].quality == "diminished"
def test_diatonic_chords_of_an_odd_scale_fall_back_to_other() -> None:
table = chords.diatonic_chords(60, scales.STEP_PATTERNS["whole tone"])
assert all(c.quality == "augmented" for c in table)
blues = chords.diatonic_chords(60, scales.STEP_PATTERNS["blues"])
assert "other" in {c.quality for c in blues}
def test_dominant_seventh_of_c() -> None:
assert chords.dominant_seventh(60, MAJOR) == [67, 71, 74, 77]
def test_tritone_pair() -> None:
assert chords.tritone_pair([67, 71, 74, 77]) == (71, 77)
assert chords.tritone_pair([60, 64, 67]) is None
def test_resolution_moves_by_contrary_semitones() -> None:
assert chords.resolution(60, MAJOR) == [(71, 72), (77, 76)]

View File

@ -0,0 +1,98 @@
# Copyright (c) 2026 Krzysztof Rudnicki
import json
import runpy
import sys
from typing import TYPE_CHECKING
import pytest
from music_theory import cli, compose, manifest, wav
if TYPE_CHECKING:
from pathlib import Path
def test_list_prints_every_demo(capsys: pytest.CaptureFixture[str]) -> None:
assert cli.main(["list"]) == 0
out = capsys.readouterr().out
assert "tone" in out
assert "bracket" in out
def test_explain_prints_facts(capsys: pytest.CaptureFixture[str]) -> None:
assert cli.main(["explain", "comma"]) == 0
assert "1.013643" in capsys.readouterr().out
def test_demo_writes_numbered_wavs(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
assert cli.main(["demo", "tone", "--out", str(tmp_path)]) == 0
assert (tmp_path / "01_tone.wav").exists()
assert "aplay -q" in capsys.readouterr().out
def test_render_writes_wav_and_manifest(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
recipe_path = tmp_path / "r.json"
recipe_path.write_text(json.dumps(compose.Recipe(bars=1).to_dict()))
out = tmp_path / "bed.wav"
man = tmp_path / "MANIFEST.json"
args = [
"render",
"--recipe",
str(recipe_path),
"--out",
str(out),
"--manifest",
str(man),
]
assert cli.main(args) == 0
entries = manifest.read(man)
assert entries["bed.wav"]["sha256"] == wav.sha256(out)
assert cli.main([*args, "--degrade", "1.0"]) == 0
recipe = manifest.read(man)["bed.wav"]["recipe"]
assert isinstance(recipe, dict)
assert recipe["degrade"] == 1.0
assert "sha256" in capsys.readouterr().out
def test_render_without_manifest(tmp_path: Path) -> None:
recipe_path = tmp_path / "r.json"
recipe_path.write_text(json.dumps({"bars": 1}))
assert (
cli.main(
["render", "--recipe", str(recipe_path), "--out", str(tmp_path / "b.wav")]
)
== 0
)
def test_render_rejects_non_object_recipe(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
recipe_path = tmp_path / "r.json"
recipe_path.write_text("[1]")
assert (
cli.main(
["render", "--recipe", str(recipe_path), "--out", str(tmp_path / "b.wav")]
)
== 1
)
assert "error" in capsys.readouterr().out
def test_parser_requires_a_command() -> None:
with pytest.raises(SystemExit):
cli.build_parser().parse_args([])
def test_module_entry_point(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr(sys, "argv", ["music_theory", "list"])
with pytest.raises(SystemExit) as info:
runpy.run_module("music_theory", run_name="__main__")
assert info.value.code == 0
assert "tone" in capsys.readouterr().out

View File

@ -0,0 +1,96 @@
# Copyright (c) 2026 Krzysztof Rudnicki
import numpy as np
import pytest
from music_theory import analysis, compose, sequencer
def test_recipe_round_trips_through_dict() -> None:
recipe = compose.Recipe(key="C4", mode="major", progression=(1, 5, 6, 4), bars=2)
assert compose.Recipe.from_dict(recipe.to_dict()) == recipe
assert recipe.to_dict()["progression"] == [1, 5, 6, 4]
def test_recipe_from_partial_dict_uses_defaults() -> None:
recipe = compose.Recipe.from_dict({"tempo_bpm": 90})
assert recipe.tempo_bpm == 90.0
assert recipe.key == compose.Recipe().key
@pytest.mark.parametrize(
("data", "match"),
[
({"colour": "blue"}, "unknown recipe keys"),
({"key": 3}, "string"),
({"tempo_bpm": "fast"}, "number"),
({"tempo_bpm": True}, "number"),
({"bars": 2.5}, "integer"),
({"bars": False}, "integer"),
({"progression": "1 5 6 4"}, "list of integers"),
({"progression": [1, "5"]}, "integer"),
({"waveform": "noise"}, "unknown waveform"),
],
)
def test_recipe_rejects_bad_data(data: dict[str, object], match: str) -> None:
with pytest.raises((ValueError, TypeError), match=match):
compose.Recipe.from_dict(data)
def test_bass_line_is_one_root_per_bar_an_octave_down() -> None:
recipe = compose.Recipe(key="C4", mode="major", progression=(1, 5), bars=4)
bass = compose.bass_line(recipe)
assert [e.midi for e in bass] == [48, 55, 48, 55]
assert all(e.length_beats == sequencer.BEATS_PER_BAR for e in bass)
def test_arpeggio_cycles_chord_tones_in_eighths() -> None:
recipe = compose.Recipe(key="C4", mode="major", progression=(1,), bars=1)
arp = compose.arpeggio(recipe)
assert len(arp) == 8
assert [e.midi for e in arp[:4]] == [60, 64, 67, 64]
assert arp[1].start_beat == 0.5
def test_melody_is_deterministic_in_scale_and_snaps_bar_starts() -> None:
recipe = compose.Recipe(key="C4", mode="major", progression=(1, 4), bars=4, seed=7)
first = compose.melody(recipe)
assert first == compose.melody(recipe)
assert first
for event in first:
assert (event.midi - 60) % 12 in {0, 2, 4, 5, 7, 9, 11}
chord_tones = {0, 4, 7, 5, 9}
for event in first:
if not event.start_beat % sequencer.BEATS_PER_BAR:
assert (event.midi - 60) % 12 in chord_tones
def test_melody_thins_out_and_detunes_when_degraded() -> None:
clean = compose.Recipe(bars=8, seed=3)
ruined = compose.with_degrade(clean, 1.0)
assert len(compose.melody(ruined)) < len(compose.melody(clean))
assert any(e.cents for e in compose.melody(ruined))
assert not any(e.cents for e in compose.melody(clean))
def test_compose_loops_on_the_bar_and_is_normalised() -> None:
recipe = compose.Recipe(bars=2, tempo_bpm=120.0)
signal = compose.compose(recipe)
assert len(signal) == sequencer.loop_frames(120.0, 8.0)
assert analysis.peak(signal) == pytest.approx(0.8)
assert np.array_equal(signal, compose.compose(recipe))
def test_degrade_zero_is_byte_identical_and_full_is_darker() -> None:
clean = compose.Recipe(bars=2, mode="major")
assert np.array_equal(
compose.compose(clean), compose.compose(compose.with_degrade(clean, 0.0))
)
ruined = compose.compose(compose.with_degrade(clean, 1.0))
assert analysis.spectral_centroid(ruined) < analysis.spectral_centroid(
compose.compose(clean)
)
assert compose.effective(compose.with_degrade(clean, 1.0)).mode == "minor"
def test_length_beats() -> None:
assert compose.length_beats(compose.Recipe(bars=3)) == 12.0

View File

@ -0,0 +1,49 @@
# Copyright (c) 2026 Krzysztof Rudnicki
import numpy as np
import pytest
from music_theory import analysis, compose, degrade, synth
def test_clamp() -> None:
assert degrade.clamp(-1.0) == 0.0
assert degrade.clamp(2.0) == 1.0
assert degrade.clamp(0.3) == 0.3
def test_darker_mode() -> None:
assert degrade.darker_mode("major") == "minor"
assert degrade.darker_mode("Ionian") == "aeolian"
assert degrade.darker_mode("blues") == "blues"
def test_apply_to_recipe_flips_mode_only_past_threshold() -> None:
base = compose.Recipe(mode="major", tempo_bpm=100.0)
mild = degrade.apply_to_recipe(compose.with_degrade(base, 0.2))
assert mild.mode == "major"
assert mild.tempo_bpm == pytest.approx(93.0)
full = degrade.apply_to_recipe(compose.with_degrade(base, 1.0))
assert full.mode == "minor"
assert full.tempo_bpm == pytest.approx(65.0)
def test_rest_probability_and_detune() -> None:
rng = np.random.default_rng(0)
assert degrade.rest_probability(1.0) == degrade.MAX_REST_PROBABILITY
assert degrade.detune_cents(0.0, rng) == 0.0
cents = degrade.detune_cents(1.0, rng)
assert abs(cents) <= degrade.MAX_DETUNE_CENTS
def test_bitcrush_quantises() -> None:
out = degrade.bitcrush(np.array([0.123456, -0.5]), 2.0)
assert list(out) == [0.0, -0.5]
def test_process_zero_is_a_copy_and_full_is_darker() -> None:
saw = synth.oscillator(220.0, 0.5, "saw")
same = degrade.process(saw, 0.0, synth.SAMPLE_RATE, 1)
assert np.array_equal(same, saw)
assert same is not saw
ruined = degrade.process(saw, 1.0, synth.SAMPLE_RATE, 1)
assert analysis.spectral_centroid(ruined) < analysis.spectral_centroid(saw)

View File

@ -0,0 +1,66 @@
# Copyright (c) 2026 Krzysztof Rudnicki
import numpy as np
import pytest
from music_theory import analysis, demo_support, demos, synth
def test_registry_covers_every_section_in_order() -> None:
names = list(demos.REGISTRY)
assert names[0] == "tone"
assert names[-1] == "bracket"
assert len(names) == 17
@pytest.mark.parametrize("name", list(demos.REGISTRY))
def test_every_demo_renders_audio_with_facts(name: str) -> None:
demo = demos.run(name)
assert demo.name == name
assert demo.facts
assert demo.signal.size > 0
assert analysis.peak(demo.signal) == pytest.approx(synth.DEFAULT_PEAK)
assert np.isfinite(demo.signal).all()
def test_run_rejects_unknown() -> None:
with pytest.raises(ValueError, match="unknown demo"):
demos.run("silence")
def test_selected_expands_all_and_validates() -> None:
assert demos.selected("all") == list(demos.REGISTRY)
assert demos.selected("comma") == ["comma"]
with pytest.raises(ValueError, match="unknown demo"):
demos.selected("nope")
def test_tone_demo_peaks_at_440() -> None:
demo = demos.run("tone")
assert analysis.dominant_frequency(demo.signal) == pytest.approx(440.0, abs=1.0)
def test_resolution_demo_names_both_semitone_moves() -> None:
facts = demos.run("resolution").facts
assert "B4 -> C5 by one semitone" in facts
assert "F5 -> E5 by one semitone" in facts
def test_divisions_demo_answers_twelve() -> None:
assert any(fact.endswith(": 12") for fact in demos.run("divisions").facts)
def test_bracket_demo_is_darker_second() -> None:
facts = demos.run("bracket").facts
clean = float(facts[0].rsplit(" ", 2)[1])
ruined = float(facts[1].rsplit(" ", 2)[1])
assert ruined < clean
def test_support_helpers() -> None:
line = demo_support.tones([220.0, 440.0], 0.1)
assert line.size > 2 * int(0.1 * synth.SAMPLE_RATE)
both = demo_support.pair(220.0, 330.0, 0.1)
assert analysis.peak(both) == pytest.approx(synth.DEFAULT_PEAK)
assert demo_support.names([60, 69]) == "C4 A4"
assert demo_support.chord_line([[60, 64, 67]], 1.0).size > 0
assert demo_support.note_line([60, 62], 0.5).size > 0

View File

@ -0,0 +1,38 @@
# Copyright (c) 2026 Krzysztof Rudnicki
import json
from typing import TYPE_CHECKING
import pytest
from music_theory import __version__, compose, manifest, wav
if TYPE_CHECKING:
from pathlib import Path
def test_entry_records_recipe_version_and_hash(tmp_path: Path) -> None:
recipe = compose.Recipe(bars=1)
signal = compose.compose(recipe)
path = wav.write(tmp_path / "bed.wav", signal, recipe.sample_rate)
record = manifest.entry(recipe, path, signal)
assert record["file"] == "bed.wav"
assert record["music_theory_version"] == __version__
assert record["sha256"] == wav.sha256(path)
assert record["frames"] == len(signal)
assert record["recipe"] == recipe.to_dict()
def test_write_and_read(tmp_path: Path) -> None:
path = tmp_path / "deep" / "m.json"
manifest.write(path, {"b": {"x": 1}, "a": {"y": 2}})
text = path.read_text()
assert text.endswith("\n")
assert text.index('"a"') < text.index('"b"')
assert manifest.read(path) == {"a": {"y": 2}, "b": {"x": 1}}
def test_read_rejects_non_object(tmp_path: Path) -> None:
path = tmp_path / "m.json"
path.write_text(json.dumps([1, 2]))
with pytest.raises(TypeError, match="manifest object"):
manifest.read(path)

View File

@ -0,0 +1,67 @@
# Copyright (c) 2026 Krzysztof Rudnicki
import pytest
from music_theory import pitch
def test_a4_is_440_hz() -> None:
assert pitch.midi_to_hz(69) == pytest.approx(440.0)
def test_octave_doubles_frequency() -> None:
assert pitch.midi_to_hz(81) == pytest.approx(880.0)
assert pitch.midi_to_hz(57) == pytest.approx(220.0)
def test_semitone_ratio_is_twelfth_root_of_two() -> None:
assert pitch.semitone_ratio(12) == pytest.approx(2.0)
assert pitch.semitone_ratio(1) == pytest.approx(1.059463, abs=1e-6)
def test_hz_to_midi_round_trips() -> None:
for midi in (0, 60, 69, 127):
assert pitch.hz_to_midi(pitch.midi_to_hz(midi)) == pytest.approx(midi)
def test_hz_to_midi_rejects_non_positive() -> None:
with pytest.raises(ValueError, match="positive"):
pitch.hz_to_midi(0.0)
def test_cents_of_an_octave_is_1200() -> None:
assert pitch.cents(2.0) == pytest.approx(1200.0)
def test_cents_rejects_non_positive() -> None:
with pytest.raises(ValueError, match="positive"):
pitch.cents(-1.0)
@pytest.mark.parametrize(
("midi", "name"), [(60, "C4"), (69, "A4"), (61, "C#4"), (59, "B3"), (12, "C0")]
)
def test_note_name(midi: int, name: str) -> None:
assert pitch.note_name(midi) == name
@pytest.mark.parametrize(
("name", "midi"), [("C4", 60), ("A4", 69), ("C#3", 49), ("Bb3", 58), ("Db-1", 1)]
)
def test_parse_note(name: str, midi: int) -> None:
assert pitch.parse_note(name) == midi
@pytest.mark.parametrize("bad", ["", "4", "H4", "C", "Cb4"])
def test_parse_note_rejects_garbage(bad: str) -> None:
with pytest.raises(ValueError, match=r"note|pitch class"):
pitch.parse_note(bad)
def test_transpose_adds_a_constant() -> None:
assert pitch.transpose([60, 64, 67], 4) == [64, 68, 71]
def test_pitch_class_wraps() -> None:
assert pitch.pitch_class(60) == 0
assert pitch.pitch_class(71) == 11
assert pitch.pitch_class(72) == 0

View File

@ -0,0 +1,36 @@
# Copyright (c) 2026 Krzysztof Rudnicki
import pytest
from music_theory import progressions, scales
MAJOR = scales.STEP_PATTERNS["major"]
def test_named_lookup_is_case_insensitive() -> None:
assert progressions.named("POP") == (1, 5, 6, 4)
def test_named_rejects_unknown() -> None:
with pytest.raises(ValueError, match="unknown progression"):
progressions.named("prog rock")
def test_chords_for_resolves_degrees() -> None:
prog = progressions.chords_for(60, MAJOR, (1, 5, 6, 4))
assert progressions.numerals(prog) == "I V vi IV"
assert prog[1].notes == (67, 71, 74)
assert prog[2].degree == 6
def test_chords_for_rejects_out_of_range_degree() -> None:
with pytest.raises(ValueError, match="outside"):
progressions.chords_for(60, MAJOR, (8,))
with pytest.raises(ValueError, match="outside"):
progressions.chords_for(60, MAJOR, (0,))
def test_transpose_adds_a_constant_to_every_note() -> None:
prog = progressions.chords_for(60, MAJOR, (1, 4))
moved = progressions.transpose(prog, 4)
assert moved[0].notes == (64, 68, 71)
assert moved[1].numeral == "IV"

View File

@ -0,0 +1,70 @@
# Copyright (c) 2026 Krzysztof Rudnicki
import pytest
from music_theory import scales
def test_every_pattern_sums_to_twelve() -> None:
for pattern in scales.STEP_PATTERNS.values():
assert sum(pattern) == 12
def test_c_major_from_the_article() -> None:
assert scales.build(60, scales.STEP_PATTERNS["major"]) == [
60,
62,
64,
65,
67,
69,
71,
72,
]
def test_aeolian_is_major_rotated_five() -> None:
assert scales.mode("aeolian") == scales.rotate(scales.STEP_PATTERNS["major"], 5)
assert scales.mode("aeolian") == scales.STEP_PATTERNS["minor"]
assert scales.mode("Ionian") == scales.STEP_PATTERNS["major"]
def test_rotate_wraps_and_handles_empty() -> None:
assert scales.rotate((1, 2, 3), 4) == (2, 3, 1)
assert scales.rotate((), 3) == ()
def test_mode_rejects_unknown() -> None:
with pytest.raises(ValueError, match="unknown mode"):
scales.mode("klingon")
def test_pattern_named_accepts_scales_and_modes() -> None:
assert scales.pattern_named("minor") == scales.STEP_PATTERNS["minor"]
assert scales.pattern_named("dorian") == scales.mode("dorian")
with pytest.raises(ValueError, match="unknown scale"):
scales.pattern_named("nope")
def test_validate_rejects_bad_sums_and_steps() -> None:
with pytest.raises(ValueError, match="sums to"):
scales.validate((2, 2, 2))
with pytest.raises(ValueError, match="non-positive"):
scales.validate((13, -1))
def test_offsets_exclude_the_octave() -> None:
assert scales.offsets(scales.STEP_PATTERNS["major"]) == [0, 2, 4, 5, 7, 9, 11]
def test_pitch_at_degree_wraps_octaves() -> None:
major = scales.STEP_PATTERNS["major"]
assert scales.pitch_at_degree(60, major, 0) == 60
assert scales.pitch_at_degree(60, major, 7) == 72
assert scales.pitch_at_degree(60, major, -1) == 59
assert scales.pitch_at_degree(60, major, 9) == 76
def test_is_in_scale() -> None:
major = scales.STEP_PATTERNS["major"]
assert scales.is_in_scale(60, major, 76)
assert not scales.is_in_scale(60, major, 61)

View File

@ -0,0 +1,51 @@
# Copyright (c) 2026 Krzysztof Rudnicki
import numpy as np
import pytest
from music_theory import analysis, sequencer, synth
def test_beat_seconds() -> None:
assert sequencer.beat_seconds(120.0) == 0.5
with pytest.raises(ValueError, match="tempo"):
sequencer.beat_seconds(0.0)
def test_loop_frames_floors() -> None:
assert sequencer.loop_frames(120.0, 4.0, 22050) == 44100
assert sequencer.loop_frames(100.0, 1.0, 22050) == 13230
def test_render_is_exactly_the_requested_length() -> None:
events = sequencer.sequence([69, 69, 69], 1.0)
out = sequencer.render(events, 120.0, 4.0, 1000)
assert len(out) == 2000
def test_render_clips_notes_past_the_end_and_skips_late_starts() -> None:
late = sequencer.NoteEvent(69, 10.0, 1.0)
overrun = sequencer.NoteEvent(69, 1.5, 4.0)
out = sequencer.render([late, overrun], 120.0, 2.0, 1000)
assert len(out) == 1000
assert np.any(out[800:])
def test_render_note_applies_velocity_and_detune() -> None:
quiet = sequencer.render_note(sequencer.NoteEvent(69, 0.0, 1.0, 0.5), 60.0, 22050)
loud = sequencer.render_note(sequencer.NoteEvent(69, 0.0, 1.0, 1.0), 60.0, 22050)
assert analysis.peak(quiet) == pytest.approx(analysis.peak(loud) / 2)
up = sequencer.render_note(
sequencer.NoteEvent(69, 0.0, 1.0, cents=1200.0), 60.0, 22050
)
assert analysis.dominant_frequency(up) == pytest.approx(880.0, abs=1.0)
def test_stack_shares_the_level_between_notes() -> None:
chord = sequencer.stack([60, 64, 67], 0.0, 1.0, "saw", synth.Envelope())
assert all(e.velocity == pytest.approx(1 / 3) for e in chord)
assert sequencer.stack([], 0.0, 1.0) == []
def test_sequence_lays_notes_end_to_end() -> None:
events = sequencer.sequence([60, 62], 0.5)
assert [e.start_beat for e in events] == [0.0, 0.5]

View File

@ -0,0 +1,80 @@
# Copyright (c) 2026 Krzysztof Rudnicki
from typing import cast
import numpy as np
import pytest
from music_theory import analysis, synth
def test_time_axis_length_and_negative() -> None:
assert len(synth.time_axis(1.0, 100)) == 100
assert len(synth.time_axis(-1.0, 100)) == 0
@pytest.mark.parametrize("waveform", synth.WAVEFORMS)
def test_every_waveform_peaks_at_its_frequency(waveform: synth.Waveform) -> None:
signal = synth.oscillator(440.0, 1.0, waveform)
assert analysis.dominant_frequency(signal) == pytest.approx(440.0, abs=1.0)
assert np.max(np.abs(signal)) <= 1.0
def test_oscillator_rejects_unknown_waveform() -> None:
with pytest.raises(ValueError, match="unknown waveform"):
synth.oscillator(440.0, 0.1, cast("synth.Waveform", "noise"))
def test_square_is_two_valued() -> None:
assert set(np.unique(synth.oscillator(10.0, 0.5, "square"))) == {-1.0, 1.0}
def test_additive_stacks_harmonics() -> None:
signal = synth.additive(100.0, 1.0, [0.0, 1.0])
assert analysis.dominant_frequency(signal) == pytest.approx(200.0, abs=1.0)
def test_adsr_shape() -> None:
gain = synth.adsr(synth.Envelope(0.1, 0.1, 0.5, 0.1), 1.0, 100)
assert len(gain) == 100
assert gain[0] == 0.0
assert gain[10] == pytest.approx(1.0)
assert gain[50] == pytest.approx(0.5)
assert gain[-1] < 0.5
assert gain[-1] >= 0.0
def test_adsr_longer_than_the_note_is_clamped() -> None:
gain = synth.adsr(synth.Envelope(1.0, 1.0, 0.5, 1.0), 0.1, 100)
assert len(gain) == 10
assert np.all(gain >= 0.0)
assert synth.adsr(synth.Envelope(), -1.0, 100).size == 0
def test_lowpass_darkens_and_zero_cutoff_silences() -> None:
saw = synth.oscillator(220.0, 0.5, "saw")
dark = synth.lowpass(saw, 500.0)
assert analysis.spectral_centroid(dark) < analysis.spectral_centroid(saw)
assert not np.any(synth.lowpass(saw, 0.0))
def test_detune_by_cents() -> None:
assert synth.detune(440.0, 1200.0) == pytest.approx(880.0)
assert synth.detune(440.0, 0.0) == 440.0
def test_mix_pads_shorter_signals() -> None:
mixed = synth.mix(np.ones(3), np.ones(5))
assert list(mixed) == [2.0, 2.0, 2.0, 1.0, 1.0]
assert synth.mix().size == 0
def test_normalize_scales_peak_and_keeps_silence() -> None:
assert np.max(np.abs(synth.normalize(np.array([0.1, -0.2])))) == pytest.approx(0.8)
assert not np.any(synth.normalize(np.zeros(4)))
assert synth.normalize(np.zeros(0)).size == 0
def test_to_int16_clips() -> None:
out = synth.to_int16(np.array([2.0, -2.0, 0.0]))
assert list(out) == [32767, -32767, 0]
assert out.dtype == np.int16

View File

@ -0,0 +1,65 @@
# Copyright (c) 2026 Krzysztof Rudnicki
from fractions import Fraction
import pytest
from music_theory import tuning
def test_harmonic_series_multiplies() -> None:
assert tuning.harmonic_series(100.0, 4) == [100.0, 200.0, 300.0, 400.0]
def test_repeat_cycles_is_the_denominator() -> None:
assert tuning.repeat_cycles(Fraction(3, 2)) == 2
assert tuning.repeat_cycles(tuning.JUST_RATIOS["minor second"]) == 15
def test_pythagorean_comma_value() -> None:
assert tuning.pythagorean_comma() == pytest.approx(1.0136432647705078)
def test_stack_fifths_folds_into_the_octave() -> None:
assert tuning.stack_fifths(0) == 1.0
folded = tuning.stack_fifths(12)
assert folded >= 1.0
assert folded < 2.0
assert tuning.stack_fifths(12) == pytest.approx(tuning.pythagorean_comma())
def test_tempered_fifth_error_is_about_a_tenth_of_a_percent() -> None:
score = tuning.fifth_error(12)
assert score.step == 7
assert score.ratio == pytest.approx(1.498307, abs=1e-6)
assert score.error == pytest.approx(0.00113, abs=1e-5)
def test_fifth_error_rejects_non_positive() -> None:
with pytest.raises(ValueError, match="positive"):
tuning.fifth_error(0)
def test_twelve_is_the_first_acceptable_division() -> None:
assert tuning.first_acceptable_division(25) == 12
def test_no_acceptable_division_below_twelve() -> None:
assert tuning.first_acceptable_division(11) is None
def test_best_divisions_is_sorted_by_error() -> None:
scores = tuning.best_divisions(25)
errors = [s.error for s in scores]
assert errors == sorted(errors)
assert len(scores) == 25
small = [s.divisions for s in scores if s.divisions <= 12]
assert small[0] == 12
def test_beat_frequency_is_the_difference() -> None:
assert tuning.beat_frequency(440.0, 446.0) == pytest.approx(6.0)
assert tuning.beat_frequency(446.0, 440.0) == pytest.approx(6.0)
def test_tritone_ratio_is_root_two() -> None:
assert pytest.approx(2.0) == tuning.TRITONE_RATIO**2

View File

@ -0,0 +1,42 @@
# Copyright (c) 2026 Krzysztof Rudnicki
from typing import TYPE_CHECKING
import wave
import numpy as np
import pytest
from music_theory import synth, wav
if TYPE_CHECKING:
from pathlib import Path
def test_write_and_read_round_trip(tmp_path: Path) -> None:
signal = synth.oscillator(440.0, 0.1)
path = wav.write(tmp_path / "sub" / "a.wav", signal, 8000)
back, rate = wav.read(path)
assert rate == 8000
assert len(back) == len(signal)
assert np.allclose(back, signal, atol=1e-3)
def test_read_rejects_stereo(tmp_path: Path) -> None:
path = tmp_path / "stereo.wav"
with wave.Wave_write(str(path)) as handle:
handle.setnchannels(2)
handle.setsampwidth(2)
handle.setframerate(8000)
handle.writeframes(b"\0\0\0\0")
with pytest.raises(ValueError, match="16-bit mono"):
wav.read(path)
def test_sha256_is_stable(tmp_path: Path) -> None:
path = wav.write(tmp_path / "a.wav", np.zeros(10))
assert wav.sha256(path) == wav.sha256(path)
assert len(wav.sha256(path)) == 64
def test_duration_and_play_command(tmp_path: Path) -> None:
assert wav.duration_seconds(np.zeros(22050)) == 1.0
assert wav.play_command(tmp_path / "x.wav").startswith("aplay -q ")

View File

@ -0,0 +1,115 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tuning: why the tuning everyone wants cannot exist, and what we use instead.
Article sections 3, 4 and 5. Consonance is the ear finding a repeating
pattern quickly, which small-integer ratios give it. Stacking twelve pure
fifths overshoots seven octaves (the Pythagorean comma) because 3**n can
never equal 2**m, so equal temperament splits the octave into twelve equal
multiplicative steps and accepts a fifth that is 0.11 % flat everywhere.
"""
from __future__ import annotations
from dataclasses import dataclass
from fractions import Fraction
import math
## The just intervals, as the small-integer ratios the harmonic series gives.
JUST_RATIOS: dict[str, Fraction] = {
"unison": Fraction(1, 1),
"octave": Fraction(2, 1),
"perfect fifth": Fraction(3, 2),
"perfect fourth": Fraction(4, 3),
"major third": Fraction(5, 4),
"minor third": Fraction(6, 5),
"major sixth": Fraction(5, 3),
"minor second": Fraction(16, 15),
}
## The tritone: sqrt(2), halfway up the octave, no integer ratio at all.
TRITONE_RATIO = math.sqrt(2.0)
## A fifth is "acceptable" when it is within this of 3/2 (the article's bar:
## twelve is the first division whose fifth lands within about 0.1 %).
ACCEPTABLE_FIFTH_ERROR = 0.002
## How many pure fifths it takes to come back round to the same pitch class.
FIFTHS_IN_CYCLE = 12
OCTAVES_IN_CYCLE = 7
_PURE_FIFTH = 1.5
_OCTAVE = 2.0
@dataclass(frozen=True)
class DivisionScore:
"""How well an n-way equal division of the octave approximates a fifth."""
divisions: int
step: int
ratio: float
error: float
def harmonic_series(fundamental_hz: float, count: int) -> list[float]:
"""The first ``count`` harmonics: 1x, 2x, 3x ... the fundamental."""
return [fundamental_hz * n for n in range(1, count + 1)]
def repeat_cycles(ratio: Fraction) -> int:
"""Cycles of the LOWER note before two notes' combined wave repeats.
For 3/2 the lower note completes 2 cycles while the upper completes 3
and the pattern restarts: the ear locks on almost at once. For 16/15 it
takes 15 cycles, which is why a minor second sounds rough.
"""
return ratio.denominator
def pythagorean_comma() -> float:
"""(3/2) ** 12 / 2 ** 7 -- about 1.0136, the gap that breaks pure tuning."""
return _PURE_FIFTH**FIFTHS_IN_CYCLE / _OCTAVE**OCTAVES_IN_CYCLE
def stack_fifths(count: int) -> float:
"""Ratio after ``count`` pure fifths, folded back into one octave."""
ratio = _PURE_FIFTH**count
while ratio >= _OCTAVE:
ratio /= _OCTAVE
return ratio
def fifth_error(divisions: int) -> DivisionScore:
"""Closest step of an n-way equal division to a pure fifth, and its error."""
if divisions <= 0:
msg = f"divisions must be positive, got {divisions}"
raise ValueError(msg)
step = round(divisions * math.log2(_PURE_FIFTH))
ratio = _OCTAVE ** (step / divisions)
return DivisionScore(divisions, step, ratio, abs(ratio / _PURE_FIFTH - 1.0))
def best_divisions(max_divisions: int) -> list[DivisionScore]:
"""Every division up to ``max_divisions``, best fifth first.
Brute force, exactly as the article does it: the answer to "why twelve"
is that twelve is the smallest count whose fifth clears the bar, and the
next improvements (19, 31, 53) buy little for a lot more notes.
"""
scores = [fifth_error(n) for n in range(1, max_divisions + 1)]
return sorted(scores, key=lambda score: (score.error, score.divisions))
def first_acceptable_division(
max_divisions: int, tolerance: float = ACCEPTABLE_FIFTH_ERROR
) -> int | None:
"""Smallest division whose fifth is within ``tolerance`` of 3/2."""
for n in range(1, max_divisions + 1):
if fifth_error(n).error <= tolerance:
return n
return None
def beat_frequency(hz_a: float, hz_b: float) -> float:
"""How many times per second two close tones beat against each other."""
return abs(hz_a - hz_b)

View File

@ -0,0 +1,68 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""WAV: write what was rendered so a person can hear it, and fingerprint it.
16-bit mono PCM is what Godot's ``AudioStreamWAV`` and ``aplay`` both take
without conversion. The SHA-256 is what a manifest records so a re-render
can prove it reproduced the committed file byte for byte.
"""
from __future__ import annotations
import hashlib
from typing import TYPE_CHECKING
import wave
import numpy as np
from music_theory import synth
if TYPE_CHECKING:
from pathlib import Path
_BYTES_PER_SAMPLE = 2
_CHANNELS = 1
def write(
path: Path, signal: synth.Signal, sample_rate: int = synth.SAMPLE_RATE
) -> Path:
"""Write a float signal as 16-bit mono PCM and return the path."""
path.parent.mkdir(parents=True, exist_ok=True)
with wave.Wave_write(str(path)) as handle:
handle.setnchannels(_CHANNELS)
handle.setsampwidth(_BYTES_PER_SAMPLE)
handle.setframerate(sample_rate)
handle.writeframes(synth.to_int16(signal).tobytes())
return path
def read(path: Path) -> tuple[synth.Signal, int]:
"""Read a 16-bit mono WAV back into a float signal and its sample rate."""
with wave.Wave_read(str(path)) as handle:
if (
handle.getsampwidth() != _BYTES_PER_SAMPLE
or handle.getnchannels() != _CHANNELS
):
msg = f"{path} is not 16-bit mono"
raise ValueError(msg)
rate = handle.getframerate()
raw = handle.readframes(handle.getnframes())
samples = np.frombuffer(raw, dtype=np.int16).astype(np.float64) / 32767.0
return samples, rate
def sha256(path: Path) -> str:
"""Hex digest of the file's bytes."""
return hashlib.sha256(path.read_bytes()).hexdigest()
def duration_seconds(
signal: synth.Signal, sample_rate: int = synth.SAMPLE_RATE
) -> float:
"""Length of a signal in seconds."""
return len(signal) / sample_rate
def play_command(path: Path) -> str:
"""The one line a person runs to hear the file."""
return f"aplay -q {path}"

142
music_theory/pyproject.toml Normal file
View File

@ -0,0 +1,142 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "music_theory"
version = "0.1.0"
description = "Music theory from first principles, as code: pitch, tuning, scales, chords, progressions, and a tiny synth to hear them"
requires-python = ">=3.14"
dependencies = ["numpy==2.5.3"]
[tool.setuptools.packages.find]
include = ["music_theory*"]
[tool.ruff]
target-version = "py314"
include = ["*.py", "**/*.py"]
exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"]
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"D203",
"D213",
"COM812",
"ISC001",
"S603",
]
fixable = ["ALL"]
unfixable = []
[tool.ruff.lint.per-file-ignores]
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
"**/test_*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.lint.isort]
force-single-line = false
force-sort-within-sections = true
known-first-party = ["music_theory"]
[tool.ruff.lint.flake8-quotes]
docstring-quotes = "double"
inline-quotes = "double"
[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
docstring-code-format = true
[tool.mypy]
python_version = "3.14"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
disallow_any_unimported = true
disallow_any_explicit = false
disallow_any_generics = true
disallow_subclassing_any = true
strict_equality = true
extra_checks = true
ignore_missing_imports = true
show_error_codes = true
color_output = true
exclude = [".venv/"]
[tool.pylint.main]
analyse-fallback-blocks = true
persistent = true
jobs = 0
py-version = "3.14"
ignore = [".venv", "__pycache__"]
ignore-patterns = [".*\\.pyi$"]
[tool.pylint.messages_control]
enable = "all"
disable = []
[tool.pylint.design]
min-public-methods = 0
max-module-lines = 1000
max-attributes = 12
[tool.bandit]
exclude_dirs = ["tests", ".venv"]
[tool.pytest.ini_options]
testpaths = ["music_theory/tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"--strict-markers",
"--strict-config",
"-ra",
"--cov=music_theory",
"--cov-branch",
"--cov-report=term-missing",
"--cov-report=lcov",
]
filterwarnings = [
"error",
"ignore::DeprecationWarning",
"default::pytest.PytestUnraisableExceptionWarning",
]
[tool.coverage.run]
source = ["music_theory"]
branch = true
omit = [
"*/__pycache__/*",
"*/tests/*",
"*/.venv/*",
]
[tool.coverage.report]
fail_under = 100
show_missing = true
skip_covered = false
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
'if __name__ == "__main__":',
]
partial_branches = ["pragma: no branch"]

View File

@ -0,0 +1,11 @@
# Exact pins, enforced by the shared dependency-freshness gate: a `>=`
# floor is satisfied by anything newer, so it tells a checker nothing
# about whether this repo is actually current.
bandit[toml]==1.9.4
mypy==2.3.1
numpy==2.5.3
pre-commit==4.6.2
pylint==4.0.8
pytest==9.1.1
pytest-cov==7.1.0
ruff==0.16.7