Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,17 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install ruff
# ruff is pinned because this step gates for real now: an unpinned
# formatter changes its own style on its own schedule, which would turn
# the build red with nobody having touched the repository. Bumping it is
# a deliberate commit that carries the reformatting with it.
- run: pip install ruff==0.16.3
- run: ruff check plainsong tests
# This step carried `continue-on-error: true` and had never once passed:
# it reported 60 unformatted files and exit 1 on every run since it was
# written, and the job went green anyway. A check that cannot fail is
# decoration -- see docs/verification.md, which this repository wrote.
- run: ruff format --check plainsong tests
continue-on-error: true

# Every other job runs with the repository on sys.path, which is structurally
# blind to packaging. That is not hypothetical: the spec files once lived
Expand Down
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,41 @@ Notable changes, newest first. Dates are ISO 8601.

## Unreleased

### CI was red, and one of its checks could not go red at all

Four jobs were failing on master and had been since 19 August. The cause of
three of them was one line.

`plainsong/render/chunked.py` imported NumPy at module scope, and
`render/__init__` imports that module eagerly -- so every renderer needed NumPy,
including the pure-stdlib MIDI writer. `plainsong compile song.song -o out.mid`,
with no audio anywhere in it, failed on a machine without NumPy. That is the
rule this project cares most about, broken in the direction it is meant to
prevent. The import now sits in the two functions that use it. **PyPI is not
affected** -- this landed after 1.4.0 was tagged, so no released version carries
it.

The other two: `src/genome.py` defines a classmethod named `random`, which
shadows the `random` module for the rest of the class body, so the next
`rng: random.Random` annotation was evaluated against the classmethod and raised
at import -- taking the whole pytest run down with it. And `tests/test_chunked.py`
imported both NumPy and pytest at module scope, so on the stdlib-only job -- which
has neither, and runs `unittest discover`, which imports every test module -- an
absent optional dependency became a collection error rather than a skip.

The fourth is the one worth reading. `ruff format --check` carried
`continue-on-error: true`, and **had never passed**: it reported 60 unformatted
files and exit 1 on every run since it was written, while the job went green.
A check that cannot fail is decoration -- which is the first rule in this
repository's own `docs/verification.md`, sitting in this repository's own CI.
The suppression is gone, the 63 files are formatted, and ruff is pinned, because
a gating formatter that upgrades itself would turn the build red with nobody
having touched the code.

The corpus fingerprint is what makes the reformatting safe to believe: 6,321
files compile to exactly the music they did before.


### `plainsong mcp` is deprecated

`plainsong-mcp` 1.0.0 is on PyPI, so for the first time there is somewhere to
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ for hardware MIDI — are detected when present and never required.
plainsong chart song.song -o chart.svg
```

![a chord chart rendered from one of the bundled examples](docs/img/creatures-of-interval.svg)
![a chord chart rendered from one of the bundled examples](https://raw.githubusercontent.com/SuperInstance/plainsong/master/docs/img/creatures-of-interval.svg)

That image is the SVG above, committed to this repository and embedded with an
`<img>` tag — which is the only way a chart appears in markdown on a platform
Expand Down
4 changes: 3 additions & 1 deletion plainsong/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,7 @@ def __getattr__(name: str):
if name in {"compile_text", "compile_file", "CompileResult"}:
from .pipeline import CompileResult, compile_file, compile_text

return {"compile_text": compile_text, "compile_file": compile_file, "CompileResult": CompileResult}[name]
return {"compile_text": compile_text, "compile_file": compile_file, "CompileResult": CompileResult}[
name
]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
21 changes: 9 additions & 12 deletions plainsong/agent/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,7 @@ def resolve(self, relative: str, for_write: bool = False) -> Path:
return target

raise SandboxError(
f"{relative!r} is outside the working directory ({self.root}); "
"use a relative path inside it"
f"{relative!r} is outside the working directory ({self.root}); use a relative path inside it"
)

def relative(self, path: Path) -> str:
Expand Down Expand Up @@ -151,11 +150,7 @@ def add(
self.register(Tool(name, description, parameters, handler, dangerous))

def specs(self) -> list[ToolSpec]:
return [
tool.spec()
for tool in self.tools.values()
if self.allow_dangerous or not tool.dangerous
]
return [tool.spec() for tool in self.tools.values() if self.allow_dangerous or not tool.dangerous]

def call(self, name: str, arguments: dict[str, Any]) -> str:
"""Run a tool and return what it said. Failures are text, not exceptions."""
Expand Down Expand Up @@ -216,7 +211,9 @@ def _register_builtins(self) -> None:
self.add(
"list_files",
"List files in the working directory.",
_schema({"path": _string("Directory relative to the working directory. Defaults to the root.")}),
_schema(
{"path": _string("Directory relative to the working directory. Defaults to the root.")}
),
self._list_files,
)
self.add(
Expand Down Expand Up @@ -430,9 +427,7 @@ def _transpose_score(self, path: str, key: str) -> str:
moved = transpose(source.read_text(encoding="utf-8"), key)
except TheoryError as exc:
return f"error: {exc}; give a key such as Dm, F#, Bb or 'A minor'"
target = self.sandbox.resolve(
f"{Path(path).stem}-{key.replace('#', 'sharp')}.song", for_write=True
)
target = self.sandbox.resolve(f"{Path(path).stem}-{key.replace('#', 'sharp')}.song", for_write=True)
target.write_text(moved, encoding="utf-8")
return f"wrote {self.sandbox.relative(target)}\n\n{moved[:1500]}"

Expand Down Expand Up @@ -466,6 +461,8 @@ def _verify_specs(self, tag: str = "") -> str:
def _record_decision(self, note: str) -> str:
self.journal.append(note)
journal_path = self.sandbox.resolve("BUILD-JOURNAL.md", for_write=True)
existing = journal_path.read_text(encoding="utf-8") if journal_path.exists() else "# Build journal\n"
existing = (
journal_path.read_text(encoding="utf-8") if journal_path.exists() else "# Build journal\n"
)
journal_path.write_text(f"{existing.rstrip()}\n- {note}\n", encoding="utf-8")
return f"recorded in {self.sandbox.relative(journal_path)}"
4 changes: 1 addition & 3 deletions plainsong/connectors/builtin.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,7 @@ def send(self, arrangement: Arrangement, **options: Any) -> ConnectorResult:
from ..render.backends import play_audio

target = Path(options.get("path") or self.config.paths.output_dir / "playback.wav")
synth = Synthesiser(
AudioOptions(sample_rate=int(self.config.get("render", "sample_rate", 44100)))
)
synth = Synthesiser(AudioOptions(sample_rate=int(self.config.get("render", "sample_rate", 44100))))
synth.write(arrangement, target)
outcome = play_audio(target)
return ConnectorResult(outcome.ok, detail=outcome.message or "played", outputs=[str(target)])
Expand Down
29 changes: 12 additions & 17 deletions plainsong/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,18 @@
# notes are dense, an octave is a large melodic leap, four sounding voices is a
# full texture.
REFERENCES = {
"note_density": 4.0, # onsets per beat
"rhythmic_complexity": 1.0, # stdev of inter-onset intervals, in beats
"velocity_std": 32.0, # MIDI velocity
"contour_direction": 12.0, # semitones, signed
"interval_size": 12.0, # semitones
"chord_density": 6.0, # simultaneous notes
"note_density": 4.0, # onsets per beat
"rhythmic_complexity": 1.0, # stdev of inter-onset intervals, in beats
"velocity_std": 32.0, # MIDI velocity
"contour_direction": 12.0, # semitones, signed
"interval_size": 12.0, # semitones
"chord_density": 6.0, # simultaneous notes
}

MIDI_MAX = 127.0
BASS_CEILING = 48 # below this is bass register
TREBLE_FLOOR = 84 # above this is treble activity
BEAT = 1.0 # a beat, in the units the arranger works in
BASS_CEILING = 48 # below this is bass register
TREBLE_FLOOR = 84 # above this is treble activity
BEAT = 1.0 # a beat, in the units the arranger works in
ON_BEAT_TOLERANCE = 1e-3
PITCH_CLASSES = 12

Expand Down Expand Up @@ -222,9 +222,7 @@ def _describe_bar(
"velocity_mean": _clip(_mean(velocities) / MIDI_MAX),
"velocity_std": _clip(_stdev(velocities) / REFERENCES["velocity_std"]),
"syncopation": (off_beat / len(onsets)) if onsets else 0.0,
"contour_direction": _clip(
_mean(intervals) / REFERENCES["contour_direction"], -1.0, 1.0
),
"contour_direction": _clip(_mean(intervals) / REFERENCES["contour_direction"], -1.0, 1.0),
"interval_size": _clip(
_mean([abs(interval) for interval in intervals]) / REFERENCES["interval_size"]
),
Expand Down Expand Up @@ -258,8 +256,7 @@ def _melodic_intervals(lines: Sequence[Sequence[Any]], start: float, end: float)
key=lambda note: (note.start, note.pitch),
)
intervals.extend(
float(within[index + 1].pitch - within[index].pitch)
for index in range(len(within) - 1)
float(within[index + 1].pitch - within[index].pitch) for index in range(len(within) - 1)
)
return intervals

Expand All @@ -285,9 +282,7 @@ def summarise(bars: Sequence[BarFeatures]) -> dict[str, float]:
"""The mean of each feature over a run of bars."""
if not bars:
return dict.fromkeys(FEATURE_NAMES, 0.0)
return {
name: _round(_mean([bar.values[name] for bar in bars])) for name in FEATURE_NAMES
}
return {name: _round(_mean([bar.values[name] for bar in bars])) for name in FEATURE_NAMES}


def format_table(bars: Sequence[BarFeatures], width: int = 6) -> str:
Expand Down
Loading
Loading