Add a plain-text multi-voice score format

Todo: fda71ed1
This commit is contained in:
Krzysztof Rudnicki 2026-11-07 08:26:25 +01:00
parent 7af6afe51a
commit 7f3f79f58d
23 changed files with 1163 additions and 24 deletions

View File

@ -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

View File

@ -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: >-

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
```

View 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

View 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

View File

@ -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)

View File

@ -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)

View 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)

View 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

View 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)

View File

@ -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

View File

@ -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

View 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)

View File

@ -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))

View File

@ -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",

View File

@ -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

View 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 "$@"

View 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"])

View File

@ -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