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