mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 18:07:24 +01:00
Merge origin/main into main
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
holds pre-commit / pre-commit (push) Has been cancelled
holds tests / test (push) Has been cancelled
music_theory tests / test (push) Has been cancelled
sickdays pre-commit / pre-commit (push) Has been cancelled
sickdays tests / test (push) Has been cancelled
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
holds pre-commit / pre-commit (push) Has been cancelled
holds tests / test (push) Has been cancelled
music_theory tests / test (push) Has been cancelled
sickdays pre-commit / pre-commit (push) Has been cancelled
sickdays tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
This commit is contained in:
commit
1a74345c40
2
.github/workflows/music-theory-tests.yml
vendored
2
.github/workflows/music-theory-tests.yml
vendored
@ -28,4 +28,4 @@ jobs:
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: python -m pytest -v
|
||||
run: python -m pytest -v --cov=music_theory
|
||||
|
||||
@ -66,9 +66,14 @@ repos:
|
||||
# answer and passes with a loud banner; `--no-verify` is banned here, so
|
||||
# a hook that hard-failed on a flaky connection would leave no way to
|
||||
# commit at all. CI, always online, is the strict authority.
|
||||
#
|
||||
# Scoped to manifests this branch changed (vs merge-base with main), so
|
||||
# `--all-files` asks "did THIS change leave a stale pin?" and not "is
|
||||
# every project in the monorepo current?". CI `--all --strict` keeps
|
||||
# the repo-wide answer. See scripts/dep_freshness_changed.sh.
|
||||
- id: dependency-freshness
|
||||
name: dependencies on newest stable
|
||||
entry: bash scripts/check_dependency_freshness.sh
|
||||
entry: bash scripts/dep_freshness_changed.sh
|
||||
language: system
|
||||
pass_filenames: true
|
||||
files: >-
|
||||
|
||||
@ -7,4 +7,4 @@ pre-commit==4.7.0
|
||||
pylint==4.1.3
|
||||
pytest==9.2.0
|
||||
pytest-cov==7.1.0
|
||||
ruff==0.17.3
|
||||
ruff==0.17.4
|
||||
|
||||
@ -7,4 +7,4 @@ pre-commit==4.7.0
|
||||
pylint==4.1.3
|
||||
pytest==9.2.0
|
||||
pytest-cov==7.1.0
|
||||
ruff==0.17.3
|
||||
ruff==0.17.4
|
||||
|
||||
@ -135,7 +135,7 @@ repos:
|
||||
hooks:
|
||||
- id: pytest-coverage
|
||||
name: pytest with coverage enforcement
|
||||
entry: scripts/run_subproject_tests.sh music_theory
|
||||
entry: scripts/run_subproject_tests.sh music_theory --cov=music_theory
|
||||
language: script
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
|
||||
@ -83,6 +83,9 @@ 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.
|
||||
|
||||
A hand-written score (`score.py`, `examples/`) spells notes out in text:
|
||||
3/4, 6/8 or any x/2, x/4, x/8 meter, named voices, noise drums, A/B sections.
|
||||
|
||||
## 11. The complete composition
|
||||
|
||||
Pick a key and a progression, play the root as bass, the chord tones as an
|
||||
|
||||
@ -70,6 +70,15 @@ 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.
|
||||
|
||||
## Render a score
|
||||
|
||||
```
|
||||
python -m music_theory render --score examples/waltz.txt --out waltz.wav
|
||||
```
|
||||
|
||||
Named voices, noise drums, `meter`, A/B sections. Format: `score.py`
|
||||
docstring; examples: `examples/waltz.txt` (3/4), `examples/jig.txt` (6/8).
|
||||
|
||||
## Render sound effects
|
||||
|
||||
`sfx` renders spellcode's spell and move sounds -- sfxr-style parameter
|
||||
@ -116,7 +125,7 @@ name, so a re-render is byte-identical. A sound is a few `sfx.Layer`s
|
||||
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
python -m pytest # 100 % branch coverage is enforced
|
||||
python -m pytest --cov=music_theory # 100 % branch coverage is enforced
|
||||
pre-commit run --config music_theory/.pre-commit-config.yaml --all-files # from ~/src/utils
|
||||
```
|
||||
|
||||
|
||||
25
music_theory/examples/jig.txt
Normal file
25
music_theory/examples/jig.txt
Normal file
@ -0,0 +1,25 @@
|
||||
# Jig in G major, 6/8: two groups of three eighths a bar, felt in two.
|
||||
# 175 quarter bpm (dotted quarter ~117). Form A A B B, 16 bars = 48 beats
|
||||
# = 362879 frames (loop_frames floors 48 * 60/175 * 22050).
|
||||
# Measured dominant frequency: 97.2 Hz ~ G2 (98.00 Hz), the bass's tonic.
|
||||
title jig
|
||||
meter 6/8
|
||||
tempo 175
|
||||
seed 20261106
|
||||
voice bass wave=triangle vel=0.8
|
||||
voice lead wave=square vel=0.25
|
||||
voice drums noise vel=0.25
|
||||
|
||||
section A bars=4
|
||||
bass: G2/1.5 D3/1.5 | C3/1.5 G2/1.5 | G2/1.5 D3/1.5 | D3/1.5 A2/1.5
|
||||
lead: G5/1 B5/0.5 D6/1 B5/0.5 | C6/1 E6/0.5 D6/1 B5/0.5
|
||||
lead: G5/0.5 A5/0.5 B5/0.5 A5/0.5 G5/0.5 E5/0.5 | D5/1 F#5/0.5 A5/1 F#5/0.5
|
||||
drums: k . h s . h | k . h s . h | k . h s . h | k . h s s s
|
||||
|
||||
section B bars=4
|
||||
bass: C3/1.5 G2/1.5 | G2/1.5 D3/1.5 | D3/1.5 A2/1.5 | G2/3
|
||||
lead: E5/1 G5/0.5 C6/1 G5/0.5 | D5/1 G5/0.5 B5/1 G5/0.5
|
||||
lead: A5/0.5 B5/0.5 A5/0.5 F#5/0.5 E5/0.5 D5/0.5 | G5/1.5 G4/1.5
|
||||
drums: k . h s . h | k . h s . h | k . h s . h | k . . k . .
|
||||
|
||||
play A A B B
|
||||
33
music_theory/examples/waltz.txt
Normal file
33
music_theory/examples/waltz.txt
Normal file
@ -0,0 +1,33 @@
|
||||
# Waltz in C major, 3/4: oom-pah-pah, bass on beat one, chord tones on two
|
||||
# and three. 120 bpm (11025 frames a beat at 22050 Hz, so sections join on
|
||||
# a whole frame). Form A B, 16 bars = 48 beats = 529200 frames.
|
||||
# Measured dominant frequency: 130.7 Hz = C3 (130.81 Hz), the bass's
|
||||
# most-sustained note (8 of 16 bars).
|
||||
title waltz
|
||||
meter 3/4
|
||||
tempo 120
|
||||
seed 20261106
|
||||
voice bass wave=triangle vel=0.9
|
||||
voice pah wave=triangle vel=0.3
|
||||
voice lead wave=sine vel=0.5
|
||||
voice drums noise vel=0.25
|
||||
|
||||
section A bars=8
|
||||
bass: C3/3 | C3/3 | G2/3 | G2/3 | G2/3 | G2/3 | C3/3 | C3/3
|
||||
pah: -/1 E4/1 G4/1 | -/1 E4/1 G4/1 | -/1 B3/1 D4/1 | -/1 B3/1 D4/1
|
||||
pah: -/1 B3/1 F4/1 | -/1 B3/1 F4/1 | -/1 E4/1 G4/1 | -/1 E4/1 G4/1
|
||||
lead: E5/2 G5/1 | C6/3 | B5/2 A5/1 | G5/3
|
||||
lead: F5/2 G5/1 | D5/2 B4/1 | C5/2 E5/1 | C5/3
|
||||
drums: k . h . h . | k . h . h . | k . h . h . | k . h . h .
|
||||
drums: k . h . h . | k . h . h . | k . h . h . | k . s . s .
|
||||
|
||||
section B bars=8
|
||||
bass: F2/3 | F2/3 | C3/3 | C3/3 | G2/3 | G2/3 | C3/3 | C3/3
|
||||
pah: -/1 A3/1 C4/1 | -/1 A3/1 C4/1 | -/1 E4/1 G4/1 | -/1 E4/1 G4/1
|
||||
pah: -/1 B3/1 F4/1 | -/1 B3/1 D4/1 | -/1 E4/1 G4/1 | -/1 E4/1 G4/1
|
||||
lead: A5/2 C6/1 | F5/3 | G5/2 E5/1 | C5/3
|
||||
lead: D5/1 F5/1 B5/1 | G5/3 | E5/2 D5/1 | C5/3
|
||||
drums: k . h . h . | k . h . h . | k . h . h . | k . h . h .
|
||||
drums: k . h . h . | k . h . h . | k . h . s . | k . s s s s
|
||||
|
||||
play A B
|
||||
@ -2,7 +2,8 @@
|
||||
"""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``
|
||||
``render --recipe recipe.json --out bed.wav [--manifest m.json]`` and
|
||||
``render (--recipe recipe.json | --score piece.txt) --out bed.wav
|
||||
[--manifest m.json]`` and
|
||||
``sfx --out DIR [--sample-rate HZ] [--only NAME ...]``.
|
||||
"""
|
||||
|
||||
@ -14,10 +15,20 @@ from pathlib import Path
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from music_theory import compose, demos, manifest, sfx, sfx_presets, sfx_render, wav
|
||||
from music_theory import (
|
||||
compose,
|
||||
demos,
|
||||
manifest,
|
||||
score,
|
||||
sfx,
|
||||
sfx_presets,
|
||||
sfx_render,
|
||||
wav,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from music_theory.demo_support import Demo
|
||||
from music_theory.synth import Signal
|
||||
|
||||
|
||||
def _emit(line: str) -> None:
|
||||
@ -34,8 +45,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
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 = sub.add_parser("render", help="render a recipe JSON or a score to a WAV")
|
||||
source = render.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--recipe", type=Path, help="recipe JSON file")
|
||||
source.add_argument("--score", type=Path, help="plain-text score 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(
|
||||
@ -91,7 +104,27 @@ def cmd_render(
|
||||
recipe = compose.Recipe.from_dict(data)
|
||||
if degrade is not None:
|
||||
recipe = compose.with_degrade(recipe, degrade)
|
||||
signal = compose.compose(recipe)
|
||||
return _write(compose.compose(recipe), recipe, out, manifest_path)
|
||||
|
||||
|
||||
def cmd_render_score(
|
||||
score_path: Path, out: Path, manifest_path: Path | None, degrade: float | None
|
||||
) -> int:
|
||||
"""Render a score file to a WAV; a bad score writes nothing."""
|
||||
if degrade is not None:
|
||||
_emit("error: --degrade applies to --recipe only")
|
||||
return 1
|
||||
try:
|
||||
piece = score.parse(score_path.read_text())
|
||||
except score.ScoreError as error:
|
||||
_emit(f"error: {score_path}: {error}")
|
||||
return 1
|
||||
return _write(compose.compose_score(piece), piece, out, manifest_path)
|
||||
|
||||
|
||||
def _write(
|
||||
signal: Signal, recipe: manifest.Recordable, out: Path, manifest_path: Path | None
|
||||
) -> int:
|
||||
wav.write(out, signal, recipe.sample_rate)
|
||||
record = manifest.entry(recipe, out, signal)
|
||||
if manifest_path is not None:
|
||||
@ -130,4 +163,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return cmd_demo(args.name, args.out)
|
||||
if args.command == "sfx":
|
||||
return cmd_sfx(args.out, args.sample_rate, args.only)
|
||||
if args.score is not None:
|
||||
return cmd_render_score(args.score, args.out, args.manifest, args.degrade)
|
||||
return cmd_render(args.recipe, args.out, args.manifest, args.degrade)
|
||||
|
||||
@ -11,12 +11,24 @@ bytes -- the property a game asset manifest needs.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, fields, replace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
from music_theory import degrade, pitch, progressions, scales, sequencer, synth
|
||||
from music_theory import (
|
||||
degrade,
|
||||
pitch,
|
||||
progressions,
|
||||
scales,
|
||||
score_events,
|
||||
sequencer,
|
||||
synth,
|
||||
)
|
||||
from music_theory.pitch import SEMITONES_PER_OCTAVE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from music_theory import score
|
||||
|
||||
## Voice levels, chosen so the bass carries and the melody sits on top.
|
||||
BASS_VELOCITY = 0.9
|
||||
ARPEGGIO_VELOCITY = 0.35
|
||||
@ -228,6 +240,11 @@ def compose(recipe: Recipe) -> synth.Signal:
|
||||
return synth.normalize(signal)
|
||||
|
||||
|
||||
def compose_score(piece: score.Score) -> synth.Signal:
|
||||
"""Render a written score to a loop-length signal, normalised like a recipe."""
|
||||
return synth.normalize(score_events.render(piece))
|
||||
|
||||
|
||||
def with_degrade(recipe: Recipe, amount: float) -> Recipe:
|
||||
"""The same recipe at another degrade amount: the compositional bracket."""
|
||||
return replace(recipe, degrade=amount)
|
||||
|
||||
250
music_theory/music_theory/score.py
Normal file
250
music_theory/music_theory/score.py
Normal file
@ -0,0 +1,250 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Score: a plain-text piece with named voices, noise drums and A/B sections.
|
||||
|
||||
Article section 10 treats notation as a serialization format; this is a
|
||||
small one for text files. A ``Recipe`` generates its notes, a score spells
|
||||
them out, and both render through the same sequencer. One directive per
|
||||
line; a line starting with ``#`` is a comment::
|
||||
|
||||
title waltz
|
||||
meter 3/4
|
||||
tempo 120
|
||||
seed 7
|
||||
voice lead wave=triangle vel=0.5
|
||||
voice drums noise vel=0.4
|
||||
section A bars=2
|
||||
lead: C5/2 E5/1 | G5/3
|
||||
drums: k . h . h . | k . s . h .
|
||||
play A A
|
||||
|
||||
``tempo`` counts quarter notes and ``/length`` is in quarter beats (default
|
||||
1), so a 3/4 or 6/8 bar holds three. ``-`` is a rest. Drum steps are ``k``
|
||||
kick, ``s`` snare, ``h`` hat or ``.``, one per eighth. ``play`` is optional
|
||||
(default: sections in file order). A bar that does not add up to the meter
|
||||
is an error naming the line, so a typo never shifts every later note.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from music_theory import score_tokens, sequencer, synth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from music_theory.score_tokens import Bar
|
||||
|
||||
DEFAULT_TEMPO = 120.0
|
||||
DEFAULT_VELOCITY = 0.5
|
||||
|
||||
|
||||
class ScoreError(ValueError):
|
||||
"""A score that cannot be rendered; the message names the line."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Voice:
|
||||
"""A named part: pitched (one waveform) or a noise drum kit."""
|
||||
|
||||
name: str
|
||||
noise: bool = False
|
||||
waveform: synth.Waveform = "triangle"
|
||||
velocity: float = DEFAULT_VELOCITY
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Section:
|
||||
"""A named run of bars; a voice with no lines in it rests."""
|
||||
|
||||
name: str
|
||||
bars: int
|
||||
parts: dict[str, tuple[Bar, ...]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Score:
|
||||
"""A parsed score plus the text it came from (kept for the manifest)."""
|
||||
|
||||
title: str
|
||||
meter: sequencer.Meter
|
||||
tempo_bpm: float
|
||||
seed: int
|
||||
sample_rate: int
|
||||
voices: tuple[Voice, ...]
|
||||
sections: tuple[Section, ...]
|
||||
order: tuple[str, ...]
|
||||
source: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
"""Manifest form: the full score text, which re-parses to this score."""
|
||||
return {"format": "score", "title": self.title, "text": self.source}
|
||||
|
||||
|
||||
class _Parser:
|
||||
"""Line-by-line state; ``finish`` checks what needs the whole file."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.title = ""
|
||||
self.meter = sequencer.Meter()
|
||||
self.tempo = DEFAULT_TEMPO
|
||||
self.seed = 0
|
||||
self.rate = synth.SAMPLE_RATE
|
||||
self.voices: dict[str, Voice] = {}
|
||||
## (name, bars, header line, bars written so far per voice)
|
||||
self.sections: list[tuple[str, int, int, dict[str, list[Bar]]]] = []
|
||||
self.order: tuple[tuple[str, ...], int] | None = None
|
||||
|
||||
def line(self, number: int, text: str) -> None:
|
||||
"""Apply one non-comment line."""
|
||||
head, colon, tail = text.partition(":")
|
||||
if colon and len(head.split()) == 1:
|
||||
self._part(head.strip(), tail)
|
||||
return
|
||||
word, _, rest = text.partition(" ")
|
||||
if word not in self._HANDLERS:
|
||||
msg = f"unknown directive {word!r}"
|
||||
raise ValueError(msg)
|
||||
self._HANDLERS[word](self, number, rest.strip())
|
||||
|
||||
def _set_title(self, _: int, rest: str) -> None:
|
||||
self.title = rest
|
||||
|
||||
def _set_tempo(self, _: int, rest: str) -> None:
|
||||
self.tempo = score_tokens.positive(rest, "tempo")
|
||||
|
||||
def _set_seed(self, _: int, rest: str) -> None:
|
||||
self.seed = score_tokens.count(rest, "seed", 0)
|
||||
|
||||
def _set_rate(self, _: int, rest: str) -> None:
|
||||
self.rate = score_tokens.count(rest, "rate", 1)
|
||||
|
||||
def _set_meter(self, _: int, rest: str) -> None:
|
||||
if self.sections:
|
||||
msg = "meter must come before the first section"
|
||||
raise ValueError(msg)
|
||||
self.meter = sequencer.Meter.parse(rest)
|
||||
|
||||
def _voice(self, _: int, rest: str) -> None:
|
||||
name, *options = rest.split() or [""]
|
||||
if not name:
|
||||
msg = "voice needs a name"
|
||||
raise ValueError(msg)
|
||||
if name in self.voices:
|
||||
msg = f"duplicate voice {name!r}"
|
||||
raise ValueError(msg)
|
||||
settings = dict(option.partition("=")[::2] for option in options)
|
||||
noise = "noise" in options
|
||||
if noise:
|
||||
del settings["noise"]
|
||||
if set(settings) - {"wave", "vel"} or "" in settings.values():
|
||||
msg = f"unknown voice option in {rest!r}; use noise, wave=, vel="
|
||||
raise ValueError(msg)
|
||||
if noise and "wave" in settings:
|
||||
msg = "a noise voice takes no wave="
|
||||
raise ValueError(msg)
|
||||
waveform = score_tokens.waveform(settings.get("wave", "triangle"))
|
||||
velocity = score_tokens.positive(
|
||||
settings.get("vel", str(DEFAULT_VELOCITY)), "vel"
|
||||
)
|
||||
self.voices[name] = Voice(name, noise, waveform, velocity)
|
||||
|
||||
def _section(self, number: int, rest: str) -> None:
|
||||
name, _, bars = rest.partition(" bars=")
|
||||
if not name or not bars:
|
||||
msg = "section needs a name and bars=N"
|
||||
raise ValueError(msg)
|
||||
if any(name == known for known, *_ in self.sections):
|
||||
msg = f"duplicate section {name!r}"
|
||||
raise ValueError(msg)
|
||||
self.sections.append((name, score_tokens.count(bars, "bars", 1), number, {}))
|
||||
|
||||
def _play(self, number: int, rest: str) -> None:
|
||||
if not rest:
|
||||
msg = "play needs at least one section name"
|
||||
raise ValueError(msg)
|
||||
self.order = (tuple(rest.split()), number)
|
||||
|
||||
def _part(self, name: str, rest: str) -> None:
|
||||
if not self.sections:
|
||||
msg = "part outside a section"
|
||||
raise ValueError(msg)
|
||||
if name not in self.voices:
|
||||
msg = f"unknown voice {name!r}"
|
||||
raise ValueError(msg)
|
||||
step = score_tokens.drum_step if self.voices[name].noise else score_tokens.note
|
||||
bars = self.sections[-1][3].setdefault(name, [])
|
||||
expected = self.meter.beats_per_bar
|
||||
for bar_text in rest.strip().strip("|").split("|"):
|
||||
measure = tuple(step(token) for token in bar_text.split())
|
||||
got = sum(length for _, length in measure)
|
||||
if not math.isclose(got, expected):
|
||||
msg = (
|
||||
f"voice {name} bar {len(bars) + 1}: "
|
||||
f"expected {expected:g} beats, got {got:g}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
bars.append(measure)
|
||||
|
||||
_HANDLERS: ClassVar[dict[str, Callable[[_Parser, int, str], None]]] = {
|
||||
"title": _set_title,
|
||||
"meter": _set_meter,
|
||||
"tempo": _set_tempo,
|
||||
"seed": _set_seed,
|
||||
"rate": _set_rate,
|
||||
"voice": _voice,
|
||||
"section": _section,
|
||||
"play": _play,
|
||||
}
|
||||
|
||||
def finish(self, source: str) -> Score:
|
||||
"""Check bar counts and play order, then build the ``Score``."""
|
||||
if not self.sections:
|
||||
msg = "score has no sections"
|
||||
raise ScoreError(msg)
|
||||
for name, count, number, parts in self.sections:
|
||||
for voice, bars in parts.items():
|
||||
if len(bars) != count:
|
||||
msg = (
|
||||
f"line {number}: section {name}: voice {voice} has "
|
||||
f"{len(bars)} bars, expected {count}"
|
||||
)
|
||||
raise ScoreError(msg)
|
||||
names = tuple(name for name, *_ in self.sections)
|
||||
order, number = self.order or (names, 0)
|
||||
for name in order:
|
||||
if name not in names:
|
||||
msg = f"line {number}: unknown section {name!r}"
|
||||
raise ScoreError(msg)
|
||||
sections = tuple(
|
||||
Section(name, count, {v: tuple(b) for v, b in parts.items()})
|
||||
for name, count, _, parts in self.sections
|
||||
)
|
||||
return Score(
|
||||
self.title,
|
||||
self.meter,
|
||||
self.tempo,
|
||||
self.seed,
|
||||
self.rate,
|
||||
tuple(self.voices.values()),
|
||||
sections,
|
||||
order,
|
||||
source,
|
||||
)
|
||||
|
||||
|
||||
def parse(text: str) -> Score:
|
||||
"""Parse score text; every error is a ``ScoreError`` naming its line."""
|
||||
parser = _Parser()
|
||||
for number, raw in enumerate(text.splitlines(), start=1):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
try:
|
||||
parser.line(number, line)
|
||||
except ValueError as error:
|
||||
msg = f"line {number}: {error}"
|
||||
raise ScoreError(msg) from error
|
||||
return parser.finish(text)
|
||||
88
music_theory/music_theory/score_events.py
Normal file
88
music_theory/music_theory/score_events.py
Normal file
@ -0,0 +1,88 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Score events: lay a parsed score's sections end to end on the beat grid.
|
||||
|
||||
``play A B A`` is a timeline: each section starts where the previous one
|
||||
ended, and every bar inside it starts at a whole multiple of the meter's
|
||||
beats. Pitched voices become ``NoteEvent``s, noise voices ``DrumHit``s.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from music_theory import sequencer, synth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
from music_theory.score import Score, Section, Voice
|
||||
from music_theory.score_tokens import Step
|
||||
|
||||
## Note shape for every pitched voice: struck, short decay, clean release.
|
||||
NOTE_ENVELOPE = synth.Envelope(0.01, 0.08, 0.6, 0.08)
|
||||
|
||||
|
||||
def _timeline(piece: Score) -> Iterator[tuple[float, Section]]:
|
||||
by_name = {section.name: section for section in piece.sections}
|
||||
offset = 0.0
|
||||
for name in piece.order:
|
||||
yield offset, by_name[name]
|
||||
offset += by_name[name].bars * piece.meter.beats_per_bar
|
||||
|
||||
|
||||
def _steps(piece: Score, voice: Voice) -> Iterator[tuple[float, Step]]:
|
||||
for offset, section in _timeline(piece):
|
||||
for index, measure in enumerate(section.parts.get(voice.name, ())):
|
||||
beat = offset + index * piece.meter.beats_per_bar
|
||||
for step in measure:
|
||||
yield beat, step
|
||||
beat += step[1]
|
||||
|
||||
|
||||
def length_beats(piece: Score) -> float:
|
||||
"""Total length in quarter beats: every played bar times the meter."""
|
||||
return sum(section.bars for _, section in _timeline(piece)) * (
|
||||
piece.meter.beats_per_bar
|
||||
)
|
||||
|
||||
|
||||
def note_events(piece: Score) -> list[sequencer.NoteEvent]:
|
||||
"""Every pitched note, in play order; rests leave a gap."""
|
||||
return [
|
||||
sequencer.NoteEvent(
|
||||
value, beat, length, voice.velocity, voice.waveform, NOTE_ENVELOPE
|
||||
)
|
||||
for voice in piece.voices
|
||||
if not voice.noise
|
||||
for beat, (value, length) in _steps(piece, voice)
|
||||
if isinstance(value, int)
|
||||
]
|
||||
|
||||
|
||||
def drum_parts(piece: Score) -> list[tuple[int, list[sequencer.DrumHit]]]:
|
||||
"""Each noise voice's hits, keyed by the voice's index (its seed salt)."""
|
||||
return [
|
||||
(
|
||||
index,
|
||||
[
|
||||
sequencer.DrumHit(value, beat, length, voice.velocity)
|
||||
for beat, (value, length) in _steps(piece, voice)
|
||||
if isinstance(value, str)
|
||||
],
|
||||
)
|
||||
for index, voice in enumerate(piece.voices)
|
||||
if voice.noise
|
||||
]
|
||||
|
||||
|
||||
def render(piece: Score) -> synth.Signal:
|
||||
"""Mix every voice into one buffer exactly the score's length, unnormalised."""
|
||||
beats = length_beats(piece)
|
||||
total = sequencer.render(
|
||||
note_events(piece), piece.tempo_bpm, beats, piece.sample_rate
|
||||
)
|
||||
for index, hits in drum_parts(piece):
|
||||
total += sequencer.render_hits(
|
||||
hits, piece.tempo_bpm, beats, piece.sample_rate, (piece.seed, index)
|
||||
)
|
||||
return total
|
||||
70
music_theory/music_theory/score_tokens.py
Normal file
70
music_theory/music_theory/score_tokens.py
Normal file
@ -0,0 +1,70 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Score tokens: the words of a score line -- notes, drum steps, numbers.
|
||||
|
||||
Split from ``score`` so the line parser stays readable: each function here
|
||||
reads one token and raises ``ValueError`` with what was wrong; ``score``
|
||||
adds the line number.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from music_theory import pitch, sequencer, synth
|
||||
|
||||
REST = "-"
|
||||
DRUM_STEP_BEATS = 0.5
|
||||
_DRUM_STEPS: dict[str, sequencer.DrumKind | None] = {
|
||||
"k": "kick",
|
||||
"s": "snare",
|
||||
"h": "hat",
|
||||
".": None,
|
||||
}
|
||||
|
||||
## One step of a part: a MIDI note, a drum kind or a rest, and its length.
|
||||
type Step = tuple[int | sequencer.DrumKind | None, float]
|
||||
type Bar = tuple[Step, ...]
|
||||
|
||||
|
||||
def positive(text: str, what: str) -> float:
|
||||
"""``text`` as a finite number above zero."""
|
||||
try:
|
||||
value = float(text)
|
||||
except ValueError:
|
||||
value = 0.0
|
||||
if not value > 0.0 or not math.isfinite(value):
|
||||
msg = f"{what} must be a positive number, got {text!r}"
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
|
||||
def count(text: str, what: str, minimum: int) -> int:
|
||||
"""``text`` as a whole number of at least ``minimum``."""
|
||||
if not text.isdigit() or int(text) < minimum:
|
||||
msg = f"{what} must be an integer >= {minimum}, got {text!r}"
|
||||
raise ValueError(msg)
|
||||
return int(text)
|
||||
|
||||
|
||||
def waveform(name: str) -> synth.Waveform:
|
||||
"""The oscillator shape called ``name``."""
|
||||
for shape in synth.WAVEFORMS:
|
||||
if shape == name:
|
||||
return shape
|
||||
msg = f"unknown waveform {name!r}; choose one of {synth.WAVEFORMS}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def note(token: str) -> Step:
|
||||
"""``C5/2`` or ``-/1``: a MIDI note or rest, and its length in beats."""
|
||||
name, _, length = token.partition("/")
|
||||
beats = positive(length or "1", "note length")
|
||||
return (None if name == REST else pitch.parse_note(name), beats)
|
||||
|
||||
|
||||
def drum_step(token: str) -> Step:
|
||||
"""``k``, ``s``, ``h`` or ``.``: one eighth of kick, snare, hat or rest."""
|
||||
if token not in _DRUM_STEPS:
|
||||
msg = f"unknown drum step {token!r}; use k, s, h or ."
|
||||
raise ValueError(msg)
|
||||
return (_DRUM_STEPS[token], DRUM_STEP_BEATS)
|
||||
@ -10,15 +10,81 @@ beats is what makes a loop close on a bar boundary.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import numpy as np
|
||||
|
||||
from music_theory import pitch, synth
|
||||
from music_theory import biquad, pitch, synth
|
||||
|
||||
## A 4/4 bar, the article's default and the only meter used here.
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
## A 4/4 bar, the article's default and what every ``Recipe`` renders in.
|
||||
BEATS_PER_BAR = 4
|
||||
SECONDS_PER_MINUTE = 60.0
|
||||
|
||||
## Beats are always quarter notes (tempo counts them), so a meter's bottom
|
||||
## number only rescales: 6/8 is three quarter beats in two groups of three.
|
||||
QUARTER = 4
|
||||
## Halves, quarters, eighths: anything finer would split a drum eighth.
|
||||
METER_DENOMINATORS = (2, 4, 8)
|
||||
|
||||
type DrumKind = Literal["kick", "snare", "hat"]
|
||||
DRUM_KINDS: tuple[DrumKind, ...] = ("kick", "snare", "hat")
|
||||
## Noise drums: (filter, cutoff Hz, decay time constant in seconds). Low
|
||||
## noise thumps, mid noise cracks, high noise ticks.
|
||||
_DRUMS: dict[DrumKind, tuple[biquad.FilterKind, float, float]] = {
|
||||
"kick": ("lowpass", 160.0, 0.07),
|
||||
"snare": ("bandpass", 1800.0, 0.05),
|
||||
"hat": ("highpass", 7000.0, 0.015),
|
||||
}
|
||||
## Short linear fade so a hit cut at its slot never ends in a click.
|
||||
_FADE_SECONDS = 0.005
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Meter:
|
||||
"""Time signature: ``numerator`` notes of 1/``denominator`` per bar."""
|
||||
|
||||
numerator: int = BEATS_PER_BAR
|
||||
denominator: int = QUARTER
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject meters the beat grid cannot hold."""
|
||||
if self.numerator <= 0 or self.denominator not in METER_DENOMINATORS:
|
||||
msg = (
|
||||
f"unsupported meter {self}; bottom must be one of {METER_DENOMINATORS}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""The usual written form, e.g. ``3/4``."""
|
||||
return f"{self.numerator}/{self.denominator}"
|
||||
|
||||
@property
|
||||
def beats_per_bar(self) -> float:
|
||||
"""Quarter-note beats in one bar: 3/4 and 6/8 are both 3."""
|
||||
return self.numerator * QUARTER / self.denominator
|
||||
|
||||
@classmethod
|
||||
def parse(cls, text: str) -> Meter:
|
||||
"""Meter from ``"3/4"``-style text."""
|
||||
top, _, bottom = text.partition("/")
|
||||
if not (top.isdigit() and bottom.isdigit()):
|
||||
msg = f"not a meter: {text!r}"
|
||||
raise ValueError(msg)
|
||||
return cls(int(top), int(bottom))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DrumHit:
|
||||
"""One unpitched hit: kind, start and slot length in beats, 0..1 velocity."""
|
||||
|
||||
kind: DrumKind
|
||||
start_beat: float
|
||||
length_beats: float
|
||||
velocity: float = 1.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NoteEvent:
|
||||
@ -74,11 +140,61 @@ def render(
|
||||
)
|
||||
for event in events:
|
||||
start = loop_frames(tempo_bpm, event.start_beat, sample_rate)
|
||||
if start < len(total):
|
||||
_add_at(total, start, render_note(event, tempo_bpm, sample_rate))
|
||||
return total
|
||||
|
||||
|
||||
def _add_at(total: synth.Signal, start: int, sound: synth.Signal) -> None:
|
||||
end = min(start + len(sound), len(total))
|
||||
total[start:end] += sound[: end - start]
|
||||
|
||||
|
||||
def drum_sample(
|
||||
kind: DrumKind,
|
||||
frames: int,
|
||||
rng: np.random.Generator,
|
||||
sample_rate: int = synth.SAMPLE_RATE,
|
||||
) -> synth.Signal:
|
||||
"""Filtered noise with an exponential decay, peak 1, faded to 0 at the end."""
|
||||
if kind not in _DRUMS:
|
||||
msg = f"unknown drum {kind!r}; choose one of {DRUM_KINDS}"
|
||||
raise ValueError(msg)
|
||||
shape, cutoff, decay = _DRUMS[kind]
|
||||
times = np.arange(frames, dtype=np.float64) / sample_rate
|
||||
hit = biquad.biquad(
|
||||
rng.uniform(-1.0, 1.0, frames), shape, cutoff, sample_rate=sample_rate
|
||||
) * np.exp(-times / decay)
|
||||
fade = min(max(int(_FADE_SECONDS * sample_rate), 1), frames)
|
||||
hit[frames - fade :] *= np.linspace(1.0, 0.0, fade)
|
||||
return synth.normalize(hit, 1.0)
|
||||
|
||||
|
||||
def render_hits(
|
||||
hits: list[DrumHit],
|
||||
tempo_bpm: float,
|
||||
length_beats: float,
|
||||
sample_rate: int,
|
||||
seed: Sequence[int],
|
||||
) -> synth.Signal:
|
||||
"""Mix drum hits into a buffer exactly ``length_beats`` long.
|
||||
|
||||
Each kind's noise comes from its own generator seeded by ``seed`` plus
|
||||
the kind, so every kick is the same kick wherever it falls: a section
|
||||
sounds identical whether or not another section played before it.
|
||||
"""
|
||||
total = np.zeros(loop_frames(tempo_bpm, length_beats, sample_rate))
|
||||
cache: dict[tuple[DrumKind, int], synth.Signal] = {}
|
||||
for hit in hits:
|
||||
start = loop_frames(tempo_bpm, hit.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]
|
||||
frames = loop_frames(tempo_bpm, hit.length_beats, sample_rate)
|
||||
key = (hit.kind, frames)
|
||||
if key not in cache:
|
||||
rng = np.random.default_rng([*seed, DRUM_KINDS.index(hit.kind)])
|
||||
cache[key] = drum_sample(hit.kind, frames, rng, sample_rate)
|
||||
_add_at(total, start, cache[key] * hit.velocity)
|
||||
return total
|
||||
|
||||
|
||||
|
||||
@ -119,3 +119,54 @@ def test_sfx_rejects_unknown_preset(
|
||||
with pytest.raises(SystemExit):
|
||||
cli.main(["sfx", "--out", str(tmp_path), "--only", "frost_hit"])
|
||||
assert "invalid choice" in capsys.readouterr().err
|
||||
|
||||
|
||||
SCORE = "meter 3/4\ntempo 120\nvoice v\nsection A bars=2\nv: A4/3 | C5/3\n"
|
||||
|
||||
|
||||
def test_render_score_writes_wav_and_manifest(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
score_path = tmp_path / "s.txt"
|
||||
score_path.write_text(SCORE)
|
||||
out = tmp_path / "s.wav"
|
||||
man = tmp_path / "MANIFEST.json"
|
||||
args = ["render", "--score", str(score_path), "--out", str(out)]
|
||||
assert cli.main([*args, "--manifest", str(man)]) == 0
|
||||
entry = manifest.read(man)["s.wav"]
|
||||
assert entry["frames"] == 66150
|
||||
assert entry["recipe"] == {"format": "score", "title": "", "text": SCORE}
|
||||
assert entry["sha256"] == wav.sha256(out)
|
||||
assert "sha256" in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "extra", "message"),
|
||||
[
|
||||
(SCORE.replace("A4/3", "A4/5"), [], "line 5: voice v bar 1: expected 3"),
|
||||
(SCORE, ["--degrade", "0.5"], "--degrade applies to --recipe only"),
|
||||
],
|
||||
)
|
||||
def test_render_score_errors_write_no_wav(
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
text: str,
|
||||
extra: list[str],
|
||||
message: str,
|
||||
) -> None:
|
||||
score_path = tmp_path / "s.txt"
|
||||
score_path.write_text(text)
|
||||
out = tmp_path / "s.wav"
|
||||
args = ["render", "--score", str(score_path), "--out", str(out), *extra]
|
||||
assert cli.main(args) == 1
|
||||
assert not out.exists()
|
||||
assert message in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sources", [[], ["--recipe", "r.json", "--score", "s.txt"]])
|
||||
def test_render_needs_exactly_one_source(
|
||||
sources: list[str], capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
cli.main(["render", *sources, "--out", "x.wav"])
|
||||
assert "--recipe" in capsys.readouterr().err
|
||||
|
||||
196
music_theory/music_theory/tests/test_score.py
Normal file
196
music_theory/music_theory/tests/test_score.py
Normal file
@ -0,0 +1,196 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from music_theory import analysis, compose, score, score_events, sequencer
|
||||
|
||||
## 120 bpm at 22050 Hz is 11025 frames a beat: every bar starts on a whole
|
||||
## frame, so a section rendered alone lines up sample for sample.
|
||||
WALTZ = """\
|
||||
# a test waltz
|
||||
title test waltz
|
||||
meter 3/4
|
||||
tempo 120
|
||||
seed 7
|
||||
voice lead wave=sine vel=0.5
|
||||
voice bass wave=sine vel=0.9
|
||||
voice drums noise vel=0.4
|
||||
section A bars=2
|
||||
lead: A4/3 | A4/2 -/1
|
||||
bass: A2/3 |
|
||||
bass: A2/1.5 A2/1.5
|
||||
drums: k . h . h . | k . s . h .
|
||||
section B bars=1
|
||||
lead: E5/1 E5/1 E5/1
|
||||
drums: k h h h s h
|
||||
"""
|
||||
|
||||
|
||||
def _render(text: str) -> np.ndarray:
|
||||
return score_events.render(score.parse(text))
|
||||
|
||||
|
||||
def _drop(text: str, prefix: str) -> str:
|
||||
return "".join(
|
||||
line for line in text.splitlines(keepends=True) if not line.startswith(prefix)
|
||||
)
|
||||
|
||||
|
||||
def test_parse_reads_header_voices_sections_and_default_order() -> None:
|
||||
piece = score.parse(WALTZ)
|
||||
assert piece.title == "test waltz"
|
||||
assert str(piece.meter) == "3/4"
|
||||
assert piece.tempo_bpm == 120.0
|
||||
assert piece.seed == 7
|
||||
assert piece.sample_rate == 22050
|
||||
assert [v.name for v in piece.voices] == ["lead", "bass", "drums"]
|
||||
assert [v.noise for v in piece.voices] == [False, False, True]
|
||||
assert piece.order == ("A", "B")
|
||||
assert score_events.length_beats(piece) == 9.0
|
||||
assert piece.to_dict() == {"format": "score", "title": "test waltz", "text": WALTZ}
|
||||
|
||||
|
||||
def test_header_defaults_and_rate() -> None:
|
||||
piece = score.parse("rate 8000\nvoice v\nsection A bars=1\nv: C4/4\n")
|
||||
assert (piece.meter.beats_per_bar, piece.tempo_bpm, piece.seed) == (4.0, 120.0, 0)
|
||||
assert piece.sample_rate == 8000
|
||||
assert piece.voices[0].waveform == "triangle"
|
||||
|
||||
|
||||
def test_note_events_follow_the_bars_and_skip_rests() -> None:
|
||||
events = score_events.note_events(score.parse(WALTZ))
|
||||
lead = [(e.midi, e.start_beat, e.length_beats) for e in events if e.velocity == 0.5]
|
||||
assert lead == [(69, 0.0, 3.0), (69, 3.0, 2.0), (76, 6.0, 1.0), (76, 7.0, 1.0),
|
||||
(76, 8.0, 1.0)] # fmt: skip
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("meter", "frames"), [("3/4", 3), ("6/8", 3), ("4/4", 4)])
|
||||
def test_meter_renders_bar_exact_loops(meter: str, frames: int) -> None:
|
||||
text = f"meter {meter}\ntempo 100\nvoice d noise\nsection A bars=4\n"
|
||||
signal = compose.compose_score(score.parse(text))
|
||||
assert len(signal) == sequencer.loop_frames(100.0, 4 * frames)
|
||||
assert not np.any(signal)
|
||||
|
||||
|
||||
def test_six_eight_drums_take_six_eighths_a_bar() -> None:
|
||||
text = "meter 6/8\nvoice d noise\nsection A bars=1\nd: k . . s . .\n"
|
||||
hits = score_events.drum_parts(score.parse(text))
|
||||
assert hits == [(0, [sequencer.DrumHit("kick", 0.0, 0.5, 0.5),
|
||||
sequencer.DrumHit("snare", 1.5, 0.5, 0.5)])] # fmt: skip
|
||||
|
||||
|
||||
def test_waltz_is_bar_exact_normalised_and_byte_identical() -> None:
|
||||
signal = compose.compose_score(score.parse(WALTZ))
|
||||
assert len(signal) == sequencer.loop_frames(120.0, 9.0)
|
||||
assert analysis.peak(signal) == pytest.approx(0.8)
|
||||
assert np.array_equal(signal, compose.compose_score(score.parse(WALTZ)))
|
||||
|
||||
|
||||
def test_two_named_voices_stack() -> None:
|
||||
no_drums = _drop(WALTZ, "drums:")
|
||||
both = _render(no_drums)
|
||||
lead = _render(_drop(no_drums, "bass:"))
|
||||
bass = _render(_drop(no_drums, "lead:"))
|
||||
assert np.allclose(both, lead + bass)
|
||||
assert not np.array_equal(both, lead)
|
||||
assert analysis.dominant_frequency(lead) == pytest.approx(440.0, abs=1.0)
|
||||
assert analysis.dominant_frequency(bass) == pytest.approx(110.0, abs=1.0)
|
||||
|
||||
|
||||
def test_noise_drums_are_seeded_bright_and_not_silent() -> None:
|
||||
drums_only = _drop(_drop(WALTZ, "lead:"), "bass:")
|
||||
first = _render(drums_only)
|
||||
assert np.any(first)
|
||||
assert np.array_equal(first, _render(drums_only))
|
||||
assert not np.array_equal(first, _render(drums_only.replace("seed 7", "seed 8")))
|
||||
pitched = _render(_drop(WALTZ, "drums:"))
|
||||
full = _render(WALTZ)
|
||||
assert analysis.spectral_centroid(full) > analysis.spectral_centroid(pitched)
|
||||
assert analysis.dominant_frequency(full) == pytest.approx(
|
||||
analysis.dominant_frequency(pitched), abs=1.0
|
||||
)
|
||||
|
||||
|
||||
def test_play_order_renders_sections_in_sequence() -> None:
|
||||
a_then_b = _render(WALTZ + "play A B\n")
|
||||
a_only = _render(WALTZ + "play A\n")
|
||||
b_only = _render(WALTZ + "play B\n")
|
||||
assert np.array_equal(a_then_b, np.concatenate([a_only, b_only]))
|
||||
assert np.array_equal(_render(WALTZ), a_then_b)
|
||||
assert np.array_equal(
|
||||
_render(WALTZ + "play B A\n"), np.concatenate([b_only, a_only])
|
||||
)
|
||||
repeated = score.parse(WALTZ + "play A B A\n")
|
||||
assert score_events.length_beats(repeated) == 15.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "match"),
|
||||
[
|
||||
(
|
||||
"voice v\nsection A bars=1\nv: C4/5\n",
|
||||
"line 3: voice v bar 1: expected 4 beats, got 5",
|
||||
),
|
||||
(
|
||||
"meter 3/4\nvoice v\nsection A bars=1\nv: C4/2 D4/3\n",
|
||||
"expected 3 beats, got 5",
|
||||
),
|
||||
("voice d noise\nsection A bars=1\nd: k . .\n", "expected 4 beats, got 1.5"),
|
||||
("voice v\nsection A bars=1\nw: C4/4\n", "line 3: unknown voice 'w'"),
|
||||
("meter 7/3\n", "line 1: .*meter"),
|
||||
("voice v\n", "no sections"),
|
||||
("voice v\nsection A bars=1\nplay A C\n", "line 3: unknown section 'C'"),
|
||||
(
|
||||
"voice v\nsection A bars=2\nv: C4/4\n",
|
||||
"line 2: section A: voice v has 1 bars, expected 2",
|
||||
),
|
||||
(
|
||||
(
|
||||
"voice v\nsection A bars=1\nsection B bars=1\nv: C4/4 |\n"
|
||||
"section A bars=1\n"
|
||||
),
|
||||
"line 5: duplicate section 'A'",
|
||||
),
|
||||
("voice v\nvoice v\n", "line 2: duplicate voice 'v'"),
|
||||
("v: C4/4\n", "line 1: part outside a section"),
|
||||
("section A bars=1\nmeter 3/4\n", "line 2: meter must come before"),
|
||||
("voice v\nsection A bars=1\nv: H4/4\n", "line 3: .*H4"),
|
||||
("voice v\nsection A bars=1\nv: C4/x\n", "line 3: .*length"),
|
||||
("voice v\nsection A bars=1\nv: C4/0\n", "line 3: .*length"),
|
||||
("voice d noise\nsection A bars=1\nd: k x . . . . . .\n", "drum step 'x'"),
|
||||
("voice v wave=organ\n", "line 1: unknown waveform"),
|
||||
("voice v loud=1\n", "line 1: unknown voice option"),
|
||||
("voice d noise wave=saw\n", "line 1: a noise voice"),
|
||||
("voice\n", "line 1: voice needs a name"),
|
||||
("voice v vel=x\n", "line 1: .*number"),
|
||||
("tempo 0\n", "line 1: tempo"),
|
||||
("seed -1\n", "line 1: seed"),
|
||||
("seed 1.5\n", "line 1: .*integer"),
|
||||
("section A\n", "line 1: section needs"),
|
||||
("section A bars=0\n", "line 1: .*bars"),
|
||||
("swing 60\n", "line 1: unknown directive 'swing'"),
|
||||
("play\n", "line 1: play needs"),
|
||||
],
|
||||
)
|
||||
def test_parse_errors_name_the_line(text: str, match: str) -> None:
|
||||
with pytest.raises(score.ScoreError, match=match):
|
||||
score.parse(text)
|
||||
|
||||
|
||||
EXAMPLES = Path(__file__).parents[2] / "examples"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "meter", "bars", "hz"),
|
||||
[("waltz", "3/4", 16, 130.81), ("jig", "6/8", 16, 98.0)],
|
||||
)
|
||||
def test_examples_render_bar_exact_at_their_stated_pitch(
|
||||
name: str, meter: str, bars: int, hz: float
|
||||
) -> None:
|
||||
piece = score.parse((EXAMPLES / f"{name}.txt").read_text())
|
||||
signal = compose.compose_score(piece)
|
||||
assert str(piece.meter) == meter
|
||||
assert len(signal) == sequencer.loop_frames(piece.tempo_bpm, bars * 3.0)
|
||||
assert analysis.dominant_frequency(signal) == pytest.approx(hz, abs=1.0)
|
||||
@ -1,4 +1,6 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
from typing import cast
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
@ -49,3 +51,52 @@ def test_stack_shares_the_level_between_notes() -> None:
|
||||
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]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "beats"), [("4/4", 4.0), ("3/4", 3.0), ("6/8", 3.0), ("2/2", 4.0)]
|
||||
)
|
||||
def test_meter_beats_per_bar_counts_quarter_notes(text: str, beats: float) -> None:
|
||||
meter = sequencer.Meter.parse(text)
|
||||
assert meter.beats_per_bar == beats
|
||||
assert str(meter) == text
|
||||
|
||||
|
||||
def test_meter_default_is_the_old_fixed_four() -> None:
|
||||
assert sequencer.Meter().beats_per_bar == sequencer.BEATS_PER_BAR
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["7/3", "0/4", "3", "a/4", "3/4/4"])
|
||||
def test_meter_rejects_what_is_not_a_meter(text: str) -> None:
|
||||
with pytest.raises(ValueError, match="meter"):
|
||||
sequencer.Meter.parse(text)
|
||||
|
||||
|
||||
def test_render_hits_is_exactly_the_requested_length_and_seeded() -> None:
|
||||
hits = [
|
||||
sequencer.DrumHit("kick", 0.0, 0.5),
|
||||
sequencer.DrumHit("hat", 1.0, 0.5, 0.5),
|
||||
sequencer.DrumHit("snare", 1.5, 4.0),
|
||||
sequencer.DrumHit("kick", 9.0, 0.5),
|
||||
]
|
||||
out = sequencer.render_hits(hits, 120.0, 2.0, 1000, (3, 0))
|
||||
assert len(out) == 1000
|
||||
assert np.any(out[:250])
|
||||
assert np.any(out[800:])
|
||||
same = sequencer.render_hits(hits, 120.0, 2.0, 1000, (3, 0))
|
||||
assert np.array_equal(out, same)
|
||||
other = sequencer.render_hits(hits, 120.0, 2.0, 1000, (4, 0))
|
||||
assert not np.array_equal(out, other)
|
||||
|
||||
|
||||
def test_drum_kinds_differ_in_brightness_and_fade_to_silence() -> None:
|
||||
def centroid(kind: sequencer.DrumKind) -> float:
|
||||
sample = sequencer.drum_sample(kind, 4000, np.random.default_rng(1), 22050)
|
||||
assert sample[-1] == 0.0
|
||||
assert analysis.peak(sample) == pytest.approx(1.0)
|
||||
return analysis.spectral_centroid(sample)
|
||||
|
||||
assert centroid("kick") < centroid("snare") < centroid("hat")
|
||||
cowbell = cast("sequencer.DrumKind", "cowbell")
|
||||
with pytest.raises(ValueError, match="drum"):
|
||||
sequencer.drum_sample(cowbell, 100, np.random.default_rng(1))
|
||||
|
||||
@ -121,7 +121,9 @@ addopts = [
|
||||
"--strict-markers",
|
||||
"--strict-config",
|
||||
"-ra",
|
||||
"--cov=music_theory",
|
||||
## No --cov here: an addopts source would widen every narrowed run
|
||||
## (``--cov=music_theory.score``) back to the whole package. CI and the
|
||||
## pre-push hook pass --cov=music_theory explicitly.
|
||||
"--cov-branch",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=lcov",
|
||||
|
||||
@ -2,10 +2,10 @@
|
||||
# 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.4.0
|
||||
mypy==2.4.1
|
||||
numpy==2.5.4
|
||||
pre-commit==4.7.0
|
||||
pylint==4.1.3
|
||||
pytest==9.1.1
|
||||
pytest==9.2.0
|
||||
pytest-cov==7.1.0
|
||||
ruff==0.17.2
|
||||
ruff==0.17.4
|
||||
|
||||
74
scripts/dep_freshness_changed.sh
Executable file
74
scripts/dep_freshness_changed.sh
Executable file
@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# dep_freshness_changed.sh - run the dependency-freshness gate on the
|
||||
# manifests THIS BRANCH changed, not on every manifest pre-commit hands over.
|
||||
#
|
||||
# Why: `pre-commit run --all-files` (the todoImplementer runner's check) passes
|
||||
# every manifest in the monorepo, so one feature branch went red on 43 stale
|
||||
# pins in 12 projects it never touched. Scoping to changed manifests makes the
|
||||
# hook answer "did this change leave a stale pin?". The repo-wide question
|
||||
# stays with CI (.github/workflows/dependency-freshness.yml: --all --strict).
|
||||
#
|
||||
# "Changed" = differs from the merge-base with $DEP_FRESHNESS_BASE (default
|
||||
# main, then origin/main), working tree included, plus untracked files.
|
||||
# A plain commit only passes staged files, which are all "changed", so normal
|
||||
# commits behave exactly as before.
|
||||
#
|
||||
# Fails closed: no resolvable base, or a cwd that is not the repo root (so
|
||||
# paths cannot be compared), forwards every file unfiltered.
|
||||
#
|
||||
# Usage: scripts/dep_freshness_changed.sh <file>...
|
||||
# Env: DEP_FRESHNESS_GATE gate to call (default: check_dependency_freshness.sh
|
||||
# next to this script); DEP_FRESHNESS_BASE (main)
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly SCRIPT_DIR
|
||||
readonly GATE="${DEP_FRESHNESS_GATE:-$SCRIPT_DIR/check_dependency_freshness.sh}"
|
||||
readonly BASE="${DEP_FRESHNESS_BASE:-main}"
|
||||
|
||||
# Print the merge-base with $BASE (or origin/$BASE); empty when neither exists.
|
||||
merge_base() {
|
||||
local ref
|
||||
for ref in "$BASE" "origin/$BASE"; do
|
||||
if git rev-parse --verify --quiet "$ref^{commit}" >/dev/null; then
|
||||
git merge-base HEAD "$ref" 2>/dev/null && return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Print root-relative paths changed since $1, one per line.
|
||||
changed_since() {
|
||||
git diff --name-only "$1"
|
||||
git ls-files --others --exclude-standard --full-name
|
||||
}
|
||||
|
||||
main() {
|
||||
local base
|
||||
base="$(merge_base || true)"
|
||||
if [[ -z "$base" || -n "$(git rev-parse --show-prefix)" ]]; then
|
||||
exec bash "$GATE" "$@"
|
||||
fi
|
||||
|
||||
local -A changed=()
|
||||
local path
|
||||
while IFS= read -r path; do
|
||||
changed["$path"]=1
|
||||
done < <(changed_since "$base")
|
||||
|
||||
local -a keep=()
|
||||
for path in "$@"; do
|
||||
if [[ -n "${changed[$path]:-}" ]]; then
|
||||
keep+=("$path")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#keep[@]} -eq 0 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
exec bash "$GATE" "${keep[@]}"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
114
scripts/tests/test_dep_freshness_changed.py
Normal file
114
scripts/tests/test_dep_freshness_changed.py
Normal file
@ -0,0 +1,114 @@
|
||||
"""dep_freshness_changed.sh: the pre-commit hook only gates manifests this branch changed.
|
||||
|
||||
Each test builds a throwaway git repo with a `main` branch and points the
|
||||
wrapper at a stub gate (DEP_FRESHNESS_GATE) that records its arguments, so
|
||||
nothing touches the network and the only thing under test is the filtering.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "dep_freshness_changed.sh"
|
||||
|
||||
STUB = """\
|
||||
#!/bin/bash
|
||||
printf '%s\\n' "$@" > "$STUB_LOG"
|
||||
exit "${STUB_EXIT:-0}"
|
||||
"""
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> None:
|
||||
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> Path:
|
||||
"""Repo on `main` with two committed manifests, then a feature branch."""
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
_git(root, "init", "-q", "-b", "main")
|
||||
_git(root, "config", "user.email", "t@example.invalid")
|
||||
_git(root, "config", "user.name", "t")
|
||||
for sub in ("touched", "untouched"):
|
||||
(root / sub).mkdir()
|
||||
(root / sub / "requirements.txt").write_text("ruff==0.1.0\n")
|
||||
_git(root, "add", ".")
|
||||
_git(root, "commit", "-q", "-m", "init")
|
||||
_git(root, "checkout", "-q", "-b", "feature")
|
||||
return root
|
||||
|
||||
|
||||
def _run(
|
||||
repo: Path, tmp_path: Path, *files: str, **env: str
|
||||
) -> tuple[int, list[str] | None]:
|
||||
stub = tmp_path / "stub.sh"
|
||||
stub.write_text(STUB)
|
||||
stub.chmod(0o755)
|
||||
log = tmp_path / "stub.log"
|
||||
log.unlink(missing_ok=True)
|
||||
full_env = {
|
||||
**os.environ,
|
||||
"DEP_FRESHNESS_GATE": str(stub),
|
||||
"STUB_LOG": str(log),
|
||||
**env,
|
||||
}
|
||||
proc = subprocess.run(
|
||||
["bash", str(SCRIPT), *files],
|
||||
check=False,
|
||||
cwd=repo,
|
||||
env=full_env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
forwarded = log.read_text().split() if log.exists() else None
|
||||
return proc.returncode, forwarded
|
||||
|
||||
|
||||
ALL = ("touched/requirements.txt", "untouched/requirements.txt")
|
||||
|
||||
|
||||
def test_forwards_only_uncommitted_change(repo: Path, tmp_path: Path) -> None:
|
||||
(repo / "touched" / "requirements.txt").write_text("ruff==0.2.0\n")
|
||||
assert _run(repo, tmp_path, *ALL) == (0, ["touched/requirements.txt"])
|
||||
|
||||
|
||||
def test_forwards_committed_branch_change(repo: Path, tmp_path: Path) -> None:
|
||||
(repo / "touched" / "requirements.txt").write_text("ruff==0.2.0\n")
|
||||
_git(repo, "commit", "-q", "-am", "bump")
|
||||
assert _run(repo, tmp_path, *ALL) == (0, ["touched/requirements.txt"])
|
||||
|
||||
|
||||
def test_forwards_untracked_manifest(repo: Path, tmp_path: Path) -> None:
|
||||
(repo / "new").mkdir()
|
||||
(repo / "new" / "requirements.txt").write_text("ruff==0.1.0\n")
|
||||
assert _run(repo, tmp_path, *ALL, "new/requirements.txt") == (
|
||||
0,
|
||||
["new/requirements.txt"],
|
||||
)
|
||||
|
||||
|
||||
def test_no_branch_change_skips_gate(repo: Path, tmp_path: Path) -> None:
|
||||
assert _run(repo, tmp_path, *ALL) == (0, None)
|
||||
|
||||
|
||||
def test_gate_failure_propagates(repo: Path, tmp_path: Path) -> None:
|
||||
(repo / "touched" / "requirements.txt").write_text("ruff==0.2.0\n")
|
||||
assert _run(repo, tmp_path, *ALL, STUB_EXIT="1") == (
|
||||
1,
|
||||
["touched/requirements.txt"],
|
||||
)
|
||||
|
||||
|
||||
def test_unresolvable_base_fails_closed(repo: Path, tmp_path: Path) -> None:
|
||||
code, forwarded = _run(repo, tmp_path, *ALL, DEP_FRESHNESS_BASE="no-such-ref")
|
||||
assert (code, forwarded) == (0, list(ALL))
|
||||
|
||||
|
||||
def test_subdirectory_cwd_fails_closed(repo: Path, tmp_path: Path) -> None:
|
||||
code, forwarded = _run(repo / "touched", tmp_path, "requirements.txt")
|
||||
assert (code, forwarded) == (0, ["requirements.txt"])
|
||||
@ -2,9 +2,9 @@
|
||||
# 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.4.0
|
||||
mypy==2.4.1
|
||||
pre-commit==4.7.0
|
||||
pylint==4.1.3
|
||||
pytest==9.1.1
|
||||
pytest==9.2.0
|
||||
pytest-cov==7.1.0
|
||||
ruff==0.17.2
|
||||
ruff==0.17.4
|
||||
|
||||
Loading…
Reference in New Issue
Block a user