From d5b3f2cff33b153522cc47cda8d544494ef3c328 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 11 Sep 2026 18:29:13 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01VC17zymxkeiUcHrMvt8W5W --- .github/workflows/music-theory-tests.yml | 31 +++ README.md | 14 ++ music_theory/.pre-commit-config.yaml | 150 +++++++++++ music_theory/DOCS-article-walkthrough.md | 101 ++++++++ music_theory/README.md | 98 ++++++++ music_theory/music_theory/__init__.py | 17 ++ music_theory/music_theory/__main__.py | 11 + music_theory/music_theory/analysis.py | 41 +++ music_theory/music_theory/chords.py | 139 +++++++++++ music_theory/music_theory/cli.py | 107 ++++++++ music_theory/music_theory/compose.py | 233 ++++++++++++++++++ music_theory/music_theory/degrade.py | 112 +++++++++ music_theory/music_theory/demo_support.py | 71 ++++++ music_theory/music_theory/demos.py | 55 +++++ music_theory/music_theory/demos_a.py | 173 +++++++++++++ music_theory/music_theory/demos_b.py | 184 ++++++++++++++ music_theory/music_theory/manifest.py | 52 ++++ music_theory/music_theory/pitch.py | 104 ++++++++ music_theory/music_theory/progressions.py | 72 ++++++ music_theory/music_theory/scales.py | 120 +++++++++ music_theory/music_theory/sequencer.py | 110 +++++++++ music_theory/music_theory/synth.py | 155 ++++++++++++ music_theory/music_theory/tests/__init__.py | 2 + .../music_theory/tests/test_analysis.py | 32 +++ .../music_theory/tests/test_chords.py | 71 ++++++ music_theory/music_theory/tests/test_cli.py | 98 ++++++++ .../music_theory/tests/test_compose.py | 96 ++++++++ .../music_theory/tests/test_degrade.py | 49 ++++ music_theory/music_theory/tests/test_demos.py | 66 +++++ .../music_theory/tests/test_manifest.py | 38 +++ music_theory/music_theory/tests/test_pitch.py | 67 +++++ .../music_theory/tests/test_progressions.py | 36 +++ .../music_theory/tests/test_scales.py | 70 ++++++ .../music_theory/tests/test_sequencer.py | 51 ++++ music_theory/music_theory/tests/test_synth.py | 80 ++++++ .../music_theory/tests/test_tuning.py | 65 +++++ music_theory/music_theory/tests/test_wav.py | 42 ++++ music_theory/music_theory/tuning.py | 115 +++++++++ music_theory/music_theory/wav.py | 68 +++++ music_theory/pyproject.toml | 142 +++++++++++ music_theory/requirements.txt | 11 + 41 files changed, 3349 insertions(+) create mode 100644 .github/workflows/music-theory-tests.yml create mode 100644 music_theory/.pre-commit-config.yaml create mode 100644 music_theory/DOCS-article-walkthrough.md create mode 100644 music_theory/README.md create mode 100644 music_theory/music_theory/__init__.py create mode 100644 music_theory/music_theory/__main__.py create mode 100644 music_theory/music_theory/analysis.py create mode 100644 music_theory/music_theory/chords.py create mode 100644 music_theory/music_theory/cli.py create mode 100644 music_theory/music_theory/compose.py create mode 100644 music_theory/music_theory/degrade.py create mode 100644 music_theory/music_theory/demo_support.py create mode 100644 music_theory/music_theory/demos.py create mode 100644 music_theory/music_theory/demos_a.py create mode 100644 music_theory/music_theory/demos_b.py create mode 100644 music_theory/music_theory/manifest.py create mode 100644 music_theory/music_theory/pitch.py create mode 100644 music_theory/music_theory/progressions.py create mode 100644 music_theory/music_theory/scales.py create mode 100644 music_theory/music_theory/sequencer.py create mode 100644 music_theory/music_theory/synth.py create mode 100644 music_theory/music_theory/tests/__init__.py create mode 100644 music_theory/music_theory/tests/test_analysis.py create mode 100644 music_theory/music_theory/tests/test_chords.py create mode 100644 music_theory/music_theory/tests/test_cli.py create mode 100644 music_theory/music_theory/tests/test_compose.py create mode 100644 music_theory/music_theory/tests/test_degrade.py create mode 100644 music_theory/music_theory/tests/test_demos.py create mode 100644 music_theory/music_theory/tests/test_manifest.py create mode 100644 music_theory/music_theory/tests/test_pitch.py create mode 100644 music_theory/music_theory/tests/test_progressions.py create mode 100644 music_theory/music_theory/tests/test_scales.py create mode 100644 music_theory/music_theory/tests/test_sequencer.py create mode 100644 music_theory/music_theory/tests/test_synth.py create mode 100644 music_theory/music_theory/tests/test_tuning.py create mode 100644 music_theory/music_theory/tests/test_wav.py create mode 100644 music_theory/music_theory/tuning.py create mode 100644 music_theory/music_theory/wav.py create mode 100644 music_theory/pyproject.toml create mode 100644 music_theory/requirements.txt diff --git a/.github/workflows/music-theory-tests.yml b/.github/workflows/music-theory-tests.yml new file mode 100644 index 0000000..ef3fe35 --- /dev/null +++ b/.github/workflows/music-theory-tests.yml @@ -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 diff --git a/README.md b/README.md index 2cbe544..9633a5e 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/music_theory/.pre-commit-config.yaml b/music_theory/.pre-commit-config.yaml new file mode 100644 index 0000000..835c6b4 --- /dev/null +++ b/music_theory/.pre-commit-config.yaml @@ -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 diff --git a/music_theory/DOCS-article-walkthrough.md b/music_theory/DOCS-article-walkthrough.md new file mode 100644 index 0000000..2816245 --- /dev/null +++ b/music_theory/DOCS-article-walkthrough.md @@ -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`. diff --git a/music_theory/README.md b/music_theory/README.md new file mode 100644 index 0000000..459d907 --- /dev/null +++ b/music_theory/README.md @@ -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. diff --git a/music_theory/music_theory/__init__.py b/music_theory/music_theory/__init__.py new file mode 100644 index 0000000..497d640 --- /dev/null +++ b/music_theory/music_theory/__init__.py @@ -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" diff --git a/music_theory/music_theory/__main__.py b/music_theory/music_theory/__main__.py new file mode 100644 index 0000000..dff871e --- /dev/null +++ b/music_theory/music_theory/__main__.py @@ -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()) diff --git a/music_theory/music_theory/analysis.py b/music_theory/music_theory/analysis.py new file mode 100644 index 0000000..b1ebee5 --- /dev/null +++ b/music_theory/music_theory/analysis.py @@ -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 diff --git a/music_theory/music_theory/chords.py b/music_theory/music_theory/chords.py new file mode 100644 index 0000000..0305bbe --- /dev/null +++ b/music_theory/music_theory/chords.py @@ -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 diff --git a/music_theory/music_theory/cli.py b/music_theory/music_theory/cli.py new file mode 100644 index 0000000..1f39afc --- /dev/null +++ b/music_theory/music_theory/cli.py @@ -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 --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) diff --git a/music_theory/music_theory/compose.py b/music_theory/music_theory/compose.py new file mode 100644 index 0000000..7d21dea --- /dev/null +++ b/music_theory/music_theory/compose.py @@ -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) diff --git a/music_theory/music_theory/degrade.py b/music_theory/music_theory/degrade.py new file mode 100644 index 0000000..9f5861b --- /dev/null +++ b/music_theory/music_theory/degrade.py @@ -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) diff --git a/music_theory/music_theory/demo_support.py b/music_theory/music_theory/demo_support.py new file mode 100644 index 0000000..9134169 --- /dev/null +++ b/music_theory/music_theory/demo_support.py @@ -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) diff --git a/music_theory/music_theory/demos.py b/music_theory/music_theory/demos.py new file mode 100644 index 0000000..afe1092 --- /dev/null +++ b/music_theory/music_theory/demos.py @@ -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] diff --git a/music_theory/music_theory/demos_a.py b/music_theory/music_theory/demos_a.py new file mode 100644 index 0000000..8f351ac --- /dev/null +++ b/music_theory/music_theory/demos_a.py @@ -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)) diff --git a/music_theory/music_theory/demos_b.py b/music_theory/music_theory/demos_b.py new file mode 100644 index 0000000..516eb51 --- /dev/null +++ b/music_theory/music_theory/demos_b.py @@ -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)) diff --git a/music_theory/music_theory/manifest.py b/music_theory/music_theory/manifest.py new file mode 100644 index 0000000..2aa2db8 --- /dev/null +++ b/music_theory/music_theory/manifest.py @@ -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 diff --git a/music_theory/music_theory/pitch.py b/music_theory/music_theory/pitch.py new file mode 100644 index 0000000..152631c --- /dev/null +++ b/music_theory/music_theory/pitch.py @@ -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 diff --git a/music_theory/music_theory/progressions.py b/music_theory/music_theory/progressions.py new file mode 100644 index 0000000..04d173c --- /dev/null +++ b/music_theory/music_theory/progressions.py @@ -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) diff --git a/music_theory/music_theory/scales.py b/music_theory/music_theory/scales.py new file mode 100644 index 0000000..110cde9 --- /dev/null +++ b/music_theory/music_theory/scales.py @@ -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) diff --git a/music_theory/music_theory/sequencer.py b/music_theory/music_theory/sequencer.py new file mode 100644 index 0000000..e9d36a1 --- /dev/null +++ b/music_theory/music_theory/sequencer.py @@ -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 + ] diff --git a/music_theory/music_theory/synth.py b/music_theory/music_theory/synth.py new file mode 100644 index 0000000..642f464 --- /dev/null +++ b/music_theory/music_theory/synth.py @@ -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) diff --git a/music_theory/music_theory/tests/__init__.py b/music_theory/music_theory/tests/__init__.py new file mode 100644 index 0000000..20687ce --- /dev/null +++ b/music_theory/music_theory/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Tests for music_theory.""" diff --git a/music_theory/music_theory/tests/test_analysis.py b/music_theory/music_theory/tests/test_analysis.py new file mode 100644 index 0000000..5955f50 --- /dev/null +++ b/music_theory/music_theory/tests/test_analysis.py @@ -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 diff --git a/music_theory/music_theory/tests/test_chords.py b/music_theory/music_theory/tests/test_chords.py new file mode 100644 index 0000000..818ebd7 --- /dev/null +++ b/music_theory/music_theory/tests/test_chords.py @@ -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)] diff --git a/music_theory/music_theory/tests/test_cli.py b/music_theory/music_theory/tests/test_cli.py new file mode 100644 index 0000000..728022c --- /dev/null +++ b/music_theory/music_theory/tests/test_cli.py @@ -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 diff --git a/music_theory/music_theory/tests/test_compose.py b/music_theory/music_theory/tests/test_compose.py new file mode 100644 index 0000000..3759754 --- /dev/null +++ b/music_theory/music_theory/tests/test_compose.py @@ -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 diff --git a/music_theory/music_theory/tests/test_degrade.py b/music_theory/music_theory/tests/test_degrade.py new file mode 100644 index 0000000..426b172 --- /dev/null +++ b/music_theory/music_theory/tests/test_degrade.py @@ -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) diff --git a/music_theory/music_theory/tests/test_demos.py b/music_theory/music_theory/tests/test_demos.py new file mode 100644 index 0000000..710a19a --- /dev/null +++ b/music_theory/music_theory/tests/test_demos.py @@ -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 diff --git a/music_theory/music_theory/tests/test_manifest.py b/music_theory/music_theory/tests/test_manifest.py new file mode 100644 index 0000000..543b42d --- /dev/null +++ b/music_theory/music_theory/tests/test_manifest.py @@ -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) diff --git a/music_theory/music_theory/tests/test_pitch.py b/music_theory/music_theory/tests/test_pitch.py new file mode 100644 index 0000000..721fe37 --- /dev/null +++ b/music_theory/music_theory/tests/test_pitch.py @@ -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 diff --git a/music_theory/music_theory/tests/test_progressions.py b/music_theory/music_theory/tests/test_progressions.py new file mode 100644 index 0000000..b83139b --- /dev/null +++ b/music_theory/music_theory/tests/test_progressions.py @@ -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" diff --git a/music_theory/music_theory/tests/test_scales.py b/music_theory/music_theory/tests/test_scales.py new file mode 100644 index 0000000..cf7fdb0 --- /dev/null +++ b/music_theory/music_theory/tests/test_scales.py @@ -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) diff --git a/music_theory/music_theory/tests/test_sequencer.py b/music_theory/music_theory/tests/test_sequencer.py new file mode 100644 index 0000000..e815a09 --- /dev/null +++ b/music_theory/music_theory/tests/test_sequencer.py @@ -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] diff --git a/music_theory/music_theory/tests/test_synth.py b/music_theory/music_theory/tests/test_synth.py new file mode 100644 index 0000000..ffa02be --- /dev/null +++ b/music_theory/music_theory/tests/test_synth.py @@ -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 diff --git a/music_theory/music_theory/tests/test_tuning.py b/music_theory/music_theory/tests/test_tuning.py new file mode 100644 index 0000000..a40e739 --- /dev/null +++ b/music_theory/music_theory/tests/test_tuning.py @@ -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 diff --git a/music_theory/music_theory/tests/test_wav.py b/music_theory/music_theory/tests/test_wav.py new file mode 100644 index 0000000..5b2e427 --- /dev/null +++ b/music_theory/music_theory/tests/test_wav.py @@ -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 ") diff --git a/music_theory/music_theory/tuning.py b/music_theory/music_theory/tuning.py new file mode 100644 index 0000000..7ae5101 --- /dev/null +++ b/music_theory/music_theory/tuning.py @@ -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) diff --git a/music_theory/music_theory/wav.py b/music_theory/music_theory/wav.py new file mode 100644 index 0000000..8753657 --- /dev/null +++ b/music_theory/music_theory/wav.py @@ -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}" diff --git a/music_theory/pyproject.toml b/music_theory/pyproject.toml new file mode 100644 index 0000000..aa5c0d7 --- /dev/null +++ b/music_theory/pyproject.toml @@ -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"] diff --git a/music_theory/requirements.txt b/music_theory/requirements.txt new file mode 100644 index 0000000..a0d6f35 --- /dev/null +++ b/music_theory/requirements.txt @@ -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