diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8cf0923d..0887c00f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d66b16c..375a41d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 2bfb3c94..a03958e8 100644
--- a/README.md
+++ b/README.md
@@ -272,7 +272,7 @@ for hardware MIDI — are detected when present and never required.
plainsong chart song.song -o chart.svg
```
-
+
That image is the SVG above, committed to this repository and embedded with an
`
` tag — which is the only way a chart appears in markdown on a platform
diff --git a/plainsong/__init__.py b/plainsong/__init__.py
index 38f927ac..53ed04eb 100644
--- a/plainsong/__init__.py
+++ b/plainsong/__init__.py
@@ -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}")
diff --git a/plainsong/agent/tools.py b/plainsong/agent/tools.py
index 2ed4f685..131398ff 100644
--- a/plainsong/agent/tools.py
+++ b/plainsong/agent/tools.py
@@ -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:
@@ -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."""
@@ -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(
@@ -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]}"
@@ -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)}"
diff --git a/plainsong/connectors/builtin.py b/plainsong/connectors/builtin.py
index 3c053c33..ed710b77 100644
--- a/plainsong/connectors/builtin.py
+++ b/plainsong/connectors/builtin.py
@@ -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)])
diff --git a/plainsong/features.py b/plainsong/features.py
index b5ff37b4..874a61fe 100644
--- a/plainsong/features.py
+++ b/plainsong/features.py
@@ -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
@@ -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"]
),
@@ -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
@@ -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:
diff --git a/plainsong/interfaces/cli.py b/plainsong/interfaces/cli.py
index 42eb91dc..fb235b81 100644
--- a/plainsong/interfaces/cli.py
+++ b/plainsong/interfaces/cli.py
@@ -314,9 +314,7 @@ def cmd_check(args: argparse.Namespace, config: Config, out: Out) -> int:
arrangement = arrange(score)
notes = arrangement.note_count
row_warnings = [
- diagnostic
- for diagnostic in arrangement.diagnostics
- if diagnostic.severity == "warning"
+ diagnostic for diagnostic in arrangement.diagnostics if diagnostic.severity == "warning"
]
if notes == 0:
warnings += 1
@@ -435,8 +433,18 @@ def cmd_chord(args: argparse.Namespace, config: Config, out: Out) -> int:
#: Which degree is which, in words, so the explanation reads like a
#: musician talking rather than like a table dump.
- labels = {1: "root", 3: "third", 5: "fifth", 6: "sixth", 7: "seventh",
- 9: "ninth", 11: "eleventh", 13: "thirteenth", 2: "second", 4: "fourth"}
+ labels = {
+ 1: "root",
+ 3: "third",
+ 5: "fifth",
+ 6: "sixth",
+ 7: "seventh",
+ 9: "ninth",
+ 11: "eleventh",
+ 13: "thirteenth",
+ 2: "second",
+ 4: "fourth",
+ }
natural = {1: 0, 2: 2, 3: 4, 4: 5, 5: 7, 6: 9, 7: 11, 9: 14, 11: 17, 13: 21}
results = []
@@ -558,12 +566,14 @@ def cmd_voicing(args: argparse.Namespace, config: Config, out: Out) -> int:
# Adjacent pairs, so the tail is one shorter on purpose.
pairs = zip(ordered, ordered[1:], strict=False)
muddy += sum(1 for a, b in pairs if b - a < 3 and a < 48)
- rows.append({
- "strategy": name,
- "named_kept": round(100 * kept / max(total, 1), 1),
- "guide_kept": round(100 * guides / max(guide_total, 1), 1),
- "muddy": muddy,
- })
+ rows.append(
+ {
+ "strategy": name,
+ "named_kept": round(100 * kept / max(total, 1), 1),
+ "guide_kept": round(100 * guides / max(guide_total, 1), 1),
+ "muddy": muddy,
+ }
+ )
if not out.json_mode:
out.say(f"{'strategy':<9} {'symbol kept':>12} {'guide tones':>12} {'muddy':>7}")
for row in rows:
@@ -699,8 +709,7 @@ def cmd_lyrics(args: argparse.Namespace, config: Config, out: Out) -> int:
written_at = "--" if row["written"] is None else f"{row['written']:g}"
marker = "" if row["written"] == row["bound"] else " <- moves"
out.dim(
- f" {row['syllable']:<14}{written_at:>12}{row['bound']:>10g}"
- f"{row['held']:>8g}{marker}"
+ f" {row['syllable']:<14}{written_at:>12}{row['bound']:>10g}{row['held']:>8g}{marker}"
)
for diagnostic in bound.diagnostics:
if diagnostic not in loose.diagnostics:
@@ -736,9 +745,7 @@ def cmd_fingerprint(args: argparse.Namespace, config: Config, out: Out) -> int:
# silently truncates the longer one, so computing the diff before this
# check would report a confident and wrong set of moved files.
if len(expected) != len(actual):
- out.fail(
- f"the corpus changed size: {len(expected) - 1} files recorded, {len(entries)} found"
- )
+ out.fail(f"the corpus changed size: {len(expected) - 1} files recorded, {len(entries)} found")
out.dim("re-record with --write if files were added or removed on purpose")
return 1
moved = [
@@ -796,7 +803,9 @@ def cmd_library(args: argparse.Namespace, config: Config, out: Out) -> int:
out.table(rows or [("(none)", "")])
return 0
- entries = library.search(args.query, limit=args.limit) if args.query else library.entries(limit=args.limit)
+ entries = (
+ library.search(args.query, limit=args.limit) if args.query else library.entries(limit=args.limit)
+ )
out.data([entry.as_dict() for entry in entries])
if not entries:
out.say("nothing found")
@@ -1222,10 +1231,14 @@ def build_parser() -> argparse.ArgumentParser:
compile_parser = subparsers.add_parser("compile", help="compile notation to MIDI and audio")
compile_parser.add_argument("file", help="a .song file")
compile_parser.add_argument("-o", "--midi", metavar="PATH", help="MIDI output path")
- compile_parser.add_argument("-a", "--audio", metavar="PATH", nargs="?", const="", help="audio output path")
+ compile_parser.add_argument(
+ "-a", "--audio", metavar="PATH", nargs="?", const="", help="audio output path"
+ )
compile_parser.add_argument("--no-midi", action="store_true", help="skip the MIDI file")
compile_parser.add_argument("--play", action="store_true", help="play the audio when it is ready")
- compile_parser.add_argument("--backend", default="auto", help="audio backend: auto, builtin, fluidsynth")
+ compile_parser.add_argument(
+ "--backend", default="auto", help="audio backend: auto, builtin, fluidsynth"
+ )
compile_parser.add_argument("--soundfont", metavar="PATH", help="soundfont for the fluidsynth backend")
compile_parser.add_argument("--dialect", default="auto", choices=["auto", "absolute", "relative"])
compile_parser.add_argument("--semitones", type=int, default=0, help="transpose while compiling")
@@ -1256,7 +1269,9 @@ def build_parser() -> argparse.ArgumentParser:
play_parser = subparsers.add_parser("play", help="compile and play")
play_parser.add_argument("file", help="a .song file or a library entry")
play_parser.add_argument("--backend", default="auto")
- play_parser.add_argument("--port", nargs="?", const="", metavar="NAME", help="play to a MIDI port instead")
+ play_parser.add_argument(
+ "--port", nargs="?", const="", metavar="NAME", help="play to a MIDI port instead"
+ )
play_parser.set_defaults(func=cmd_play)
info_parser = subparsers.add_parser("info", help="summarise a piece")
@@ -1279,24 +1294,22 @@ def build_parser() -> argparse.ArgumentParser:
transpose_parser.add_argument("-i", "--in-place", action="store_true")
transpose_parser.set_defaults(func=cmd_transpose)
- chord_parser = subparsers.add_parser(
- "chord", help="read a chord symbol and say what is in it"
- )
+ chord_parser = subparsers.add_parser("chord", help="read a chord symbol and say what is in it")
chord_parser.add_argument("symbol", nargs="+", help="one or more chord symbols")
chord_parser.add_argument(
- "--explain", action="store_true",
+ "--explain",
+ action="store_true",
help="show every degree, what bent it, and what is deliberately absent",
)
chord_parser.add_argument("--octave", type=int, default=3, help="octave for MIDI numbers")
chord_parser.add_argument("--flats", action="store_true", help="spell with flats")
chord_parser.set_defaults(func=cmd_chord)
- voicing_parser = subparsers.add_parser(
- "voicing", help="show which notes a chord sounds, and why those"
- )
+ voicing_parser = subparsers.add_parser("voicing", help="show which notes a chord sounds, and why those")
voicing_parser.add_argument("symbol", nargs="*", help="chord symbols")
voicing_parser.add_argument(
- "--compare", action="store_true",
+ "--compare",
+ action="store_true",
help="score every strategy over the library, on the chords where the cap bites",
)
voicing_parser.add_argument("--limit", type=int, default=4, help="how many voices")
@@ -1316,9 +1329,7 @@ def build_parser() -> argparse.ArgumentParser:
chart_parser.add_argument("--no-lyrics", action="store_true", help="chords only")
chart_parser.set_defaults(func=cmd_chart)
- lyrics_parser = subparsers.add_parser(
- "lyrics", help="show which note each syllable is sung on"
- )
+ lyrics_parser = subparsers.add_parser("lyrics", help="show which note each syllable is sung on")
lyrics_parser.add_argument("file", help="a .song file")
lyrics_parser.set_defaults(func=cmd_lyrics)
@@ -1356,9 +1367,7 @@ def build_parser() -> argparse.ArgumentParser:
spec_parser.set_defaults(func=cmd_spec)
providers_parser = subparsers.add_parser("providers", help="list model providers")
- providers_parser.add_argument(
- "--check", nargs="?", const=True, metavar="ID", help="make a test call"
- )
+ providers_parser.add_argument("--check", nargs="?", const=True, metavar="ID", help="make a test call")
providers_parser.set_defaults(func=cmd_providers)
setup_parser = subparsers.add_parser("setup", help="connect a model provider")
@@ -1382,9 +1391,7 @@ def build_parser() -> argparse.ArgumentParser:
agent_parser.add_argument("--max-steps", type=int, default=0)
agent_parser.set_defaults(func=cmd_agent)
- build_parser = subparsers.add_parser(
- "build", help="have the agent tailor this install to your machine"
- )
+ build_parser = subparsers.add_parser("build", help="have the agent tailor this install to your machine")
build_parser.add_argument("goal", nargs="?", default="", help="what you want to build")
build_parser.add_argument("--provider", default="")
build_parser.add_argument("--model", default="")
@@ -1398,9 +1405,7 @@ def build_parser() -> argparse.ArgumentParser:
serve_parser.add_argument("--open", action="store_true", help="open a browser")
serve_parser.set_defaults(func=cmd_serve)
- mcp_parser = subparsers.add_parser(
- "mcp", help="serve over the Model Context Protocol, for agents"
- )
+ mcp_parser = subparsers.add_parser("mcp", help="serve over the Model Context Protocol, for agents")
mcp_parser.add_argument("--http", action="store_true", help="serve over HTTP instead of stdio")
mcp_parser.add_argument("--host", default="127.0.0.1", help="HTTP bind address (loopback)")
mcp_parser.add_argument("--port", type=int, default=8766, help="HTTP port")
@@ -1411,9 +1416,7 @@ def build_parser() -> argparse.ArgumentParser:
tui_parser.add_argument("file", nargs="?", default="", help="open this file")
tui_parser.set_defaults(func=cmd_tui)
- bridge_parser = subparsers.add_parser(
- "bridge", help="answer model requests on behalf of a host agent"
- )
+ bridge_parser = subparsers.add_parser("bridge", help="answer model requests on behalf of a host agent")
bridge_parser.add_argument(
"action", nargs="?", default="status", choices=["status", "list", "answer", "watch"]
)
diff --git a/plainsong/interfaces/tui.py b/plainsong/interfaces/tui.py
index 3b035cd7..7c7b2aa4 100644
--- a/plainsong/interfaces/tui.py
+++ b/plainsong/interfaces/tui.py
@@ -191,11 +191,11 @@ def _init_colours() -> None:
return
curses.start_color()
curses.use_default_colors()
- curses.init_pair(1, curses.COLOR_CYAN, -1) # headings
+ curses.init_pair(1, curses.COLOR_CYAN, -1) # headings
curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_CYAN) # selection
- curses.init_pair(3, curses.COLOR_YELLOW, -1) # status
- curses.init_pair(4, curses.COLOR_GREEN, -1) # good
- curses.init_pair(5, curses.COLOR_RED, -1) # bad
+ curses.init_pair(3, curses.COLOR_YELLOW, -1) # status
+ curses.init_pair(4, curses.COLOR_GREEN, -1) # good
+ curses.init_pair(5, curses.COLOR_RED, -1) # bad
def _pair(index: int):
@@ -241,7 +241,7 @@ def _draw(screen, state: TuiState, show_help: bool) -> None:
for row, entry in enumerate(items[state.offset : state.offset + list_height]):
index = state.offset + row
- label = f"{entry.title[:split - 12]:<{max(1, split - 12)}} {entry.key or '-':>4}"
+ label = f"{entry.title[: split - 12]:<{max(1, split - 12)}} {entry.key or '-':>4}"
attr = _pair(2) if index == state.selected else 0
_safe_add(screen, row + 2, 1, label.ljust(split - 2), attr)
@@ -281,9 +281,7 @@ def _draw_detail(screen, state: TuiState, column: int, span: int) -> None:
for fact in facts:
_safe_add(screen, row, column, fact[:span])
row += 1
- voices = ", ".join(
- f"{track['name']}({track['notes']})" for track in arrangement.get("tracks", [])
- )
+ voices = ", ".join(f"{track['name']}({track['notes']})" for track in arrangement.get("tracks", []))
if voices:
_safe_add(screen, row, column, f"voices: {voices}"[:span])
row += 1
diff --git a/plainsong/library.py b/plainsong/library.py
index 3aa23368..9d3506d9 100644
--- a/plainsong/library.py
+++ b/plainsong/library.py
@@ -38,11 +38,11 @@
def bundled_songbook() -> Path:
return Path(__file__).resolve().parent / BUNDLED_SONGBOOK
+
+
INDEX_VERSION = 2
-HEADER_RE = re.compile(
- r"^(?:\*\*)?TRACK\s*:\s*(?P
.+?)(?:\*\*)?$", re.IGNORECASE | re.MULTILINE
-)
+HEADER_RE = re.compile(r"^(?:\*\*)?TRACK\s*:\s*(?P.+?)(?:\*\*)?$", re.IGNORECASE | re.MULTILINE)
KEY_RE = re.compile(r"^key\s*:\s*(?P[^|\n]+)", re.IGNORECASE | re.MULTILINE)
TEMPO_RE = re.compile(r"tempo\s*:\s*(?P\d+)", re.IGNORECASE)
@@ -243,9 +243,7 @@ def find(self, reference: str) -> LibraryEntry | None:
"""Resolve a path, a filename or a title to one entry."""
candidate = Path(reference)
if candidate.exists() and candidate.suffix == ".song":
- return LibraryEntry(
- path=candidate, name=candidate.stem, title=candidate.stem, collection=""
- )
+ return LibraryEntry(path=candidate, name=candidate.stem, title=candidate.stem, collection="")
lowered = reference.strip().lower()
for entry in self.all():
if lowered in (entry.name.lower(), entry.title.lower(), str(entry.path).lower()):
diff --git a/plainsong/llm/credentials.py b/plainsong/llm/credentials.py
index 9565d32f..38c38106 100644
--- a/plainsong/llm/credentials.py
+++ b/plainsong/llm/credentials.py
@@ -26,7 +26,6 @@
from ..runtime import _toml as tomllib
-
def _read_store(paths: Paths) -> dict[str, str]:
path = paths.secrets_file
if not path.exists():
diff --git a/plainsong/llm/providers/anthropic.py b/plainsong/llm/providers/anthropic.py
index 7bfa3b83..052bdb43 100644
--- a/plainsong/llm/providers/anthropic.py
+++ b/plainsong/llm/providers/anthropic.py
@@ -51,7 +51,11 @@ def _convert(self, messages: list[Message]) -> tuple[str, list[dict[str, Any]]]:
"content": message.content,
}
# Consecutive tool results belong in one user turn.
- if converted and converted[-1]["role"] == "user" and isinstance(converted[-1]["content"], list):
+ if (
+ converted
+ and converted[-1]["role"] == "user"
+ and isinstance(converted[-1]["content"], list)
+ ):
existing = converted[-1]["content"]
if existing and existing[0].get("type") == "tool_result":
existing.append(block)
@@ -107,7 +111,9 @@ def complete(self, request: CompletionRequest) -> CompletionResponse:
provider=self.id,
)
if data.get("type") == "error":
- raise ProviderError(str(data.get("error", {}).get("message", "unknown error")), provider=self.id)
+ raise ProviderError(
+ str(data.get("error", {}).get("message", "unknown error")), provider=self.id
+ )
text_parts: list[str] = []
tool_calls: list[ToolCall] = []
diff --git a/plainsong/llm/providers/echo.py b/plainsong/llm/providers/echo.py
index 0c727b5f..a099801d 100644
--- a/plainsong/llm/providers/echo.py
+++ b/plainsong/llm/providers/echo.py
@@ -79,10 +79,14 @@ def _compose(self, prompt: str) -> str:
key=palette["key"],
tempo=tempo,
mood=palette["mood"],
- c1=palette["chords"][0], c2=palette["chords"][1],
- c3=palette["chords"][2], c4=palette["chords"][3],
- m1=palette["melody"][0], m2=palette["melody"][1],
- m3=palette["melody"][2], m4=palette["melody"][3],
+ c1=palette["chords"][0],
+ c2=palette["chords"][1],
+ c3=palette["chords"][2],
+ c4=palette["chords"][3],
+ m1=palette["melody"][0],
+ m2=palette["melody"][1],
+ m3=palette["melody"][2],
+ m4=palette["melody"][3],
)
def complete(self, request: CompletionRequest) -> CompletionResponse:
diff --git a/plainsong/llm/transport.py b/plainsong/llm/transport.py
index 25656db6..9ac5c218 100644
--- a/plainsong/llm/transport.py
+++ b/plainsong/llm/transport.py
@@ -143,4 +143,6 @@ def request_stream(
except urllib.error.HTTPError as exc:
raise _classify(exc.code, exc.read().decode("utf-8", errors="replace"), provider) from exc
except urllib.error.URLError as exc:
- raise ProviderError(f"could not reach {url}: {exc.reason}", provider=provider, retryable=True) from exc
+ raise ProviderError(
+ f"could not reach {url}: {exc.reason}", provider=provider, retryable=True
+ ) from exc
diff --git a/plainsong/llm/types.py b/plainsong/llm/types.py
index ceccb028..5d10a060 100644
--- a/plainsong/llm/types.py
+++ b/plainsong/llm/types.py
@@ -69,7 +69,9 @@ def from_dict(cls, data: dict[str, Any]) -> Message:
role=data.get("role", "user"),
content=data.get("content", "") or "",
tool_calls=[
- ToolCall(id=call.get("id", ""), name=call.get("name", ""), arguments=call.get("arguments", {}))
+ ToolCall(
+ id=call.get("id", ""), name=call.get("name", ""), arguments=call.get("arguments", {})
+ )
for call in data.get("tool_calls", [])
],
tool_call_id=data.get("tool_call_id", ""),
@@ -120,9 +122,7 @@ def _strip_unsupported(schema: dict[str, Any]) -> dict[str, Any]:
if isinstance(value, dict):
cleaned[key] = _strip_unsupported(value)
elif isinstance(value, list):
- cleaned[key] = [
- _strip_unsupported(item) if isinstance(item, dict) else item for item in value
- ]
+ cleaned[key] = [_strip_unsupported(item) if isinstance(item, dict) else item for item in value]
else:
cleaned[key] = value
return cleaned
diff --git a/plainsong/mcp/__main__.py b/plainsong/mcp/__main__.py
index f25ea699..f0da2128 100644
--- a/plainsong/mcp/__main__.py
+++ b/plainsong/mcp/__main__.py
@@ -30,9 +30,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--sessions", metavar="DIR", help="where ensemble sessions live; defaults to the workspace"
)
- parser.add_argument(
- "--allow-dangerous", action="store_true", help="offer tools that need approval"
- )
+ parser.add_argument("--allow-dangerous", action="store_true", help="offer tools that need approval")
parser.add_argument("--list-tools", action="store_true", help="print the tools and exit")
return parser
diff --git a/plainsong/mcp/ensemble.py b/plainsong/mcp/ensemble.py
index 395b59f1..ea334e56 100644
--- a/plainsong/mcp/ensemble.py
+++ b/plainsong/mcp/ensemble.py
@@ -90,9 +90,7 @@ def safe_name(name: str, what: str = "session") -> str:
"""A name that is safe as a directory or file name, or an error."""
cleaned = str(name).strip().lstrip("@").lower().replace(" ", "-")
if not cleaned or not set(cleaned) <= NAME_ALLOWED:
- raise EnsembleError(
- f"{what} names may use letters, digits, '-' and '_' only; got {name!r}"
- )
+ raise EnsembleError(f"{what} names may use letters, digits, '-' and '_' only; got {name!r}")
return cleaned
@@ -343,10 +341,7 @@ def parts(self) -> dict[str, str]:
directory = self.directory / PARTS
if not directory.is_dir():
return {}
- return {
- path.stem: path.read_text(encoding="utf-8")
- for path in sorted(directory.glob("*.song"))
- }
+ return {path.stem: path.read_text(encoding="utf-8") for path in sorted(directory.glob("*.song"))}
def entries(self, limit: int = 0) -> list[dict[str, Any]]:
"""The change log, oldest first; *limit* keeps the last N."""
@@ -428,11 +423,7 @@ def leave(self, voice: str, agent: str) -> dict[str, Any]:
return {"session": self.name, "voice": voice_label(voice), "released": True}
def _free(self, manifest: Manifest) -> list[str]:
- return [
- voice_label(name)
- for name, state in sorted(manifest.voices.items())
- if not state.owner
- ]
+ return [voice_label(name) for name, state in sorted(manifest.voices.items()) if not state.owner]
# -- writing -------------------------------------------------------------
@@ -648,11 +639,7 @@ def status(self) -> dict[str, Any]:
"tempo": manifest.tempo,
"meter": manifest.meter,
"voices": [found.as_dict() for _, found in sorted(manifest.voices.items())],
- "held": [
- voice_label(name)
- for name, state in sorted(manifest.voices.items())
- if state.owner
- ],
+ "held": [voice_label(name) for name, state in sorted(manifest.voices.items()) if state.owner],
"bars": score.bar_count,
"errors": [diag.format() for diag in score.errors()],
"warnings": len(score.warnings()),
@@ -757,9 +744,7 @@ def list_sessions(root: Path | None = None, paths: Paths | None = None) -> list[
base = root or ensemble_root(paths)
if not base.is_dir():
return []
- return sorted(
- entry.name for entry in base.iterdir() if (entry / MANIFEST).is_file()
- )
+ return sorted(entry.name for entry in base.iterdir() if (entry / MANIFEST).is_file())
def _sections(sections: list[Any] | None, bars: int) -> list[dict[str, Any]]:
@@ -915,8 +900,10 @@ def _bar_table(score: Score) -> list[dict[str, Any]]:
for line in section.lines:
if line.role == ROLE_NOTE or not line.cells:
continue
- label = voice_label(line.name.lower()) if line.role == ROLE_PLAYER else ROW_LABEL.get(
- line.role, line.role
+ label = (
+ voice_label(line.name.lower())
+ if line.role == ROLE_PLAYER
+ else ROW_LABEL.get(line.role, line.role)
)
cells.setdefault(label, []).extend(line.cells)
length = max((len(found) for found in cells.values()), default=0)
diff --git a/plainsong/mcp/resources.py b/plainsong/mcp/resources.py
index 9b6124c1..a3a9e514 100644
--- a/plainsong/mcp/resources.py
+++ b/plainsong/mcp/resources.py
@@ -78,8 +78,7 @@ def as_dict(self) -> dict[str, Any]:
Template(
f"{SCHEME}://session/{{name}}",
"ensemble session",
- "The state of one ensemble session: header, form, voices, claims and the "
- "current merged score.",
+ "The state of one ensemble session: header, form, voices, claims and the current merged score.",
JSON,
),
Template(
@@ -133,15 +132,12 @@ def list(self) -> list[dict[str, Any]]:
Resource(
f"{SCHEME}://capabilities",
"host capabilities",
- "What this machine can do: optional libraries, soundfonts, MIDI ports, "
- "audio playback.",
+ "What this machine can do: optional libraries, soundfonts, MIDI ports, audio playback.",
JSON,
),
]
for spec in self._specs():
- found.append(
- Resource(f"{SCHEME}://spec/{spec.id}", f"spec: {spec.id}", spec.title, JSON)
- )
+ found.append(Resource(f"{SCHEME}://spec/{spec.id}", f"spec: {spec.id}", spec.title, JSON))
for name in self._sessions():
found.append(
Resource(
diff --git a/plainsong/mcp/selfcheck.py b/plainsong/mcp/selfcheck.py
index 83fd6842..9a060925 100644
--- a/plainsong/mcp/selfcheck.py
+++ b/plainsong/mcp/selfcheck.py
@@ -226,15 +226,13 @@ def check_resources_and_prompts() -> tuple[bool, str]:
if wanted not in patterns:
return False, f"{wanted} is not offered as a template"
- read = _call(
- server, "resources/read", {"uri": "plainsong://capabilities"}, identifier=3
- )["result"]["contents"][0]
+ read = _call(server, "resources/read", {"uri": "plainsong://capabilities"}, identifier=3)["result"][
+ "contents"
+ ][0]
if not read["text"].strip().startswith("{"):
return False, "capabilities did not come back as JSON"
- missing = _call(
- server, "resources/read", {"uri": "plainsong://spec/nothing"}, identifier=4
- )
+ missing = _call(server, "resources/read", {"uri": "plainsong://spec/nothing"}, identifier=4)
if "error" not in missing:
return False, "an unknown resource was not reported as an error"
@@ -260,9 +258,7 @@ def check_conductor_bridge() -> tuple[bool, str]:
"arguments": {
"content": SAMPLE,
"directives": {
- "directives": [
- {"action": "lay_back", "intensity": 0.5, "duration_beats": 4}
- ]
+ "directives": [{"action": "lay_back", "intensity": 0.5, "duration_beats": 4}]
},
"features": False,
},
diff --git a/plainsong/mcp/server.py b/plainsong/mcp/server.py
index c2ee4e5d..7660efd7 100644
--- a/plainsong/mcp/server.py
+++ b/plainsong/mcp/server.py
@@ -76,9 +76,7 @@ def __init__(
from ..agent.tools import ToolRegistry
self.config = config or load_config()
- self.registry = registry or ToolRegistry(
- config=self.config, allow_dangerous=allow_dangerous
- )
+ self.registry = registry or ToolRegistry(config=self.config, allow_dangerous=allow_dangerous)
mcp_tools.register(self.registry, session_root=session_root)
self.resources = Resources(self.config, session_root=session_root)
self.initialized = False
@@ -117,9 +115,7 @@ def initialize(self, params: dict[str, Any]) -> dict[str, Any]:
requested = str(params.get("protocolVersion", "") or "")
# Speak the client's version when we know it, ours when we do not. A
# client that cannot live with the answer says so and disconnects.
- self.protocol_version = (
- requested if requested in SUPPORTED_PROTOCOL_VERSIONS else PROTOCOL_VERSION
- )
+ self.protocol_version = requested if requested in SUPPORTED_PROTOCOL_VERSIONS else PROTOCOL_VERSION
self.client = dict(params.get("clientInfo") or {})
return {
"protocolVersion": self.protocol_version,
@@ -162,9 +158,7 @@ def call_tool(self, params: dict[str, Any]) -> dict[str, Any]:
raise protocol.invalid_params("arguments must be an object")
known = {spec.name for spec in self.registry.specs()}
if name not in known:
- raise protocol.invalid_params(
- f"unknown tool: {name}", {"tools": sorted(known)}
- )
+ raise protocol.invalid_params(f"unknown tool: {name}", {"tools": sorted(known)})
# The registry is not built for two callers at once, and a tool that
# writes files is not something to run twice over.
@@ -222,9 +216,7 @@ def get_prompt(self, params: dict[str, Any]) -> dict[str, Any]:
name = params.get("name")
if not isinstance(name, str) or name not in PROMPTS:
- raise protocol.invalid_params(
- f"unknown prompt: {name!r}", {"prompts": sorted(PROMPTS)}
- )
+ raise protocol.invalid_params(f"unknown prompt: {name!r}", {"prompts": sorted(PROMPTS)})
text = load_prompt(name)
if not text:
raise RpcError(protocol.INTERNAL_ERROR, f"the {name} prompt is missing from this install")
diff --git a/plainsong/mcp/tools.py b/plainsong/mcp/tools.py
index 6cc18622..21e84d0a 100644
--- a/plainsong/mcp/tools.py
+++ b/plainsong/mcp/tools.py
@@ -134,9 +134,7 @@ def ensemble_read(
known = ens.list_sessions(session_root, paths)
return {"sessions": known, "note": "pass a session name to read one"}
try:
- return _session(session).read(
- voice=voice, agent=agent, bars=bars, history=history
- )
+ return _session(session).read(voice=voice, agent=agent, bars=bars, history=history)
except ens.EnsembleError as exc:
return f"error: {exc}"
diff --git a/plainsong/notation/arrange.py b/plainsong/notation/arrange.py
index 424bd562..baaa41db 100644
--- a/plainsong/notation/arrange.py
+++ b/plainsong/notation/arrange.py
@@ -41,9 +41,12 @@
DEGREE_RE = re.compile(r"^([b#♭♯]?)([1-7])([\^_']*)$")
SUBDIVISION_UNITS = {
- "4th": 1.0, "quarter": 1.0,
- "8th": 0.5, "eighth": 0.5,
- "16th": 0.25, "sixteenth": 0.25,
+ "4th": 1.0,
+ "quarter": 1.0,
+ "8th": 0.5,
+ "eighth": 0.5,
+ "16th": 0.25,
+ "sixteenth": 0.25,
"32nd": 0.125,
"triplet": 1.0 / 3.0,
}
@@ -65,17 +68,17 @@
class ArrangeOptions:
"""Knobs the caller may turn. Defaults match the documented behaviour."""
- bar_fill: str = "rescale" # rescale | grid
+ bar_fill: str = "rescale" # rescale | grid
humanize: bool = True
humanize_seed: int = 42
humanize_velocity: int = 6
- swing: float | None = None # None means take it from the score
+ swing: float | None = None # None means take it from the score
melody_instrument: str = "piano"
chords_instrument: str = "nylon guitar"
chord_voicing_octave: int = 3
max_chord_notes: int = 4
voicing: str = "guide"
- lyrics: str = "independent" # independent | bound
+ lyrics: str = "independent" # independent | bound
"""Which notes to keep when a chord names more than ``max_chord_notes``.
``guide`` gives up the fifth first and the root second, keeping the third,
@@ -205,9 +208,7 @@ def _resolve_pitches(self, token: str, octave: int) -> tuple[int, ...]:
accidental, number, marks = degree.groups()
shift = {"b": -1, "♭": -1, "#": 1, "♯": 1}.get(accidental, 0)
octave_shift = marks.count("^") + marks.count("'") - marks.count("_")
- pitches.append(
- self.score.meta.key.degree_pitch(int(number), octave + octave_shift, shift)
- )
+ pitches.append(self.score.meta.key.degree_pitch(int(number), octave + octave_shift, shift))
continue
return ()
return tuple(pitch for pitch in pitches if 0 <= pitch <= 127)
@@ -243,7 +244,7 @@ def _slot_positions(
message=f"bar holds {int(capacity)} slots but the row wrote "
f"{total_weight:g}; {dropped} token(s) dropped",
line=line.line_number,
- hint="use the default bar_fill = \"rescale\" to fit the tokens to the bar instead",
+ hint='use the default bar_fill = "rescale" to fit the tokens to the bar instead',
source=line.raw,
)
)
@@ -325,9 +326,7 @@ def arrange(self) -> Arrangement:
unit = SUBDIVISION_UNITS.get(str(meta.subdivision).lower(), 0.5)
for section in self.score.sections:
- playable = [
- line for line in section.lines if line.cells and line.role != ROLE_NOTE
- ]
+ playable = [line for line in section.lines if line.cells and line.role != ROLE_NOTE]
if not playable:
continue
section_starts.append((section.name, cursor))
@@ -456,9 +455,7 @@ def _place_lyrics(self, line: Line, origin: float, bar_beats: float, out: list[L
for token_index, token in enumerate(cell.tokens):
start = bar_start + token_index * step
out.append(LyricEvent(start=start, text=token))
- self.grid.add(
- token=token, row=ROLE_LYRICS, kind="text", onset=start, width=step
- )
+ self.grid.add(token=token, row=ROLE_LYRICS, kind="text", onset=start, width=step)
def _place_row(
self,
@@ -490,9 +487,7 @@ def flush() -> None:
pending.clear()
if line.barred:
- groups = [
- (origin + index * bar_beats, cell.tokens) for index, cell in enumerate(line.cells)
- ]
+ groups = [(origin + index * bar_beats, cell.tokens) for index, cell in enumerate(line.cells)]
else:
groups = [(origin, [token for cell in line.cells for token in cell.tokens])]
@@ -528,9 +523,7 @@ def flush() -> None:
# Recorded before the dispatch below, so that a rest and a
# sustain -- which produce no note and would otherwise leave no
# trace -- still occupy their column.
- self.grid.add(
- token=slot.text, row=grid_row, kind=slot.kind, onset=start, width=length
- )
+ self.grid.add(token=slot.text, row=grid_row, kind=slot.kind, onset=start, width=length)
if slot.kind == "sustain":
if pending:
pitches, note_start, _ = pending[-1]
diff --git a/plainsong/notation/chordsymbol.py b/plainsong/notation/chordsymbol.py
index 1c4f8e44..76952222 100644
--- a/plainsong/notation/chordsymbol.py
+++ b/plainsong/notation/chordsymbol.py
@@ -76,11 +76,11 @@ class ChordSymbolError(ValueError):
DEGREE_SEMITONES: dict[int, int] = {
1: 0,
- 2: 2, # only ever reached through sus2 or add2; folded onto 9 elsewhere
+ 2: 2, # only ever reached through sus2 or add2; folded onto 9 elsewhere
3: 4,
- 4: 5, # likewise sus4 / add4, folded onto 11
+ 4: 5, # likewise sus4 / add4, folded onto 11
5: 7,
- 6: 9, # a sixth is a sixth, not a thirteenth: it sits below the seventh
+ 6: 9, # a sixth is a sixth, not a thirteenth: it sits below the seventh
7: 11,
9: 14,
11: 17,
@@ -158,21 +158,42 @@ class Core:
CORE_ALIASES: dict[str, str] = {
# major
- "maj": "maj", "major": "maj", "ma": "maj", "mj": "maj", "M": "maj",
- "Δ": "maj", "∆": "maj", "^": "maj",
+ "maj": "maj",
+ "major": "maj",
+ "ma": "maj",
+ "mj": "maj",
+ "M": "maj",
+ "Δ": "maj",
+ "∆": "maj",
+ "^": "maj",
# minor. `-` is the Real Book's spelling and is very common.
- "m": "min", "min": "min", "mi": "min", "minor": "min", "-": "min",
+ "m": "min",
+ "min": "min",
+ "mi": "min",
+ "minor": "min",
+ "-": "min",
"moll": "min",
# diminished
- "dim": "dim", "o": "dim", "°": "dim", "º": "dim",
+ "dim": "dim",
+ "o": "dim",
+ "°": "dim",
+ "º": "dim",
# half-diminished
- "ø": "halfdim", "Ø": "halfdim", "halfdim": "halfdim", "h": "halfdim",
+ "ø": "halfdim",
+ "Ø": "halfdim",
+ "halfdim": "halfdim",
+ "h": "halfdim",
# augmented
- "aug": "aug", "+": "aug",
+ "aug": "aug",
+ "+": "aug",
# suspended
- "sus": "sus4", "sus4": "sus4", "sus2": "sus2",
+ "sus": "sus4",
+ "sus4": "sus4",
+ "sus2": "sus2",
# no third
- "5": "power", "no3": "power", "omit3": "power",
+ "5": "power",
+ "no3": "power",
+ "omit3": "power",
}
#: Longest first, so a scan cannot stop early on a prefix.
@@ -203,8 +224,13 @@ class Core:
# --- accidentals -----------------------------------------------------------
_UNICODE = {
- "♭": "b", "♯": "#", "𝄫": "bb", "𝄪": "##",
- "−": "-", "–": "-", "—": "-", # minus signs that are not hyphens
+ "♭": "b",
+ "♯": "#",
+ "𝄫": "bb",
+ "𝄪": "##",
+ "−": "-",
+ "–": "-",
+ "—": "-", # minus signs that are not hyphens
"#": "#",
}
@@ -271,7 +297,7 @@ def _scan_root(text: str) -> tuple[int, str]:
raise ChordSymbolError(f"no chord root in {text!r}")
letter, accidentals = match.groups()
shift = sum(_ACCIDENTAL_SHIFT[character] for character in accidentals)
- return (LETTER_PC[letter.upper()] + shift) % 12, text[match.end():]
+ return (LETTER_PC[letter.upper()] + shift) % 12, text[match.end() :]
def _split_bass(text: str) -> tuple[str, str | None]:
@@ -341,7 +367,7 @@ def _written_suffix(token: str) -> str:
text = token.strip()
head, _bass = _split_bass(_normalise(text))
match = _ROOT_RE.match(head)
- return head[match.end():] if match else head
+ return head[match.end() :] if match else head
# A modification: what to do, to which degree, and by how much.
@@ -378,13 +404,13 @@ def _scan_suffix(suffix: str, original: str) -> tuple[str, int, list[Modificatio
matched = False
for word, operation in (("add", "add"), ("omit", "omit"), ("no", "omit")):
if rest[: len(word)].lower() == word:
- tail = rest[len(word):]
+ tail = rest[len(word) :]
shift, tail = _leading_accidental(tail)
degree_match = _DEGREE_RE.match(tail)
if degree_match:
degree = int(degree_match.group(1))
mods.append((operation, _fold(degree), shift))
- rest = tail[degree_match.end():]
+ rest = tail[degree_match.end() :]
matched = True
break
if matched:
@@ -411,7 +437,7 @@ def _scan_suffix(suffix: str, original: str) -> tuple[str, int, list[Modificatio
if degree_match:
degree = int(degree_match.group(1))
mods.append(("alter", _fold(degree), shift))
- rest = rest[1 + degree_match.end():]
+ rest = rest[1 + degree_match.end() :]
continue
# A number: either how far to stack, or a sixth, or -- when it is
@@ -428,7 +454,7 @@ def _scan_suffix(suffix: str, original: str) -> tuple[str, int, list[Modificatio
degree_match = _DEGREE_RE.match(rest)
if degree_match:
value = int(degree_match.group(1))
- tail = rest[degree_match.end():]
+ tail = rest[degree_match.end() :]
if value == 5 and core_name is None and not tail:
# A bare `C5`: root and fifth, no third. Anywhere else a 5 is
# either a stack height nobody writes or part of `b5`/`#5`,
@@ -479,7 +505,7 @@ def _scan_suffix(suffix: str, original: str) -> tuple[str, int, list[Modificatio
if core_name is None:
core_name = name
elif core_name == "min" and name == "maj":
- core_name = "minmaj" # CmMaj7, C-Δ7
+ core_name = "minmaj" # CmMaj7, C-Δ7
elif core_name == "maj" and name == "min":
core_name = "minmaj"
elif name in ("sus4", "sus2"):
@@ -489,7 +515,7 @@ def _scan_suffix(suffix: str, original: str) -> tuple[str, int, list[Modificatio
core_name = name
if alias in SEVENTH_IMPLIED:
stack = max(stack, 7)
- rest = rest[len(alias):]
+ rest = rest[len(alias) :]
break
else:
raise ChordSymbolError(f"unreadable chord quality {suffix!r} in {original!r}")
diff --git a/plainsong/notation/lyrics.py b/plainsong/notation/lyrics.py
index cb3b44ba..dbe8cd04 100644
--- a/plainsong/notation/lyrics.py
+++ b/plainsong/notation/lyrics.py
@@ -92,9 +92,7 @@ def bind(grid: TimeGrid) -> tuple[list[LyricEvent], list[Diagnostic]]:
unbindable.append(bar)
for placement in sorted(by_bar[bar], key=lambda p: p.unit):
events.append(
- LyricEvent(
- start=placement.onset, text=placement.token, duration=placement.width
- )
+ LyricEvent(start=placement.onset, text=placement.token, duration=placement.width)
)
continue
diff --git a/plainsong/notation/merge.py b/plainsong/notation/merge.py
index b8bddf61..ca0531c4 100644
--- a/plainsong/notation/merge.py
+++ b/plainsong/notation/merge.py
@@ -40,7 +40,7 @@ class Cell:
"""One bar of one row, in one section. The unit a merge reasons about."""
section: int
- row: str # "chords" | "melody" | "lyrics" | "player:bass"
+ row: str # "chords" | "melody" | "lyrics" | "player:bass"
bar: int
def __str__(self) -> str:
@@ -139,13 +139,9 @@ def merge(base: str, mine: str, theirs: str, dialect: str = "auto") -> MergeResu
overlap = my_edit.cells & their_edit.cells
# Both sides writing a cell the same way is agreement, not collision.
- conflicts = sorted(
- cell for cell in overlap if mine_cells.get(cell) != their_cells.get(cell)
- )
+ conflicts = sorted(cell for cell in overlap if mine_cells.get(cell) != their_cells.get(cell))
if conflicts:
- return MergeResult(
- ok=False, conflicts=conflicts, mine=my_edit, theirs=their_edit
- )
+ return MergeResult(ok=False, conflicts=conflicts, mine=my_edit, theirs=their_edit)
merged = dict(base_cells)
for cell in my_edit.cells:
diff --git a/plainsong/notation/parser.py b/plainsong/notation/parser.py
index 9ca46f85..3efe020c 100644
--- a/plainsong/notation/parser.py
+++ b/plainsong/notation/parser.py
@@ -45,16 +45,34 @@
MD_TITLE_RE = re.compile(r"^\s*#\s+(.+?)\s*$")
SECTION_RE = re.compile(r"^\s*\[([^\]]+)\]\s*(?:\(([^)]*)\))?\s*$")
LABEL_RE = re.compile(r"^\s*([A-Za-z][A-Za-z0-9 _-]{0,20})\s*:\s*(.*)$")
-OPTION_RE = re.compile(r"^\s*(vel|velocity|inst|instrument|program|pan|oct|octave)\s*[:=]\s*(.+?)\s*$", re.IGNORECASE)
+OPTION_RE = re.compile(
+ r"^\s*(vel|velocity|inst|instrument|program|pan|oct|octave)\s*[:=]\s*(.+?)\s*$", re.IGNORECASE
+)
# Stage options may also be written at the end of a player's note row. They are
# only taken as options when the value reads as one, so a bar that happens to
# start with the word stays a bar.
STAGE_OPTION_RE = re.compile(r"^\s*(pos|position|speech|feel)\s*[:=]\s*(.+?)\s*$", re.IGNORECASE)
METADATA_KEYS = {
- "key", "tempo", "bpm", "swing", "subdivision", "time", "meter", "mood",
- "style", "feel", "artist", "composer", "source", "capo", "genre", "year",
- "arranger", "difficulty", "notes",
+ "key",
+ "tempo",
+ "bpm",
+ "swing",
+ "subdivision",
+ "time",
+ "meter",
+ "mood",
+ "style",
+ "feel",
+ "artist",
+ "composer",
+ "source",
+ "capo",
+ "genre",
+ "year",
+ "arranger",
+ "difficulty",
+ "notes",
}
ROLE_LABELS = {
@@ -80,8 +98,18 @@
# that said exactly what it meant was told it had made a mistake. Both
# spellings are in the wild.
REST_TOKENS = {
- "(rest)", "rest", "r", "_", "0", "(silence)", "x", "(x)", "--",
- "n.c.", "nc", "n.c",
+ "(rest)",
+ "rest",
+ "r",
+ "_",
+ "0",
+ "(silence)",
+ "x",
+ "(x)",
+ "--",
+ "n.c.",
+ "nc",
+ "n.c",
}
SUSTAIN_CHARS = "~"
@@ -199,9 +227,7 @@ def detect_dialect(text: str) -> str:
if not tokens:
continue
relative = sum(
- 1
- for tok in tokens
- if theory.is_roman(tok) or re.match(r"^[b#]?[1-7][\^_',:]*$", tok)
+ 1 for tok in tokens if theory.is_roman(tok) or re.match(r"^[b#]?[1-7][\^_',:]*$", tok)
)
if relative >= len(tokens) * 0.5:
relative_rows += 1
@@ -229,7 +255,9 @@ def __init__(self, text: str, dialect: str = "auto", path: str = "") -> None:
def _note(self, severity: str, message: str, line: int, hint: str = "", source: str = "") -> None:
self.diagnostics.append(
- Diagnostic(severity=severity, message=message, line=line, hint=hint, source=source.strip()[:120])
+ Diagnostic(
+ severity=severity, message=message, line=line, hint=hint, source=source.strip()[:120]
+ )
)
# -- entry point ---------------------------------------------------------
@@ -488,9 +516,7 @@ def _handle_role(self, role: str, payload: str, index: int, raw: str) -> None:
if not cells:
self._note("info", f"empty {role} row", index, source=raw)
return
- self._append_line(
- Line(role=role, cells=cells, line_number=index, raw=raw, barred="|" in payload)
- )
+ self._append_line(Line(role=role, cells=cells, line_number=index, raw=raw, barred="|" in payload))
def _handle_player(self, line: str, index: int) -> None:
name, is_declaration, remainder = split_player_line(line)
@@ -546,9 +572,7 @@ def _handle_player(self, line: str, index: int) -> None:
self._place_from_options(name, options)
if not cells_text:
- self._append_line(
- Line(role=ROLE_NOTE, name=name, options=options, line_number=index, raw=line)
- )
+ self._append_line(Line(role=ROLE_NOTE, name=name, options=options, line_number=index, raw=line))
return
cells = [Cell(tokens=self._tokenise(text, ROLE_PLAYER), line=index) for text in cells_text]
@@ -583,9 +607,7 @@ def _handle_bare_table(self, line: str, index: int, raw: str) -> None:
self._append_line(Line(role=ROLE_NOTE, cells=[], line_number=index, raw=raw))
return
cells = [Cell(tokens=self._tokenise(text, role), line=index) for text in cells_text]
- self._append_line(
- Line(role=role, cells=cells, line_number=index, raw=raw, barred="|" in line)
- )
+ self._append_line(Line(role=role, cells=cells, line_number=index, raw=raw, barred="|" in line))
def _classify_bare(self, tokens: list[str], strict: bool = False) -> str | None:
"""Decide whether an unlabelled row is harmony or melody.
@@ -679,7 +701,9 @@ def _finish_section(self) -> None:
def _validate(self) -> None:
if not self.sections:
- self._note("error", "no sections found", 1, hint="start a section with a header such as [Verse]")
+ self._note(
+ "error", "no sections found", 1, hint="start a section with a header such as [Verse]"
+ )
return
playable = 0
for section in self.sections:
@@ -706,7 +730,9 @@ def _validate(self) -> None:
source=first.raw,
)
playable += sum(
- 1 for line in section.lines if line.cells and line.role in {ROLE_CHORDS, ROLE_MELODY, ROLE_PLAYER}
+ 1
+ for line in section.lines
+ if line.cells and line.role in {ROLE_CHORDS, ROLE_MELODY, ROLE_PLAYER}
)
if playable == 0:
self._note(
diff --git a/plainsong/notation/theory.py b/plainsong/notation/theory.py
index 2e60a1e0..f6f52e66 100644
--- a/plainsong/notation/theory.py
+++ b/plainsong/notation/theory.py
@@ -155,7 +155,13 @@
)
ROMAN_DEGREES = {
- "I": 1, "II": 2, "III": 3, "IV": 4, "V": 5, "VI": 6, "VII": 7,
+ "I": 1,
+ "II": 2,
+ "III": 3,
+ "IV": 4,
+ "V": 5,
+ "VI": 6,
+ "VII": 7,
}
_PITCH_RE = re.compile(r"^([A-Ga-g])([#b♯♭]*)(-?\d+)?$")
@@ -353,9 +359,16 @@ def parse_chord(token: str) -> Chord:
_LEGACY_BY_CORE = {
- "maj": "maj", "dom": "7", "min": "min", "minmaj": "minmaj7",
- "halfdim": "min7b5", "dim": "dim", "aug": "aug",
- "sus4": "sus4", "sus2": "sus2", "power": "5",
+ "maj": "maj",
+ "dom": "7",
+ "min": "min",
+ "minmaj": "minmaj7",
+ "halfdim": "min7b5",
+ "dim": "dim",
+ "aug": "aug",
+ "sus4": "sus4",
+ "sus2": "sus2",
+ "power": "5",
}
@@ -441,12 +454,23 @@ def __eq__(self, other: object) -> bool:
MODE_WORDS = {
- "major": "major", "maj": "major", "": "major", "ionian": "ionian",
- "minor": "minor", "min": "minor", "m": "minor", "aeolian": "aeolian",
- "dorian": "dorian", "phrygian": "phrygian", "lydian": "lydian",
- "mixolydian": "mixolydian", "locrian": "locrian",
- "harmonic minor": "harmonic_minor", "harmonic_minor": "harmonic_minor",
- "melodic minor": "melodic_minor", "melodic_minor": "melodic_minor",
+ "major": "major",
+ "maj": "major",
+ "": "major",
+ "ionian": "ionian",
+ "minor": "minor",
+ "min": "minor",
+ "m": "minor",
+ "aeolian": "aeolian",
+ "dorian": "dorian",
+ "phrygian": "phrygian",
+ "lydian": "lydian",
+ "mixolydian": "mixolydian",
+ "locrian": "locrian",
+ "harmonic minor": "harmonic_minor",
+ "harmonic_minor": "harmonic_minor",
+ "melodic minor": "melodic_minor",
+ "melodic_minor": "melodic_minor",
"blues": "blues",
}
diff --git a/plainsong/notation/timegrid.py b/plainsong/notation/timegrid.py
index dd55d261..f40c593e 100644
--- a/plainsong/notation/timegrid.py
+++ b/plainsong/notation/timegrid.py
@@ -45,12 +45,12 @@ class Placement:
"""One written token, positioned on the common matrix."""
token: str
- row: str # "chords" | "melody" | "lyrics" | "player:bass"
- kind: str # note | chord | sustain | rest | text
- bar: int # absolute bar index from the start of the piece
- onset: float # beats from the start of the piece
- width: float # beats
- unit: float # position within its own bar, 0.0 <= unit < 1.0
+ row: str # "chords" | "melody" | "lyrics" | "player:bass"
+ kind: str # note | chord | sustain | rest | text
+ bar: int # absolute bar index from the start of the piece
+ onset: float # beats from the start of the piece
+ width: float # beats
+ unit: float # position within its own bar, 0.0 <= unit < 1.0
@property
def sounds(self) -> bool:
@@ -77,9 +77,7 @@ def add(self, *, token: str, row: str, kind: str, onset: float, width: float) ->
unit = position - bar
if unit < _EPSILON:
unit = 0.0
- placement = Placement(
- token=token, row=row, kind=kind, bar=bar, onset=onset, width=width, unit=unit
- )
+ placement = Placement(token=token, row=row, kind=kind, bar=bar, onset=onset, width=width, unit=unit)
self.placements.append(placement)
return placement
@@ -139,10 +137,6 @@ def column(self, bar: int, unit: float, tolerance: float = 1e-6) -> list[Placeme
reader would say are in the same column, as opposed to the ones that
merely look that way."""
return sorted(
- (
- p
- for p in self.placements
- if p.bar == bar and abs(p.unit - unit) <= tolerance
- ),
+ (p for p in self.placements if p.bar == bar and abs(p.unit - unit) <= tolerance),
key=lambda p: p.row,
)
diff --git a/plainsong/notation/voicing.py b/plainsong/notation/voicing.py
index 955dcbbf..d94d8f05 100644
--- a/plainsong/notation/voicing.py
+++ b/plainsong/notation/voicing.py
@@ -39,15 +39,15 @@
#: the chord's identity. Everything above the seventh sits between the two
#: groups: more expendable than a guide tone, far less expendable than a fifth.
DROP_ORDER: dict[int, int] = {
- 5: 0, # first to go
- 1: 1, # then the root
- 11: 2, # then the eleventh, which is the muddiest extension
+ 5: 0, # first to go
+ 1: 1, # then the root
+ 11: 2, # then the eleventh, which is the muddiest extension
9: 3,
13: 4,
6: 5,
2: 5,
4: 5,
- 3: 9, # never, in practice
+ 3: 9, # never, in practice
7: 9,
}
@@ -178,7 +178,7 @@ def voice(
if not degrees:
offsets = sorted(set(chord.intervals()))
kept = offsets[:limit] if limit else offsets
- return Voicing(tuple(root + o for o in kept), tuple(offsets[len(kept):]))
+ return Voicing(tuple(root + o for o in kept), tuple(offsets[len(kept) :]))
chosen = STRATEGIES.get(strategy, _guide)(degrees, root, limit)
notes = [n for n in chosen.notes if 0 <= n <= 127]
diff --git a/plainsong/perform/conduct.py b/plainsong/perform/conduct.py
index c97bab74..1cbff5cf 100644
--- a/plainsong/perform/conduct.py
+++ b/plainsong/perform/conduct.py
@@ -236,11 +236,10 @@ class DirectiveSet:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> DirectiveSet:
raw = data.get("directives") or []
- directives = tuple(
- Directive.from_dict(item) for item in raw if isinstance(item, dict)
- )
+ directives = tuple(Directive.from_dict(item) for item in raw if isinstance(item, dict))
problems = [
- f"directive {index}: not an object" for index, item in enumerate(raw)
+ f"directive {index}: not an object"
+ for index, item in enumerate(raw)
if not isinstance(item, dict)
]
return cls(
@@ -603,9 +602,8 @@ def describe(directives: Any) -> str:
reading = read(directives)
lines: list[str] = []
for directive in reading.directives:
- window = (
- f"from beat {directive.offset_beats:g}"
- + (f" for {directive.duration_beats:g}" if directive.duration_beats else " onwards")
+ window = f"from beat {directive.offset_beats:g}" + (
+ f" for {directive.duration_beats:g}" if directive.duration_beats else " onwards"
)
target = ", ".join(directive.target) or "everyone"
mark = "" if directive.handled else " (not a timing action; ignored here)"
diff --git a/plainsong/perform/profiles.py b/plainsong/perform/profiles.py
index 245583d6..c96467d4 100644
--- a/plainsong/perform/profiles.py
+++ b/plainsong/perform/profiles.py
@@ -83,27 +83,27 @@ def total(self) -> float:
PROGRAM_RANGES: tuple[tuple[int, int, SpeechProfile], ...] = (
(0, 7, PIANO),
(8, 15, MALLET),
- (16, 18, ELECTRIC), # drawbar, percussive and rock organ are electronic
- (19, 19, ORGAN_LARGE), # church organ
+ (16, 18, ELECTRIC), # drawbar, percussive and rock organ are electronic
+ (19, 19, ORGAN_LARGE), # church organ
(20, 20, REED_ORGAN),
- (21, 23, REED_ORGAN), # accordion, harmonica, tango accordion
+ (21, 23, REED_ORGAN), # accordion, harmonica, tango accordion
(24, 31, PLUCKED),
(32, 39, PLUCKED_BASS),
- (40, 44, BOWED), # solo violin through tremolo strings
- (45, 46, PLUCKED), # pizzicato strings, harp
- (47, 47, PERCUSSION), # timpani
+ (40, 44, BOWED), # solo violin through tremolo strings
+ (45, 46, PLUCKED), # pizzicato strings, harp
+ (47, 47, PERCUSSION), # timpani
(48, 51, BOWED_SECTION),
(52, 54, VOICE),
- (55, 55, PERCUSSION), # orchestra hit
+ (55, 55, PERCUSSION), # orchestra hit
(56, 63, BRASS),
(64, 71, WOODWIND),
(72, 79, WOODWIND_FLUE),
(80, 87, ELECTRIC),
(88, 95, PAD),
(96, 103, PAD),
- (104, 109, PLUCKED), # sitar, banjo, shamisen, koto, kalimba, bagpipe
- (110, 110, BOWED), # fiddle
- (111, 111, WOODWIND), # shanai
+ (104, 109, PLUCKED), # sitar, banjo, shamisen, koto, kalimba, bagpipe
+ (110, 110, BOWED), # fiddle
+ (111, 111, WOODWIND), # shanai
(112, 119, PERCUSSION),
(120, 127, ELECTRIC),
)
@@ -111,9 +111,26 @@ def total(self) -> float:
BY_NAME: dict[str, SpeechProfile] = {
profile.name: profile
for profile in (
- PERCUSSION, MALLET, PLUCKED, PLUCKED_BASS, PIANO, ELECTRIC, BOWED, BOWED_SHORT,
- BOWED_SECTION, BRASS, BRASS_SOFT, WOODWIND, WOODWIND_FLUE, VOICE, REED_ORGAN,
- ORGAN_SMALL, ORGAN, ORGAN_LARGE, PAD, GENERIC,
+ PERCUSSION,
+ MALLET,
+ PLUCKED,
+ PLUCKED_BASS,
+ PIANO,
+ ELECTRIC,
+ BOWED,
+ BOWED_SHORT,
+ BOWED_SECTION,
+ BRASS,
+ BRASS_SOFT,
+ WOODWIND,
+ WOODWIND_FLUE,
+ VOICE,
+ REED_ORGAN,
+ ORGAN_SMALL,
+ ORGAN,
+ ORGAN_LARGE,
+ PAD,
+ GENERIC,
)
}
diff --git a/plainsong/perform/solve.py b/plainsong/perform/solve.py
index 1297eab4..e9987d49 100644
--- a/plainsong/perform/solve.py
+++ b/plainsong/perform/solve.py
@@ -108,9 +108,7 @@ def offsets(self, shaping: Shaping = NEUTRAL, compensate: bool = True) -> tuple[
alignment = shaping.alignment if compensate else 0.0
correction = self.speech + shaping.preparation + self.reference_propagation + self.p_center
emission = self.feel * shaping.feel_scale + shaping.feel - alignment * correction
- arrival = (
- emission + self.speech + shaping.preparation + self.p_center + self.observed_propagation
- )
+ arrival = emission + self.speech + shaping.preparation + self.p_center + self.observed_propagation
return emission, arrival
def as_dict(self) -> dict[str, Any]:
@@ -271,8 +269,7 @@ def apply_to(
arrangement.diagnostics.append(
Diagnostic(
severity="warning",
- message=f"nobody called {frame!r} is on this stage; listening at "
- f"{solution.frame} instead",
+ message=f"nobody called {frame!r} is on this stage; listening at {solution.frame} instead",
hint="frames are: " + ", ".join(frames_for(stage)),
)
)
@@ -327,19 +324,14 @@ def analyse(arrangement: Any, frame: str = "") -> dict[str, Any]:
if stage is None:
return {"stage": False, "reason": "this piece has no [Stage] block"}
- voices = [
- (track.name.strip().lower(), track.program, track.is_drum)
- for track in arrangement.tracks
- ]
+ voices = [(track.name.strip().lower(), track.program, track.is_drum) for track in arrangement.tracks]
chosen = solve(stage, voices, frame=frame)
# Standing at a desk, the reference you actually judge by is your own
# sound, which reaches you first. This is the number a player would
# describe as "the timpani are late".
relative: list[dict[str, Any]] = []
- own = chosen.frame[len(PLAYER_FRAME_PREFIX) :] if chosen.frame.startswith(
- PLAYER_FRAME_PREFIX
- ) else ""
+ own = chosen.frame[len(PLAYER_FRAME_PREFIX) :] if chosen.frame.startswith(PLAYER_FRAME_PREFIX) else ""
if own and own in chosen.voices:
anchor = chosen.voices[own].arrival_offset
relative = [
@@ -422,9 +414,7 @@ def format_report(report: dict[str, Any]) -> str:
)
widths = [max(len(row[index]) for row in rows) for index in range(len(header))]
for row in rows:
- lines.append(
- " " + " ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)).rstrip()
- )
+ lines.append(" " + " ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)).rstrip())
if solution["lead_in_beats"]:
lines.append(
f" the piece begins {solution['lead_in_beats']:.3f} beats later than written, so the "
@@ -508,9 +498,7 @@ def movement(before: Any, after: Any) -> dict[str, Any]:
def spread(arrangement: Any) -> float:
firsts = [
- min(note.arrival_time for note in track.notes)
- for track in arrangement.tracks
- if track.notes
+ min(note.arrival_time for note in track.notes) for track in arrangement.tracks if track.notes
]
return (max(firsts) - min(firsts)) * to_ms if firsts else 0.0
@@ -540,9 +528,7 @@ def format_movement(report: dict[str, Any]) -> str:
widths = [max(len(row[index]) for row in rows) for index in range(len(header))]
lines = ["what the directives did"]
for row in rows:
- lines.append(
- " " + " ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)).rstrip()
- )
+ lines.append(" " + " ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)).rstrip())
lines.append(
f" spread at the listener {report['spread_before_ms']:.0f} ms -> "
f"{report['spread_after_ms']:.0f} ms, "
diff --git a/plainsong/perform/stage.py b/plainsong/perform/stage.py
index c3d3188b..c697f18c 100644
--- a/plainsong/perform/stage.py
+++ b/plainsong/perform/stage.py
@@ -112,9 +112,7 @@ class Stage:
temperature: float = 20.0
compensate: bool = True
- listeners: dict[str, tuple[float, float]] = field(
- default_factory=lambda: dict(DEFAULT_LISTENERS)
- )
+ listeners: dict[str, tuple[float, float]] = field(default_factory=lambda: dict(DEFAULT_LISTENERS))
placements: dict[str, Placement] = field(default_factory=dict)
problems: list[tuple[int, str]] = field(default_factory=list)
"""(line number, message) for anything in the block that could not be read."""
diff --git a/plainsong/perform/tools.py b/plainsong/perform/tools.py
index 72d4cc20..c13cef62 100644
--- a/plainsong/perform/tools.py
+++ b/plainsong/perform/tools.py
@@ -98,9 +98,7 @@ def ensemble_report(path: str = "", content: str = "", frame: str = "") -> str:
return problem
score = parse(text, path=path)
if score.has_errors:
- return "the notation has errors:\n" + "\n".join(
- f" {diag.format()}" for diag in score.errors()
- )
+ return "the notation has errors:\n" + "\n".join(f" {diag.format()}" for diag in score.errors())
if score.meta.stage is None:
return (
"this piece has no [Stage] block, so every voice is heard where it is written. "
@@ -125,9 +123,7 @@ def speech_profiles() -> str:
def directive_reference() -> str:
return DIRECTIVE_HELP
- def conduct_score(
- directives: str, path: str = "", content: str = "", frame: str = ""
- ) -> str:
+ def conduct_score(directives: str, path: str = "", content: str = "", frame: str = "") -> str:
from ..notation import arrange, parse
from ..notation.arrange import ArrangeOptions
from . import conduct
@@ -138,9 +134,7 @@ def conduct_score(
return problem
score = parse(text, path=path)
if score.has_errors:
- return "the notation has errors:\n" + "\n".join(
- f" {diag.format()}" for diag in score.errors()
- )
+ return "the notation has errors:\n" + "\n".join(f" {diag.format()}" for diag in score.errors())
reading = conduct.read(directives)
written = arrange(score, ArrangeOptions(frame=frame))
conducted = conduct.apply(written, reading, frame=frame)
diff --git a/plainsong/render/__init__.py b/plainsong/render/__init__.py
index 1c015874..a0e4aeeb 100644
--- a/plainsong/render/__init__.py
+++ b/plainsong/render/__init__.py
@@ -1,8 +1,8 @@
"""Rendering: MIDI files, audio synthesis and optional external backends."""
from .audio import AudioOptions, Synthesiser, write_wav
-from .chunked import write_wav_chunked
from .backends import BackendResult, choose_audio_backend, convert_audio, play_audio, render_with_fluidsynth
+from .chunked import write_wav_chunked
from .midi import MidiWriter, midi_bytes, write_midi
from .voices import Voice, voice_for_program
diff --git a/plainsong/render/audio.py b/plainsong/render/audio.py
index 2be0f72b..f5ec13e5 100644
--- a/plainsong/render/audio.py
+++ b/plainsong/render/audio.py
@@ -43,10 +43,10 @@ class AudioOptions:
sample_rate: int = 44100
normalize: float = 0.89
- tail: float = 1.2 # seconds of room left after the last note
- max_voices: int = 64 # simultaneous notes before gain protection
+ tail: float = 1.2 # seconds of room left after the last note
+ max_voices: int = 64 # simultaneous notes before gain protection
use_numpy: bool = True
- lowpass: bool = True # gentle one-pole smoothing on the master mix
+ lowpass: bool = True # gentle one-pole smoothing on the master mix
def midi_to_hz(pitch: int) -> float:
@@ -219,7 +219,11 @@ def render(self, arrangement: Arrangement):
total_seconds = arrangement.duration_seconds + options.tail
total_samples = max(1, int(total_seconds * rate))
- mix = _np.zeros(total_samples, dtype=_np.float64) if self.backend == "numpy" else [0.0] * total_samples
+ mix = (
+ _np.zeros(total_samples, dtype=_np.float64)
+ if self.backend == "numpy"
+ else [0.0] * total_samples
+ )
for track in arrangement.tracks:
voice = voice_for_program(track.program, track.is_drum)
diff --git a/plainsong/render/backends.py b/plainsong/render/backends.py
index 3d6f0f09..06a6dc82 100644
--- a/plainsong/render/backends.py
+++ b/plainsong/render/backends.py
@@ -98,10 +98,16 @@ def render_with_fluidsynth(
target.parent.mkdir(parents=True, exist_ok=True)
ok, message = _run(
[
- "fluidsynth", "-ni", "-g", "0.8",
- "-r", str(sample_rate),
- "-F", str(target),
- str(font), str(midi_path),
+ "fluidsynth",
+ "-ni",
+ "-g",
+ "0.8",
+ "-r",
+ str(sample_rate),
+ "-F",
+ str(target),
+ str(font),
+ str(midi_path),
]
)
if not ok:
@@ -157,9 +163,7 @@ def send_to_midi_port(midi_path: str | Path, port: str | None = None) -> Backend
try:
import mido # type: ignore
except ImportError:
- return BackendResult(
- False, "mido", message="pip install mido python-rtmidi to play to a MIDI port"
- )
+ return BackendResult(False, "mido", message="pip install mido python-rtmidi to play to a MIDI port")
try:
names = mido.get_output_names()
if not names:
diff --git a/plainsong/render/chart.py b/plainsong/render/chart.py
index 753b356e..27c3eaea 100644
--- a/plainsong/render/chart.py
+++ b/plainsong/render/chart.py
@@ -58,11 +58,11 @@
class ChartOptions:
"""Everything about the drawing, in staff spaces unless stated."""
- staff_space: float = 7.0 # px; the one number the chart scales from
+ staff_space: float = 7.0 # px; the one number the chart scales from
bars_per_line: int = 4
- bar_width: float = 16.0 # staff spaces
- line_height: float = 7.5 # staff spaces between system baselines
- margin: float = 3.5 # staff spaces
+ bar_width: float = 16.0 # staff spaces
+ line_height: float = 7.5 # staff spaces between system baselines
+ margin: float = 3.5 # staff spaces
show_lyrics: bool = True
title: bool = True
@@ -90,12 +90,7 @@ def text_width(text: str, font_size: float, bold: bool = False) -> float:
def _escape(text: str) -> str:
- return (
- text.replace("&", "&")
- .replace("<", "<")
- .replace(">", ">")
- .replace('"', """)
- )
+ return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
def _text(x: float, y: float, body: str, size: float, cls: str, anchor: str = "start") -> str:
@@ -134,9 +129,7 @@ def _chords_by_bar(arrangement: Arrangement) -> dict[int, list[tuple[float, str]
return {bar: sorted(units.items()) for bar, units in seen.items()}
-def _required_width(
- items: list[tuple[float, str]], size: float, bold: bool, gap: float
-) -> float:
+def _required_width(items: list[tuple[float, str]], size: float, bold: bool, gap: float) -> float:
"""The narrowest bar width at which none of these items overlap.
Each item sits at `unit * width`, so the next one starting at `next_unit`
@@ -170,11 +163,7 @@ def _lyrics_by_bar(arrangement: Arrangement, bar_beats: float) -> dict[int, list
def _sections_by_bar(arrangement: Arrangement, bar_beats: float) -> dict[int, str]:
- return {
- int(beat / bar_beats + 1e-9): name
- for name, beat in arrangement.section_starts
- if name
- }
+ return {int(beat / bar_beats + 1e-9): name for name, beat in arrangement.section_starts if name}
_STYLE = """
@@ -231,7 +220,7 @@ def render(arrangement: Arrangement, options: ChartOptions | None = None) -> str
line_h = options.line_height * space
if lyrics:
- line_h += space * 2.2 # room for the words, and for the next section label
+ line_h += space * 2.2 # room for the words, and for the next section label
heading = size * 2.3 if (options.title and arrangement.meta.title) else 0.0
width = margin * 2 + bar_w * per_line
@@ -251,8 +240,7 @@ def render(arrangement: Arrangement, options: ChartOptions | None = None) -> str
# right-aligned metadata line collide, and which one wins depends on
# the title, so there is no width at which the layout is safe.
meta_line = (
- f"{arrangement.meta.key.text} · {arrangement.meta.meter}"
- f" · {arrangement.meta.tempo:g} bpm"
+ f"{arrangement.meta.key.text} · {arrangement.meta.meter} · {arrangement.meta.tempo:g} bpm"
)
parts.append(_text(margin, margin + size * 1.95, meta_line, size * 0.62, "faint"))
diff --git a/plainsong/render/chunked.py b/plainsong/render/chunked.py
index 72278f7e..266f3d57 100644
--- a/plainsong/render/chunked.py
+++ b/plainsong/render/chunked.py
@@ -2,20 +2,27 @@
Streams synthesis in fixed-size chunks so peak memory is O(chunk + longest_note),
not O(total_samples). Produces byte-identical output to ``write_wav``.
-Requires NumPy.
+
+Requires NumPy, which is why the import sits inside the two functions that use
+it rather than at module scope. ``render/__init__`` imports this module eagerly,
+so a top-level ``import numpy`` makes *every* renderer -- including the pure
+stdlib MIDI writer -- unavailable without it, and `plainsong compile -o out.mid`
+fails on a machine that has no NumPy. That is the whole point of the rule.
"""
from __future__ import annotations
import wave
from pathlib import Path
-
-import numpy as np
+from typing import TYPE_CHECKING
from ..notation.ir import Arrangement, Note, Track
-from .audio import AudioOptions, Synthesiser, midi_to_hz
+from .audio import AudioOptions, Synthesiser
from .voices import Voice, voice_for_program
+if TYPE_CHECKING:
+ import numpy as np
+
def _note_info(
track: Track, note: Note, voice: Voice, rate: int, bps: float, total_samples: int
@@ -44,9 +51,7 @@ def _add_note_to_chunk(
ov_e = min(note_end, chunk_end)
if ov_s >= ov_e:
return
- buf[ov_s - chunk_start : ov_e - chunk_start] += (
- block[ov_s - note_start : ov_e - note_start] * gain
- )
+ buf[ov_s - chunk_start : ov_e - chunk_start] += block[ov_s - note_start : ov_e - note_start] * gain
def write_wav_chunked(
@@ -61,6 +66,8 @@ def write_wav_chunked(
Two-pass: first pass finds global peak after lowpass (needed for
normalisation), second pass writes. Peak memory is O(chunk_samples + longest_note).
"""
+ import numpy as np
+
opts = options or AudioOptions()
synth = Synthesiser(opts)
if synth.backend != "numpy":
@@ -97,9 +104,7 @@ def _process_chunk(s: int, e: int, acc: float) -> tuple[np.ndarray, float]:
break # sorted, rest are even later
if ne <= s:
continue # note ended before this chunk
- block = np.asarray(
- synth._note_samples(voice, pitch, nsamples), dtype=np.float64
- )
+ block = np.asarray(synth._note_samples(voice, pitch, nsamples), dtype=np.float64)
_add_note_to_chunk(buf, ns, block, gain, s, e)
if do_lp:
filtered = np.empty_like(buf)
diff --git a/plainsong/render/fontmetrics.py b/plainsong/render/fontmetrics.py
index 145689f4..0a2ee39e 100644
--- a/plainsong/render/fontmetrics.py
+++ b/plainsong/render/fontmetrics.py
@@ -26,7 +26,10 @@
UNITS_PER_EM = 1000
#: Characters the reference font does not contain at all.
-MISSING = ("\u266d", "\u266e",)
+MISSING = (
+ "\u266d",
+ "\u266e",
+)
WIDTHS: dict[str, int] = {
" ": 278,
diff --git a/plainsong/render/voices.py b/plainsong/render/voices.py
index 0981858a..0a682688 100644
--- a/plainsong/render/voices.py
+++ b/plainsong/render/voices.py
@@ -21,15 +21,15 @@ class Voice:
name: str
harmonics: tuple[float, ...] = (1.0, 0.4, 0.2, 0.1)
- attack: float = 0.01 # seconds
- decay: float = 0.20 # seconds
- sustain: float = 0.6 # fraction of peak
- release: float = 0.18 # seconds
- noise: float = 0.0 # blend of white noise, 0..1
+ attack: float = 0.01 # seconds
+ decay: float = 0.20 # seconds
+ sustain: float = 0.6 # fraction of peak
+ release: float = 0.18 # seconds
+ noise: float = 0.0 # blend of white noise, 0..1
vibrato_hz: float = 0.0
- vibrato_depth: float = 0.0 # fraction of a semitone
+ vibrato_depth: float = 0.0 # fraction of a semitone
gain: float = 1.0
- percussive: bool = False # ignore sustain, decay straight to silence
+ percussive: bool = False # ignore sustain, decay straight to silence
def envelope_points(self, duration: float) -> tuple[float, float, float, float]:
"""Attack, decay, sustain level and release scaled to fit *duration*."""
@@ -42,83 +42,145 @@ def envelope_points(self, duration: float) -> tuple[float, float, float, float]:
PIANO = Voice(
name="piano",
harmonics=(1.0, 0.42, 0.22, 0.11, 0.06, 0.03),
- attack=0.004, decay=0.45, sustain=0.28, release=0.25, percussive=True,
+ attack=0.004,
+ decay=0.45,
+ sustain=0.28,
+ release=0.25,
+ percussive=True,
)
ELECTRIC_PIANO = Voice(
name="electric piano",
harmonics=(1.0, 0.28, 0.14, 0.35, 0.05),
- attack=0.006, decay=0.5, sustain=0.32, release=0.3, percussive=True,
+ attack=0.006,
+ decay=0.5,
+ sustain=0.32,
+ release=0.3,
+ percussive=True,
)
BELL = Voice(
name="bell",
harmonics=(1.0, 0.0, 0.6, 0.0, 0.35, 0.0, 0.2),
- attack=0.002, decay=0.9, sustain=0.05, release=0.6, percussive=True,
+ attack=0.002,
+ decay=0.9,
+ sustain=0.05,
+ release=0.6,
+ percussive=True,
)
ORGAN = Voice(
name="organ",
harmonics=(1.0, 0.7, 0.5, 0.35, 0.25, 0.18, 0.12),
- attack=0.02, decay=0.05, sustain=0.9, release=0.08,
+ attack=0.02,
+ decay=0.05,
+ sustain=0.9,
+ release=0.08,
)
GUITAR = Voice(
name="guitar",
harmonics=(1.0, 0.55, 0.32, 0.18, 0.1, 0.05),
- attack=0.005, decay=0.6, sustain=0.2, release=0.3, percussive=True,
+ attack=0.005,
+ decay=0.6,
+ sustain=0.2,
+ release=0.3,
+ percussive=True,
)
BASS = Voice(
name="bass",
harmonics=(1.0, 0.5, 0.18, 0.06),
- attack=0.008, decay=0.4, sustain=0.45, release=0.2, gain=1.15,
+ attack=0.008,
+ decay=0.4,
+ sustain=0.45,
+ release=0.2,
+ gain=1.15,
)
STRINGS = Voice(
name="strings",
harmonics=(1.0, 0.6, 0.4, 0.28, 0.2, 0.14, 0.1),
- attack=0.12, decay=0.15, sustain=0.85, release=0.35,
- vibrato_hz=5.2, vibrato_depth=0.035,
+ attack=0.12,
+ decay=0.15,
+ sustain=0.85,
+ release=0.35,
+ vibrato_hz=5.2,
+ vibrato_depth=0.035,
)
CHOIR = Voice(
name="choir",
harmonics=(1.0, 0.5, 0.3, 0.12, 0.28, 0.08),
- attack=0.09, decay=0.2, sustain=0.8, release=0.4,
- vibrato_hz=4.6, vibrato_depth=0.03, noise=0.015,
+ attack=0.09,
+ decay=0.2,
+ sustain=0.8,
+ release=0.4,
+ vibrato_hz=4.6,
+ vibrato_depth=0.03,
+ noise=0.015,
)
BRASS = Voice(
name="brass",
harmonics=(1.0, 0.8, 0.62, 0.45, 0.3, 0.2, 0.12),
- attack=0.05, decay=0.12, sustain=0.78, release=0.18, gain=0.95,
+ attack=0.05,
+ decay=0.12,
+ sustain=0.78,
+ release=0.18,
+ gain=0.95,
)
REED = Voice(
name="reed",
harmonics=(1.0, 0.15, 0.5, 0.1, 0.28, 0.05),
- attack=0.04, decay=0.1, sustain=0.82, release=0.15,
- vibrato_hz=5.0, vibrato_depth=0.02,
+ attack=0.04,
+ decay=0.1,
+ sustain=0.82,
+ release=0.15,
+ vibrato_hz=5.0,
+ vibrato_depth=0.02,
)
FLUTE = Voice(
name="flute",
harmonics=(1.0, 0.12, 0.05),
- attack=0.06, decay=0.1, sustain=0.85, release=0.18,
- noise=0.035, vibrato_hz=5.5, vibrato_depth=0.025,
+ attack=0.06,
+ decay=0.1,
+ sustain=0.85,
+ release=0.18,
+ noise=0.035,
+ vibrato_hz=5.5,
+ vibrato_depth=0.025,
)
LEAD = Voice(
name="lead",
harmonics=(1.0, 0.5, 0.33, 0.25, 0.2, 0.16, 0.14, 0.12),
- attack=0.01, decay=0.15, sustain=0.7, release=0.12,
+ attack=0.01,
+ decay=0.15,
+ sustain=0.7,
+ release=0.12,
)
PAD = Voice(
name="pad",
harmonics=(1.0, 0.45, 0.3, 0.22, 0.15, 0.1),
- attack=0.35, decay=0.3, sustain=0.75, release=0.6,
- vibrato_hz=3.1, vibrato_depth=0.02, gain=0.85,
+ attack=0.35,
+ decay=0.3,
+ sustain=0.75,
+ release=0.6,
+ vibrato_hz=3.1,
+ vibrato_depth=0.02,
+ gain=0.85,
)
PLUCK = Voice(
name="pluck",
harmonics=(1.0, 0.4, 0.25, 0.15, 0.08),
- attack=0.003, decay=0.35, sustain=0.1, release=0.2, percussive=True,
+ attack=0.003,
+ decay=0.35,
+ sustain=0.1,
+ release=0.2,
+ percussive=True,
)
DRUM = Voice(
name="drum",
harmonics=(1.0, 0.3),
- attack=0.001, decay=0.18, sustain=0.0, release=0.08,
- noise=0.85, percussive=True, gain=1.1,
+ attack=0.001,
+ decay=0.18,
+ sustain=0.0,
+ release=0.08,
+ noise=0.85,
+ percussive=True,
+ gain=1.1,
)
# General MIDI program ranges, in order. First match wins.
@@ -145,8 +207,21 @@ def envelope_points(self, duration: float) -> tuple[float, float, float, float]:
BY_NAME: dict[str, Voice] = {
voice.name: voice
for voice in (
- PIANO, ELECTRIC_PIANO, BELL, ORGAN, GUITAR, BASS, STRINGS,
- CHOIR, BRASS, REED, FLUTE, LEAD, PAD, PLUCK, DRUM,
+ PIANO,
+ ELECTRIC_PIANO,
+ BELL,
+ ORGAN,
+ GUITAR,
+ BASS,
+ STRINGS,
+ CHOIR,
+ BRASS,
+ REED,
+ FLUTE,
+ LEAD,
+ PAD,
+ PLUCK,
+ DRUM,
)
}
diff --git a/plainsong/runtime/_toml.py b/plainsong/runtime/_toml.py
index dfde115a..f6d7c1d2 100644
--- a/plainsong/runtime/_toml.py
+++ b/plainsong/runtime/_toml.py
@@ -25,7 +25,13 @@
BARE_KEY_CHARS = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-")
ESCAPES = {
- '"': '"', "\\": "\\", "b": "\b", "f": "\f", "n": "\n", "r": "\r", "t": "\t",
+ '"': '"',
+ "\\": "\\",
+ "b": "\b",
+ "f": "\f",
+ "n": "\n",
+ "r": "\r",
+ "t": "\t",
}
diff --git a/plainsong/selfcheck.py b/plainsong/selfcheck.py
index cfd3a265..c0946b54 100644
--- a/plainsong/selfcheck.py
+++ b/plainsong/selfcheck.py
@@ -64,9 +64,7 @@ def check_bar_fill() -> tuple[bool, str]:
"""An unusual token count divides its bar instead of spilling over."""
from .notation import arrange, parse
- text = (
- "[A]\nMelody: | C4 D4 E4 F4 G4 A4 B4 C5 C4 D4 E4 F4 G4 A4 B4 C5 D5 |\n"
- )
+ text = "[A]\nMelody: | C4 D4 E4 F4 G4 A4 B4 C5 C4 D4 E4 F4 G4 A4 B4 C5 D5 |\n"
arrangement = arrange(parse(text))
if arrangement.total_beats != 4.0:
return False, f"17 tokens should still fill one bar, got {arrangement.total_beats} beats"
@@ -163,12 +161,12 @@ def check_chord_vocabulary() -> tuple[bool, str]:
# symbol -> semitones above the root
"C7b9#11": (0, 4, 7, 10, 13, 18),
"F13#11": (0, 4, 7, 10, 14, 18, 21),
- "C7M": (0, 4, 7, 11), # sétima maior
- "EbMaj7": (0, 4, 7, 11), # capitalised, which used to refuse
+ "C7M": (0, 4, 7, 11), # sétima maior
+ "EbMaj7": (0, 4, 7, 11), # capitalised, which used to refuse
"C13": (0, 4, 7, 10, 14, 21), # no eleventh: it fights the major third
"Cm13": (0, 3, 7, 10, 14, 17, 21), # minor third, so the eleventh stays
- "C9sus4": (0, 5, 7, 10, 14), # no third, so nothing to avoid
- "Bb-7": (0, 3, 7, 10), # a minus before a seven is minor
+ "C9sus4": (0, 5, 7, 10, 14), # no third, so nothing to avoid
+ "Bb-7": (0, 3, 7, 10), # a minus before a seven is minor
}
for symbol, intervals in expected.items():
try:
@@ -225,12 +223,7 @@ def check_transpose() -> tuple[bool, str]:
return False, f"note count changed: {original.note_count} -> {moved.note_count}"
def pitches(arrangement, role):
- return [
- note.pitch
- for track in arrangement.tracks
- if track.role == role
- for note in track.notes
- ]
+ return [note.pitch for track in arrangement.tracks if track.role == role for note in track.notes]
melody_before, melody_after = pitches(original, "melody"), pitches(moved, "melody")
shifts = {later - earlier for earlier, later in zip(melody_before, melody_after, strict=True)}
@@ -396,10 +389,7 @@ def check_arrival_solver() -> tuple[bool, str]:
if report["solution"]["spread_ms"] > 1.0:
return False, f"arrivals are {report['solution']['spread_ms']}ms apart at the podium"
- first = {
- track.name: min(note.arrival_time for note in track.notes)
- for track in arrangement.tracks
- }
+ first = {track.name: min(note.arrival_time for note in track.notes) for track in arrangement.tracks}
if abs(first["timpani"] - first["organ"]) > 1e-6:
return False, f"first arrivals differ by {abs(first['timpani'] - first['organ'])} beats"
if min(note.emission_time for _track, note in arrangement.iter_notes()) < 0.0:
@@ -428,9 +418,7 @@ def lead(arrangement, name):
note = min(track.notes, key=lambda item: item.start)
return note.arrival_time - note.emission_time
- landing = {
- track.name: min(note.arrival_time for note in track.notes) for track in conducted.tracks
- }
+ landing = {track.name: min(note.arrival_time for note in track.notes) for track in conducted.tracks}
if abs(landing["timpani"] - landing["organ"]) > 1e-6:
return False, "the directive pulled the ensemble apart"
organ = lead(conducted, "organ") - lead(written, "organ")
@@ -497,11 +485,7 @@ def check_lyric_binding() -> tuple[bool, str]:
# `came` is written directly beneath `C5`. The lyric row divides the bar
# into three and the melody into four, so written as-is it sounds two
# thirds of a beat after the note it sits under.
- text = (
- "[V1]\n"
- "Melody: | A4 . C5 E5 |\n"
- "Lyrics: | the tide came |\n"
- )
+ text = "[V1]\nMelody: | A4 . C5 E5 |\nLyrics: | the tide came |\n"
loose = arrange(parse(text), ArrangeOptions(humanize=False))
starts = [round(event.start, 3) for event in loose.lyrics]
if starts != [0.0, 1.333, 2.667]:
diff --git a/plainsong/specs.py b/plainsong/specs.py
index e15b93c5..f856e466 100644
--- a/plainsong/specs.py
+++ b/plainsong/specs.py
@@ -112,7 +112,10 @@ def _execute(self, paths: Paths, report: CapabilityReport) -> tuple[bool, str]:
if not shutil.which(argv[0]):
return (self.optional, f"skipped: {argv[0]} is not installed")
completed = subprocess.run(
- argv, capture_output=True, timeout=300, check=False,
+ argv,
+ capture_output=True,
+ timeout=300,
+ check=False,
cwd=str(paths.project_root or Path.cwd()),
)
output = (completed.stdout or completed.stderr).decode("utf-8", "replace").strip()
@@ -179,8 +182,7 @@ def as_dict(self) -> dict[str, Any]:
"title": self.spec.title,
"status": self.status,
"checks": [
- {"id": check.id, "status": check.status, "detail": check.detail}
- for check in self.checks
+ {"id": check.id, "status": check.status, "detail": check.detail} for check in self.checks
],
}
diff --git a/src/genome.py b/src/genome.py
index 7eb7f29d..d1b29571 100644
--- a/src/genome.py
+++ b/src/genome.py
@@ -12,6 +12,13 @@
Ported from flux-genome-rs/src/genome.rs.
"""
+# `MusicalGenome.random` is a classmethod, which shadows the `random` module
+# for the remainder of the class body -- so a later `rng: random.Random`
+# annotation resolves to the classmethod and raises at import. Deferring
+# annotations means they are never evaluated there, and the method keeps its
+# name.
+from __future__ import annotations
+
import math
import random
diff --git a/tests/test_agent.py b/tests/test_agent.py
index 7a9b1d93..3a46079e 100644
--- a/tests/test_agent.py
+++ b/tests/test_agent.py
@@ -65,9 +65,7 @@ def test_extra_readable_paths_are_read_only(self):
class TestTools(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
- self.registry = ToolRegistry(
- sandbox=Sandbox(root=Path(self.directory.name)), config=load_config()
- )
+ self.registry = ToolRegistry(sandbox=Sandbox(root=Path(self.directory.name)), config=load_config())
def tearDown(self):
self.directory.cleanup()
@@ -138,9 +136,7 @@ class TestAgentLoop(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.config = load_config()
- self.registry = ToolRegistry(
- sandbox=Sandbox(root=Path(self.directory.name)), config=self.config
- )
+ self.registry = ToolRegistry(sandbox=Sandbox(root=Path(self.directory.name)), config=self.config)
def tearDown(self):
self.directory.cleanup()
@@ -160,7 +156,9 @@ def test_tool_call_then_reply(self):
[
CompletionResponse(
tool_calls=[
- ToolCall(id="1", name="write_score", arguments={"path": "x.song", "content": NOTATION})
+ ToolCall(
+ id="1", name="write_score", arguments={"path": "x.song", "content": NOTATION}
+ )
]
),
CompletionResponse(text="wrote it"),
@@ -215,9 +213,7 @@ def test_events_are_emitted(self):
CompletionResponse(text="done"),
]
)
- agent = Agent(
- provider=provider, tools=self.registry, config=self.config, on_event=events.append
- )
+ agent = Agent(provider=provider, tools=self.registry, config=self.config, on_event=events.append)
agent.run("hello")
kinds = [event.kind for event in events]
self.assertIn("tool_call", kinds)
@@ -288,8 +284,11 @@ def test_a_dangerous_tool_is_refused_unless_it_is_allowed(self):
with self.subTest(allow_dangerous=allowed):
registry = self._registry(allowed)
registry.add(
- "detonate", "test-only", {"type": "object", "properties": {}},
- lambda: "boom", dangerous=True,
+ "detonate",
+ "test-only",
+ {"type": "object", "properties": {}},
+ lambda: "boom",
+ dangerous=True,
)
text, failed = registry.call_result("detonate", {})
if allowed:
@@ -304,8 +303,11 @@ def test_a_dangerous_tool_is_not_even_listed_without_the_flag(self):
"""A model must not be told about a tool it will then be refused."""
registry = self._registry(False)
registry.add(
- "detonate", "test-only", {"type": "object", "properties": {}},
- lambda: "boom", dangerous=True,
+ "detonate",
+ "test-only",
+ {"type": "object", "properties": {}},
+ lambda: "boom",
+ dangerous=True,
)
self.assertNotIn("detonate", [spec.name for spec in registry.specs()])
diff --git a/tests/test_chart.py b/tests/test_chart.py
index 7b589004..e1f9b973 100644
--- a/tests/test_chart.py
+++ b/tests/test_chart.py
@@ -51,9 +51,7 @@ def test_it_reaches_outside_itself_for_nothing(self):
self.assertEqual(svg.count("http://www.w3.org/2000/svg"), 1)
without_namespace = svg.replace("http://www.w3.org/2000/svg", "")
for forbidden in ("http://", "https://", "@import", "