mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
music_theory: music theory from first principles, as code you can hear
New subproject porting Luke Haas's "Music Theory for Programmers" section by section into typed Python: pitch (MIDI <-> Hz), tuning (harmonic series, the Pythagorean comma, brute-forced "why twelve"), scales as step arrays with modes as rotations, chords stacked in thirds with Roman numerals and the V7 -> I resolution, named progressions, a numpy synth (waveforms, ADSR, low-pass), a bar-exact sequencer, and a seeded Recipe -> WAV composer. `degrade` is the same piece ruined by a 0..1 amount, so an ending can be the opening destroyed on the same seed; filtering runs LAST because filtering first measured brighter than the clean render. 17 demos (`python -m music_theory demo all`) each print the article's numbers -- the comma is 1.013643, the tempered fifth is 0.113 % flat, twelve is the first acceptable division -- and the FFT of the render, so the claims are tested rather than asserted. 157 tests, 100 % branch coverage, ruff ALL, mypy strict, pylint 10. Consumed next by roadside-assistance for its three world-state beds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VC17zymxkeiUcHrMvt8W5W
This commit is contained in:
parent
94407268f0
commit
d5b3f2cff3
31
.github/workflows/music-theory-tests.yml
vendored
Normal file
31
.github/workflows/music-theory-tests.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
name: music_theory tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# The workflow's own file counts as music_theory: editing only this file used
|
||||
# to trigger nothing, so a fix to the job itself could never prove itself.
|
||||
paths: ['music_theory/**', '.github/workflows/music-theory-tests.yml']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['music_theory/**', '.github/workflows/music-theory-tests.yml']
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: music_theory
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: python -m pytest -v
|
||||
14
README.md
14
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,
|
||||
|
||||
150
music_theory/.pre-commit-config.yaml
Normal file
150
music_theory/.pre-commit-config.yaml
Normal file
@ -0,0 +1,150 @@
|
||||
# Pre-commit Configuration for music_theory
|
||||
# Install: pre-commit install && pre-commit install --hook-type pre-push
|
||||
# Run: pre-commit run --all-files
|
||||
# Update: pre-commit autoupdate
|
||||
|
||||
# NOTE: this subproject lives inside the ~/utils monorepo, whose git root is
|
||||
# one level up. pre-commit always runs hooks from the git root, so every path
|
||||
# below is written relative to THAT, not to this directory, and the top-level
|
||||
# `files` key keeps these hooks off the sibling subprojects. CI invokes this
|
||||
# config exactly the same way:
|
||||
# pre-commit run --config music_theory/.pre-commit-config.yaml --all-files
|
||||
files: ^music_theory/
|
||||
|
||||
default_language_version:
|
||||
python: python3
|
||||
default_stages: [pre-commit]
|
||||
fail_fast: false
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
args: [--markdown-linebreak-ext=md]
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=2000]
|
||||
- id: check-merge-conflict
|
||||
- id: detect-private-key
|
||||
- id: debug-statements
|
||||
- id: name-tests-test
|
||||
args: [--pytest-test-first]
|
||||
- id: check-ast
|
||||
- id: mixed-line-ending
|
||||
args: [--fix=lf]
|
||||
- id: requirements-txt-fixer
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: file-length-cap
|
||||
name: file length <= 250 lines
|
||||
entry: bash scripts/check_file_length.sh
|
||||
language: system
|
||||
pass_filenames: true
|
||||
- id: subproject-scope
|
||||
name: subproject scope regex still matches
|
||||
entry: scripts/check_subproject_scope.sh music_theory
|
||||
language: script
|
||||
# always_run so the top-level `files` filter cannot skip the one hook
|
||||
# whose job is to notice that filter has stopped matching anything.
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
- id: no-noqa
|
||||
name: Block noqa comments
|
||||
entry: '(?i)#\s*(noqa|type:\s*ignore)'
|
||||
language: pygrep
|
||||
types: [python]
|
||||
- id: no-ruff-noqa
|
||||
name: Block ruff noqa file-level comments
|
||||
entry: '(?i)#\s*ruff:\s*noqa'
|
||||
language: pygrep
|
||||
types: [python]
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
|
||||
types_or: [python, pyi]
|
||||
- id: ruff-format
|
||||
types_or: [python, pyi]
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.13.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
# Without numpy the NDArray alias is an unresolved variable and
|
||||
# every signature using it fails valid-type.
|
||||
- numpy==2.5.3
|
||||
args:
|
||||
- --ignore-missing-imports
|
||||
- --no-error-summary
|
||||
- --disable-error-code=no-untyped-def
|
||||
- --disable-error-code=no-untyped-call
|
||||
- --disable-error-code=var-annotated
|
||||
- --disable-error-code=no-any-unimported
|
||||
- --disable-error-code=type-arg
|
||||
- --disable-error-code=no-any-return
|
||||
- --disable-error-code=misc
|
||||
- --disable-error-code=unused-ignore
|
||||
- --disable-error-code=unreachable
|
||||
- --disable-error-code=assignment
|
||||
- --disable-error-code=no-redef
|
||||
- --disable-error-code=attr-defined
|
||||
- --disable-error-code=arg-type
|
||||
- --disable-error-code=union-attr
|
||||
- --disable-error-code=call-overload
|
||||
- --disable-error-code=return-value
|
||||
- --disable-error-code=redundant-cast
|
||||
- --disable-error-code=empty-body
|
||||
- --disable-error-code=list-item
|
||||
|
||||
- repo: https://github.com/pylint-dev/pylint
|
||||
rev: v3.3.2
|
||||
hooks:
|
||||
- id: pylint
|
||||
args:
|
||||
- --rcfile=music_theory/pyproject.toml
|
||||
- --fail-under=8.0
|
||||
- --jobs=4
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
# numpy is a real runtime dependency; without it every module is an
|
||||
# import-error and the score collapses below the gate.
|
||||
- numpy==2.5.3
|
||||
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.9.4
|
||||
hooks:
|
||||
- id: bandit
|
||||
args:
|
||||
- -c
|
||||
- music_theory/pyproject.toml
|
||||
- --severity-level=high
|
||||
- --confidence-level=medium
|
||||
- --skip=B113
|
||||
additional_dependencies: ["bandit[toml]"]
|
||||
exclude: ^(music_theory/music_theory/tests/|.*test.*\.py$)
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest-coverage
|
||||
name: pytest with coverage enforcement
|
||||
entry: scripts/run_subproject_tests.sh music_theory
|
||||
language: script
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
stages: [pre-push]
|
||||
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.3.0
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --skip=*.json,*.lock,.git,__pycache__,.venv
|
||||
101
music_theory/DOCS-article-walkthrough.md
Normal file
101
music_theory/DOCS-article-walkthrough.md
Normal file
@ -0,0 +1,101 @@
|
||||
# The article, section by section, as code
|
||||
|
||||
Each row: the article's claim, where it lives here, and the demo that lets
|
||||
you hear it. Run `python -m music_theory demo all --out /tmp/mt` once and
|
||||
follow along with `aplay`.
|
||||
|
||||
## 1. Sound is a number that changes over time
|
||||
|
||||
A note is a frequency in Hz. Pitch perception is multiplicative: "one
|
||||
semitone up" means "multiply by 2^(1/12)", never "add N Hz".
|
||||
`pitch.midi_to_hz` is that exponent counted from A4 = 440 Hz.
|
||||
Demo `tone`; `analysis.dominant_frequency` confirms the FFT peak is 440.
|
||||
|
||||
## 2. Envelopes and timbre
|
||||
|
||||
Volume over time (attack, decay, sustain, release) is what makes a sound a
|
||||
note. A 10 ms attack reads as struck, 500 ms as bowed; the release is what
|
||||
stops a click at the end. `synth.Envelope` + `synth.adsr`. Timbre is the
|
||||
recipe of overtones at 2×, 3×, 4× the fundamental; sine, triangle, square
|
||||
and saw are four such recipes (`synth.oscillator`). Demos `envelope`,
|
||||
`waveforms`; the printed spectral centroid rises from sine to saw.
|
||||
|
||||
## 3. Octaves and consonance
|
||||
|
||||
Doubling the frequency gives the "same" note (`octaves`). Two notes sound
|
||||
consonant when their ratio is simple: for p/q the combined wave repeats
|
||||
after q cycles of the lower note (`tuning.repeat_cycles`), so 3/2 locks in
|
||||
after 2 cycles, 16/15 after 15, and √2 (the tritone) never. Demo
|
||||
`consonance`.
|
||||
|
||||
## 4. The Pythagorean comma
|
||||
|
||||
Stack twelve pure fifths and you overshoot seven octaves by
|
||||
(3/2)¹² / 2⁷ = 1.013643, about 23.5 cents. The proof is one line: 3ⁿ = 2ᵐ
|
||||
has no integer solution because both are prime. "The system we want does
|
||||
not exist and never has." `tuning.pythagorean_comma`; demo `comma` plays
|
||||
A4 against A4 × comma and you hear it beat six times a second.
|
||||
|
||||
## 5. Equal temperament, and why twelve
|
||||
|
||||
Split the octave into twelve equal multiplicative steps. Every interval
|
||||
except the octave is slightly wrong, but wrong by the same amount in every
|
||||
key. The fifth becomes 2^(7/12) = 1.498307, 0.113 % flat.
|
||||
`tuning.best_divisions` brute-forces every division up to 25: twelve is the
|
||||
smallest whose fifth is within 0.2 % of 3/2 (`first_acceptable_division`).
|
||||
Demos `temperament`, `divisions`.
|
||||
|
||||
## 6. Scales
|
||||
|
||||
A scale is a subset of the twelve notes with UNEVEN gaps, so the ear can
|
||||
tell where it is. Encode it as the step array: major `[2,2,1,2,2,2,1]`,
|
||||
minor `[2,1,2,2,1,2,2]`. `scales.build(60, major)` is the article's
|
||||
`reduce` → `[60,62,64,65,67,69,71,72]`. All seven modes are the same array
|
||||
rotated (`scales.mode`); aeolian is rotation 5 and equals natural minor.
|
||||
Demos `scale`, `modes`.
|
||||
|
||||
## 7. Chords
|
||||
|
||||
Stack every other scale degree (`chords.stacked_thirds`). Major triad
|
||||
`[0,4,7]`, minor `[0,3,7]`: one array element is the entire happy/sad
|
||||
difference. Sevenths add one more third. Demo `chords`.
|
||||
|
||||
## 8. Keys and Roman numerals
|
||||
|
||||
Build a triad on each degree and you get the diatonic chords of the key:
|
||||
C major → C Dm Em F G Am B° (`chords.diatonic_chords`). Roman numerals are
|
||||
relative addressing, so `progressions.transpose` is adding one constant to
|
||||
every MIDI number. Demos `diatonic`, `transpose`.
|
||||
|
||||
## 9. Tension and resolution
|
||||
|
||||
V7 contains the leading tone (B in C) and a tritone (B–F). Both resolve by
|
||||
a single semitone into the tonic: B→C, F→E, contrary motion, the tritone
|
||||
collapsing into a major third. `chords.resolution` returns exactly those
|
||||
two moves. Common loops (I V vi IV, ii V I, vi IV I V, I vi IV V) are in
|
||||
`progressions.NAMED`. Demos `resolution`, `progressions`.
|
||||
|
||||
## 10. Notation as a serialization format
|
||||
|
||||
The staff is diatonic, not chromatic; sharps and flats are the escape
|
||||
hatch; durations are powers of two and a dot multiplies by 1.5; tempo
|
||||
turns beats into seconds. Here that is `sequencer.NoteEvent` (pitch, start
|
||||
beat, length in beats) and `sequencer.beat_seconds`. No demo; the
|
||||
sequencer is what every later demo renders through.
|
||||
|
||||
## 11. The complete composition
|
||||
|
||||
Pick a key and a progression, play the root as bass, the chord tones as an
|
||||
arpeggio, and a melody drawn from the scale. Constraining every voice to
|
||||
the scale is what makes any melody fit. `compose.Recipe` +
|
||||
`compose.compose`, seeded so the same recipe always renders the same bytes.
|
||||
Demo `piece`.
|
||||
|
||||
## Beyond the article: degrade
|
||||
|
||||
If the piece is data, "the ending is the opening, destroyed" is a transform
|
||||
on that data. `degrade` darkens the mode past 0.5, drags the tempo, thins
|
||||
and detunes the melody, then bit-crushes, hisses and low-passes the render.
|
||||
Order matters: filtering LAST is what makes the result measurably darker
|
||||
(spectral centroid 514 Hz against 2207 Hz clean); filtering first left it
|
||||
brighter than the original. Demo `bracket`.
|
||||
98
music_theory/README.md
Normal file
98
music_theory/README.md
Normal file
@ -0,0 +1,98 @@
|
||||
# music_theory
|
||||
|
||||
Music theory from first principles, as typed Python you can hear. Follows
|
||||
Luke Haas's ["Music Theory for Programmers"](https://runjs.app/blog/music-theory-for-programmers)
|
||||
section by section: a sound is a number changing over time, pitch is
|
||||
multiplicative, the tuning everyone wants cannot exist (3ⁿ ≠ 2ᵐ), so equal
|
||||
temperament trades purity for a system that works in every key. Scales,
|
||||
chords and progressions are then plain integer arrays on top of that, and a
|
||||
small numpy synth turns them into WAV files.
|
||||
|
||||
Section-by-section notes, with what to listen for, live in
|
||||
[`DOCS-article-walkthrough.md`](DOCS-article-walkthrough.md).
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
pip install "music_theory @ git+https://github.com/kuhyx/utils@music-theory-v0.1.0#subdirectory=music_theory"
|
||||
```
|
||||
|
||||
Only dependency: numpy. Playback is left to `aplay` (or anything that opens
|
||||
a 16-bit mono WAV); the package never touches an audio device itself.
|
||||
|
||||
## Hear the article
|
||||
|
||||
```
|
||||
python -m music_theory list # every demo and its section
|
||||
python -m music_theory explain comma # just the numbers
|
||||
python -m music_theory demo all --out /tmp/mt # WAV per section + facts
|
||||
aplay -q /tmp/mt/06_comma.wav
|
||||
```
|
||||
|
||||
| demo | article section | what you hear |
|
||||
|---|---|---|
|
||||
| `tone` | 1 sound | A4, 440 Hz; the FFT peak is printed |
|
||||
| `envelope` | 2 envelopes | same sine with a 10 ms then a 500 ms attack |
|
||||
| `waveforms` | 2 timbre | sine, triangle, square, saw at 220 Hz |
|
||||
| `octaves` | 3 octaves | A1 to A5, each double the last |
|
||||
| `consonance` | 3 consonance | 2:1, 3:2, 4:3, 5:4, 16:15, then √2 |
|
||||
| `comma` | 4 the comma | A4 against A4 × 1.0136, beating 6 times a second |
|
||||
| `temperament` | 5 equal steps | twelve steps of 2^(1/12) |
|
||||
| `divisions` | 5 why twelve | pure fifth vs the 5-, 7-, 12-, 19-, 24-TET fifth |
|
||||
| `scale` | 6 scales | C major, then A minor |
|
||||
| `modes` | 6 modes | seven rotations of the same array |
|
||||
| `chords` | 7 qualities | major, minor, dim, aug, and three sevenths on C |
|
||||
| `diatonic` | 8 keys | I ii iii IV V vi vii° of C |
|
||||
| `transpose` | 8 transposition | I V vi IV in C, then +4 in E |
|
||||
| `resolution` | 9 tension | V7 then I; the tritone collapses |
|
||||
| `progressions` | 9 loops | pop, jazz, axis, fifties |
|
||||
| `piece` | 11 the piece | bass + arpeggio + seeded melody, A minor |
|
||||
| `bracket` | (beyond) | the same piece clean, then degraded |
|
||||
|
||||
## Render a recipe
|
||||
|
||||
A composition is data. `render` turns a recipe JSON into a loop-length WAV
|
||||
and records its provenance (recipe, package version, SHA-256) in a manifest:
|
||||
|
||||
```json
|
||||
{"key": "A3", "mode": "major", "tempo_bpm": 112, "progression": [1, 6, 3, 7],
|
||||
"bars": 16, "seed": 20260911, "waveform": "saw", "degrade": 0.0}
|
||||
```
|
||||
|
||||
```
|
||||
python -m music_theory render --recipe city.json --out city.wav --manifest MANIFEST.json
|
||||
python -m music_theory render --recipe city.json --out ruin.wav --degrade 1.0 --manifest MANIFEST.json
|
||||
```
|
||||
|
||||
`degrade` (0..1) is the same piece, ruined: past 0.5 the mode flips to its
|
||||
darker relative, the tempo drags, melody notes drop out and drift off pitch,
|
||||
and the signal is bit-crushed, hissed and low-passed. `degrade: 0` is byte-
|
||||
identical to the original, so "the ending is the opening, destroyed" is a
|
||||
one-flag transform on the same seed.
|
||||
|
||||
## Modules
|
||||
|
||||
| module | article | contents |
|
||||
|---|---|---|
|
||||
| `pitch` | 1, 5 | MIDI ↔ Hz (440·2^((n−69)/12)), cents, note names |
|
||||
| `tuning` | 3, 4, 5 | harmonic series, just ratios, the comma, `best_divisions` |
|
||||
| `scales` | 6 | step patterns, modes as rotations, `pitch_at_degree` |
|
||||
| `chords` | 7, 9 | qualities, stacked thirds, Roman numerals, V7 → I |
|
||||
| `progressions` | 8, 9 | named loops, degree → chord, transpose |
|
||||
| `synth` | 1, 2 | waveforms, ADSR, low-pass, mix, normalise |
|
||||
| `sequencer` | 10 | beat grid, `NoteEvent`, bar-exact render |
|
||||
| `compose` | 11 | `Recipe` → bass, arpeggio, melody |
|
||||
| `degrade` | — | the ruin transform |
|
||||
| `analysis` | — | FFT peak and spectral centroid, so claims are testable |
|
||||
| `wav`, `manifest` | — | 16-bit mono PCM out, SHA-256 provenance |
|
||||
|
||||
## Develop
|
||||
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
python -m pytest # 100 % branch coverage is enforced
|
||||
pre-commit run --config music_theory/.pre-commit-config.yaml --all-files # from the utils root
|
||||
```
|
||||
|
||||
Consumed by `~/roadside-assistance` (`tools/render_music_beds.sh`) for its
|
||||
three world-state music beds.
|
||||
17
music_theory/music_theory/__init__.py
Normal file
17
music_theory/music_theory/__init__.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Music theory from first principles, as code.
|
||||
|
||||
Follows Luke Haas's "Music Theory for Programmers"
|
||||
(https://runjs.app/blog/music-theory-for-programmers) section by section:
|
||||
a sound is a number changing over time, pitch is multiplicative, the tuning
|
||||
everyone wants cannot exist, so equal temperament trades purity for a
|
||||
system that works in every key. Scales, chords and progressions are then
|
||||
plain arrays of integers on top of that.
|
||||
|
||||
Every module is pure and typed; ``synth``/``sequencer``/``compose`` turn
|
||||
the integers into audio with numpy so each idea can be heard, not just read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.1.0"
|
||||
11
music_theory/music_theory/__main__.py
Normal file
11
music_theory/music_theory/__main__.py
Normal file
@ -0,0 +1,11 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""``python -m music_theory`` entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from music_theory.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
41
music_theory/music_theory/analysis.py
Normal file
41
music_theory/music_theory/analysis.py
Normal file
@ -0,0 +1,41 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Analysis: measure a rendered signal so a claim about it can be checked.
|
||||
|
||||
Not in the article, but every demo here makes a claim ("this is 440 Hz",
|
||||
"this is darker") and a claim about audio that only a listener can verify
|
||||
is one the test suite cannot hold. These two measurements let it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from music_theory.synth import SAMPLE_RATE, Signal
|
||||
|
||||
|
||||
def dominant_frequency(signal: Signal, sample_rate: int = SAMPLE_RATE) -> float:
|
||||
"""Frequency of the strongest FFT bin, in Hz (0.0 for silence)."""
|
||||
if not signal.size:
|
||||
return 0.0
|
||||
spectrum = np.abs(np.fft.rfft(signal))
|
||||
if not np.any(spectrum > 0.0):
|
||||
return 0.0
|
||||
bins = np.fft.rfftfreq(len(signal), d=1.0 / sample_rate)
|
||||
return float(bins[int(np.argmax(spectrum))])
|
||||
|
||||
|
||||
def spectral_centroid(signal: Signal, sample_rate: int = SAMPLE_RATE) -> float:
|
||||
"""Amplitude-weighted mean frequency: lower means darker."""
|
||||
if not signal.size:
|
||||
return 0.0
|
||||
spectrum = np.abs(np.fft.rfft(signal))
|
||||
total = float(np.sum(spectrum))
|
||||
if not total:
|
||||
return 0.0
|
||||
bins = np.fft.rfftfreq(len(signal), d=1.0 / sample_rate)
|
||||
return float(np.sum(bins * spectrum) / total)
|
||||
|
||||
|
||||
def peak(signal: Signal) -> float:
|
||||
"""Loudest absolute sample value."""
|
||||
return float(np.max(np.abs(signal))) if len(signal) else 0.0
|
||||
139
music_theory/music_theory/chords.py
Normal file
139
music_theory/music_theory/chords.py
Normal file
@ -0,0 +1,139 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Chords: scale notes stacked in thirds, and why V wants to become I.
|
||||
|
||||
Article sections 7 and 9. A chord is every other note of the scale from a
|
||||
starting degree, which stacks harmonics that already share overtones. The
|
||||
whole difference between a major and a minor triad is one array element
|
||||
([0, 4, 7] against [0, 3, 7]). The dominant seventh holds both the leading
|
||||
tone and a tritone, and both resolve by a single semitone into the tonic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from music_theory import scales
|
||||
from music_theory.pitch import SEMITONES_PER_OCTAVE
|
||||
|
||||
Intervals = tuple[int, ...]
|
||||
|
||||
## Semitone offsets of each chord quality from its root.
|
||||
QUALITIES: dict[str, Intervals] = {
|
||||
"major": (0, 4, 7),
|
||||
"minor": (0, 3, 7),
|
||||
"diminished": (0, 3, 6),
|
||||
"augmented": (0, 4, 8),
|
||||
"major7": (0, 4, 7, 11),
|
||||
"minor7": (0, 3, 7, 10),
|
||||
"dominant7": (0, 4, 7, 10),
|
||||
"half-diminished7": (0, 3, 6, 10),
|
||||
}
|
||||
|
||||
## Roman numerals per scale degree; case and the ° come from the quality.
|
||||
_NUMERALS: tuple[str, ...] = ("I", "II", "III", "IV", "V", "VI", "VII")
|
||||
|
||||
## Interval sizes the ear cares about here.
|
||||
TRITONE = 6
|
||||
SEMITONE = 1
|
||||
_FIFTH_DEGREE = 4
|
||||
_TRIAD_SIZE = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiatonicChord:
|
||||
"""One chord built on a scale degree, with its Roman numeral address."""
|
||||
|
||||
degree: int
|
||||
numeral: str
|
||||
quality: str
|
||||
notes: tuple[int, ...]
|
||||
|
||||
|
||||
def chord(root_midi: int, quality: str) -> list[int]:
|
||||
"""MIDI notes of a chord of the given quality on a root."""
|
||||
if quality not in QUALITIES:
|
||||
msg = f"unknown chord quality {quality!r}"
|
||||
raise ValueError(msg)
|
||||
return [root_midi + offset for offset in QUALITIES[quality]]
|
||||
|
||||
|
||||
def quality_of(notes: list[int]) -> str | None:
|
||||
"""Name of the quality whose intervals match these notes, if any."""
|
||||
if not notes:
|
||||
return None
|
||||
intervals = tuple(note - notes[0] for note in notes)
|
||||
for name, shape in QUALITIES.items():
|
||||
if shape == intervals:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def stacked_thirds(
|
||||
root_midi: int, pattern: scales.StepPattern, degree: int, size: int = _TRIAD_SIZE
|
||||
) -> list[int]:
|
||||
"""Every other scale note from ``degree``: the chord on that degree."""
|
||||
return [
|
||||
scales.pitch_at_degree(root_midi, pattern, degree + 2 * i) for i in range(size)
|
||||
]
|
||||
|
||||
|
||||
def roman(degree: int, quality: str) -> str:
|
||||
"""Roman numeral for a 0-based degree: upper case major, lower minor, ° dim."""
|
||||
numeral = _NUMERALS[degree % len(_NUMERALS)]
|
||||
if quality in {"minor", "minor7"}:
|
||||
return numeral.lower()
|
||||
if quality in {"diminished", "half-diminished7"}:
|
||||
return numeral.lower() + "°"
|
||||
if quality == "augmented":
|
||||
return numeral + "+"
|
||||
return numeral
|
||||
|
||||
|
||||
def diatonic_chords(
|
||||
root_midi: int, pattern: scales.StepPattern, size: int = _TRIAD_SIZE
|
||||
) -> list[DiatonicChord]:
|
||||
"""The chord on every degree of the scale (C major: C Dm Em F G Am B°)."""
|
||||
result = []
|
||||
for degree in range(len(pattern)):
|
||||
notes = stacked_thirds(root_midi, pattern, degree, size)
|
||||
quality = quality_of(notes) or "other"
|
||||
result.append(
|
||||
DiatonicChord(degree, roman(degree, quality), quality, tuple(notes))
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def dominant_seventh(key_root: int, pattern: scales.StepPattern) -> list[int]:
|
||||
"""The V7 chord of a key: the fifth degree with a minor seventh on top."""
|
||||
fifth = scales.pitch_at_degree(key_root, pattern, _FIFTH_DEGREE)
|
||||
return chord(fifth, "dominant7")
|
||||
|
||||
|
||||
def tritone_pair(notes: list[int]) -> tuple[int, int] | None:
|
||||
"""The two notes six semitones apart, if the chord contains a tritone."""
|
||||
for i, low in enumerate(notes):
|
||||
for high in notes[i + 1 :]:
|
||||
if (high - low) % SEMITONES_PER_OCTAVE == TRITONE:
|
||||
return (low, high)
|
||||
return None
|
||||
|
||||
|
||||
def resolution(key_root: int, pattern: scales.StepPattern) -> list[tuple[int, int]]:
|
||||
"""How each tense note of V7 moves into I, as (from, to) pairs.
|
||||
|
||||
In C: B (the leading tone) rises to C and F (the seventh) falls to E.
|
||||
Together they are the tritone collapsing by contrary semitones into a
|
||||
stable major third -- the strongest pull in Western harmony.
|
||||
"""
|
||||
seventh = dominant_seventh(key_root, pattern)
|
||||
tonic = stacked_thirds(key_root, pattern, 0)
|
||||
# The tonic in every nearby octave, so B4 finds C5 and F5 finds E5.
|
||||
targets = [
|
||||
t + o for t in tonic for o in (-SEMITONES_PER_OCTAVE, 0, SEMITONES_PER_OCTAVE)
|
||||
]
|
||||
moves = []
|
||||
for note in seventh:
|
||||
close = [t for t in targets if abs(t - note) == SEMITONE]
|
||||
if close:
|
||||
moves.append((note, close[0]))
|
||||
return moves
|
||||
107
music_theory/music_theory/cli.py
Normal file
107
music_theory/music_theory/cli.py
Normal file
@ -0,0 +1,107 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Command line: hear a section of the article, or render a recipe to a file.
|
||||
|
||||
Subcommands: ``list``, ``explain <demo|all>``, ``demo <demo|all> --out DIR``
|
||||
and ``render --recipe recipe.json --out bed.wav [--manifest m.json]``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from music_theory import compose, demos, manifest, wav
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from music_theory.demo_support import Demo
|
||||
|
||||
|
||||
def _emit(line: str) -> None:
|
||||
sys.stdout.write(line + "\n")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""The argument parser, separate from ``main`` so it can be tested."""
|
||||
parser = argparse.ArgumentParser(prog="music_theory", description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
sub.add_parser("list", help="names of every demo")
|
||||
explain = sub.add_parser("explain", help="print a demo's facts without rendering")
|
||||
explain.add_argument("name", help="demo name or 'all'")
|
||||
demo = sub.add_parser("demo", help="render a demo to WAV and print its facts")
|
||||
demo.add_argument("name", help="demo name or 'all'")
|
||||
demo.add_argument("--out", type=Path, required=True, help="directory for the WAVs")
|
||||
render = sub.add_parser("render", help="render a recipe JSON to a WAV")
|
||||
render.add_argument("--recipe", type=Path, required=True, help="recipe JSON file")
|
||||
render.add_argument("--out", type=Path, required=True, help="WAV to write")
|
||||
render.add_argument("--manifest", type=Path, help="manifest JSON to update")
|
||||
render.add_argument(
|
||||
"--degrade", type=float, help="override the recipe's degrade amount"
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def cmd_list() -> int:
|
||||
"""Print every demo name with its article section."""
|
||||
for name, builder in demos.REGISTRY.items():
|
||||
_emit(f"{name:12s} {builder.__doc__ or ''}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_explain(name: str) -> int:
|
||||
"""Print the facts of one or all demos."""
|
||||
for demo_name in demos.selected(name):
|
||||
_print_demo(demos.run(demo_name))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_demo(name: str, out_dir: Path) -> int:
|
||||
"""Render one or all demos into ``out_dir`` and print how to hear them."""
|
||||
for index, demo_name in enumerate(demos.selected(name)):
|
||||
demo = demos.run(demo_name)
|
||||
path = wav.write(out_dir / f"{index + 1:02d}_{demo_name}.wav", demo.signal)
|
||||
_print_demo(demo)
|
||||
_emit(f" hear it: {wav.play_command(path)}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_render(
|
||||
recipe_path: Path, out: Path, manifest_path: Path | None, degrade: float | None
|
||||
) -> int:
|
||||
"""Render a recipe file to a WAV and record it in the manifest."""
|
||||
data = json.loads(recipe_path.read_text())
|
||||
if not isinstance(data, dict):
|
||||
_emit(f"error: {recipe_path} does not hold a recipe object")
|
||||
return 1
|
||||
recipe = compose.Recipe.from_dict(data)
|
||||
if degrade is not None:
|
||||
recipe = compose.with_degrade(recipe, degrade)
|
||||
signal = compose.compose(recipe)
|
||||
wav.write(out, signal, recipe.sample_rate)
|
||||
record = manifest.entry(recipe, out, signal)
|
||||
if manifest_path is not None:
|
||||
entries = manifest.read(manifest_path) if manifest_path.exists() else {}
|
||||
entries[out.name] = record
|
||||
manifest.write(manifest_path, entries)
|
||||
_emit(f"{out}: {record['seconds']:.2f} s, sha256 {record['sha256']}")
|
||||
return 0
|
||||
|
||||
|
||||
def _print_demo(demo: Demo) -> None:
|
||||
_emit(f"[{demo.section}] {demo.name}")
|
||||
for fact in demo.facts:
|
||||
_emit(f" {fact}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Entry point; returns the process exit code."""
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.command == "list":
|
||||
return cmd_list()
|
||||
if args.command == "explain":
|
||||
return cmd_explain(args.name)
|
||||
if args.command == "demo":
|
||||
return cmd_demo(args.name, args.out)
|
||||
return cmd_render(args.recipe, args.out, args.manifest, args.degrade)
|
||||
233
music_theory/music_theory/compose.py
Normal file
233
music_theory/music_theory/compose.py
Normal file
@ -0,0 +1,233 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Compose: the article's closing example, generalised into a recipe.
|
||||
|
||||
Article section 11: pick a key, pick a progression, play a bass note, an
|
||||
arpeggio and a melody drawn from the scale. Constraining every voice to
|
||||
the scale is what makes any melody "fit". Everything is derived from one
|
||||
frozen ``Recipe`` plus a seed, so the same recipe always renders the same
|
||||
bytes -- the property a game asset manifest needs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, fields, replace
|
||||
|
||||
import numpy as np
|
||||
|
||||
from music_theory import degrade, pitch, progressions, scales, sequencer, synth
|
||||
from music_theory.pitch import SEMITONES_PER_OCTAVE
|
||||
|
||||
## Voice levels, chosen so the bass carries and the melody sits on top.
|
||||
BASS_VELOCITY = 0.9
|
||||
ARPEGGIO_VELOCITY = 0.35
|
||||
MELODY_VELOCITY = 0.5
|
||||
|
||||
## Eighth-note arpeggio: two chord tones per beat.
|
||||
ARPEGGIO_NOTES_PER_BEAT = 2
|
||||
_ARPEGGIO_ORDER = (0, 1, 2, 1)
|
||||
|
||||
## The melody wanders inside two octaves above the root, one step at a time.
|
||||
MELODY_RANGE_DEGREES = 14
|
||||
_MELODY_STEPS = (-2, -1, -1, 0, 1, 1, 2)
|
||||
|
||||
BASS_ENVELOPE = synth.Envelope(0.02, 0.1, 0.7, 0.2)
|
||||
ARPEGGIO_ENVELOPE = synth.Envelope(0.005, 0.05, 0.5, 0.05)
|
||||
MELODY_ENVELOPE = synth.Envelope(0.02, 0.08, 0.6, 0.1)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Recipe:
|
||||
"""Everything that decides what a piece sounds like."""
|
||||
|
||||
key: str = "A3"
|
||||
mode: str = "minor"
|
||||
tempo_bpm: float = 100.0
|
||||
progression: tuple[int, ...] = (1, 6, 3, 7)
|
||||
bars: int = 8
|
||||
seed: int = 0
|
||||
waveform: synth.Waveform = "saw"
|
||||
degrade: float = 0.0
|
||||
sample_rate: int = synth.SAMPLE_RATE
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
"""JSON-ready form (tuples become lists)."""
|
||||
data = asdict(self)
|
||||
data["progression"] = list(self.progression)
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, object]) -> Recipe:
|
||||
"""Inverse of ``to_dict``; unknown keys and wrong types are errors."""
|
||||
unknown = set(data) - {field.name for field in fields(cls)}
|
||||
if unknown:
|
||||
msg = f"unknown recipe keys: {sorted(unknown)}"
|
||||
raise ValueError(msg)
|
||||
base = cls()
|
||||
return cls(
|
||||
key=_as_str(data.get("key", base.key)),
|
||||
mode=_as_str(data.get("mode", base.mode)),
|
||||
tempo_bpm=_as_float(data.get("tempo_bpm", base.tempo_bpm)),
|
||||
progression=_as_ints(data.get("progression", base.progression)),
|
||||
bars=_as_int(data.get("bars", base.bars)),
|
||||
seed=_as_int(data.get("seed", base.seed)),
|
||||
waveform=_as_waveform(data.get("waveform", base.waveform)),
|
||||
degrade=_as_float(data.get("degrade", base.degrade)),
|
||||
sample_rate=_as_int(data.get("sample_rate", base.sample_rate)),
|
||||
)
|
||||
|
||||
|
||||
def _as_str(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
msg = f"expected a string, got {value!r}"
|
||||
raise TypeError(msg)
|
||||
return value
|
||||
|
||||
|
||||
def _as_float(value: object) -> float:
|
||||
# bool is an int subclass; a recipe with tempo `true` is a typo, not 1 bpm.
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
msg = f"expected a number, got {value!r}"
|
||||
raise TypeError(msg)
|
||||
return float(value)
|
||||
|
||||
|
||||
def _as_int(value: object) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
msg = f"expected an integer, got {value!r}"
|
||||
raise TypeError(msg)
|
||||
return value
|
||||
|
||||
|
||||
def _as_ints(value: object) -> tuple[int, ...]:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
msg = f"expected a list of integers, got {value!r}"
|
||||
raise TypeError(msg)
|
||||
return tuple(_as_int(item) for item in value)
|
||||
|
||||
|
||||
def _as_waveform(value: object) -> synth.Waveform:
|
||||
name = _as_str(value)
|
||||
for waveform in synth.WAVEFORMS:
|
||||
if waveform == name:
|
||||
return waveform
|
||||
msg = f"unknown waveform {name!r}; choose one of {synth.WAVEFORMS}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def effective(recipe: Recipe) -> Recipe:
|
||||
"""The recipe after its own ``degrade`` amount has darkened it."""
|
||||
return degrade.apply_to_recipe(recipe)
|
||||
|
||||
|
||||
def _chords(recipe: Recipe) -> list[progressions.ProgressionChord]:
|
||||
root = pitch.parse_note(recipe.key)
|
||||
pattern = scales.pattern_named(recipe.mode)
|
||||
return progressions.chords_for(root, pattern, recipe.progression)
|
||||
|
||||
|
||||
def bass_line(recipe: Recipe) -> list[sequencer.NoteEvent]:
|
||||
"""One whole-bar root note per bar, an octave below the chord."""
|
||||
chords = _chords(recipe)
|
||||
beats = sequencer.BEATS_PER_BAR
|
||||
return [
|
||||
sequencer.NoteEvent(
|
||||
chords[bar_index % len(chords)].notes[0] - SEMITONES_PER_OCTAVE,
|
||||
bar_index * beats,
|
||||
beats,
|
||||
BASS_VELOCITY,
|
||||
"triangle",
|
||||
BASS_ENVELOPE,
|
||||
)
|
||||
for bar_index in range(recipe.bars)
|
||||
]
|
||||
|
||||
|
||||
def arpeggio(recipe: Recipe) -> list[sequencer.NoteEvent]:
|
||||
"""Chord tones cycled in eighth notes, low-high-low, every bar."""
|
||||
chords = _chords(recipe)
|
||||
per_bar = sequencer.BEATS_PER_BAR * ARPEGGIO_NOTES_PER_BEAT
|
||||
length = 1.0 / ARPEGGIO_NOTES_PER_BEAT
|
||||
result = []
|
||||
for bar_index in range(recipe.bars):
|
||||
notes = chords[bar_index % len(chords)].notes
|
||||
for slot in range(per_bar):
|
||||
midi = notes[_ARPEGGIO_ORDER[slot % len(_ARPEGGIO_ORDER)] % len(notes)]
|
||||
start = bar_index * sequencer.BEATS_PER_BAR + slot * length
|
||||
result.append(
|
||||
sequencer.NoteEvent(
|
||||
midi,
|
||||
start,
|
||||
length,
|
||||
ARPEGGIO_VELOCITY,
|
||||
recipe.waveform,
|
||||
ARPEGGIO_ENVELOPE,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def melody(recipe: Recipe) -> list[sequencer.NoteEvent]:
|
||||
"""A seeded random walk over scale degrees, one quarter note per beat.
|
||||
|
||||
Beat one of every bar snaps to a chord tone so the line agrees with the
|
||||
harmony under it; the other beats just stay in the scale, which is
|
||||
enough for them to sound intentional.
|
||||
"""
|
||||
rng = np.random.default_rng(recipe.seed)
|
||||
root = pitch.parse_note(recipe.key) + SEMITONES_PER_OCTAVE
|
||||
pattern = scales.pattern_named(recipe.mode)
|
||||
chords = _chords(recipe)
|
||||
rest_chance = degrade.rest_probability(recipe.degrade)
|
||||
degree = len(pattern)
|
||||
result = []
|
||||
for beat in range(recipe.bars * sequencer.BEATS_PER_BAR):
|
||||
bar_index, position = divmod(beat, sequencer.BEATS_PER_BAR)
|
||||
step = int(rng.choice(_MELODY_STEPS))
|
||||
degree = int(np.clip(degree + step, 0, MELODY_RANGE_DEGREES))
|
||||
midi = scales.pitch_at_degree(root, pattern, degree)
|
||||
if not position:
|
||||
midi = _nearest_chord_tone(midi, chords[bar_index % len(chords)].notes)
|
||||
if rng.random() < rest_chance:
|
||||
continue
|
||||
cents = degrade.detune_cents(recipe.degrade, rng)
|
||||
result.append(
|
||||
sequencer.NoteEvent(
|
||||
midi, beat, 1.0, MELODY_VELOCITY, "triangle", MELODY_ENVELOPE, cents
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _nearest_chord_tone(midi: int, chord: tuple[int, ...]) -> int:
|
||||
candidates = [
|
||||
n + o for n in chord for o in (-SEMITONES_PER_OCTAVE, 0, SEMITONES_PER_OCTAVE)
|
||||
]
|
||||
return min(candidates, key=lambda n: (abs(n - midi), n))
|
||||
|
||||
|
||||
def events(recipe: Recipe) -> list[sequencer.NoteEvent]:
|
||||
"""Every note of the piece, from the degrade-adjusted recipe."""
|
||||
adjusted = effective(recipe)
|
||||
return bass_line(adjusted) + arpeggio(adjusted) + melody(adjusted)
|
||||
|
||||
|
||||
def length_beats(recipe: Recipe) -> float:
|
||||
"""Total length in beats: bars times four."""
|
||||
return float(recipe.bars * sequencer.BEATS_PER_BAR)
|
||||
|
||||
|
||||
def compose(recipe: Recipe) -> synth.Signal:
|
||||
"""Render the piece to a loop-length signal, degraded and normalised."""
|
||||
adjusted = effective(recipe)
|
||||
signal = sequencer.render(
|
||||
events(recipe), adjusted.tempo_bpm, length_beats(adjusted), adjusted.sample_rate
|
||||
)
|
||||
signal = degrade.process(
|
||||
signal, adjusted.degrade, adjusted.sample_rate, adjusted.seed
|
||||
)
|
||||
return synth.normalize(signal)
|
||||
|
||||
|
||||
def with_degrade(recipe: Recipe, amount: float) -> Recipe:
|
||||
"""The same recipe at another degrade amount: the compositional bracket."""
|
||||
return replace(recipe, degrade=amount)
|
||||
112
music_theory/music_theory/degrade.py
Normal file
112
music_theory/music_theory/degrade.py
Normal file
@ -0,0 +1,112 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Degrade: the same piece, ruined by a controllable amount.
|
||||
|
||||
Not in the article, but built from its parts. If a composition is data,
|
||||
"the ending is the opening, destroyed" is a transform on that data: the
|
||||
mode darkens, the tempo drags, the melody thins out and drifts off pitch,
|
||||
and the signal itself loses its top end and its resolution. Amount 0 is
|
||||
the original, byte for byte; amount 1 is as far as this goes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
from music_theory import synth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from music_theory.compose import Recipe
|
||||
|
||||
## Above this much degradation the mode flips to its darker relative.
|
||||
MODE_THRESHOLD = 0.5
|
||||
|
||||
## Full degradation slows the tempo by this fraction.
|
||||
TEMPO_DROP = 0.35
|
||||
|
||||
## Full degradation drops this fraction of melody notes.
|
||||
MAX_REST_PROBABILITY = 0.5
|
||||
|
||||
## Full degradation lets each melody note drift up to this many cents.
|
||||
MAX_DETUNE_CENTS = 30.0
|
||||
|
||||
## Low-pass cutoff sweeps from bright to muffled.
|
||||
BRIGHT_CUTOFF_HZ = 9000.0
|
||||
DARK_CUTOFF_HZ = 700.0
|
||||
|
||||
## Bit depth sweeps from clean 16-bit to a crunchy 8-bit.
|
||||
CLEAN_BITS = 16.0
|
||||
CRUSHED_BITS = 8.0
|
||||
|
||||
## Noise floor at full degradation, relative to full scale.
|
||||
MAX_NOISE = 0.02
|
||||
|
||||
## Every bright mode's darker relative.
|
||||
DARKER_MODE: dict[str, str] = {
|
||||
"major": "minor",
|
||||
"ionian": "aeolian",
|
||||
"lydian": "dorian",
|
||||
"mixolydian": "aeolian",
|
||||
"dorian": "phrygian",
|
||||
"major pentatonic": "minor pentatonic",
|
||||
"minor": "harmonic minor",
|
||||
"aeolian": "harmonic minor",
|
||||
}
|
||||
|
||||
|
||||
def clamp(amount: float) -> float:
|
||||
"""Degrade amounts live in 0..1."""
|
||||
return min(max(amount, 0.0), 1.0)
|
||||
|
||||
|
||||
def darker_mode(mode: str) -> str:
|
||||
"""The mode one shade darker, or the same mode if there is none."""
|
||||
return DARKER_MODE.get(mode.lower(), mode)
|
||||
|
||||
|
||||
def apply_to_recipe(recipe: Recipe) -> Recipe:
|
||||
"""Darken the mode past the threshold and drag the tempo proportionally."""
|
||||
amount = clamp(recipe.degrade)
|
||||
mode = darker_mode(recipe.mode) if amount >= MODE_THRESHOLD else recipe.mode
|
||||
tempo = recipe.tempo_bpm * (1.0 - TEMPO_DROP * amount)
|
||||
return replace(recipe, mode=mode, tempo_bpm=tempo, degrade=amount)
|
||||
|
||||
|
||||
def rest_probability(amount: float) -> float:
|
||||
"""Chance a melody note is dropped."""
|
||||
return MAX_REST_PROBABILITY * clamp(amount)
|
||||
|
||||
|
||||
def detune_cents(amount: float, rng: np.random.Generator) -> float:
|
||||
"""A per-note pitch drift drawn from the seeded generator."""
|
||||
if amount <= 0.0:
|
||||
return 0.0
|
||||
return float(rng.uniform(-MAX_DETUNE_CENTS, MAX_DETUNE_CENTS) * clamp(amount))
|
||||
|
||||
|
||||
def bitcrush(signal: synth.Signal, bits: float) -> synth.Signal:
|
||||
"""Quantise to 2**bits levels."""
|
||||
levels = 2.0**bits
|
||||
return np.asarray(np.round(signal * levels) / levels, dtype=np.float64)
|
||||
|
||||
|
||||
def process(
|
||||
signal: synth.Signal, amount: float, sample_rate: int, seed: int
|
||||
) -> synth.Signal:
|
||||
"""Low-pass, bit-crush and add noise in proportion to ``amount``."""
|
||||
amount = clamp(amount)
|
||||
if not amount:
|
||||
return signal.copy()
|
||||
cutoff = BRIGHT_CUTOFF_HZ + (DARK_CUTOFF_HZ - BRIGHT_CUTOFF_HZ) * amount
|
||||
bits = CLEAN_BITS + (CRUSHED_BITS - CLEAN_BITS) * amount
|
||||
rng = np.random.default_rng(seed)
|
||||
out = bitcrush(signal, bits)
|
||||
out = out + rng.standard_normal(len(out)) * MAX_NOISE * amount
|
||||
# FILTER LAST, TWICE. Crushing and noise both add broadband hiss; if the
|
||||
# low-pass ran first the result measured BRIGHTER than the original
|
||||
# (spectral centroid 2624 Hz against 2207 Hz on the first render). Two
|
||||
# one-pole passes give a real 12 dB/octave muffle.
|
||||
out = synth.lowpass(out, cutoff, sample_rate)
|
||||
return synth.lowpass(out, cutoff, sample_rate)
|
||||
71
music_theory/music_theory/demo_support.py
Normal file
71
music_theory/music_theory/demo_support.py
Normal file
@ -0,0 +1,71 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Shared plumbing for the demos: a result type and a few signal builders."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from music_theory import pitch, sequencer, synth
|
||||
|
||||
## Demo notes get a short envelope so consecutive tones never click.
|
||||
NOTE_ENVELOPE = synth.Envelope(0.01, 0.02, 0.9, 0.05)
|
||||
|
||||
## Silence between tones so the ear resets.
|
||||
GAP_SECONDS = 0.08
|
||||
|
||||
## Tempo for the note sequences: quick enough to hear as a line.
|
||||
DEMO_TEMPO = 150.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Demo:
|
||||
"""A rendered demo: what it demonstrates, the facts, and the audio."""
|
||||
|
||||
name: str
|
||||
section: str
|
||||
facts: tuple[str, ...]
|
||||
signal: synth.Signal
|
||||
|
||||
|
||||
def tone(hz: float, seconds: float, waveform: synth.Waveform = "sine") -> synth.Signal:
|
||||
"""One enveloped tone at a frequency."""
|
||||
return synth.oscillator(hz, seconds, waveform) * synth.adsr(NOTE_ENVELOPE, seconds)
|
||||
|
||||
|
||||
def tones(
|
||||
frequencies: list[float], seconds: float, waveform: synth.Waveform = "sine"
|
||||
) -> synth.Signal:
|
||||
"""Tones one after another with a short gap between them."""
|
||||
gap = np.zeros(int(GAP_SECONDS * synth.SAMPLE_RATE))
|
||||
parts = [tone(hz, seconds, waveform) for hz in frequencies]
|
||||
return np.concatenate([np.concatenate((part, gap)) for part in parts])
|
||||
|
||||
|
||||
def pair(hz_low: float, hz_high: float, seconds: float) -> synth.Signal:
|
||||
"""Two tones sounded together, at equal level."""
|
||||
return synth.normalize(
|
||||
tone(hz_low, seconds) + tone(hz_high, seconds), synth.DEFAULT_PEAK
|
||||
)
|
||||
|
||||
|
||||
def note_line(midis: list[int], beats_each: float = 0.5) -> synth.Signal:
|
||||
"""A melodic line of MIDI notes, rendered through the sequencer."""
|
||||
events = sequencer.sequence(midis, beats_each, "triangle", NOTE_ENVELOPE)
|
||||
return sequencer.render(events, DEMO_TEMPO, beats_each * len(midis))
|
||||
|
||||
|
||||
def chord_line(chords: list[list[int]], beats_each: float = 2.0) -> synth.Signal:
|
||||
"""Chords one after another, each held for ``beats_each``."""
|
||||
events: list[sequencer.NoteEvent] = []
|
||||
for index, notes in enumerate(chords):
|
||||
events += sequencer.stack(
|
||||
notes, index * beats_each, beats_each, "triangle", NOTE_ENVELOPE
|
||||
)
|
||||
return sequencer.render(events, DEMO_TEMPO, beats_each * len(chords))
|
||||
|
||||
|
||||
def names(midis: list[int]) -> str:
|
||||
"""Note names of a list of MIDI numbers, space separated."""
|
||||
return " ".join(pitch.note_name(m) for m in midis)
|
||||
55
music_theory/music_theory/demos.py
Normal file
55
music_theory/music_theory/demos.py
Normal file
@ -0,0 +1,55 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The demo registry: one entry per article section, in reading order."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from music_theory import demos_a, demos_b, synth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from music_theory.demo_support import Demo
|
||||
|
||||
REGISTRY: dict[str, Callable[[], Demo]] = {
|
||||
"tone": demos_a.tone_demo,
|
||||
"envelope": demos_a.envelope_demo,
|
||||
"waveforms": demos_a.waveforms_demo,
|
||||
"octaves": demos_a.octaves_demo,
|
||||
"consonance": demos_a.consonance_demo,
|
||||
"comma": demos_a.comma_demo,
|
||||
"temperament": demos_a.temperament_demo,
|
||||
"divisions": demos_a.divisions_demo,
|
||||
"scale": demos_b.scale_demo,
|
||||
"modes": demos_b.modes_demo,
|
||||
"chords": demos_b.chords_demo,
|
||||
"diatonic": demos_b.diatonic_demo,
|
||||
"transpose": demos_b.transpose_demo,
|
||||
"resolution": demos_b.resolution_demo,
|
||||
"progressions": demos_b.progressions_demo,
|
||||
"piece": demos_b.piece_demo,
|
||||
"bracket": demos_b.bracket_demo,
|
||||
}
|
||||
|
||||
|
||||
def run(name: str) -> Demo:
|
||||
"""Build one demo by name."""
|
||||
if name not in REGISTRY:
|
||||
msg = f"unknown demo {name!r}; choose one of {tuple(REGISTRY)}"
|
||||
raise ValueError(msg)
|
||||
demo = REGISTRY[name]()
|
||||
# Every demo leaves the same headroom, so switching between files is not
|
||||
# a volume jump and no render sits exactly on full scale.
|
||||
return replace(demo, signal=synth.normalize(demo.signal))
|
||||
|
||||
|
||||
def selected(name: str) -> list[str]:
|
||||
"""Names to run for a CLI argument: one name, or "all"."""
|
||||
if name == "all":
|
||||
return list(REGISTRY)
|
||||
run_check = run # validates the name eagerly for a clear error
|
||||
if name not in REGISTRY:
|
||||
run_check(name)
|
||||
return [name]
|
||||
173
music_theory/music_theory/demos_a.py
Normal file
173
music_theory/music_theory/demos_a.py
Normal file
@ -0,0 +1,173 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Demos for article sections 1-5: sound, timbre, octaves, the comma, temperament."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from music_theory import analysis, pitch, synth, tuning
|
||||
from music_theory.demo_support import Demo, pair, tone, tones
|
||||
|
||||
_A4 = pitch.A4_HZ
|
||||
_A3 = 220.0
|
||||
_LONG = 1.0
|
||||
_SHORT = 0.5
|
||||
_OCTAVES = (55.0, 110.0, 220.0, 440.0, 880.0)
|
||||
_MAX_DIVISIONS = 25
|
||||
_SHOWN_DIVISIONS = (5, 7, 12, 19, 24)
|
||||
_STRUCK = synth.Envelope(0.01, 0.1, 0.6, 0.2)
|
||||
_BOWED = synth.Envelope(0.5, 0.1, 0.8, 0.2)
|
||||
|
||||
|
||||
def tone_demo() -> Demo:
|
||||
"""Section 1: a sound is a number that changes over time."""
|
||||
signal = tone(_A4, _LONG)
|
||||
return Demo(
|
||||
"tone",
|
||||
"1 sound as numerical change",
|
||||
(
|
||||
f"A4 = {_A4:.0f} Hz = MIDI {pitch.A4_MIDI}",
|
||||
f"one semitone up multiplies by {pitch.semitone_ratio(1):.6f}",
|
||||
f"FFT peak of the render: {analysis.dominant_frequency(signal):.1f} Hz",
|
||||
),
|
||||
signal,
|
||||
)
|
||||
|
||||
|
||||
def envelope_demo() -> Demo:
|
||||
"""Section 2: a 10 ms attack sounds struck, a 500 ms attack sounds bowed."""
|
||||
seconds = 1.2
|
||||
struck = synth.oscillator(_A3, seconds) * synth.adsr(_STRUCK, seconds)
|
||||
bowed = synth.oscillator(_A3, seconds) * synth.adsr(_BOWED, seconds)
|
||||
signal = synth.mix(struck, _shift(bowed, seconds + 0.3))
|
||||
return Demo(
|
||||
"envelope",
|
||||
"2 envelopes",
|
||||
(
|
||||
(
|
||||
f"same {_A3:.0f} Hz sine twice: "
|
||||
f"attack {_STRUCK.attack * 1000:.0f} ms then "
|
||||
f"{_BOWED.attack * 1000:.0f} ms"
|
||||
),
|
||||
"the release fades to zero so neither note ends in a click",
|
||||
),
|
||||
signal,
|
||||
)
|
||||
|
||||
|
||||
def waveforms_demo() -> Demo:
|
||||
"""Section 2: four overtone recipes at the same pitch."""
|
||||
parts = [tone(_A3, _SHORT, w) for w in synth.WAVEFORMS]
|
||||
facts = tuple(
|
||||
f"{w}: spectral centroid {analysis.spectral_centroid(p):.0f} Hz"
|
||||
for w, p in zip(synth.WAVEFORMS, parts, strict=True)
|
||||
)
|
||||
gap = np.zeros(int(0.2 * synth.SAMPLE_RATE))
|
||||
signal = np.concatenate([np.concatenate((part, gap)) for part in parts])
|
||||
return Demo(
|
||||
"waveforms",
|
||||
"2 timbre",
|
||||
(*facts, "same pitch, only the overtones differ"),
|
||||
signal,
|
||||
)
|
||||
|
||||
|
||||
def octaves_demo() -> Demo:
|
||||
"""Section 3: doubling the frequency gives the same note."""
|
||||
return Demo(
|
||||
"octaves",
|
||||
"3 octaves",
|
||||
tuple(
|
||||
f"{hz:.0f} Hz = {pitch.note_name(round(pitch.hz_to_midi(hz)))}"
|
||||
for hz in _OCTAVES
|
||||
),
|
||||
tones(list(_OCTAVES), _SHORT),
|
||||
)
|
||||
|
||||
|
||||
def consonance_demo() -> Demo:
|
||||
"""Section 3: simple ratios lock in fast; the tritone never does."""
|
||||
pairs = [
|
||||
("octave", 2.0),
|
||||
("perfect fifth", 1.5),
|
||||
("perfect fourth", 4.0 / 3.0),
|
||||
("major third", 1.25),
|
||||
("minor second", 16.0 / 15.0),
|
||||
("tritone", tuning.TRITONE_RATIO),
|
||||
]
|
||||
facts = []
|
||||
for name, _ in pairs:
|
||||
if name == "tritone":
|
||||
facts.append("tritone (sqrt 2): the pattern never repeats")
|
||||
else:
|
||||
facts.append(
|
||||
f"{name}: repeats after "
|
||||
f"{tuning.repeat_cycles(tuning.JUST_RATIOS[name])} cycles"
|
||||
)
|
||||
signal = synth.mix(
|
||||
*[_shift(pair(_A3, _A3 * r, 0.8), i * 1.0) for i, (_, r) in enumerate(pairs)]
|
||||
)
|
||||
return Demo("consonance", "3 consonance", tuple(facts), signal)
|
||||
|
||||
|
||||
def comma_demo() -> Demo:
|
||||
"""Section 4: twelve fifths overshoot seven octaves, audibly."""
|
||||
comma = tuning.pythagorean_comma()
|
||||
beats = tuning.beat_frequency(_A4, _A4 * comma)
|
||||
seconds = 2.0
|
||||
return Demo(
|
||||
"comma",
|
||||
"4 the Pythagorean comma",
|
||||
(
|
||||
f"(3/2)^12 / 2^7 = {comma:.6f} ({pitch.cents(comma):.2f} cents)",
|
||||
"3^n can never equal 2^m: both are prime, so no n and m close the loop",
|
||||
f"A4 against A4 x comma beats {beats:.1f} times/s",
|
||||
),
|
||||
pair(_A4, _A4 * comma, seconds),
|
||||
)
|
||||
|
||||
|
||||
def temperament_demo() -> Demo:
|
||||
"""Section 5: twelve equal steps, and what the fifth costs."""
|
||||
steps = [
|
||||
_A3 * pitch.semitone_ratio(n) for n in range(pitch.SEMITONES_PER_OCTAVE + 1)
|
||||
]
|
||||
fifth = tuning.fifth_error(pitch.SEMITONES_PER_OCTAVE)
|
||||
return Demo(
|
||||
"temperament",
|
||||
"5 equal temperament",
|
||||
(
|
||||
f"step = 2^(1/12) = {pitch.semitone_ratio(1):.6f}",
|
||||
(
|
||||
f"tempered fifth = 2^(7/12) = {fifth.ratio:.6f}, "
|
||||
f"{fifth.error * 100:.3f} % flat of 3/2"
|
||||
),
|
||||
f"MIDI 69 -> {pitch.midi_to_hz(69):.1f} Hz",
|
||||
),
|
||||
tones(steps, 0.25),
|
||||
)
|
||||
|
||||
|
||||
def divisions_demo() -> Demo:
|
||||
"""Section 5: brute-force why the octave has twelve notes."""
|
||||
first = tuning.first_acceptable_division(_MAX_DIVISIONS)
|
||||
facts = [
|
||||
f"{s.divisions:2d}-TET: fifth = step {s.step:2d} = {s.ratio:.5f}, "
|
||||
f"error {s.error * 100:.3f} %"
|
||||
for s in sorted(
|
||||
tuning.best_divisions(_MAX_DIVISIONS), key=lambda s: s.divisions
|
||||
)
|
||||
if s.divisions in _SHOWN_DIVISIONS
|
||||
]
|
||||
facts.append(
|
||||
f"first division within {tuning.ACCEPTABLE_FIFTH_ERROR * 100:.1f} %: {first}"
|
||||
)
|
||||
frequencies = []
|
||||
for n in _SHOWN_DIVISIONS:
|
||||
frequencies += [_A3, _A3 * 1.5, _A3 * tuning.fifth_error(n).ratio]
|
||||
return Demo("divisions", "5 why twelve", tuple(facts), tones(frequencies, 0.3))
|
||||
|
||||
|
||||
def _shift(signal: synth.Signal, seconds: float) -> synth.Signal:
|
||||
"""The signal preceded by ``seconds`` of silence."""
|
||||
return np.concatenate((np.zeros(int(seconds * synth.SAMPLE_RATE)), signal))
|
||||
184
music_theory/music_theory/demos_b.py
Normal file
184
music_theory/music_theory/demos_b.py
Normal file
@ -0,0 +1,184 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Demos for article sections 6-11: scales, chords, keys, resolution, the piece."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from music_theory import (
|
||||
analysis,
|
||||
chords,
|
||||
compose,
|
||||
pitch,
|
||||
progressions,
|
||||
scales,
|
||||
synth,
|
||||
wav,
|
||||
)
|
||||
from music_theory.demo_support import Demo, chord_line, names, note_line
|
||||
|
||||
_C4 = 60
|
||||
_E_MAJOR_SHIFT = 4
|
||||
_TRIAD_QUALITIES = ("major", "minor", "diminished", "augmented")
|
||||
_SEVENTH_QUALITIES = ("major7", "minor7", "dominant7")
|
||||
_HALF_SECOND_FRAMES = synth.SAMPLE_RATE // 2
|
||||
_THIRD_INDEX = 1
|
||||
_SEVENTH_INDEX = 3
|
||||
|
||||
|
||||
def scale_demo() -> Demo:
|
||||
"""Section 6: a scale is a step array folded from a root."""
|
||||
major = scales.build(_C4, scales.STEP_PATTERNS["major"])
|
||||
minor = scales.build(pitch.parse_note("A3"), scales.STEP_PATTERNS["minor"])
|
||||
return Demo(
|
||||
"scale",
|
||||
"6 scales",
|
||||
(
|
||||
f"major steps {scales.STEP_PATTERNS['major']} from C4 -> {major}",
|
||||
f"that is {names(major)}",
|
||||
f"minor steps {scales.STEP_PATTERNS['minor']} from A3 -> {names(minor)}",
|
||||
),
|
||||
note_line(major + minor),
|
||||
)
|
||||
|
||||
|
||||
def modes_demo() -> Demo:
|
||||
"""Section 6: all seven modes are the same array rotated."""
|
||||
lines: list[int] = []
|
||||
facts = []
|
||||
for index, name in enumerate(scales.MODE_NAMES):
|
||||
pattern = scales.mode(name)
|
||||
facts.append(f"{name:10s} = major rotated {index}: {pattern}")
|
||||
lines += scales.build(_C4, pattern)
|
||||
facts.append("ionian is the major scale, aeolian (rotation 5) the natural minor")
|
||||
return Demo("modes", "6 modes", tuple(facts), note_line(lines, 0.25))
|
||||
|
||||
|
||||
def chords_demo() -> Demo:
|
||||
"""Section 7: one array element is the whole major/minor difference."""
|
||||
qualities = (*_TRIAD_QUALITIES, *_SEVENTH_QUALITIES)
|
||||
stacks = [chords.chord(_C4, q) for q in qualities]
|
||||
facts = tuple(
|
||||
f"{q:11s} {chords.QUALITIES[q]} -> {names(s)}"
|
||||
for q, s in zip(qualities, stacks, strict=True)
|
||||
)
|
||||
return Demo("chords", "7 chord qualities", facts, chord_line(stacks, 1.5))
|
||||
|
||||
|
||||
def diatonic_demo() -> Demo:
|
||||
"""Section 8: the seven chords that live inside one key."""
|
||||
table = chords.diatonic_chords(_C4, scales.STEP_PATTERNS["major"])
|
||||
facts = tuple(
|
||||
f"{c.numeral:4s} {c.quality:10s} {names(list(c.notes))}" for c in table
|
||||
)
|
||||
return Demo(
|
||||
"diatonic", "8 keys", facts, chord_line([list(c.notes) for c in table], 1.0)
|
||||
)
|
||||
|
||||
|
||||
def transpose_demo() -> Demo:
|
||||
"""Section 8: the same numerals in two keys differ by one constant."""
|
||||
pattern = scales.STEP_PATTERNS["major"]
|
||||
in_c = progressions.chords_for(_C4, pattern, progressions.named("pop"))
|
||||
in_e = progressions.transpose(in_c, _E_MAJOR_SHIFT)
|
||||
both = [list(c.notes) for c in in_c] + [list(c.notes) for c in in_e]
|
||||
return Demo(
|
||||
"transpose",
|
||||
"8 transposition",
|
||||
(
|
||||
f"{progressions.numerals(in_c)} in C: {_names_of(in_c)}",
|
||||
f"add {_E_MAJOR_SHIFT} to every note -> in E: {_names_of(in_e)}",
|
||||
),
|
||||
chord_line(both, 1.0),
|
||||
)
|
||||
|
||||
|
||||
def _names_of(prog: list[progressions.ProgressionChord]) -> list[str]:
|
||||
return [names(list(c.notes)) for c in prog]
|
||||
|
||||
|
||||
def resolution_demo() -> Demo:
|
||||
"""Section 9: V7 pulls into I through a leading tone and a tritone."""
|
||||
pattern = scales.STEP_PATTERNS["major"]
|
||||
seventh = chords.dominant_seventh(_C4, pattern)
|
||||
tonic = chords.stacked_thirds(_C4, pattern, 0)
|
||||
# The third and the seventh of any dominant seventh are a tritone apart.
|
||||
tritone = [seventh[_THIRD_INDEX], seventh[_SEVENTH_INDEX]]
|
||||
moves = chords.resolution(_C4, pattern)
|
||||
facts = [f"V7 in C = {names(seventh)}"]
|
||||
facts.append(f"tritone inside it: {names(tritone)} (six semitones apart)")
|
||||
facts += [
|
||||
f"{pitch.note_name(a)} -> {pitch.note_name(b)} by one semitone"
|
||||
for a, b in moves
|
||||
]
|
||||
return Demo(
|
||||
"resolution",
|
||||
"9 tension and resolution",
|
||||
tuple(facts),
|
||||
chord_line([seventh, tonic], 2.0),
|
||||
)
|
||||
|
||||
|
||||
def progressions_demo() -> Demo:
|
||||
"""Section 9: the loops everyone has heard."""
|
||||
pattern = scales.STEP_PATTERNS["major"]
|
||||
facts = []
|
||||
lines: list[list[int]] = []
|
||||
for name in ("pop", "jazz", "axis", "fifties"):
|
||||
prog = progressions.chords_for(_C4, pattern, progressions.named(name))
|
||||
facts.append(f"{name:8s} {progressions.numerals(prog)}")
|
||||
lines += [list(c.notes) for c in prog]
|
||||
return Demo(
|
||||
"progressions", "9 common progressions", tuple(facts), chord_line(lines, 1.0)
|
||||
)
|
||||
|
||||
|
||||
def piece_demo() -> Demo:
|
||||
"""Section 11: the whole article in one recipe."""
|
||||
recipe = compose.Recipe(bars=4)
|
||||
signal = compose.compose(recipe)
|
||||
return Demo(
|
||||
"piece",
|
||||
"11 a complete composition",
|
||||
(
|
||||
(
|
||||
f"key {recipe.key} {recipe.mode}, {recipe.tempo_bpm:.0f} bpm, "
|
||||
f"degrees {recipe.progression}"
|
||||
),
|
||||
(
|
||||
f"bass + arpeggio + seeded melody, "
|
||||
f"{wav.duration_seconds(signal):.1f} s, loops on the bar"
|
||||
),
|
||||
),
|
||||
signal,
|
||||
)
|
||||
|
||||
|
||||
def bracket_demo() -> Demo:
|
||||
"""Beyond the article: the same piece, then the same piece ruined."""
|
||||
clean = compose.Recipe(bars=4, mode="major", tempo_bpm=112.0)
|
||||
ruined = compose.with_degrade(clean, 1.0)
|
||||
a = compose.compose(clean)
|
||||
b = compose.compose(ruined)
|
||||
return Demo(
|
||||
"bracket",
|
||||
"12 degrade (not in the article)",
|
||||
(
|
||||
(
|
||||
f"clean: {compose.effective(clean).mode}, "
|
||||
f"centroid {analysis.spectral_centroid(a):.0f} Hz"
|
||||
),
|
||||
(
|
||||
f"ruined: {compose.effective(ruined).mode}, "
|
||||
f"{compose.effective(ruined).tempo_bpm:.0f} bpm, "
|
||||
f"centroid {analysis.spectral_centroid(b):.0f} Hz"
|
||||
),
|
||||
"same seed, same chords: the ending is the opening, degraded",
|
||||
),
|
||||
_join(a, b),
|
||||
)
|
||||
|
||||
|
||||
def _join(a: synth.Signal, b: synth.Signal) -> synth.Signal:
|
||||
"""Two signals with half a second of silence between them."""
|
||||
return np.concatenate((a, np.zeros(_HALF_SECOND_FRAMES), b))
|
||||
52
music_theory/music_theory/manifest.py
Normal file
52
music_theory/music_theory/manifest.py
Normal file
@ -0,0 +1,52 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Manifest: the recipe of every rendered file, so it can be rebuilt.
|
||||
|
||||
A committed audio file whose recipe lives only in a chat transcript is
|
||||
unreproducible. An entry here holds the full recipe, the package version
|
||||
that rendered it and the SHA-256 of the output, so a re-render can prove
|
||||
it reproduced the shipped bytes -- or show exactly what changed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from music_theory import __version__, wav
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from music_theory.compose import Recipe
|
||||
from music_theory.synth import Signal
|
||||
|
||||
Entry = dict[str, object]
|
||||
|
||||
|
||||
def entry(recipe: Recipe, path: Path, signal: Signal) -> Entry:
|
||||
"""Provenance record for one rendered file."""
|
||||
return {
|
||||
"file": path.name,
|
||||
"recipe": recipe.to_dict(),
|
||||
"music_theory_version": __version__,
|
||||
"sample_rate": recipe.sample_rate,
|
||||
"frames": len(signal),
|
||||
"seconds": wav.duration_seconds(signal, recipe.sample_rate),
|
||||
"sha256": wav.sha256(path),
|
||||
}
|
||||
|
||||
|
||||
def write(path: Path, entries: dict[str, Entry]) -> Path:
|
||||
"""Write entries as sorted, indented JSON with a trailing newline."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(entries, indent=2, sort_keys=True) + "\n")
|
||||
return path
|
||||
|
||||
|
||||
def read(path: Path) -> dict[str, Entry]:
|
||||
"""Load a manifest written by ``write``."""
|
||||
data = json.loads(path.read_text())
|
||||
if not isinstance(data, dict):
|
||||
msg = f"{path} does not hold a manifest object"
|
||||
raise TypeError(msg)
|
||||
return data
|
||||
104
music_theory/music_theory/pitch.py
Normal file
104
music_theory/music_theory/pitch.py
Normal file
@ -0,0 +1,104 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Pitch: the arithmetic between a MIDI number, a note name and a frequency.
|
||||
|
||||
Article section 1 and 5. Pitch perception is MULTIPLICATIVE -- going up one
|
||||
semitone always multiplies the frequency by the same factor, never adds the
|
||||
same number of hertz -- so every conversion here is an exponent, and the
|
||||
MIDI number is simply that exponent counted in twelfths from A4 = 440 Hz.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
## The reference every other pitch is derived from.
|
||||
A4_HZ = 440.0
|
||||
A4_MIDI = 69
|
||||
|
||||
## Twelve equal steps per octave (why twelve: see ``tuning.best_divisions``).
|
||||
SEMITONES_PER_OCTAVE = 12
|
||||
|
||||
## Cents subdivide a semitone into 100, so an octave is 1200.
|
||||
CENTS_PER_OCTAVE = 1200.0
|
||||
|
||||
NOTE_NAMES: tuple[str, ...] = (
|
||||
"C",
|
||||
"C#",
|
||||
"D",
|
||||
"D#",
|
||||
"E",
|
||||
"F",
|
||||
"F#",
|
||||
"G",
|
||||
"G#",
|
||||
"A",
|
||||
"A#",
|
||||
"B",
|
||||
)
|
||||
|
||||
## Flat spellings map onto the sharp above the note below them.
|
||||
_FLAT_TO_SHARP = {"Db": "C#", "Eb": "D#", "Gb": "F#", "Ab": "G#", "Bb": "A#"}
|
||||
|
||||
## MIDI octave numbering puts C4 (middle C) at 60, so octave 0 starts at 12.
|
||||
_OCTAVE_OFFSET = 1
|
||||
|
||||
|
||||
def semitone_ratio(semitones: float) -> float:
|
||||
"""Frequency multiplier for a distance in semitones (2 ** (n / 12))."""
|
||||
return float(2.0 ** (semitones / SEMITONES_PER_OCTAVE))
|
||||
|
||||
|
||||
def midi_to_hz(midi: float) -> float:
|
||||
"""Frequency of a MIDI note: 440 * 2 ** ((n - 69) / 12)."""
|
||||
return A4_HZ * semitone_ratio(midi - A4_MIDI)
|
||||
|
||||
|
||||
def hz_to_midi(hz: float) -> float:
|
||||
"""Fractional MIDI number of a frequency (the inverse of ``midi_to_hz``)."""
|
||||
if hz <= 0.0:
|
||||
msg = f"frequency must be positive, got {hz}"
|
||||
raise ValueError(msg)
|
||||
return A4_MIDI + SEMITONES_PER_OCTAVE * math.log2(hz / A4_HZ)
|
||||
|
||||
|
||||
def cents(ratio: float) -> float:
|
||||
"""Size of a frequency ratio in cents (1200 per octave)."""
|
||||
if ratio <= 0.0:
|
||||
msg = f"ratio must be positive, got {ratio}"
|
||||
raise ValueError(msg)
|
||||
return CENTS_PER_OCTAVE * math.log2(ratio)
|
||||
|
||||
|
||||
def note_name(midi: int) -> str:
|
||||
"""Scientific pitch name of a MIDI note, e.g. 69 -> "A4"."""
|
||||
octave, index = divmod(midi, SEMITONES_PER_OCTAVE)
|
||||
return f"{NOTE_NAMES[index]}{octave - _OCTAVE_OFFSET}"
|
||||
|
||||
|
||||
def parse_note(name: str) -> int:
|
||||
"""MIDI number of a note name such as "A4", "C#3" or "Bb2"."""
|
||||
letters = name.rstrip("-0123456789")
|
||||
digits = name[len(letters) :]
|
||||
if not letters or not digits:
|
||||
msg = f"not a note name: {name!r}"
|
||||
raise ValueError(msg)
|
||||
letters = _FLAT_TO_SHARP.get(letters, letters)
|
||||
if letters not in NOTE_NAMES:
|
||||
msg = f"unknown pitch class in {name!r}"
|
||||
raise ValueError(msg)
|
||||
octave = int(digits)
|
||||
return (octave + _OCTAVE_OFFSET) * SEMITONES_PER_OCTAVE + NOTE_NAMES.index(letters)
|
||||
|
||||
|
||||
def transpose(midis: Sequence[int], semitones: int) -> list[int]:
|
||||
"""Shift every note by a constant (article section 8: transposition)."""
|
||||
return [midi + semitones for midi in midis]
|
||||
|
||||
|
||||
def pitch_class(midi: int) -> int:
|
||||
"""The note within the octave, 0 (C) to 11 (B)."""
|
||||
return midi % SEMITONES_PER_OCTAVE
|
||||
72
music_theory/music_theory/progressions.py
Normal file
72
music_theory/music_theory/progressions.py
Normal file
@ -0,0 +1,72 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Progressions: chord sequences addressed by scale degree, not by note.
|
||||
|
||||
Article section 8. Roman numerals are RELATIVE addressing: "I V vi IV" is
|
||||
the same progression in every key, and transposing is adding one constant to
|
||||
every MIDI number. Degrees here are 1-based as musicians write them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from music_theory import chords, scales
|
||||
|
||||
Degrees = tuple[int, ...]
|
||||
|
||||
## Progressions the article names, 1-based degrees.
|
||||
NAMED: dict[str, Degrees] = {
|
||||
"pop": (1, 5, 6, 4),
|
||||
"jazz": (2, 5, 1),
|
||||
"axis": (6, 4, 1, 5),
|
||||
"fifties": (1, 6, 4, 5),
|
||||
"article": (1, 6, 3, 7),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProgressionChord:
|
||||
"""A chord of a progression: its 1-based degree, numeral and notes."""
|
||||
|
||||
degree: int
|
||||
numeral: str
|
||||
notes: tuple[int, ...]
|
||||
|
||||
|
||||
def named(name: str) -> Degrees:
|
||||
"""Degrees of a named progression, e.g. "pop" -> (1, 5, 6, 4)."""
|
||||
key = name.lower()
|
||||
if key not in NAMED:
|
||||
msg = f"unknown progression {name!r}; choose one of {tuple(NAMED)}"
|
||||
raise ValueError(msg)
|
||||
return NAMED[key]
|
||||
|
||||
|
||||
def chords_for(
|
||||
root_midi: int, pattern: scales.StepPattern, degrees: Degrees
|
||||
) -> list[ProgressionChord]:
|
||||
"""Resolve 1-based degrees into the diatonic chords of a key."""
|
||||
table = chords.diatonic_chords(root_midi, pattern)
|
||||
result = []
|
||||
for degree in degrees:
|
||||
if not 1 <= degree <= len(table):
|
||||
msg = f"degree {degree} is outside 1..{len(table)}"
|
||||
raise ValueError(msg)
|
||||
entry = table[degree - 1]
|
||||
result.append(ProgressionChord(degree, entry.numeral, entry.notes))
|
||||
return result
|
||||
|
||||
|
||||
def transpose(
|
||||
progression: list[ProgressionChord], semitones: int
|
||||
) -> list[ProgressionChord]:
|
||||
"""The same progression in another key: add a constant to every note."""
|
||||
return [
|
||||
ProgressionChord(c.degree, c.numeral, tuple(n + semitones for n in c.notes))
|
||||
for c in progression
|
||||
]
|
||||
|
||||
|
||||
def numerals(progression: list[ProgressionChord]) -> str:
|
||||
"""Human-readable numeral string, e.g. "I V vi IV"."""
|
||||
return " ".join(c.numeral for c in progression)
|
||||
120
music_theory/music_theory/scales.py
Normal file
120
music_theory/music_theory/scales.py
Normal file
@ -0,0 +1,120 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Scales: a subset of the twelve notes, encoded as the gaps between them.
|
||||
|
||||
Article section 6. A scale is an array of step sizes that sums to twelve;
|
||||
the gaps are UNEVEN on purpose, so the ear can tell where in the octave it
|
||||
is. All seven modes are the same major-scale array rotated, which is why
|
||||
they are built here by rotation rather than stored as seven tables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from music_theory.pitch import SEMITONES_PER_OCTAVE
|
||||
|
||||
StepPattern = tuple[int, ...]
|
||||
|
||||
## Named step patterns. Every one sums to twelve.
|
||||
STEP_PATTERNS: dict[str, StepPattern] = {
|
||||
"major": (2, 2, 1, 2, 2, 2, 1),
|
||||
"minor": (2, 1, 2, 2, 1, 2, 2),
|
||||
"harmonic minor": (2, 1, 2, 2, 1, 3, 1),
|
||||
"major pentatonic": (2, 2, 3, 2, 3),
|
||||
"minor pentatonic": (3, 2, 2, 3, 2),
|
||||
"blues": (3, 2, 1, 1, 3, 2),
|
||||
"whole tone": (2, 2, 2, 2, 2, 2),
|
||||
}
|
||||
|
||||
## The seven modes are rotations of the major scale, in this order.
|
||||
MODE_NAMES: tuple[str, ...] = (
|
||||
"ionian",
|
||||
"dorian",
|
||||
"phrygian",
|
||||
"lydian",
|
||||
"mixolydian",
|
||||
"aeolian",
|
||||
"locrian",
|
||||
)
|
||||
|
||||
## Rotation indices worth naming: major is rotation 0, natural minor is 5.
|
||||
MAJOR_ROTATION = 0
|
||||
MINOR_ROTATION = 5
|
||||
|
||||
|
||||
def validate(pattern: StepPattern) -> StepPattern:
|
||||
"""Return the pattern if it spans exactly one octave, else raise."""
|
||||
if sum(pattern) != SEMITONES_PER_OCTAVE:
|
||||
msg = (
|
||||
f"step pattern {pattern} sums to {sum(pattern)}, not {SEMITONES_PER_OCTAVE}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
if any(step <= 0 for step in pattern):
|
||||
msg = f"step pattern {pattern} has a non-positive step"
|
||||
raise ValueError(msg)
|
||||
return pattern
|
||||
|
||||
|
||||
def rotate(pattern: StepPattern, count: int) -> StepPattern:
|
||||
"""The same pattern started ``count`` steps later (a mode of it)."""
|
||||
if not pattern:
|
||||
return pattern
|
||||
shift = count % len(pattern)
|
||||
return pattern[shift:] + pattern[:shift]
|
||||
|
||||
|
||||
def mode(name: str) -> StepPattern:
|
||||
"""Step pattern of a named mode, derived by rotating the major scale."""
|
||||
key = name.lower()
|
||||
if key not in MODE_NAMES:
|
||||
msg = f"unknown mode {name!r}; choose one of {MODE_NAMES}"
|
||||
raise ValueError(msg)
|
||||
return rotate(STEP_PATTERNS["major"], MODE_NAMES.index(key))
|
||||
|
||||
|
||||
def pattern_named(name: str) -> StepPattern:
|
||||
"""Look a pattern up by scale name OR mode name ("minor", "dorian", ...)."""
|
||||
key = name.lower()
|
||||
if key in STEP_PATTERNS:
|
||||
return STEP_PATTERNS[key]
|
||||
if key in MODE_NAMES:
|
||||
return mode(key)
|
||||
msg = f"unknown scale {name!r}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def offsets(pattern: StepPattern) -> list[int]:
|
||||
"""Semitone offset of each degree from the root, root included, octave excluded."""
|
||||
validate(pattern)
|
||||
result = [0]
|
||||
for step in pattern[:-1]:
|
||||
result.append(result[-1] + step)
|
||||
return result
|
||||
|
||||
|
||||
def build(root_midi: int, pattern: StepPattern) -> list[int]:
|
||||
"""MIDI notes of one octave of the scale, root through to the octave above.
|
||||
|
||||
This is the article's ``reduce`` over the step array: C major from 60
|
||||
gives [60, 62, 64, 65, 67, 69, 71, 72].
|
||||
"""
|
||||
validate(pattern)
|
||||
notes = [root_midi]
|
||||
for step in pattern:
|
||||
notes.append(notes[-1] + step)
|
||||
return notes
|
||||
|
||||
|
||||
def pitch_at_degree(root_midi: int, pattern: StepPattern, degree: int) -> int:
|
||||
"""MIDI note of a 0-based scale degree, wrapping across octaves.
|
||||
|
||||
Degree 7 of a seven-note scale is the root an octave up; degree -1 is
|
||||
the note just below the root. This is what lets a melody be written as
|
||||
degree numbers and moved up or down without leaving the scale.
|
||||
"""
|
||||
steps = offsets(pattern)
|
||||
octave, index = divmod(degree, len(steps))
|
||||
return root_midi + octave * SEMITONES_PER_OCTAVE + steps[index]
|
||||
|
||||
|
||||
def is_in_scale(root_midi: int, pattern: StepPattern, midi: int) -> bool:
|
||||
"""Whether a note belongs to the scale (in any octave)."""
|
||||
return (midi - root_midi) % SEMITONES_PER_OCTAVE in offsets(pattern)
|
||||
110
music_theory/music_theory/sequencer.py
Normal file
110
music_theory/music_theory/sequencer.py
Normal file
@ -0,0 +1,110 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Sequencer: put notes on a beat grid and render them to one buffer.
|
||||
|
||||
Article section 10 covers notation as a serialization format for exactly
|
||||
this: a note is a pitch plus a start and a length measured in beats, and
|
||||
tempo is what turns beats into seconds. Rendering to a FIXED length in
|
||||
beats is what makes a loop close on a bar boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from music_theory import pitch, synth
|
||||
|
||||
## A 4/4 bar, the article's default and the only meter used here.
|
||||
BEATS_PER_BAR = 4
|
||||
SECONDS_PER_MINUTE = 60.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NoteEvent:
|
||||
"""One note: MIDI pitch, start and length in beats, 0..1 velocity."""
|
||||
|
||||
midi: int
|
||||
start_beat: float
|
||||
length_beats: float
|
||||
velocity: float = 1.0
|
||||
waveform: synth.Waveform = "sine"
|
||||
envelope: synth.Envelope = synth.DEFAULT_ENVELOPE
|
||||
## Detune in cents, applied at render time; the pitch stays an integer
|
||||
## so the note is still addressable by scale degree.
|
||||
cents: float = 0.0
|
||||
|
||||
|
||||
def beat_seconds(tempo_bpm: float) -> float:
|
||||
"""Length of one beat at a tempo."""
|
||||
if tempo_bpm <= 0.0:
|
||||
msg = f"tempo must be positive, got {tempo_bpm}"
|
||||
raise ValueError(msg)
|
||||
return SECONDS_PER_MINUTE / tempo_bpm
|
||||
|
||||
|
||||
def loop_frames(
|
||||
tempo_bpm: float, beats: float, sample_rate: int = synth.SAMPLE_RATE
|
||||
) -> int:
|
||||
"""Frames in ``beats`` at a tempo, floored so a loop never overruns its bar."""
|
||||
return int(beats * beat_seconds(tempo_bpm) * sample_rate)
|
||||
|
||||
|
||||
def render_note(event: NoteEvent, tempo_bpm: float, sample_rate: int) -> synth.Signal:
|
||||
"""Samples of one note: oscillator times envelope times velocity."""
|
||||
seconds = event.length_beats * beat_seconds(tempo_bpm)
|
||||
hz = synth.detune(pitch.midi_to_hz(event.midi), event.cents)
|
||||
tone = synth.oscillator(hz, seconds, event.waveform, sample_rate)
|
||||
return tone * synth.adsr(event.envelope, seconds, sample_rate) * event.velocity
|
||||
|
||||
|
||||
def render(
|
||||
events: list[NoteEvent],
|
||||
tempo_bpm: float,
|
||||
length_beats: float,
|
||||
sample_rate: int = synth.SAMPLE_RATE,
|
||||
) -> synth.Signal:
|
||||
"""Mix every note into a buffer exactly ``length_beats`` long.
|
||||
|
||||
Notes that run past the end are cut, not wrapped: a loop that must end
|
||||
on the bar is more important than a note's tail.
|
||||
"""
|
||||
total = np.zeros(
|
||||
loop_frames(tempo_bpm, length_beats, sample_rate), dtype=np.float64
|
||||
)
|
||||
for event in events:
|
||||
start = loop_frames(tempo_bpm, event.start_beat, sample_rate)
|
||||
if start >= len(total):
|
||||
continue
|
||||
note = render_note(event, tempo_bpm, sample_rate)
|
||||
end = min(start + len(note), len(total))
|
||||
total[start:end] += note[: end - start]
|
||||
return total
|
||||
|
||||
|
||||
def sequence(
|
||||
midis: list[int],
|
||||
beats_each: float,
|
||||
waveform: synth.Waveform = "sine",
|
||||
envelope: synth.Envelope = synth.DEFAULT_ENVELOPE,
|
||||
) -> list[NoteEvent]:
|
||||
"""Notes played one after another, each ``beats_each`` long."""
|
||||
return [
|
||||
NoteEvent(midi, index * beats_each, beats_each, 1.0, waveform, envelope)
|
||||
for index, midi in enumerate(midis)
|
||||
]
|
||||
|
||||
|
||||
def stack(
|
||||
midis: list[int],
|
||||
start_beat: float,
|
||||
beats: float,
|
||||
waveform: synth.Waveform = "sine",
|
||||
envelope: synth.Envelope = synth.DEFAULT_ENVELOPE,
|
||||
) -> list[NoteEvent]:
|
||||
"""Notes played together (a chord), all starting at ``start_beat``."""
|
||||
velocity = 1.0 / max(len(midis), 1)
|
||||
return [
|
||||
NoteEvent(midi, start_beat, beats, velocity, waveform, envelope)
|
||||
for midi in midis
|
||||
]
|
||||
155
music_theory/music_theory/synth.py
Normal file
155
music_theory/music_theory/synth.py
Normal file
@ -0,0 +1,155 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Synth: turn frequencies into samples, with envelopes and timbre.
|
||||
|
||||
Article sections 1 and 2. A sound is a number changing over time; an
|
||||
envelope shapes its volume so a note has a beginning and an end (and no
|
||||
click), and timbre is the recipe of overtones -- sine, triangle, square and
|
||||
saw are four such recipes at the same pitch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
type Signal = npt.NDArray[np.float64]
|
||||
type Waveform = Literal["sine", "triangle", "square", "saw"]
|
||||
|
||||
## The game's engine bands are 22050 Hz mono; keeping the same rate means a
|
||||
## bed and the engine never need resampling against each other.
|
||||
SAMPLE_RATE = 22050
|
||||
|
||||
## Headroom so summed voices and the game's other sounds have room to breathe.
|
||||
DEFAULT_PEAK = 0.8
|
||||
|
||||
WAVEFORMS: tuple[Waveform, ...] = ("sine", "triangle", "square", "saw")
|
||||
|
||||
_INT16_FULL_SCALE = 32767.0
|
||||
|
||||
## A square wave is high for the first half of each cycle.
|
||||
_HALF_CYCLE = 0.5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Envelope:
|
||||
"""ADSR volume curve. Times in seconds, sustain as a 0..1 level.
|
||||
|
||||
A 10 ms attack reads as struck, 500 ms as bowed; the release is what
|
||||
stops a note ending in a click.
|
||||
"""
|
||||
|
||||
attack: float = 0.01
|
||||
decay: float = 0.05
|
||||
sustain: float = 0.8
|
||||
release: float = 0.05
|
||||
|
||||
|
||||
## Shared default so dataclass fields and argument defaults never call a
|
||||
## constructor at import time (ruff B008/RUF009).
|
||||
DEFAULT_ENVELOPE = Envelope()
|
||||
|
||||
|
||||
def time_axis(seconds: float, sample_rate: int = SAMPLE_RATE) -> Signal:
|
||||
"""Sample times for a duration, starting at 0."""
|
||||
frames = max(int(seconds * sample_rate), 0)
|
||||
return np.arange(frames, dtype=np.float64) / sample_rate
|
||||
|
||||
|
||||
def oscillator(
|
||||
hz: float,
|
||||
seconds: float,
|
||||
waveform: Waveform = "sine",
|
||||
sample_rate: int = SAMPLE_RATE,
|
||||
) -> Signal:
|
||||
"""One waveform at one frequency, amplitude 1, no envelope."""
|
||||
if waveform not in WAVEFORMS:
|
||||
msg = f"unknown waveform {waveform!r}; choose one of {WAVEFORMS}"
|
||||
raise ValueError(msg)
|
||||
phase = (time_axis(seconds, sample_rate) * hz) % 1.0
|
||||
if waveform == "sine":
|
||||
return np.sin(2.0 * math.pi * phase)
|
||||
saw = 2.0 * phase - 1.0
|
||||
if waveform == "saw":
|
||||
return saw
|
||||
if waveform == "triangle":
|
||||
return 2.0 * np.abs(saw) - 1.0
|
||||
return np.where(phase < _HALF_CYCLE, 1.0, -1.0).astype(np.float64)
|
||||
|
||||
|
||||
def additive(
|
||||
hz: float, seconds: float, amplitudes: list[float], sample_rate: int = SAMPLE_RATE
|
||||
) -> Signal:
|
||||
"""Sum of harmonics: amplitudes[k] scales the (k+1)th multiple of ``hz``."""
|
||||
total = np.zeros(int(seconds * sample_rate), dtype=np.float64)
|
||||
for index, amplitude in enumerate(amplitudes):
|
||||
total += amplitude * oscillator(hz * (index + 1), seconds, "sine", sample_rate)
|
||||
return total
|
||||
|
||||
|
||||
def adsr(envelope: Envelope, seconds: float, sample_rate: int = SAMPLE_RATE) -> Signal:
|
||||
"""Gain curve of an ADSR envelope over ``seconds``.
|
||||
|
||||
The release is carved out of the END of the duration rather than added
|
||||
after it, so a note of N seconds is exactly N seconds long and a
|
||||
sequencer can lay notes end to end without them overlapping.
|
||||
"""
|
||||
frames = max(int(seconds * sample_rate), 0)
|
||||
gain = np.zeros(frames, dtype=np.float64)
|
||||
attack = min(int(envelope.attack * sample_rate), frames)
|
||||
decay = min(int(envelope.decay * sample_rate), frames - attack)
|
||||
release = min(int(envelope.release * sample_rate), frames - attack - decay)
|
||||
sustain_start = attack + decay
|
||||
sustain_end = frames - release
|
||||
gain[:attack] = np.linspace(0.0, 1.0, attack, endpoint=False)
|
||||
gain[attack:sustain_start] = np.linspace(
|
||||
1.0, envelope.sustain, decay, endpoint=False
|
||||
)
|
||||
gain[sustain_start:sustain_end] = envelope.sustain
|
||||
gain[sustain_end:] = np.linspace(envelope.sustain, 0.0, release, endpoint=False)
|
||||
return gain
|
||||
|
||||
|
||||
def lowpass(signal: Signal, cutoff_hz: float, sample_rate: int = SAMPLE_RATE) -> Signal:
|
||||
"""One-pole low-pass: the cheapest way to make a bright sound darker."""
|
||||
if cutoff_hz <= 0.0:
|
||||
return np.zeros_like(signal)
|
||||
alpha = 1.0 - math.exp(-2.0 * math.pi * cutoff_hz / sample_rate)
|
||||
out = np.empty_like(signal)
|
||||
state = 0.0
|
||||
for index, sample in enumerate(signal):
|
||||
state += alpha * (sample - state)
|
||||
out[index] = state
|
||||
return out
|
||||
|
||||
|
||||
def detune(hz: float, cents: float) -> float:
|
||||
"""Frequency shifted by a number of cents (100 per semitone)."""
|
||||
return float(hz * 2.0 ** (cents / 1200.0))
|
||||
|
||||
|
||||
def mix(*signals: Signal) -> Signal:
|
||||
"""Sum signals of any lengths, padding the shorter ones with silence."""
|
||||
if not signals:
|
||||
return np.zeros(0, dtype=np.float64)
|
||||
length = max(len(s) for s in signals)
|
||||
total = np.zeros(length, dtype=np.float64)
|
||||
for signal in signals:
|
||||
total[: len(signal)] += signal
|
||||
return total
|
||||
|
||||
|
||||
def normalize(signal: Signal, peak: float = DEFAULT_PEAK) -> Signal:
|
||||
"""Scale so the loudest sample sits at ``peak``; silence stays silence."""
|
||||
loudest = float(np.max(np.abs(signal))) if len(signal) else 0.0
|
||||
if not loudest:
|
||||
return signal.copy()
|
||||
return signal * (peak / loudest)
|
||||
|
||||
|
||||
def to_int16(signal: Signal) -> npt.NDArray[np.int16]:
|
||||
"""Clip to [-1, 1] and scale to 16-bit PCM."""
|
||||
return (np.clip(signal, -1.0, 1.0) * _INT16_FULL_SCALE).astype(np.int16)
|
||||
2
music_theory/music_theory/tests/__init__.py
Normal file
2
music_theory/music_theory/tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Tests for music_theory."""
|
||||
32
music_theory/music_theory/tests/test_analysis.py
Normal file
32
music_theory/music_theory/tests/test_analysis.py
Normal file
@ -0,0 +1,32 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from music_theory import analysis, synth
|
||||
|
||||
|
||||
def test_dominant_frequency_of_a_sine() -> None:
|
||||
assert analysis.dominant_frequency(synth.oscillator(1000.0, 1.0)) == pytest.approx(
|
||||
1000.0, abs=1.0
|
||||
)
|
||||
|
||||
|
||||
def test_dominant_frequency_of_silence_and_empty() -> None:
|
||||
assert analysis.dominant_frequency(np.zeros(100)) == 0.0
|
||||
assert analysis.dominant_frequency(np.zeros(0)) == 0.0
|
||||
|
||||
|
||||
def test_spectral_centroid_orders_timbres() -> None:
|
||||
sine = analysis.spectral_centroid(synth.oscillator(220.0, 0.5, "sine"))
|
||||
saw = analysis.spectral_centroid(synth.oscillator(220.0, 0.5, "saw"))
|
||||
assert sine < saw
|
||||
|
||||
|
||||
def test_spectral_centroid_of_silence_and_empty() -> None:
|
||||
assert analysis.spectral_centroid(np.zeros(100)) == 0.0
|
||||
assert analysis.spectral_centroid(np.zeros(0)) == 0.0
|
||||
|
||||
|
||||
def test_peak() -> None:
|
||||
assert analysis.peak(np.array([0.1, -0.7, 0.3])) == pytest.approx(0.7)
|
||||
assert analysis.peak(np.zeros(0)) == 0.0
|
||||
71
music_theory/music_theory/tests/test_chords.py
Normal file
71
music_theory/music_theory/tests/test_chords.py
Normal file
@ -0,0 +1,71 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
import pytest
|
||||
|
||||
from music_theory import chords, scales
|
||||
|
||||
MAJOR = scales.STEP_PATTERNS["major"]
|
||||
|
||||
|
||||
def test_major_and_minor_differ_by_one_element() -> None:
|
||||
assert chords.chord(60, "major") == [60, 64, 67]
|
||||
assert chords.chord(60, "minor") == [60, 63, 67]
|
||||
|
||||
|
||||
def test_chord_rejects_unknown_quality() -> None:
|
||||
with pytest.raises(ValueError, match="quality"):
|
||||
chords.chord(60, "sus-whatever")
|
||||
|
||||
|
||||
def test_quality_of_inverts_chord() -> None:
|
||||
for name in chords.QUALITIES:
|
||||
assert chords.quality_of(chords.chord(60, name)) == name
|
||||
assert chords.quality_of([]) is None
|
||||
assert chords.quality_of([60, 61, 62]) is None
|
||||
|
||||
|
||||
def test_stacked_thirds_builds_the_triad() -> None:
|
||||
assert chords.stacked_thirds(60, MAJOR, 0) == [60, 64, 67]
|
||||
assert chords.stacked_thirds(60, MAJOR, 4, 4) == [67, 71, 74, 77]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("degree", "quality", "numeral"),
|
||||
[
|
||||
(0, "major", "I"),
|
||||
(1, "minor", "ii"),
|
||||
(6, "diminished", "vii°"),
|
||||
(2, "augmented", "III+"),
|
||||
(4, "dominant7", "V"),
|
||||
(1, "half-diminished7", "ii°"),
|
||||
(5, "minor7", "vi"),
|
||||
],
|
||||
)
|
||||
def test_roman(degree: int, quality: str, numeral: str) -> None:
|
||||
assert chords.roman(degree, quality) == numeral
|
||||
|
||||
|
||||
def test_diatonic_chords_of_c_major() -> None:
|
||||
table = chords.diatonic_chords(60, MAJOR)
|
||||
assert [c.numeral for c in table] == ["I", "ii", "iii", "IV", "V", "vi", "vii°"]
|
||||
assert table[0].notes == (60, 64, 67)
|
||||
assert table[6].quality == "diminished"
|
||||
|
||||
|
||||
def test_diatonic_chords_of_an_odd_scale_fall_back_to_other() -> None:
|
||||
table = chords.diatonic_chords(60, scales.STEP_PATTERNS["whole tone"])
|
||||
assert all(c.quality == "augmented" for c in table)
|
||||
blues = chords.diatonic_chords(60, scales.STEP_PATTERNS["blues"])
|
||||
assert "other" in {c.quality for c in blues}
|
||||
|
||||
|
||||
def test_dominant_seventh_of_c() -> None:
|
||||
assert chords.dominant_seventh(60, MAJOR) == [67, 71, 74, 77]
|
||||
|
||||
|
||||
def test_tritone_pair() -> None:
|
||||
assert chords.tritone_pair([67, 71, 74, 77]) == (71, 77)
|
||||
assert chords.tritone_pair([60, 64, 67]) is None
|
||||
|
||||
|
||||
def test_resolution_moves_by_contrary_semitones() -> None:
|
||||
assert chords.resolution(60, MAJOR) == [(71, 72), (77, 76)]
|
||||
98
music_theory/music_theory/tests/test_cli.py
Normal file
98
music_theory/music_theory/tests/test_cli.py
Normal file
@ -0,0 +1,98 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
import json
|
||||
import runpy
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from music_theory import cli, compose, manifest, wav
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_list_prints_every_demo(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert cli.main(["list"]) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "tone" in out
|
||||
assert "bracket" in out
|
||||
|
||||
|
||||
def test_explain_prints_facts(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert cli.main(["explain", "comma"]) == 0
|
||||
assert "1.013643" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_demo_writes_numbered_wavs(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert cli.main(["demo", "tone", "--out", str(tmp_path)]) == 0
|
||||
assert (tmp_path / "01_tone.wav").exists()
|
||||
assert "aplay -q" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_render_writes_wav_and_manifest(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
recipe_path = tmp_path / "r.json"
|
||||
recipe_path.write_text(json.dumps(compose.Recipe(bars=1).to_dict()))
|
||||
out = tmp_path / "bed.wav"
|
||||
man = tmp_path / "MANIFEST.json"
|
||||
args = [
|
||||
"render",
|
||||
"--recipe",
|
||||
str(recipe_path),
|
||||
"--out",
|
||||
str(out),
|
||||
"--manifest",
|
||||
str(man),
|
||||
]
|
||||
assert cli.main(args) == 0
|
||||
entries = manifest.read(man)
|
||||
assert entries["bed.wav"]["sha256"] == wav.sha256(out)
|
||||
assert cli.main([*args, "--degrade", "1.0"]) == 0
|
||||
recipe = manifest.read(man)["bed.wav"]["recipe"]
|
||||
assert isinstance(recipe, dict)
|
||||
assert recipe["degrade"] == 1.0
|
||||
assert "sha256" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_render_without_manifest(tmp_path: Path) -> None:
|
||||
recipe_path = tmp_path / "r.json"
|
||||
recipe_path.write_text(json.dumps({"bars": 1}))
|
||||
assert (
|
||||
cli.main(
|
||||
["render", "--recipe", str(recipe_path), "--out", str(tmp_path / "b.wav")]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_render_rejects_non_object_recipe(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
recipe_path = tmp_path / "r.json"
|
||||
recipe_path.write_text("[1]")
|
||||
assert (
|
||||
cli.main(
|
||||
["render", "--recipe", str(recipe_path), "--out", str(tmp_path / "b.wav")]
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert "error" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_parser_requires_a_command() -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
cli.build_parser().parse_args([])
|
||||
|
||||
|
||||
def test_module_entry_point(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["music_theory", "list"])
|
||||
with pytest.raises(SystemExit) as info:
|
||||
runpy.run_module("music_theory", run_name="__main__")
|
||||
assert info.value.code == 0
|
||||
assert "tone" in capsys.readouterr().out
|
||||
96
music_theory/music_theory/tests/test_compose.py
Normal file
96
music_theory/music_theory/tests/test_compose.py
Normal file
@ -0,0 +1,96 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from music_theory import analysis, compose, sequencer
|
||||
|
||||
|
||||
def test_recipe_round_trips_through_dict() -> None:
|
||||
recipe = compose.Recipe(key="C4", mode="major", progression=(1, 5, 6, 4), bars=2)
|
||||
assert compose.Recipe.from_dict(recipe.to_dict()) == recipe
|
||||
assert recipe.to_dict()["progression"] == [1, 5, 6, 4]
|
||||
|
||||
|
||||
def test_recipe_from_partial_dict_uses_defaults() -> None:
|
||||
recipe = compose.Recipe.from_dict({"tempo_bpm": 90})
|
||||
assert recipe.tempo_bpm == 90.0
|
||||
assert recipe.key == compose.Recipe().key
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("data", "match"),
|
||||
[
|
||||
({"colour": "blue"}, "unknown recipe keys"),
|
||||
({"key": 3}, "string"),
|
||||
({"tempo_bpm": "fast"}, "number"),
|
||||
({"tempo_bpm": True}, "number"),
|
||||
({"bars": 2.5}, "integer"),
|
||||
({"bars": False}, "integer"),
|
||||
({"progression": "1 5 6 4"}, "list of integers"),
|
||||
({"progression": [1, "5"]}, "integer"),
|
||||
({"waveform": "noise"}, "unknown waveform"),
|
||||
],
|
||||
)
|
||||
def test_recipe_rejects_bad_data(data: dict[str, object], match: str) -> None:
|
||||
with pytest.raises((ValueError, TypeError), match=match):
|
||||
compose.Recipe.from_dict(data)
|
||||
|
||||
|
||||
def test_bass_line_is_one_root_per_bar_an_octave_down() -> None:
|
||||
recipe = compose.Recipe(key="C4", mode="major", progression=(1, 5), bars=4)
|
||||
bass = compose.bass_line(recipe)
|
||||
assert [e.midi for e in bass] == [48, 55, 48, 55]
|
||||
assert all(e.length_beats == sequencer.BEATS_PER_BAR for e in bass)
|
||||
|
||||
|
||||
def test_arpeggio_cycles_chord_tones_in_eighths() -> None:
|
||||
recipe = compose.Recipe(key="C4", mode="major", progression=(1,), bars=1)
|
||||
arp = compose.arpeggio(recipe)
|
||||
assert len(arp) == 8
|
||||
assert [e.midi for e in arp[:4]] == [60, 64, 67, 64]
|
||||
assert arp[1].start_beat == 0.5
|
||||
|
||||
|
||||
def test_melody_is_deterministic_in_scale_and_snaps_bar_starts() -> None:
|
||||
recipe = compose.Recipe(key="C4", mode="major", progression=(1, 4), bars=4, seed=7)
|
||||
first = compose.melody(recipe)
|
||||
assert first == compose.melody(recipe)
|
||||
assert first
|
||||
for event in first:
|
||||
assert (event.midi - 60) % 12 in {0, 2, 4, 5, 7, 9, 11}
|
||||
chord_tones = {0, 4, 7, 5, 9}
|
||||
for event in first:
|
||||
if not event.start_beat % sequencer.BEATS_PER_BAR:
|
||||
assert (event.midi - 60) % 12 in chord_tones
|
||||
|
||||
|
||||
def test_melody_thins_out_and_detunes_when_degraded() -> None:
|
||||
clean = compose.Recipe(bars=8, seed=3)
|
||||
ruined = compose.with_degrade(clean, 1.0)
|
||||
assert len(compose.melody(ruined)) < len(compose.melody(clean))
|
||||
assert any(e.cents for e in compose.melody(ruined))
|
||||
assert not any(e.cents for e in compose.melody(clean))
|
||||
|
||||
|
||||
def test_compose_loops_on_the_bar_and_is_normalised() -> None:
|
||||
recipe = compose.Recipe(bars=2, tempo_bpm=120.0)
|
||||
signal = compose.compose(recipe)
|
||||
assert len(signal) == sequencer.loop_frames(120.0, 8.0)
|
||||
assert analysis.peak(signal) == pytest.approx(0.8)
|
||||
assert np.array_equal(signal, compose.compose(recipe))
|
||||
|
||||
|
||||
def test_degrade_zero_is_byte_identical_and_full_is_darker() -> None:
|
||||
clean = compose.Recipe(bars=2, mode="major")
|
||||
assert np.array_equal(
|
||||
compose.compose(clean), compose.compose(compose.with_degrade(clean, 0.0))
|
||||
)
|
||||
ruined = compose.compose(compose.with_degrade(clean, 1.0))
|
||||
assert analysis.spectral_centroid(ruined) < analysis.spectral_centroid(
|
||||
compose.compose(clean)
|
||||
)
|
||||
assert compose.effective(compose.with_degrade(clean, 1.0)).mode == "minor"
|
||||
|
||||
|
||||
def test_length_beats() -> None:
|
||||
assert compose.length_beats(compose.Recipe(bars=3)) == 12.0
|
||||
49
music_theory/music_theory/tests/test_degrade.py
Normal file
49
music_theory/music_theory/tests/test_degrade.py
Normal file
@ -0,0 +1,49 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from music_theory import analysis, compose, degrade, synth
|
||||
|
||||
|
||||
def test_clamp() -> None:
|
||||
assert degrade.clamp(-1.0) == 0.0
|
||||
assert degrade.clamp(2.0) == 1.0
|
||||
assert degrade.clamp(0.3) == 0.3
|
||||
|
||||
|
||||
def test_darker_mode() -> None:
|
||||
assert degrade.darker_mode("major") == "minor"
|
||||
assert degrade.darker_mode("Ionian") == "aeolian"
|
||||
assert degrade.darker_mode("blues") == "blues"
|
||||
|
||||
|
||||
def test_apply_to_recipe_flips_mode_only_past_threshold() -> None:
|
||||
base = compose.Recipe(mode="major", tempo_bpm=100.0)
|
||||
mild = degrade.apply_to_recipe(compose.with_degrade(base, 0.2))
|
||||
assert mild.mode == "major"
|
||||
assert mild.tempo_bpm == pytest.approx(93.0)
|
||||
full = degrade.apply_to_recipe(compose.with_degrade(base, 1.0))
|
||||
assert full.mode == "minor"
|
||||
assert full.tempo_bpm == pytest.approx(65.0)
|
||||
|
||||
|
||||
def test_rest_probability_and_detune() -> None:
|
||||
rng = np.random.default_rng(0)
|
||||
assert degrade.rest_probability(1.0) == degrade.MAX_REST_PROBABILITY
|
||||
assert degrade.detune_cents(0.0, rng) == 0.0
|
||||
cents = degrade.detune_cents(1.0, rng)
|
||||
assert abs(cents) <= degrade.MAX_DETUNE_CENTS
|
||||
|
||||
|
||||
def test_bitcrush_quantises() -> None:
|
||||
out = degrade.bitcrush(np.array([0.123456, -0.5]), 2.0)
|
||||
assert list(out) == [0.0, -0.5]
|
||||
|
||||
|
||||
def test_process_zero_is_a_copy_and_full_is_darker() -> None:
|
||||
saw = synth.oscillator(220.0, 0.5, "saw")
|
||||
same = degrade.process(saw, 0.0, synth.SAMPLE_RATE, 1)
|
||||
assert np.array_equal(same, saw)
|
||||
assert same is not saw
|
||||
ruined = degrade.process(saw, 1.0, synth.SAMPLE_RATE, 1)
|
||||
assert analysis.spectral_centroid(ruined) < analysis.spectral_centroid(saw)
|
||||
66
music_theory/music_theory/tests/test_demos.py
Normal file
66
music_theory/music_theory/tests/test_demos.py
Normal file
@ -0,0 +1,66 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from music_theory import analysis, demo_support, demos, synth
|
||||
|
||||
|
||||
def test_registry_covers_every_section_in_order() -> None:
|
||||
names = list(demos.REGISTRY)
|
||||
assert names[0] == "tone"
|
||||
assert names[-1] == "bracket"
|
||||
assert len(names) == 17
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(demos.REGISTRY))
|
||||
def test_every_demo_renders_audio_with_facts(name: str) -> None:
|
||||
demo = demos.run(name)
|
||||
assert demo.name == name
|
||||
assert demo.facts
|
||||
assert demo.signal.size > 0
|
||||
assert analysis.peak(demo.signal) == pytest.approx(synth.DEFAULT_PEAK)
|
||||
assert np.isfinite(demo.signal).all()
|
||||
|
||||
|
||||
def test_run_rejects_unknown() -> None:
|
||||
with pytest.raises(ValueError, match="unknown demo"):
|
||||
demos.run("silence")
|
||||
|
||||
|
||||
def test_selected_expands_all_and_validates() -> None:
|
||||
assert demos.selected("all") == list(demos.REGISTRY)
|
||||
assert demos.selected("comma") == ["comma"]
|
||||
with pytest.raises(ValueError, match="unknown demo"):
|
||||
demos.selected("nope")
|
||||
|
||||
|
||||
def test_tone_demo_peaks_at_440() -> None:
|
||||
demo = demos.run("tone")
|
||||
assert analysis.dominant_frequency(demo.signal) == pytest.approx(440.0, abs=1.0)
|
||||
|
||||
|
||||
def test_resolution_demo_names_both_semitone_moves() -> None:
|
||||
facts = demos.run("resolution").facts
|
||||
assert "B4 -> C5 by one semitone" in facts
|
||||
assert "F5 -> E5 by one semitone" in facts
|
||||
|
||||
|
||||
def test_divisions_demo_answers_twelve() -> None:
|
||||
assert any(fact.endswith(": 12") for fact in demos.run("divisions").facts)
|
||||
|
||||
|
||||
def test_bracket_demo_is_darker_second() -> None:
|
||||
facts = demos.run("bracket").facts
|
||||
clean = float(facts[0].rsplit(" ", 2)[1])
|
||||
ruined = float(facts[1].rsplit(" ", 2)[1])
|
||||
assert ruined < clean
|
||||
|
||||
|
||||
def test_support_helpers() -> None:
|
||||
line = demo_support.tones([220.0, 440.0], 0.1)
|
||||
assert line.size > 2 * int(0.1 * synth.SAMPLE_RATE)
|
||||
both = demo_support.pair(220.0, 330.0, 0.1)
|
||||
assert analysis.peak(both) == pytest.approx(synth.DEFAULT_PEAK)
|
||||
assert demo_support.names([60, 69]) == "C4 A4"
|
||||
assert demo_support.chord_line([[60, 64, 67]], 1.0).size > 0
|
||||
assert demo_support.note_line([60, 62], 0.5).size > 0
|
||||
38
music_theory/music_theory/tests/test_manifest.py
Normal file
38
music_theory/music_theory/tests/test_manifest.py
Normal file
@ -0,0 +1,38 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from music_theory import __version__, compose, manifest, wav
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_entry_records_recipe_version_and_hash(tmp_path: Path) -> None:
|
||||
recipe = compose.Recipe(bars=1)
|
||||
signal = compose.compose(recipe)
|
||||
path = wav.write(tmp_path / "bed.wav", signal, recipe.sample_rate)
|
||||
record = manifest.entry(recipe, path, signal)
|
||||
assert record["file"] == "bed.wav"
|
||||
assert record["music_theory_version"] == __version__
|
||||
assert record["sha256"] == wav.sha256(path)
|
||||
assert record["frames"] == len(signal)
|
||||
assert record["recipe"] == recipe.to_dict()
|
||||
|
||||
|
||||
def test_write_and_read(tmp_path: Path) -> None:
|
||||
path = tmp_path / "deep" / "m.json"
|
||||
manifest.write(path, {"b": {"x": 1}, "a": {"y": 2}})
|
||||
text = path.read_text()
|
||||
assert text.endswith("\n")
|
||||
assert text.index('"a"') < text.index('"b"')
|
||||
assert manifest.read(path) == {"a": {"y": 2}, "b": {"x": 1}}
|
||||
|
||||
|
||||
def test_read_rejects_non_object(tmp_path: Path) -> None:
|
||||
path = tmp_path / "m.json"
|
||||
path.write_text(json.dumps([1, 2]))
|
||||
with pytest.raises(TypeError, match="manifest object"):
|
||||
manifest.read(path)
|
||||
67
music_theory/music_theory/tests/test_pitch.py
Normal file
67
music_theory/music_theory/tests/test_pitch.py
Normal file
@ -0,0 +1,67 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
import pytest
|
||||
|
||||
from music_theory import pitch
|
||||
|
||||
|
||||
def test_a4_is_440_hz() -> None:
|
||||
assert pitch.midi_to_hz(69) == pytest.approx(440.0)
|
||||
|
||||
|
||||
def test_octave_doubles_frequency() -> None:
|
||||
assert pitch.midi_to_hz(81) == pytest.approx(880.0)
|
||||
assert pitch.midi_to_hz(57) == pytest.approx(220.0)
|
||||
|
||||
|
||||
def test_semitone_ratio_is_twelfth_root_of_two() -> None:
|
||||
assert pitch.semitone_ratio(12) == pytest.approx(2.0)
|
||||
assert pitch.semitone_ratio(1) == pytest.approx(1.059463, abs=1e-6)
|
||||
|
||||
|
||||
def test_hz_to_midi_round_trips() -> None:
|
||||
for midi in (0, 60, 69, 127):
|
||||
assert pitch.hz_to_midi(pitch.midi_to_hz(midi)) == pytest.approx(midi)
|
||||
|
||||
|
||||
def test_hz_to_midi_rejects_non_positive() -> None:
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
pitch.hz_to_midi(0.0)
|
||||
|
||||
|
||||
def test_cents_of_an_octave_is_1200() -> None:
|
||||
assert pitch.cents(2.0) == pytest.approx(1200.0)
|
||||
|
||||
|
||||
def test_cents_rejects_non_positive() -> None:
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
pitch.cents(-1.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("midi", "name"), [(60, "C4"), (69, "A4"), (61, "C#4"), (59, "B3"), (12, "C0")]
|
||||
)
|
||||
def test_note_name(midi: int, name: str) -> None:
|
||||
assert pitch.note_name(midi) == name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "midi"), [("C4", 60), ("A4", 69), ("C#3", 49), ("Bb3", 58), ("Db-1", 1)]
|
||||
)
|
||||
def test_parse_note(name: str, midi: int) -> None:
|
||||
assert pitch.parse_note(name) == midi
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "4", "H4", "C", "Cb4"])
|
||||
def test_parse_note_rejects_garbage(bad: str) -> None:
|
||||
with pytest.raises(ValueError, match=r"note|pitch class"):
|
||||
pitch.parse_note(bad)
|
||||
|
||||
|
||||
def test_transpose_adds_a_constant() -> None:
|
||||
assert pitch.transpose([60, 64, 67], 4) == [64, 68, 71]
|
||||
|
||||
|
||||
def test_pitch_class_wraps() -> None:
|
||||
assert pitch.pitch_class(60) == 0
|
||||
assert pitch.pitch_class(71) == 11
|
||||
assert pitch.pitch_class(72) == 0
|
||||
36
music_theory/music_theory/tests/test_progressions.py
Normal file
36
music_theory/music_theory/tests/test_progressions.py
Normal file
@ -0,0 +1,36 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
import pytest
|
||||
|
||||
from music_theory import progressions, scales
|
||||
|
||||
MAJOR = scales.STEP_PATTERNS["major"]
|
||||
|
||||
|
||||
def test_named_lookup_is_case_insensitive() -> None:
|
||||
assert progressions.named("POP") == (1, 5, 6, 4)
|
||||
|
||||
|
||||
def test_named_rejects_unknown() -> None:
|
||||
with pytest.raises(ValueError, match="unknown progression"):
|
||||
progressions.named("prog rock")
|
||||
|
||||
|
||||
def test_chords_for_resolves_degrees() -> None:
|
||||
prog = progressions.chords_for(60, MAJOR, (1, 5, 6, 4))
|
||||
assert progressions.numerals(prog) == "I V vi IV"
|
||||
assert prog[1].notes == (67, 71, 74)
|
||||
assert prog[2].degree == 6
|
||||
|
||||
|
||||
def test_chords_for_rejects_out_of_range_degree() -> None:
|
||||
with pytest.raises(ValueError, match="outside"):
|
||||
progressions.chords_for(60, MAJOR, (8,))
|
||||
with pytest.raises(ValueError, match="outside"):
|
||||
progressions.chords_for(60, MAJOR, (0,))
|
||||
|
||||
|
||||
def test_transpose_adds_a_constant_to_every_note() -> None:
|
||||
prog = progressions.chords_for(60, MAJOR, (1, 4))
|
||||
moved = progressions.transpose(prog, 4)
|
||||
assert moved[0].notes == (64, 68, 71)
|
||||
assert moved[1].numeral == "IV"
|
||||
70
music_theory/music_theory/tests/test_scales.py
Normal file
70
music_theory/music_theory/tests/test_scales.py
Normal file
@ -0,0 +1,70 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
import pytest
|
||||
|
||||
from music_theory import scales
|
||||
|
||||
|
||||
def test_every_pattern_sums_to_twelve() -> None:
|
||||
for pattern in scales.STEP_PATTERNS.values():
|
||||
assert sum(pattern) == 12
|
||||
|
||||
|
||||
def test_c_major_from_the_article() -> None:
|
||||
assert scales.build(60, scales.STEP_PATTERNS["major"]) == [
|
||||
60,
|
||||
62,
|
||||
64,
|
||||
65,
|
||||
67,
|
||||
69,
|
||||
71,
|
||||
72,
|
||||
]
|
||||
|
||||
|
||||
def test_aeolian_is_major_rotated_five() -> None:
|
||||
assert scales.mode("aeolian") == scales.rotate(scales.STEP_PATTERNS["major"], 5)
|
||||
assert scales.mode("aeolian") == scales.STEP_PATTERNS["minor"]
|
||||
assert scales.mode("Ionian") == scales.STEP_PATTERNS["major"]
|
||||
|
||||
|
||||
def test_rotate_wraps_and_handles_empty() -> None:
|
||||
assert scales.rotate((1, 2, 3), 4) == (2, 3, 1)
|
||||
assert scales.rotate((), 3) == ()
|
||||
|
||||
|
||||
def test_mode_rejects_unknown() -> None:
|
||||
with pytest.raises(ValueError, match="unknown mode"):
|
||||
scales.mode("klingon")
|
||||
|
||||
|
||||
def test_pattern_named_accepts_scales_and_modes() -> None:
|
||||
assert scales.pattern_named("minor") == scales.STEP_PATTERNS["minor"]
|
||||
assert scales.pattern_named("dorian") == scales.mode("dorian")
|
||||
with pytest.raises(ValueError, match="unknown scale"):
|
||||
scales.pattern_named("nope")
|
||||
|
||||
|
||||
def test_validate_rejects_bad_sums_and_steps() -> None:
|
||||
with pytest.raises(ValueError, match="sums to"):
|
||||
scales.validate((2, 2, 2))
|
||||
with pytest.raises(ValueError, match="non-positive"):
|
||||
scales.validate((13, -1))
|
||||
|
||||
|
||||
def test_offsets_exclude_the_octave() -> None:
|
||||
assert scales.offsets(scales.STEP_PATTERNS["major"]) == [0, 2, 4, 5, 7, 9, 11]
|
||||
|
||||
|
||||
def test_pitch_at_degree_wraps_octaves() -> None:
|
||||
major = scales.STEP_PATTERNS["major"]
|
||||
assert scales.pitch_at_degree(60, major, 0) == 60
|
||||
assert scales.pitch_at_degree(60, major, 7) == 72
|
||||
assert scales.pitch_at_degree(60, major, -1) == 59
|
||||
assert scales.pitch_at_degree(60, major, 9) == 76
|
||||
|
||||
|
||||
def test_is_in_scale() -> None:
|
||||
major = scales.STEP_PATTERNS["major"]
|
||||
assert scales.is_in_scale(60, major, 76)
|
||||
assert not scales.is_in_scale(60, major, 61)
|
||||
51
music_theory/music_theory/tests/test_sequencer.py
Normal file
51
music_theory/music_theory/tests/test_sequencer.py
Normal file
@ -0,0 +1,51 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from music_theory import analysis, sequencer, synth
|
||||
|
||||
|
||||
def test_beat_seconds() -> None:
|
||||
assert sequencer.beat_seconds(120.0) == 0.5
|
||||
with pytest.raises(ValueError, match="tempo"):
|
||||
sequencer.beat_seconds(0.0)
|
||||
|
||||
|
||||
def test_loop_frames_floors() -> None:
|
||||
assert sequencer.loop_frames(120.0, 4.0, 22050) == 44100
|
||||
assert sequencer.loop_frames(100.0, 1.0, 22050) == 13230
|
||||
|
||||
|
||||
def test_render_is_exactly_the_requested_length() -> None:
|
||||
events = sequencer.sequence([69, 69, 69], 1.0)
|
||||
out = sequencer.render(events, 120.0, 4.0, 1000)
|
||||
assert len(out) == 2000
|
||||
|
||||
|
||||
def test_render_clips_notes_past_the_end_and_skips_late_starts() -> None:
|
||||
late = sequencer.NoteEvent(69, 10.0, 1.0)
|
||||
overrun = sequencer.NoteEvent(69, 1.5, 4.0)
|
||||
out = sequencer.render([late, overrun], 120.0, 2.0, 1000)
|
||||
assert len(out) == 1000
|
||||
assert np.any(out[800:])
|
||||
|
||||
|
||||
def test_render_note_applies_velocity_and_detune() -> None:
|
||||
quiet = sequencer.render_note(sequencer.NoteEvent(69, 0.0, 1.0, 0.5), 60.0, 22050)
|
||||
loud = sequencer.render_note(sequencer.NoteEvent(69, 0.0, 1.0, 1.0), 60.0, 22050)
|
||||
assert analysis.peak(quiet) == pytest.approx(analysis.peak(loud) / 2)
|
||||
up = sequencer.render_note(
|
||||
sequencer.NoteEvent(69, 0.0, 1.0, cents=1200.0), 60.0, 22050
|
||||
)
|
||||
assert analysis.dominant_frequency(up) == pytest.approx(880.0, abs=1.0)
|
||||
|
||||
|
||||
def test_stack_shares_the_level_between_notes() -> None:
|
||||
chord = sequencer.stack([60, 64, 67], 0.0, 1.0, "saw", synth.Envelope())
|
||||
assert all(e.velocity == pytest.approx(1 / 3) for e in chord)
|
||||
assert sequencer.stack([], 0.0, 1.0) == []
|
||||
|
||||
|
||||
def test_sequence_lays_notes_end_to_end() -> None:
|
||||
events = sequencer.sequence([60, 62], 0.5)
|
||||
assert [e.start_beat for e in events] == [0.0, 0.5]
|
||||
80
music_theory/music_theory/tests/test_synth.py
Normal file
80
music_theory/music_theory/tests/test_synth.py
Normal file
@ -0,0 +1,80 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
from typing import cast
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from music_theory import analysis, synth
|
||||
|
||||
|
||||
def test_time_axis_length_and_negative() -> None:
|
||||
assert len(synth.time_axis(1.0, 100)) == 100
|
||||
assert len(synth.time_axis(-1.0, 100)) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("waveform", synth.WAVEFORMS)
|
||||
def test_every_waveform_peaks_at_its_frequency(waveform: synth.Waveform) -> None:
|
||||
signal = synth.oscillator(440.0, 1.0, waveform)
|
||||
assert analysis.dominant_frequency(signal) == pytest.approx(440.0, abs=1.0)
|
||||
assert np.max(np.abs(signal)) <= 1.0
|
||||
|
||||
|
||||
def test_oscillator_rejects_unknown_waveform() -> None:
|
||||
with pytest.raises(ValueError, match="unknown waveform"):
|
||||
synth.oscillator(440.0, 0.1, cast("synth.Waveform", "noise"))
|
||||
|
||||
|
||||
def test_square_is_two_valued() -> None:
|
||||
assert set(np.unique(synth.oscillator(10.0, 0.5, "square"))) == {-1.0, 1.0}
|
||||
|
||||
|
||||
def test_additive_stacks_harmonics() -> None:
|
||||
signal = synth.additive(100.0, 1.0, [0.0, 1.0])
|
||||
assert analysis.dominant_frequency(signal) == pytest.approx(200.0, abs=1.0)
|
||||
|
||||
|
||||
def test_adsr_shape() -> None:
|
||||
gain = synth.adsr(synth.Envelope(0.1, 0.1, 0.5, 0.1), 1.0, 100)
|
||||
assert len(gain) == 100
|
||||
assert gain[0] == 0.0
|
||||
assert gain[10] == pytest.approx(1.0)
|
||||
assert gain[50] == pytest.approx(0.5)
|
||||
assert gain[-1] < 0.5
|
||||
assert gain[-1] >= 0.0
|
||||
|
||||
|
||||
def test_adsr_longer_than_the_note_is_clamped() -> None:
|
||||
gain = synth.adsr(synth.Envelope(1.0, 1.0, 0.5, 1.0), 0.1, 100)
|
||||
assert len(gain) == 10
|
||||
assert np.all(gain >= 0.0)
|
||||
assert synth.adsr(synth.Envelope(), -1.0, 100).size == 0
|
||||
|
||||
|
||||
def test_lowpass_darkens_and_zero_cutoff_silences() -> None:
|
||||
saw = synth.oscillator(220.0, 0.5, "saw")
|
||||
dark = synth.lowpass(saw, 500.0)
|
||||
assert analysis.spectral_centroid(dark) < analysis.spectral_centroid(saw)
|
||||
assert not np.any(synth.lowpass(saw, 0.0))
|
||||
|
||||
|
||||
def test_detune_by_cents() -> None:
|
||||
assert synth.detune(440.0, 1200.0) == pytest.approx(880.0)
|
||||
assert synth.detune(440.0, 0.0) == 440.0
|
||||
|
||||
|
||||
def test_mix_pads_shorter_signals() -> None:
|
||||
mixed = synth.mix(np.ones(3), np.ones(5))
|
||||
assert list(mixed) == [2.0, 2.0, 2.0, 1.0, 1.0]
|
||||
assert synth.mix().size == 0
|
||||
|
||||
|
||||
def test_normalize_scales_peak_and_keeps_silence() -> None:
|
||||
assert np.max(np.abs(synth.normalize(np.array([0.1, -0.2])))) == pytest.approx(0.8)
|
||||
assert not np.any(synth.normalize(np.zeros(4)))
|
||||
assert synth.normalize(np.zeros(0)).size == 0
|
||||
|
||||
|
||||
def test_to_int16_clips() -> None:
|
||||
out = synth.to_int16(np.array([2.0, -2.0, 0.0]))
|
||||
assert list(out) == [32767, -32767, 0]
|
||||
assert out.dtype == np.int16
|
||||
65
music_theory/music_theory/tests/test_tuning.py
Normal file
65
music_theory/music_theory/tests/test_tuning.py
Normal file
@ -0,0 +1,65 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
from fractions import Fraction
|
||||
|
||||
import pytest
|
||||
|
||||
from music_theory import tuning
|
||||
|
||||
|
||||
def test_harmonic_series_multiplies() -> None:
|
||||
assert tuning.harmonic_series(100.0, 4) == [100.0, 200.0, 300.0, 400.0]
|
||||
|
||||
|
||||
def test_repeat_cycles_is_the_denominator() -> None:
|
||||
assert tuning.repeat_cycles(Fraction(3, 2)) == 2
|
||||
assert tuning.repeat_cycles(tuning.JUST_RATIOS["minor second"]) == 15
|
||||
|
||||
|
||||
def test_pythagorean_comma_value() -> None:
|
||||
assert tuning.pythagorean_comma() == pytest.approx(1.0136432647705078)
|
||||
|
||||
|
||||
def test_stack_fifths_folds_into_the_octave() -> None:
|
||||
assert tuning.stack_fifths(0) == 1.0
|
||||
folded = tuning.stack_fifths(12)
|
||||
assert folded >= 1.0
|
||||
assert folded < 2.0
|
||||
assert tuning.stack_fifths(12) == pytest.approx(tuning.pythagorean_comma())
|
||||
|
||||
|
||||
def test_tempered_fifth_error_is_about_a_tenth_of_a_percent() -> None:
|
||||
score = tuning.fifth_error(12)
|
||||
assert score.step == 7
|
||||
assert score.ratio == pytest.approx(1.498307, abs=1e-6)
|
||||
assert score.error == pytest.approx(0.00113, abs=1e-5)
|
||||
|
||||
|
||||
def test_fifth_error_rejects_non_positive() -> None:
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
tuning.fifth_error(0)
|
||||
|
||||
|
||||
def test_twelve_is_the_first_acceptable_division() -> None:
|
||||
assert tuning.first_acceptable_division(25) == 12
|
||||
|
||||
|
||||
def test_no_acceptable_division_below_twelve() -> None:
|
||||
assert tuning.first_acceptable_division(11) is None
|
||||
|
||||
|
||||
def test_best_divisions_is_sorted_by_error() -> None:
|
||||
scores = tuning.best_divisions(25)
|
||||
errors = [s.error for s in scores]
|
||||
assert errors == sorted(errors)
|
||||
assert len(scores) == 25
|
||||
small = [s.divisions for s in scores if s.divisions <= 12]
|
||||
assert small[0] == 12
|
||||
|
||||
|
||||
def test_beat_frequency_is_the_difference() -> None:
|
||||
assert tuning.beat_frequency(440.0, 446.0) == pytest.approx(6.0)
|
||||
assert tuning.beat_frequency(446.0, 440.0) == pytest.approx(6.0)
|
||||
|
||||
|
||||
def test_tritone_ratio_is_root_two() -> None:
|
||||
assert pytest.approx(2.0) == tuning.TRITONE_RATIO**2
|
||||
42
music_theory/music_theory/tests/test_wav.py
Normal file
42
music_theory/music_theory/tests/test_wav.py
Normal file
@ -0,0 +1,42 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
from typing import TYPE_CHECKING
|
||||
import wave
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from music_theory import synth, wav
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_write_and_read_round_trip(tmp_path: Path) -> None:
|
||||
signal = synth.oscillator(440.0, 0.1)
|
||||
path = wav.write(tmp_path / "sub" / "a.wav", signal, 8000)
|
||||
back, rate = wav.read(path)
|
||||
assert rate == 8000
|
||||
assert len(back) == len(signal)
|
||||
assert np.allclose(back, signal, atol=1e-3)
|
||||
|
||||
|
||||
def test_read_rejects_stereo(tmp_path: Path) -> None:
|
||||
path = tmp_path / "stereo.wav"
|
||||
with wave.Wave_write(str(path)) as handle:
|
||||
handle.setnchannels(2)
|
||||
handle.setsampwidth(2)
|
||||
handle.setframerate(8000)
|
||||
handle.writeframes(b"\0\0\0\0")
|
||||
with pytest.raises(ValueError, match="16-bit mono"):
|
||||
wav.read(path)
|
||||
|
||||
|
||||
def test_sha256_is_stable(tmp_path: Path) -> None:
|
||||
path = wav.write(tmp_path / "a.wav", np.zeros(10))
|
||||
assert wav.sha256(path) == wav.sha256(path)
|
||||
assert len(wav.sha256(path)) == 64
|
||||
|
||||
|
||||
def test_duration_and_play_command(tmp_path: Path) -> None:
|
||||
assert wav.duration_seconds(np.zeros(22050)) == 1.0
|
||||
assert wav.play_command(tmp_path / "x.wav").startswith("aplay -q ")
|
||||
115
music_theory/music_theory/tuning.py
Normal file
115
music_theory/music_theory/tuning.py
Normal file
@ -0,0 +1,115 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Tuning: why the tuning everyone wants cannot exist, and what we use instead.
|
||||
|
||||
Article sections 3, 4 and 5. Consonance is the ear finding a repeating
|
||||
pattern quickly, which small-integer ratios give it. Stacking twelve pure
|
||||
fifths overshoots seven octaves (the Pythagorean comma) because 3**n can
|
||||
never equal 2**m, so equal temperament splits the octave into twelve equal
|
||||
multiplicative steps and accepts a fifth that is 0.11 % flat everywhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from fractions import Fraction
|
||||
import math
|
||||
|
||||
## The just intervals, as the small-integer ratios the harmonic series gives.
|
||||
JUST_RATIOS: dict[str, Fraction] = {
|
||||
"unison": Fraction(1, 1),
|
||||
"octave": Fraction(2, 1),
|
||||
"perfect fifth": Fraction(3, 2),
|
||||
"perfect fourth": Fraction(4, 3),
|
||||
"major third": Fraction(5, 4),
|
||||
"minor third": Fraction(6, 5),
|
||||
"major sixth": Fraction(5, 3),
|
||||
"minor second": Fraction(16, 15),
|
||||
}
|
||||
|
||||
## The tritone: sqrt(2), halfway up the octave, no integer ratio at all.
|
||||
TRITONE_RATIO = math.sqrt(2.0)
|
||||
|
||||
## A fifth is "acceptable" when it is within this of 3/2 (the article's bar:
|
||||
## twelve is the first division whose fifth lands within about 0.1 %).
|
||||
ACCEPTABLE_FIFTH_ERROR = 0.002
|
||||
|
||||
## How many pure fifths it takes to come back round to the same pitch class.
|
||||
FIFTHS_IN_CYCLE = 12
|
||||
OCTAVES_IN_CYCLE = 7
|
||||
|
||||
_PURE_FIFTH = 1.5
|
||||
_OCTAVE = 2.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DivisionScore:
|
||||
"""How well an n-way equal division of the octave approximates a fifth."""
|
||||
|
||||
divisions: int
|
||||
step: int
|
||||
ratio: float
|
||||
error: float
|
||||
|
||||
|
||||
def harmonic_series(fundamental_hz: float, count: int) -> list[float]:
|
||||
"""The first ``count`` harmonics: 1x, 2x, 3x ... the fundamental."""
|
||||
return [fundamental_hz * n for n in range(1, count + 1)]
|
||||
|
||||
|
||||
def repeat_cycles(ratio: Fraction) -> int:
|
||||
"""Cycles of the LOWER note before two notes' combined wave repeats.
|
||||
|
||||
For 3/2 the lower note completes 2 cycles while the upper completes 3
|
||||
and the pattern restarts: the ear locks on almost at once. For 16/15 it
|
||||
takes 15 cycles, which is why a minor second sounds rough.
|
||||
"""
|
||||
return ratio.denominator
|
||||
|
||||
|
||||
def pythagorean_comma() -> float:
|
||||
"""(3/2) ** 12 / 2 ** 7 -- about 1.0136, the gap that breaks pure tuning."""
|
||||
return _PURE_FIFTH**FIFTHS_IN_CYCLE / _OCTAVE**OCTAVES_IN_CYCLE
|
||||
|
||||
|
||||
def stack_fifths(count: int) -> float:
|
||||
"""Ratio after ``count`` pure fifths, folded back into one octave."""
|
||||
ratio = _PURE_FIFTH**count
|
||||
while ratio >= _OCTAVE:
|
||||
ratio /= _OCTAVE
|
||||
return ratio
|
||||
|
||||
|
||||
def fifth_error(divisions: int) -> DivisionScore:
|
||||
"""Closest step of an n-way equal division to a pure fifth, and its error."""
|
||||
if divisions <= 0:
|
||||
msg = f"divisions must be positive, got {divisions}"
|
||||
raise ValueError(msg)
|
||||
step = round(divisions * math.log2(_PURE_FIFTH))
|
||||
ratio = _OCTAVE ** (step / divisions)
|
||||
return DivisionScore(divisions, step, ratio, abs(ratio / _PURE_FIFTH - 1.0))
|
||||
|
||||
|
||||
def best_divisions(max_divisions: int) -> list[DivisionScore]:
|
||||
"""Every division up to ``max_divisions``, best fifth first.
|
||||
|
||||
Brute force, exactly as the article does it: the answer to "why twelve"
|
||||
is that twelve is the smallest count whose fifth clears the bar, and the
|
||||
next improvements (19, 31, 53) buy little for a lot more notes.
|
||||
"""
|
||||
scores = [fifth_error(n) for n in range(1, max_divisions + 1)]
|
||||
return sorted(scores, key=lambda score: (score.error, score.divisions))
|
||||
|
||||
|
||||
def first_acceptable_division(
|
||||
max_divisions: int, tolerance: float = ACCEPTABLE_FIFTH_ERROR
|
||||
) -> int | None:
|
||||
"""Smallest division whose fifth is within ``tolerance`` of 3/2."""
|
||||
for n in range(1, max_divisions + 1):
|
||||
if fifth_error(n).error <= tolerance:
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def beat_frequency(hz_a: float, hz_b: float) -> float:
|
||||
"""How many times per second two close tones beat against each other."""
|
||||
return abs(hz_a - hz_b)
|
||||
68
music_theory/music_theory/wav.py
Normal file
68
music_theory/music_theory/wav.py
Normal file
@ -0,0 +1,68 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""WAV: write what was rendered so a person can hear it, and fingerprint it.
|
||||
|
||||
16-bit mono PCM is what Godot's ``AudioStreamWAV`` and ``aplay`` both take
|
||||
without conversion. The SHA-256 is what a manifest records so a re-render
|
||||
can prove it reproduced the committed file byte for byte.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import TYPE_CHECKING
|
||||
import wave
|
||||
|
||||
import numpy as np
|
||||
|
||||
from music_theory import synth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_BYTES_PER_SAMPLE = 2
|
||||
_CHANNELS = 1
|
||||
|
||||
|
||||
def write(
|
||||
path: Path, signal: synth.Signal, sample_rate: int = synth.SAMPLE_RATE
|
||||
) -> Path:
|
||||
"""Write a float signal as 16-bit mono PCM and return the path."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with wave.Wave_write(str(path)) as handle:
|
||||
handle.setnchannels(_CHANNELS)
|
||||
handle.setsampwidth(_BYTES_PER_SAMPLE)
|
||||
handle.setframerate(sample_rate)
|
||||
handle.writeframes(synth.to_int16(signal).tobytes())
|
||||
return path
|
||||
|
||||
|
||||
def read(path: Path) -> tuple[synth.Signal, int]:
|
||||
"""Read a 16-bit mono WAV back into a float signal and its sample rate."""
|
||||
with wave.Wave_read(str(path)) as handle:
|
||||
if (
|
||||
handle.getsampwidth() != _BYTES_PER_SAMPLE
|
||||
or handle.getnchannels() != _CHANNELS
|
||||
):
|
||||
msg = f"{path} is not 16-bit mono"
|
||||
raise ValueError(msg)
|
||||
rate = handle.getframerate()
|
||||
raw = handle.readframes(handle.getnframes())
|
||||
samples = np.frombuffer(raw, dtype=np.int16).astype(np.float64) / 32767.0
|
||||
return samples, rate
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
"""Hex digest of the file's bytes."""
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def duration_seconds(
|
||||
signal: synth.Signal, sample_rate: int = synth.SAMPLE_RATE
|
||||
) -> float:
|
||||
"""Length of a signal in seconds."""
|
||||
return len(signal) / sample_rate
|
||||
|
||||
|
||||
def play_command(path: Path) -> str:
|
||||
"""The one line a person runs to hear the file."""
|
||||
return f"aplay -q {path}"
|
||||
142
music_theory/pyproject.toml
Normal file
142
music_theory/pyproject.toml
Normal file
@ -0,0 +1,142 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "music_theory"
|
||||
version = "0.1.0"
|
||||
description = "Music theory from first principles, as code: pitch, tuning, scales, chords, progressions, and a tiny synth to hear them"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = ["numpy==2.5.3"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["music_theory*"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py314"
|
||||
include = ["*.py", "**/*.py"]
|
||||
exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"D203",
|
||||
"D213",
|
||||
"COM812",
|
||||
"ISC001",
|
||||
"S603",
|
||||
]
|
||||
fixable = ["ALL"]
|
||||
unfixable = []
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
|
||||
"**/test_*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
force-single-line = false
|
||||
force-sort-within-sections = true
|
||||
known-first-party = ["music_theory"]
|
||||
|
||||
[tool.ruff.lint.flake8-quotes]
|
||||
docstring-quotes = "double"
|
||||
inline-quotes = "double"
|
||||
|
||||
[tool.ruff.lint.flake8-tidy-imports]
|
||||
ban-relative-imports = "all"
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
skip-magic-trailing-comma = false
|
||||
line-ending = "auto"
|
||||
docstring-code-format = true
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.14"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
warn_no_return = true
|
||||
warn_unreachable = true
|
||||
disallow_any_unimported = true
|
||||
disallow_any_explicit = false
|
||||
disallow_any_generics = true
|
||||
disallow_subclassing_any = true
|
||||
strict_equality = true
|
||||
extra_checks = true
|
||||
ignore_missing_imports = true
|
||||
show_error_codes = true
|
||||
color_output = true
|
||||
exclude = [".venv/"]
|
||||
|
||||
[tool.pylint.main]
|
||||
analyse-fallback-blocks = true
|
||||
persistent = true
|
||||
jobs = 0
|
||||
py-version = "3.14"
|
||||
ignore = [".venv", "__pycache__"]
|
||||
ignore-patterns = [".*\\.pyi$"]
|
||||
|
||||
[tool.pylint.messages_control]
|
||||
enable = "all"
|
||||
disable = []
|
||||
|
||||
[tool.pylint.design]
|
||||
min-public-methods = 0
|
||||
max-module-lines = 1000
|
||||
max-attributes = 12
|
||||
|
||||
[tool.bandit]
|
||||
exclude_dirs = ["tests", ".venv"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["music_theory/tests"]
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = [
|
||||
"-v",
|
||||
"--strict-markers",
|
||||
"--strict-config",
|
||||
"-ra",
|
||||
"--cov=music_theory",
|
||||
"--cov-branch",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=lcov",
|
||||
]
|
||||
filterwarnings = [
|
||||
"error",
|
||||
"ignore::DeprecationWarning",
|
||||
"default::pytest.PytestUnraisableExceptionWarning",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["music_theory"]
|
||||
branch = true
|
||||
omit = [
|
||||
"*/__pycache__/*",
|
||||
"*/tests/*",
|
||||
"*/.venv/*",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 100
|
||||
show_missing = true
|
||||
skip_covered = false
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
'if __name__ == "__main__":',
|
||||
]
|
||||
partial_branches = ["pragma: no branch"]
|
||||
11
music_theory/requirements.txt
Normal file
11
music_theory/requirements.txt
Normal file
@ -0,0 +1,11 @@
|
||||
# Exact pins, enforced by the shared dependency-freshness gate: a `>=`
|
||||
# floor is satisfied by anything newer, so it tells a checker nothing
|
||||
# about whether this repo is actually current.
|
||||
bandit[toml]==1.9.4
|
||||
mypy==2.3.1
|
||||
numpy==2.5.3
|
||||
pre-commit==4.6.2
|
||||
pylint==4.0.8
|
||||
pytest==9.1.1
|
||||
pytest-cov==7.1.0
|
||||
ruff==0.16.7
|
||||
Loading…
Reference in New Issue
Block a user