From 7f3f79f58dfa39c6c94ab8aff23a952f2ad33a55 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 7 Nov 2026 08:26:25 +0100 Subject: [PATCH] Add a plain-text multi-voice score format Todo: fda71ed1 --- .github/workflows/music-theory-tests.yml | 2 +- .pre-commit-config.yaml | 7 +- focus_project/requirements.txt | 2 +- holds/requirements.txt | 2 +- music_theory/.pre-commit-config.yaml | 2 +- music_theory/DOCS-article-walkthrough.md | 3 + music_theory/README.md | 11 +- music_theory/examples/jig.txt | 25 ++ music_theory/examples/waltz.txt | 33 +++ music_theory/music_theory/cli.py | 45 +++- music_theory/music_theory/compose.py | 19 +- music_theory/music_theory/score.py | 250 ++++++++++++++++++ music_theory/music_theory/score_events.py | 88 ++++++ music_theory/music_theory/score_tokens.py | 70 +++++ music_theory/music_theory/sequencer.py | 126 ++++++++- music_theory/music_theory/tests/test_cli.py | 51 ++++ music_theory/music_theory/tests/test_score.py | 196 ++++++++++++++ .../music_theory/tests/test_sequencer.py | 51 ++++ music_theory/pyproject.toml | 4 +- music_theory/requirements.txt | 6 +- scripts/dep_freshness_changed.sh | 74 ++++++ scripts/tests/test_dep_freshness_changed.py | 114 ++++++++ sickdays/requirements.txt | 6 +- 23 files changed, 1163 insertions(+), 24 deletions(-) create mode 100644 music_theory/examples/jig.txt create mode 100644 music_theory/examples/waltz.txt create mode 100644 music_theory/music_theory/score.py create mode 100644 music_theory/music_theory/score_events.py create mode 100644 music_theory/music_theory/score_tokens.py create mode 100644 music_theory/music_theory/tests/test_score.py create mode 100755 scripts/dep_freshness_changed.sh create mode 100644 scripts/tests/test_dep_freshness_changed.py diff --git a/.github/workflows/music-theory-tests.yml b/.github/workflows/music-theory-tests.yml index ef3fe35..94d23e4 100644 --- a/.github/workflows/music-theory-tests.yml +++ b/.github/workflows/music-theory-tests.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8c465db..2d24add 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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: >- diff --git a/focus_project/requirements.txt b/focus_project/requirements.txt index d69c161..b7f78b1 100644 --- a/focus_project/requirements.txt +++ b/focus_project/requirements.txt @@ -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 diff --git a/holds/requirements.txt b/holds/requirements.txt index d69c161..b7f78b1 100644 --- a/holds/requirements.txt +++ b/holds/requirements.txt @@ -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 diff --git a/music_theory/.pre-commit-config.yaml b/music_theory/.pre-commit-config.yaml index 5b1c6be..47ad115 100644 --- a/music_theory/.pre-commit-config.yaml +++ b/music_theory/.pre-commit-config.yaml @@ -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 diff --git a/music_theory/DOCS-article-walkthrough.md b/music_theory/DOCS-article-walkthrough.md index 2816245..b457906 100644 --- a/music_theory/DOCS-article-walkthrough.md +++ b/music_theory/DOCS-article-walkthrough.md @@ -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 diff --git a/music_theory/README.md b/music_theory/README.md index 22e5ec6..920f163 100644 --- a/music_theory/README.md +++ b/music_theory/README.md @@ -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 ``` diff --git a/music_theory/examples/jig.txt b/music_theory/examples/jig.txt new file mode 100644 index 0000000..9a2980a --- /dev/null +++ b/music_theory/examples/jig.txt @@ -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 diff --git a/music_theory/examples/waltz.txt b/music_theory/examples/waltz.txt new file mode 100644 index 0000000..d053b0d --- /dev/null +++ b/music_theory/examples/waltz.txt @@ -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 diff --git a/music_theory/music_theory/cli.py b/music_theory/music_theory/cli.py index 7174c9e..e3446fe 100644 --- a/music_theory/music_theory/cli.py +++ b/music_theory/music_theory/cli.py @@ -2,7 +2,8 @@ """Command line: hear a section of the article, or render a recipe to a file. Subcommands: ``list``, ``explain ``, ``demo --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) diff --git a/music_theory/music_theory/compose.py b/music_theory/music_theory/compose.py index 7d21dea..1284f8a 100644 --- a/music_theory/music_theory/compose.py +++ b/music_theory/music_theory/compose.py @@ -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) diff --git a/music_theory/music_theory/score.py b/music_theory/music_theory/score.py new file mode 100644 index 0000000..6403cbf --- /dev/null +++ b/music_theory/music_theory/score.py @@ -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) diff --git a/music_theory/music_theory/score_events.py b/music_theory/music_theory/score_events.py new file mode 100644 index 0000000..71a005b --- /dev/null +++ b/music_theory/music_theory/score_events.py @@ -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 diff --git a/music_theory/music_theory/score_tokens.py b/music_theory/music_theory/score_tokens.py new file mode 100644 index 0000000..d0322ee --- /dev/null +++ b/music_theory/music_theory/score_tokens.py @@ -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) diff --git a/music_theory/music_theory/sequencer.py b/music_theory/music_theory/sequencer.py index e9d36a1..c650606 100644 --- a/music_theory/music_theory/sequencer.py +++ b/music_theory/music_theory/sequencer.py @@ -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 diff --git a/music_theory/music_theory/tests/test_cli.py b/music_theory/music_theory/tests/test_cli.py index ff25d7b..3d2b618 100644 --- a/music_theory/music_theory/tests/test_cli.py +++ b/music_theory/music_theory/tests/test_cli.py @@ -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 diff --git a/music_theory/music_theory/tests/test_score.py b/music_theory/music_theory/tests/test_score.py new file mode 100644 index 0000000..15202b4 --- /dev/null +++ b/music_theory/music_theory/tests/test_score.py @@ -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) diff --git a/music_theory/music_theory/tests/test_sequencer.py b/music_theory/music_theory/tests/test_sequencer.py index e815a09..8f25b6e 100644 --- a/music_theory/music_theory/tests/test_sequencer.py +++ b/music_theory/music_theory/tests/test_sequencer.py @@ -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)) diff --git a/music_theory/pyproject.toml b/music_theory/pyproject.toml index 8c58764..ba7564d 100644 --- a/music_theory/pyproject.toml +++ b/music_theory/pyproject.toml @@ -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", diff --git a/music_theory/requirements.txt b/music_theory/requirements.txt index 9b5df1e..26531ec 100644 --- a/music_theory/requirements.txt +++ b/music_theory/requirements.txt @@ -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 diff --git a/scripts/dep_freshness_changed.sh b/scripts/dep_freshness_changed.sh new file mode 100755 index 0000000..b533ede --- /dev/null +++ b/scripts/dep_freshness_changed.sh @@ -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 ... +# 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 "$@" diff --git a/scripts/tests/test_dep_freshness_changed.py b/scripts/tests/test_dep_freshness_changed.py new file mode 100644 index 0000000..48d894e --- /dev/null +++ b/scripts/tests/test_dep_freshness_changed.py @@ -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"]) diff --git a/sickdays/requirements.txt b/sickdays/requirements.txt index 2ec6fd7..b7f78b1 100644 --- a/sickdays/requirements.txt +++ b/sickdays/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