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 ``` -![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 `` 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<title>.+?)(?:\*\*)?$", re.IGNORECASE | re.MULTILINE) KEY_RE = re.compile(r"^key\s*:\s*(?P<key>[^|\n]+)", re.IGNORECASE | re.MULTILINE) TEMPO_RE = re.compile(r"tempo\s*:\s*(?P<tempo>\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", "<script", "xlink:href", "url("): - self.assertNotIn( - forbidden, without_namespace, f"the chart reaches outside itself: {forbidden}" - ) + self.assertNotIn(forbidden, without_namespace, f"the chart reaches outside itself: {forbidden}") def test_the_same_notation_draws_the_same_bytes(self): self.assertEqual(chart(), chart()) @@ -110,9 +108,7 @@ def test_spacing_and_glyphs_rather_than_spacing(self): def test_the_declared_length_is_the_measured_advance(self): size = 28.0 - self.assertAlmostEqual( - text_width("Am", size), (WIDTHS["A"] + WIDTHS["m"]) * size / 1000.0 - ) + self.assertAlmostEqual(text_width("Am", size), (WIDTHS["A"] + WIDTHS["m"]) * size / 1000.0) def test_bold_is_measured_bold(self): # `m`, `b` and `j` differ between the faces, and those are exactly the @@ -131,9 +127,7 @@ class TestTheChartAgreesWithTheGrid(unittest.TestCase): def test_a_chord_sits_at_its_unit_within_the_bar(self): arrangement = pipeline.compile_text(SONG).arrangement - placements = [ - p for p in arrangement.grid.placements if p.kind == "chord" and p.bar == 0 - ] + placements = [p for p in arrangement.grid.placements if p.kind == "chord" and p.bar == 0] units = sorted(p.unit for p in placements) self.assertEqual([round(u, 3) for u in units], [0.0, 0.5]) @@ -141,14 +135,12 @@ def test_chords_written_on_a_melody_row_are_drawn(self): """In the relative dialect a row mixing roman numerals with scale degrees reads as melody. A chart taking only the `Chords:` row draws a page of empty bars for a piece whose harmony is written down plainly.""" - svg = chart( - "[V1]\n| i 1 . | VII 7 . |\n" - ) + svg = chart("[V1]\n| i 1 . | VII 7 . |\n") self.assertIn("VII", svg) def test_an_empty_piece_still_draws_something(self): svg = chart("[V1]\nMelody: | C4 . . . |\n") - ET.fromstring(svg) # must not raise + ET.fromstring(svg) # must not raise class TestBarsAreAsWideAsTheirContents(unittest.TestCase): @@ -170,13 +162,9 @@ def test_symbols_in_one_bar_do_not_overlap(self): ) root = ET.fromstring(svg) chords = [ - e - for e in root.iter() - if e.tag.endswith("}text") and "chord" in e.attrib.get("class", "") + e for e in root.iter() if e.tag.endswith("}text") and "chord" in e.attrib.get("class", "") ] - placed = sorted( - (float(e.attrib["x"]), float(e.attrib["textLength"])) for e in chords - ) + placed = sorted((float(e.attrib["x"]), float(e.attrib["textLength"])) for e in chords) for (x, width), (next_x, _) in zip(placed, placed[1:], strict=False): self.assertLessEqual(x + width, next_x + 1e-6, f"{x}+{width} overruns {next_x}") diff --git a/tests/test_chordsymbol.py b/tests/test_chordsymbol.py index cf4e82cb..139c0360 100644 --- a/tests/test_chordsymbol.py +++ b/tests/test_chordsymbol.py @@ -26,8 +26,7 @@ def spell(symbol: str) -> str: """ parsed = parse_symbol(symbol) return " ".join( - NAMES[(parsed.root_pc + offset) % 12] - for offset in sorted(set(parsed.degrees.values())) + NAMES[(parsed.root_pc + offset) % 12] for offset in sorted(set(parsed.degrees.values())) ) @@ -181,10 +180,12 @@ class TestNoChord(unittest.TestCase): def _arrange(self, token: str): from plainsong.notation import arrange, parse - return arrange(parse( - "**TRACK: T**\n[MetaData]\nkey: C | tempo: 120 | time: 4/4\n\n" - f"[V1] (Verse - 2 Bars)\nChords: | C . . . | {token} . . . |\n" - )) + return arrange( + parse( + "**TRACK: T**\n[MetaData]\nkey: C | tempo: 120 | time: 4/4\n\n" + f"[V1] (Verse - 2 Bars)\nChords: | C . . . | {token} . . . |\n" + ) + ) def test_no_chord_is_silence_and_says_nothing_about_it(self): for token in self.SPELLINGS: @@ -215,8 +216,7 @@ def test_the_false_positive_surface_has_not_grown(self): # behaviour the corpus depends on -- it writes voicings in lowercase -- # and this test pins it so a future change to the root scanner cannot # widen it without somebody noticing. - accepted = [w for w in ("a", "b", "c", "d", "e", "f", "g", "am", "ebb") - if _parses(w)] + accepted = [w for w in ("a", "b", "c", "d", "e", "f", "g", "am", "ebb") if _parses(w)] self.assertEqual(accepted, ["a", "b", "c", "d", "e", "f", "g", "am", "ebb"]) for word in ("bad", "cab", "dab", "face", "fade", "deaf", "decaf", "beef"): self.assertFalse(_parses(word), word) @@ -233,9 +233,27 @@ class TestTranspositionSurvivesTheNewVocabulary(unittest.TestCase): """ SYMBOLS = ( - "C7b9#11", "G7alt", "EbMaj7", "C7M", "C6/9", "Cm7b5", "F13#11", - "Bmaj7#5", "C9sus4", "Cadd11", "Am", "D/F#", "Cm/Bb", "C∆7", - "Bb-7", "Cø", "C7(b13)", "Cdim7", "C5", "C", "Csus4", + "C7b9#11", + "G7alt", + "EbMaj7", + "C7M", + "C6/9", + "Cm7b5", + "F13#11", + "Bmaj7#5", + "C9sus4", + "Cadd11", + "Am", + "D/F#", + "Cm/Bb", + "C∆7", + "Bb-7", + "Cø", + "C7(b13)", + "Cdim7", + "C5", + "C", + "Csus4", ) def test_one_step_keeps_the_pitch_classes(self): @@ -273,16 +291,30 @@ class TestNothingThatCompiledBeforeCompilesDifferently(unittest.TestCase): """ KNOWN = { - "C": (0, 4, 7), "Cm": (0, 3, 7), "C7": (0, 4, 7, 10), - "Cmaj7": (0, 4, 7, 11), "Cm7": (0, 3, 7, 10), "Cdim": (0, 3, 6), - "Cdim7": (0, 3, 6, 9), "Caug": (0, 4, 8), "Csus2": (0, 2, 7), - "Csus4": (0, 5, 7), "C6": (0, 4, 7, 9), "Cm6": (0, 3, 7, 9), - "C9": (0, 4, 7, 10, 14), "Cmaj9": (0, 4, 7, 11, 14), - "Cm9": (0, 3, 7, 10, 14), "C7sus4": (0, 5, 7, 10), - "Cm7b5": (0, 3, 6, 10), "Cadd9": (0, 4, 7, 14), - "C7b9": (0, 4, 7, 10, 13), "C7#9": (0, 4, 7, 10, 15), - "C7b5": (0, 4, 6, 10), "C7#5": (0, 4, 8, 10), - "CmMaj7": (0, 3, 7, 11), "C5": (0, 7), + "C": (0, 4, 7), + "Cm": (0, 3, 7), + "C7": (0, 4, 7, 10), + "Cmaj7": (0, 4, 7, 11), + "Cm7": (0, 3, 7, 10), + "Cdim": (0, 3, 6), + "Cdim7": (0, 3, 6, 9), + "Caug": (0, 4, 8), + "Csus2": (0, 2, 7), + "Csus4": (0, 5, 7), + "C6": (0, 4, 7, 9), + "Cm6": (0, 3, 7, 9), + "C9": (0, 4, 7, 10, 14), + "Cmaj9": (0, 4, 7, 11, 14), + "Cm9": (0, 3, 7, 10, 14), + "C7sus4": (0, 5, 7, 10), + "Cm7b5": (0, 3, 6, 10), + "Cadd9": (0, 4, 7, 14), + "C7b9": (0, 4, 7, 10, 13), + "C7#9": (0, 4, 7, 10, 15), + "C7b5": (0, 4, 6, 10), + "C7#5": (0, 4, 8, 10), + "CmMaj7": (0, 3, 7, 11), + "C5": (0, 7), } def test_the_common_vocabulary_is_unchanged(self): diff --git a/tests/test_chunked.py b/tests/test_chunked.py index 363da309..d8d560e4 100644 --- a/tests/test_chunked.py +++ b/tests/test_chunked.py @@ -1,11 +1,21 @@ from __future__ import annotations import gc +import importlib.util import tempfile +import unittest from pathlib import Path -import numpy as np -import pytest +# The chunked renderer needs NumPy by design, and this file used to import both +# numpy and pytest at module scope. CI's stdlib-only job has neither and runs +# `unittest discover`, which imports every test module -- so an absent +# dependency became a collection error that took the whole run down rather than +# a skip. `unittest.SkipTest` is understood by unittest and pytest alike and +# needs neither package installed. +# Nothing here calls NumPy directly -- `write_wav_chunked` does -- so this asks +# whether it is installed rather than binding a name it would not use. +if importlib.util.find_spec("numpy") is None: # pragma: no cover + raise unittest.SkipTest("the chunked renderer requires NumPy") from plainsong.notation.ir import Arrangement, Metadata, Note, Track from plainsong.render.audio import AudioOptions, write_wav @@ -51,9 +61,7 @@ def test_bounded_memory(): # Peak should be well under total_samples * 8 bytes (f64) limit = total_samples * 8 # full-buffer cost - assert peak < limit, ( - f"Peak memory {peak} >= full-buffer cost {limit} (samples={total_samples})" - ) + assert peak < limit, f"Peak memory {peak} >= full-buffer cost {limit} (samples={total_samples})" print(f"PASS: bounded — peak {peak} bytes < {limit} (60s @ 44100Hz)") @@ -65,6 +73,7 @@ def test_smoke_duration(): p = Path(tmp) / "smoke.wav" write_wav_chunked(arr, p, options=opts) import wave + with wave.open(str(p)) as w: frames = w.getnframes() rate = w.getframerate() diff --git a/tests/test_counterpoint.py b/tests/test_counterpoint.py index 16466a13..5ec613ce 100644 --- a/tests/test_counterpoint.py +++ b/tests/test_counterpoint.py @@ -41,9 +41,7 @@ def test_motion_score_fishing_boat(): analyzer = CounterpointAnalyzer(100) # Engine heating up while bilge level goes down (contrary — productive) for i in range(50): - analyzer.record( - [RoomSnapshot("engine", 50.0 + i), RoomSnapshot("bilge", 30.0 - i * 0.5)] - ) + analyzer.record([RoomSnapshot("engine", 50.0 + i), RoomSnapshot("bilge", 30.0 - i * 0.5)]) score = analyzer.motion_score("engine", "bilge") assert score.contrary_ratio > 0.9 assert score.quality > 0.7 @@ -92,9 +90,7 @@ def test_motion_score_empty_history(): def test_parallel_motion_low_quality(): analyzer = CounterpointAnalyzer(100) for i in range(20): - analyzer.record( - [RoomSnapshot("engine", 50.0 + i), RoomSnapshot("bilge", 30.0 + i)] - ) + analyzer.record([RoomSnapshot("engine", 50.0 + i), RoomSnapshot("bilge", 30.0 + i)]) score = analyzer.motion_score("engine", "bilge") assert score.parallel_ratio == 1.0 assert score.quality == 0.2 diff --git a/tests/test_demo.py b/tests/test_demo.py index d8cbd32b..506b2937 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -48,7 +48,7 @@ class TestDemoParity(unittest.TestCase): def test_the_page_exists_and_is_self_contained(self): """No external fetch: the demo must work offline, from a file:// URL.""" text = PAGE.read_text(encoding="utf-8") - for forbidden in ("<script src=", "<link rel=\"stylesheet\"", "@import", "fetch(", "XMLHttpRequest"): + for forbidden in ("<script src=", '<link rel="stylesheet"', "@import", "fetch(", "XMLHttpRequest"): self.assertNotIn(forbidden, text, f"the demo reaches outside itself: {forbidden}") def test_every_preset_is_valid_notation(self): @@ -131,8 +131,7 @@ def test_the_preset_durations_are_what_the_page_should_reproduce(self): self.assertGreater( total, arrangement.note_count * 0.9, - f"{name}: notes averaging under a beat suggests sustains are " - "being read as rests", + f"{name}: notes averaging under a beat suggests sustains are being read as rests", ) @@ -150,16 +149,40 @@ class _ShadowChordError(Exception): #: stop parsing again, that is exactly the regression this file exists to #: catch -- see the module docstring and docs/chords.md. VOCABULARY = ( - "CM7", "C7M", "G7alt", "C7alt", "C13", "Cadd9", "C7b9", - "C7b9#11", "C13#11", "C6/9", "Cø", "CΔ", + "CM7", + "C7M", + "G7alt", + "C7alt", + "C13", + "Cadd9", + "C7b9", + "C7b9#11", + "C13#11", + "C6/9", + "Cø", + "CΔ", # Extra coverage beyond the twelve named in the task, exercising the # rules docs/chords.md calls out explicitly (the eleventh-over-a-major- # third exception, alteration displacing the natural form, sus dropping # the third, the Brazilian 7M, the two triangles, ASCII vs Unicode # accidentals, and the historically-regressed `Bb-7`/`C-9` spellings). - "Cm13", "CmMaj7", "Bbmaj7#5", "C11", "Csus4", "C9sus4", "Cm7b5", - "EbMaj7", "G7#9", "F#dim7", "GbΔ", "Bb-7", "C-9", "C5", "Csus2", - "F13#11", "E7♭9", + "Cm13", + "CmMaj7", + "Bbmaj7#5", + "C11", + "Csus4", + "C9sus4", + "Cm7b5", + "EbMaj7", + "G7#9", + "F#dim7", + "GbΔ", + "Bb-7", + "C-9", + "C5", + "Csus2", + "F13#11", + "E7♭9", ) @@ -279,7 +302,7 @@ def _shadow_scan_root(text: str) -> tuple[int, str] | None: return None letter, accidentals = match.groups() shift = sum(_ACCIDENTAL_SHIFT[c] for c in accidentals) - return (LETTER_PC[letter.upper()] + shift) % 12, text[match.end():] + return (LETTER_PC[letter.upper()] + shift) % 12, text[match.end() :] def _shadow_scan_suffix(suffix: str, original: str, tables: dict): @@ -297,11 +320,11 @@ def _shadow_scan_suffix(suffix: str, original: str, tables: dict): matched = False for word, op in (("add", "add"), ("omit", "omit"), ("no", "omit")): if rest[: len(word)].lower() == word: - shift, tail = _leading_accidental(rest[len(word):]) + shift, tail = _leading_accidental(rest[len(word) :]) dm = _DEGREE_RE.match(tail) if dm: mods.append((op, _fold_degree(int(dm.group(1))), shift)) - rest = tail[dm.end():] + rest = tail[dm.end() :] matched = True break if matched: @@ -313,7 +336,7 @@ def _shadow_scan_suffix(suffix: str, original: str, tables: dict): dm = _DEGREE_RE.match(rest[1:]) if dm: mods.append(("alter", _fold_degree(int(dm.group(1))), shift)) - rest = rest[1 + dm.end():] + rest = rest[1 + dm.end() :] continue if rest[:2] == "69": @@ -325,7 +348,7 @@ def _shadow_scan_suffix(suffix: str, original: str, tables: dict): dm = _DEGREE_RE.match(rest) if dm: value = int(dm.group(1)) - tail = rest[dm.end():] + tail = rest[dm.end() :] if value == 5 and core_name is None and not tail: core_name = "power" rest = tail @@ -369,7 +392,7 @@ def _shadow_scan_suffix(suffix: str, original: str, tables: dict): core_name = name if alias in tables["seventh_implied"]: stack = max(stack, 7) - rest = rest[len(alias):] + rest = rest[len(alias) :] hit = True break if not hit: diff --git a/tests/test_ensemble.py b/tests/test_ensemble.py index 75b1cffb..cfe3267f 100644 --- a/tests/test_ensemble.py +++ b/tests/test_ensemble.py @@ -143,11 +143,7 @@ def test_a_part_without_a_section_goes_in_the_first_one(self) -> None: def test_writing_leaves_no_temporary_files_behind(self) -> None: self.session.write_part("bass", "alice", BASS, 0) - leftovers = [ - path.name - for path in (self.root / "harbour").rglob("*") - if path.name.endswith(".tmp") - ] + leftovers = [path.name for path in (self.root / "harbour").rglob("*") if path.name.endswith(".tmp")] self.assertEqual(leftovers, []) def test_every_change_is_logged(self) -> None: @@ -270,9 +266,7 @@ def test_the_loser_can_rebase_and_write(self) -> None: with self.assertRaises(ensemble.Conflict) as caught: self.session.write_part("bass", "bob", BASS_REVISED, 0) current = caught.exception.state - accepted = self.session.write_part( - "bass", "bob", BASS_REVISED, current["voice_version"], "rebased" - ) + accepted = self.session.write_part("bass", "bob", BASS_REVISED, current["voice_version"], "rebased") self.assertTrue(accepted["accepted"]) self.assertIn("a1 e2 a2 e2", self.session.part("bass")) @@ -302,9 +296,7 @@ def claim(voice: str) -> None: for thread in threads: thread.join() - self.assertEqual( - failures, {}, f"claims raised: {[(v, repr(e)) for v, e in failures.items()]}" - ) + self.assertEqual(failures, {}, f"claims raised: {[(v, repr(e)) for v, e in failures.items()]}") manifest = self.session.manifest() missing = [voice for voice in voices if voice not in manifest.voices] self.assertEqual(missing, [], "voices lost from the manifest -- a write overwrote another") @@ -346,9 +338,7 @@ def test_rows_come_out_in_lead_sheet_order(self) -> None: for line in self.session.score().splitlines() if line.startswith(("Chords:", "Melody:", "Lyrics:", "@")) ] - self.assertEqual( - [row.split()[0] for row in rows], ["Chords:", "@bass", "@violin1"] - ) + self.assertEqual([row.split()[0] for row in rows], ["Chords:", "@bass", "@violin1"]) def test_the_merged_score_compiles(self) -> None: self.session.write_part("bass", "alice", BASS, 0) @@ -400,9 +390,7 @@ def test_your_own_part_comes_with_the_version_to_write_against(self) -> None: state = self.session.read(voice="bass", agent="alice") self.assertEqual(state["you"]["content"], BASS) self.assertTrue(state["you"]["yours"]) - self.assertEqual( - state["you"]["base_version"], self.session.manifest().voices["bass"].version - ) + self.assertEqual(state["you"]["base_version"], self.session.manifest().voices["bass"].version) def test_recent_changes_are_included(self) -> None: recent = self.session.read(history=3)["recent"] @@ -464,12 +452,8 @@ def test_two_agents_co_author_a_piece(self) -> None: ) self.assertEqual(opened["meta"]["key"], "Dm") - first = self.payload( - self.call("ensemble_join", session="duet", voice="@bass", agent="alice") - ) - second = self.payload( - self.call("ensemble_join", session="duet", voice="@violin1", agent="bob") - ) + first = self.payload(self.call("ensemble_join", session="duet", voice="@bass", agent="alice")) + second = self.payload(self.call("ensemble_join", session="duet", voice="@violin1", agent="bob")) self.assertEqual(first["you"]["base_version"], 0) self.assertEqual(second["you"]["base_version"], 0) diff --git a/tests/test_fingerprint.py b/tests/test_fingerprint.py index 17ad02e0..6629b727 100644 --- a/tests/test_fingerprint.py +++ b/tests/test_fingerprint.py @@ -98,7 +98,10 @@ def test_files_come_out_in_a_stable_order(self): # Sorted by posix path so Windows and Linux produce the same file. The # separator is part of the string, so unsorted output would make the # recorded baseline platform-specific and the CI job useless. - names = [line.split()[-1] for line in format_report(fingerprint_paths([str(self.root)])).splitlines()[:-1]] + names = [ + line.split()[-1] + for line in format_report(fingerprint_paths([str(self.root)])).splitlines()[:-1] + ] self.assertEqual(names, sorted(names)) def test_the_total_line_is_last(self): diff --git a/tests/test_genome.py b/tests/test_genome.py index 4f29d931..52cf0643 100644 --- a/tests/test_genome.py +++ b/tests/test_genome.py @@ -3,13 +3,12 @@ import random import sys +import unittest from pathlib import Path -import pytest - sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) -from genome import N_GENES, MusicalGenome +from genome import MusicalGenome from tradition_dna import ( TRADITION_GENOMES, TRADITION_NAMES, @@ -17,6 +16,12 @@ encode_tradition, ) +# The stdlib-only CI job has no pytest and runs `unittest discover`, which +# imports every test module -- so importing pytest here took all twelve +# platform jobs down. These few assertions have exact stdlib equivalents, +# so the dependency is not worth a skip. +_assert = unittest.TestCase() + def _make_rng(): return random.Random(42) @@ -32,7 +37,7 @@ def test_new_valid(): def test_new_wrong_length(): - with pytest.raises(ValueError): + with _assert.assertRaises(ValueError): MusicalGenome([1.0] * 10) @@ -82,7 +87,7 @@ def test_from_tradition(): def test_from_unknown_tradition(): - with pytest.raises(ValueError): + with _assert.assertRaises(ValueError): MusicalGenome.from_tradition("NonExistent", _make_rng()) @@ -139,7 +144,7 @@ def test_tradition_genomes_dial_positions(): for name, centre in TRADITION_CENTRES: pos = TRADITION_GENOMES[name].dial_position() - for got, want in zip(pos, centre): + for got, want in zip(pos, centre, strict=True): assert abs(got - want) < 1.0, f"{name}: {got} vs {want}" diff --git a/tests/test_groove_tracker.py b/tests/test_groove_tracker.py index deb02995..24ee54bd 100644 --- a/tests/test_groove_tracker.py +++ b/tests/test_groove_tracker.py @@ -2,14 +2,19 @@ import math import sys +import unittest from pathlib import Path -import pytest - sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) from groove_tracker import GrooveTracker, TickEvent +# The stdlib-only CI job has no pytest and runs `unittest discover`, which +# imports every test module -- so importing pytest here took all twelve +# platform jobs down. These few assertions have exact stdlib equivalents, +# so the dependency is not worth a skip. +_assert = unittest.TestCase() + def test_perfect_sync(): tracker = GrooveTracker(100, 0.8) @@ -110,7 +115,7 @@ def test_phase_wrapping(): tracker.record_tick( TickEvent(room_name="engine", expected_phase=0.05, actual_phase=0.95, timestamp=0.0) ) - assert tracker.groove() == pytest.approx(math.exp(-0.1 * 20.0)) + _assert.assertAlmostEqual(tracker.groove(), math.exp(-0.1 * 20.0)) def test_threshold_getter(): diff --git a/tests/test_llm.py b/tests/test_llm.py index 41af4700..a7c162fd 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -42,7 +42,9 @@ def info_for(api: str, **kwargs) -> ProviderInfo: - return ProviderInfo(id=f"test-{api}", label="Test", api=api, base_url="https://example.test/v1", **kwargs) + return ProviderInfo( + id=f"test-{api}", label="Test", api=api, base_url="https://example.test/v1", **kwargs + ) class TestCatalogue(unittest.TestCase): @@ -52,7 +54,17 @@ def test_every_entry_has_an_adapter(self): def test_expected_providers_are_present(self): catalog = load_catalog() - for name in ("anthropic", "openai", "deepseek", "openrouter", "xai", "gemini", "ollama", "host", "echo"): + for name in ( + "anthropic", + "openai", + "deepseek", + "openrouter", + "xai", + "gemini", + "ollama", + "host", + "echo", + ): self.assertIn(name, catalog) def test_local_providers_need_no_key(self): diff --git a/tests/test_localhost.py b/tests/test_localhost.py index d2a2f281..c90faa65 100644 --- a/tests/test_localhost.py +++ b/tests/test_localhost.py @@ -149,9 +149,7 @@ def test_no_module_matches_loopback_names_for_itself(self): # both faults were in that decision. if 'startswith("127.' in text or '"localhost", "::1"' in text: offenders.append(str(path.relative_to(package))) - self.assertEqual( - offenders, [], f"these should call runtime.localhost instead: {offenders}" - ) + self.assertEqual(offenders, [], f"these should call runtime.localhost instead: {offenders}") def test_both_servers_read_the_host_header_through_the_shared_check(self): from pathlib import Path @@ -162,7 +160,7 @@ def test_both_servers_read_the_host_header_through_the_shared_check(self): text = (package / relative).read_text(encoding="utf-8") self.assertIn("from ", text) self.assertIn("localhost import", text) - self.assertIn("host_is_local(self.headers.get(\"Host\", \"\"))", text) + self.assertIn('host_is_local(self.headers.get("Host", ""))', text) if __name__ == "__main__": diff --git a/tests/test_lyrics.py b/tests/test_lyrics.py index aa3e0c14..3e616036 100644 --- a/tests/test_lyrics.py +++ b/tests/test_lyrics.py @@ -67,7 +67,9 @@ class TestPaddingIsNotMelisma(unittest.TestCase): melody that sustains, and reading it as a melisma pushes words off the bar. """ - KITCHEN = Path(__file__).resolve().parent.parent / "examples" / "edge-cases" / "edge-5-kitchen-sink.song" + KITCHEN = ( + Path(__file__).resolve().parent.parent / "examples" / "edge-cases" / "edge-5-kitchen-sink.song" + ) def test_dots_in_a_lyric_row_bind_to_nothing(self): text = self.KITCHEN.read_text(encoding="utf-8") @@ -165,7 +167,9 @@ def test_an_unknown_mode_still_compiles_as_the_default(self): def test_binding_moves_no_note(self): """It is a change to lyrics, and only to lyrics.""" - text = Path(__file__).resolve().parent.parent / "examples" / "edge-cases" / "edge-5-kitchen-sink.song" + text = ( + Path(__file__).resolve().parent.parent / "examples" / "edge-cases" / "edge-5-kitchen-sink.song" + ) source = text.read_text(encoding="utf-8") def pitches(mode): @@ -267,9 +271,7 @@ def test_describe_carries_the_arrangers_diagnostics(self): from plainsong.transform import describe messages = [d["message"] for d in describe(self.UNREADABLE)["diagnostics"]] - self.assertTrue( - any("nothing understood" in m and "Xm9" in m for m in messages), messages - ) + self.assertTrue(any("nothing understood" in m and "Xm9" in m for m in messages), messages) def test_a_clean_file_reports_none(self): from plainsong.transform import describe diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 73261dd9..c9a776af 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -194,20 +194,14 @@ def test_wrong_version(self) -> None: ) def test_missing_method(self) -> None: - self.assertEqual( - self.error_code('{"jsonrpc": "2.0", "id": 1}'), protocol.INVALID_REQUEST - ) + self.assertEqual(self.error_code('{"jsonrpc": "2.0", "id": 1}'), protocol.INVALID_REQUEST) def test_unknown_method(self) -> None: self.assertEqual(self.error_code(message("no/such")), protocol.METHOD_NOT_FOUND) def test_bad_params(self) -> None: - self.assertEqual( - self.error_code(message("tools/call", {})), protocol.INVALID_PARAMS - ) - self.assertEqual( - self.error_code(message("resources/read", {})), protocol.INVALID_PARAMS - ) + self.assertEqual(self.error_code(message("tools/call", {})), protocol.INVALID_PARAMS) + self.assertEqual(self.error_code(message("resources/read", {})), protocol.INVALID_PARAMS) self.assertEqual( self.error_code('{"jsonrpc": "2.0", "id": 1, "method": "ping", "params": 4}'), protocol.INVALID_PARAMS, @@ -286,9 +280,7 @@ def write(self, text: str) -> int: with tempfile.TemporaryDirectory() as directory: server = build_server(Path(directory)) - reader = io.StringIO( - message("initialize", {"protocolVersion": PROTOCOL_VERSION}, 1) + "\n" - ) + reader = io.StringIO(message("initialize", {"protocolVersion": PROTOCOL_VERSION}, 1) + "\n") self.assertEqual(protocol.serve_stdio(server.dispatcher, reader, ClosedPipe()), 0) @@ -433,9 +425,7 @@ def test_the_loopback_names_this_machine_answers(self) -> None: for host in ("localhost", "127.0.0.1", "127.0.0.1:1", "[::1]"): with self.subTest(host=host): body = message("ping").encode("utf-8") - connection = http.client.HTTPConnection( - "127.0.0.1", self.http.server_port, timeout=10 - ) + connection = http.client.HTTPConnection("127.0.0.1", self.http.server_port, timeout=10) self.addCleanup(connection.close) connection.putrequest("POST", "/", skip_host=True, skip_accept_encoding=True) connection.putheader("Host", host) @@ -464,9 +454,9 @@ def test_the_fixed_resources_are_listed_and_readable(self) -> None: listed = {entry["uri"] for entry in self.client.result("resources/list")["resources"]} self.assertIn("plainsong://notation-reference", listed) self.assertIn("plainsong://capabilities", listed) - reference = self.client.result( - "resources/read", {"uri": "plainsong://notation-reference"} - )["contents"][0] + reference = self.client.result("resources/read", {"uri": "plainsong://notation-reference"})[ + "contents" + ][0] self.assertIn("Plainsong notation reference", reference["text"]) self.assertIn("markdown", reference["mimeType"]) @@ -477,9 +467,7 @@ def test_specs_are_listed_and_readable(self) -> None: if entry["uri"].startswith("plainsong://spec/") ] self.assertTrue(listed, "no specs were offered as resources") - body = json.loads( - self.client.result("resources/read", {"uri": listed[0]})["contents"][0]["text"] - ) + body = json.loads(self.client.result("resources/read", {"uri": listed[0]})["contents"][0]["text"]) self.assertTrue(body["checks"]) def test_the_parameterised_sets_are_templates(self) -> None: @@ -505,9 +493,9 @@ def test_an_unknown_uri_is_an_error(self) -> None: def test_a_session_is_readable_as_a_resource(self) -> None: self.client.call("ensemble_open", session="reading", key="Am", tempo=96, bars=2) body = json.loads( - self.client.result("resources/read", {"uri": "plainsong://session/reading"})[ - "contents" - ][0]["text"] + self.client.result("resources/read", {"uri": "plainsong://session/reading"})["contents"][0][ + "text" + ] ) self.assertEqual(body["meta"]["key"], "Am") self.assertIn("score", body) @@ -575,7 +563,9 @@ def test_the_same_arrangement_gives_the_same_numbers(self) -> None: def test_a_silent_bar_is_all_rest(self) -> None: from plainsong.notation import arrange, parse - silent_middle = "[A]\nMelody: | C4 D4 E4 F4 |\n\n[B]\nLyrics: | one two |\n\n[C]\nMelody: | G4 A4 B4 C5 |\n" + silent_middle = ( + "[A]\nMelody: | C4 D4 E4 F4 |\n\n[B]\nLyrics: | one two |\n\n[C]\nMelody: | G4 A4 B4 C5 |\n" + ) bars = features.extract(arrange(parse(silent_middle))) self.assertEqual(len(bars), 3) self.assertEqual(bars[1].values["rest_ratio"], 1.0) @@ -586,9 +576,7 @@ def test_density_rises_with_the_notes(self) -> None: from plainsong.notation import arrange, parse sparse = features.extract(arrange(parse("[A]\nMelody: | C4 . . . |\n")))[0] - dense = features.extract( - arrange(parse("[A]\nMelody: | C4 D4 E4 F4 G4 A4 B4 C5 |\n")) - )[0] + dense = features.extract(arrange(parse("[A]\nMelody: | C4 D4 E4 F4 G4 A4 B4 C5 |\n")))[0] self.assertLess(sparse.values["note_density"], dense.values["note_density"]) def test_register_is_read_from_the_pitches(self) -> None: diff --git a/tests/test_mcp_ensemble_injection.py b/tests/test_mcp_ensemble_injection.py index 190653a9..549d2624 100644 --- a/tests/test_mcp_ensemble_injection.py +++ b/tests/test_mcp_ensemble_injection.py @@ -100,9 +100,7 @@ def test_injected_ensemble_module_is_actually_used(self) -> None: fake = fake_ensemble_module(calls) with tempfile.TemporaryDirectory() as raw: registry = build_registry(Path(raw)) - mcp_tools.register( - registry, session_root=Path(raw) / "sessions", ensemble=fake - ) + mcp_tools.register(registry, session_root=Path(raw) / "sessions", ensemble=fake) self.assertEqual(len(registry.specs()), 27, "injection must not add or drop tools") text, failed = registry.call_result("ensemble_status", {}) diff --git a/tests/test_notation.py b/tests/test_notation.py index 6ee04fdf..003b1dde 100644 --- a/tests/test_notation.py +++ b/tests/test_notation.py @@ -270,13 +270,9 @@ def test_transposing_repeatedly_does_not_grow_the_bars(self): for key in ("D", "E", "F", "G"): text = transpose(text, key) widths = { - line.role: len(line.cells) - for section in parse(text).sections - for line in section.lines + line.role: len(line.cells) for section in parse(text).sections for line in section.lines } - self.assertEqual( - set(widths.values()), {2}, f"a row changed width after transposing to {key}" - ) + self.assertEqual(set(widths.values()), {2}, f"a row changed width after transposing to {key}") def test_emitted_player_rows_read_back_identically(self): """The text a transpose writes must parse to the same shape it came from.""" @@ -322,9 +318,7 @@ def test_every_documented_example_compiles(self): for label, block in self._blocks(): with self.subTest(example=label): score = parse(block) - self.assertEqual( - [diagnostic.format() for diagnostic in score.errors()], [], label - ) + self.assertEqual([diagnostic.format() for diagnostic in score.errors()], [], label) def test_every_documented_example_makes_a_sound(self): """Parsing is not enough: a block that yields no notes teaches nothing.""" @@ -348,10 +342,7 @@ class TestDialectDetection(unittest.TestCase): success. """ - RELATIVE = ( - "Key: Bb\nMeter: 3/4\nTempo: 60\n\n" - "[Intro]\nI | 1 . . | 5 . . |\nIV | 4 . . | 6 . . |\n" - ) + RELATIVE = "Key: Bb\nMeter: 3/4\nTempo: 60\n\n[Intro]\nI | 1 . . | 5 . . |\nIV | 4 . . | 6 . . |\n" ABSOLUTE = ( "**TRACK: T**\n[MetaData]\nkey: Am | tempo: 96 | time: 4/4\n\n" "[V1] (Verse - 2 Bars)\nChords: | Am . . . | F . . . |\n" diff --git a/tests/test_perform.py b/tests/test_perform.py index 369823d9..821a09ce 100644 --- a/tests/test_perform.py +++ b/tests/test_perform.py @@ -94,11 +94,9 @@ def test_every_program_lands_somewhere(self): self.assertLess(profile.speech, 0.5) def test_percussion_is_the_fast_end_and_organ_the_slow_one(self): - self.assertEqual(profiles.profile_for_program(47).name, "percussion") # timpani + self.assertEqual(profiles.profile_for_program(47).name, "percussion") # timpani self.assertEqual(profiles.profile_for_program(19).name, "organ-large") # church organ - self.assertLess( - profiles.profile_for_program(47).total, profiles.profile_for_program(19).total - ) + self.assertLess(profiles.profile_for_program(47).total, profiles.profile_for_program(19).total) def test_drums_are_always_percussion(self): self.assertEqual(profiles.profile_for_program(48, is_drum=True).name, "percussion") @@ -248,10 +246,7 @@ def test_nobody_has_to_play_before_the_file_starts(self): def test_arrivals_coincide_after_the_lead_in(self): arrangement = arrange(parse(STAGED)) - 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} self.assertAlmostEqual(first["timpani"], first["organ"], places=6) def test_midi_carries_emission_and_audio_carries_arrival(self): @@ -266,7 +261,8 @@ def test_compensation_can_be_turned_off_for_one_render(self): organ = next(track for track in loose.tracks if track.name == "organ") timpani = next(track for track in loose.tracks if track.name == "timpani") self.assertGreater( - organ.notes[0].arrival_time - timpani.notes[0].arrival_time, 0.1 # beats + organ.notes[0].arrival_time - timpani.notes[0].arrival_time, + 0.1, # beats ) # ... and the score itself is untouched by that choice. self.assertTrue(parse(STAGED).meta.stage.compensate) @@ -274,9 +270,7 @@ def test_compensation_can_be_turned_off_for_one_render(self): def test_the_smeared_render_is_different_audio(self): options = AudioOptions(sample_rate=8000, tail=0.2) tight = Synthesiser(options).to_wav_bytes(arrange(parse(STAGED))) - loose = Synthesiser(options).to_wav_bytes( - arrange(parse(STAGED), ArrangeOptions(compensate=False)) - ) + loose = Synthesiser(options).to_wav_bytes(arrange(parse(STAGED), ArrangeOptions(compensate=False))) self.assertNotEqual(tight, loose) def test_solved_times_are_deterministic(self): @@ -462,8 +456,11 @@ def test_drag_accumulates_across_its_window(self): written = next(item for item in self.arrangement.tracks if item.name == "timpani") lateness = [ note.arrival_time - conducted.lead_in - (base.arrival_time - self.arrangement.lead_in) - for note, base in zip(sorted(track.notes, key=lambda n: n.start), - sorted(written.notes, key=lambda n: n.start), strict=True) + for note, base in zip( + sorted(track.notes, key=lambda n: n.start), + sorted(written.notes, key=lambda n: n.start), + strict=True, + ) ] self.assertLess(lateness[0], lateness[-1]) self.assertAlmostEqual(lateness[0], 0.0, places=6) @@ -471,8 +468,11 @@ def test_drag_accumulates_across_its_window(self): def test_a_window_leaves_the_rest_of_the_piece_alone(self): conducted = conduct.apply( self.arrangement, - {"directives": [{"action": "lay_back", "intensity": 1.0, "offset_beats": 4, - "duration_beats": 4}]}, + { + "directives": [ + {"action": "lay_back", "intensity": 1.0, "offset_beats": 4, "duration_beats": 4} + ] + }, ) track = next(item for item in conducted.tracks if item.name == "timpani") written = next(item for item in self.arrangement.tracks if item.name == "timpani") @@ -494,17 +494,11 @@ def test_targeting_a_layer_leaves_the_others_where_they_were(self): def test_float_widens_the_spread_and_lock_in_closes_it(self): def spread(arrangement): - firsts = [ - min(note.arrival_time for note in track.notes) for track in arrangement.tracks - ] + firsts = [min(note.arrival_time for note in track.notes) for track in arrangement.tracks] return max(firsts) - min(firsts) - loose = conduct.apply( - self.arrangement, {"directives": [{"action": "float", "intensity": 1.0}]} - ) - tight = conduct.apply( - self.arrangement, {"directives": [{"action": "lock_in", "intensity": 1.0}]} - ) + loose = conduct.apply(self.arrangement, {"directives": [{"action": "float", "intensity": 1.0}]}) + tight = conduct.apply(self.arrangement, {"directives": [{"action": "lock_in", "intensity": 1.0}]}) self.assertGreater(spread(loose), spread(self.arrangement)) self.assertLess(spread(tight), 1e-9) @@ -528,8 +522,7 @@ def test_arrivals_stay_together_through_a_tempo_change(self): {"directives": [{"action": "half_time", "intensity": 1.0, "duration_beats": 8}]}, ) by_voice = { - track.name: sorted(note.arrival_time for note in track.notes) - for track in conducted.tracks + track.name: sorted(note.arrival_time for note in track.notes) for track in conducted.tracks } self.assertAlmostEqual(by_voice["timpani"][0], by_voice["organ"][0], places=6) self.assertAlmostEqual(by_voice["timpani"][-1], by_voice["organ"][-1], places=6) @@ -556,9 +549,7 @@ def lead(arrangement, name): self.assertGreater(abs(organ), abs(timpani) * 5) def test_energy_moves_velocities_not_times(self): - conducted = conduct.apply( - self.arrangement, {"energy": {"target": 0.8, "mode": "absolute"}} - ) + conducted = conduct.apply(self.arrangement, {"energy": {"target": 0.8, "mode": "absolute"}}) before = [note.start for _t, note in self.arrangement.iter_notes()] after = [note.start for _t, note in conducted.iter_notes()] self.assertEqual(before, after) @@ -570,14 +561,10 @@ def test_energy_moves_velocities_not_times(self): def test_straighten_pulls_the_offbeats_back_onto_the_grid(self): swung = arrange(parse(SWUNG)) offbeat = 0.5 + 0.6 / 6.0 - original = [ - note.start for _t, note in swung.iter_notes() if abs(note.start % 1.0 - offbeat) < 1e-6 - ] + original = [note.start for _t, note in swung.iter_notes() if abs(note.start % 1.0 - offbeat) < 1e-6] self.assertTrue(original, "the sample has no swung off-beats to straighten") straight = conduct.apply(swung, {"directives": [{"action": "straighten", "intensity": 1.0}]}) - moved = [ - note.start for _t, note in straight.iter_notes() if abs(note.start % 1.0 - 0.5) < 1e-6 - ] + moved = [note.start for _t, note in straight.iter_notes() if abs(note.start % 1.0 - 0.5) < 1e-6] self.assertEqual(len(moved), len(original)) def test_deepen_swing_pushes_them_further_out(self): diff --git a/tests/test_readme_links.py b/tests/test_readme_links.py index 8dbbb519..a94bb614 100644 --- a/tests/test_readme_links.py +++ b/tests/test_readme_links.py @@ -35,11 +35,7 @@ def targets() -> list[str]: class TestTheReadmeWorksOnPyPI(unittest.TestCase): def test_no_relative_links_remain(self): """A relative link is invisible on PyPI, silently.""" - relative = [ - t - for t in targets() - if not t.startswith(("http://", "https://", "#", "mailto:")) - ] + relative = [t for t in targets() if not t.startswith(("http://", "https://", "#", "mailto:"))] self.assertEqual(relative, [], f"these 404 on the PyPI page: {relative}") def test_every_link_into_this_repo_points_at_a_real_file(self): diff --git a/tests/test_render.py b/tests/test_render.py index 70d45305..a9a40117 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -150,8 +150,7 @@ def test_produces_audible_output(self): self.assertGreater(frames, 8000) peak = max( - abs(int.from_bytes(raw[i : i + 2], "little", signed=True)) - for i in range(0, len(raw) - 1, 2) + abs(int.from_bytes(raw[i : i + 2], "little", signed=True)) for i in range(0, len(raw) - 1, 2) ) self.assertGreater(peak, 5000, "audio is too quiet to be real output") self.assertLessEqual(peak, 32767) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index d1baa8eb..53757c10 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -245,8 +245,12 @@ def test_new_then_info_then_compile(self): self.assertIn("Test Song", out) code, _out, _err = run_cli( - "compile", str(song), "-o", str(Path(directory) / "out.mid"), - "--audio", str(Path(directory) / "out.wav"), + "compile", + str(song), + "-o", + str(Path(directory) / "out.mid"), + "--audio", + str(Path(directory) / "out.wav"), ) self.assertEqual(code, 0) self.assertTrue((Path(directory) / "out.mid").exists()) @@ -314,9 +318,7 @@ def test_the_two_version_numbers_agree(self): from plainsong.version import __version__ - pyproject = (Path(__file__).resolve().parent.parent / "pyproject.toml").read_text( - encoding="utf-8" - ) + pyproject = (Path(__file__).resolve().parent.parent / "pyproject.toml").read_text(encoding="utf-8") declared = re.search(r'(?m)^version\s*=\s*"([^"]+)"', pyproject) self.assertIsNotNone(declared, "pyproject.toml has no version") self.assertEqual( diff --git a/tests/test_timegrid.py b/tests/test_timegrid.py index 15cb91dc..c533cbc2 100644 --- a/tests/test_timegrid.py +++ b/tests/test_timegrid.py @@ -102,9 +102,7 @@ def test_a_bar_boundary_that_arrives_slightly_short_is_not_the_bar_before(self): # Onsets are produced by division, so a downbeat can arrive as # 11.999999999999998. Flooring that lands it a whole bar early. grid = TimeGrid(bar_beats=4.0) - placement = grid.add( - token="x", row="melody", kind="note", onset=11.999999999999998, width=1.0 - ) + placement = grid.add(token="x", row="melody", kind="note", onset=11.999999999999998, width=1.0) self.assertEqual(placement.bar, 3) self.assertEqual(placement.unit, 0.0) @@ -130,8 +128,12 @@ def test_building_it_moves_no_note(self): self.assertEqual( pitches, [ - (57, 0.0, 4.0), (60, 0.0, 4.0), (64, 0.0, 4.0), # Am, held - (69, 0.0, 2.0), (72, 2.0, 1.0), (76, 3.0, 1.0), # A4 . C5 E5 + (57, 0.0, 4.0), + (60, 0.0, 4.0), + (64, 0.0, 4.0), # Am, held + (69, 0.0, 2.0), + (72, 2.0, 1.0), + (76, 3.0, 1.0), # A4 . C5 E5 ], ) @@ -142,12 +144,8 @@ def test_it_emits_no_diagnostic(self): class TestPlacement(unittest.TestCase): def test_sounds_distinguishes_a_note_from_a_column_holder(self): - self.assertTrue( - Placement("C4", "melody", "note", 0, 0.0, 1.0, 0.0).sounds - ) - self.assertFalse( - Placement("the", "lyrics", "text", 0, 0.0, 1.0, 0.0).sounds - ) + self.assertTrue(Placement("C4", "melody", "note", 0, 0.0, 1.0, 0.0).sounds) + self.assertFalse(Placement("the", "lyrics", "text", 0, 0.0, 1.0, 0.0).sounds) if __name__ == "__main__": diff --git a/tests/test_toml.py b/tests/test_toml.py index 6927ec3a..3f9ac912 100644 --- a/tests/test_toml.py +++ b/tests/test_toml.py @@ -25,10 +25,10 @@ CASES = [ "a = 1\nb = -2\nc = 3.5\nd = 1e3\ne = 1_000\nf = 0xff\ng = 0b101\nh = 0o17\n", "a = true\nb = false\n", - 's1 = "hi\\nthere"\ns2 = \'raw\\nnot\'\n', + "s1 = \"hi\\nthere\"\ns2 = 'raw\\nnot'\n", 's = """\nmulti\nline\n"""\n', "s = '''\nliteral\nmulti\n'''\n", - "arr = [1, 2, 3]\nnested = [[1, 2], [3]]\nmixed = [\"a\", \"b\"]\nempty = []\n", + 'arr = [1, 2, 3]\nnested = [[1, 2], [3]]\nmixed = ["a", "b"]\nempty = []\n', "arr = [\n 1,\n 2, # trailing comment\n]\n", "[a]\nx = 1\n[a.b]\ny = 2\n", "[[t]]\nn = 1\n\n[[t]]\nn = 2\n", @@ -84,7 +84,9 @@ class TestShape(unittest.TestCase): """Checks that hold with or without tomllib present.""" def test_tables_and_arrays_of_tables(self): - data = _toml.loads('[spec]\nid = "x"\ntags = ["a", "b"]\n\n[[check]]\nid = "one"\n\n[[check]]\nid = "two"\n') + data = _toml.loads( + '[spec]\nid = "x"\ntags = ["a", "b"]\n\n[[check]]\nid = "one"\n\n[[check]]\nid = "two"\n' + ) self.assertEqual(data["spec"]["id"], "x") self.assertEqual(data["spec"]["tags"], ["a", "b"]) self.assertEqual([check["id"] for check in data["check"]], ["one", "two"]) diff --git a/tests/test_transport.py b/tests/test_transport.py index 738789a2..becaa54c 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -345,8 +345,8 @@ def test_request_stream_skips_blank_lines(self): with mock.patch("plainsong.llm.transport.urllib.request.urlopen") as mock_urlopen: lines = [ b'data: {"id": "1"}\n', - b'\n', - b' \n', + b"\n", + b" \n", b'data: {"id": "2"}\n', ] response = mock.MagicMock() @@ -362,8 +362,8 @@ def test_request_stream_skips_comment_lines_starting_with_colon(self): with mock.patch("plainsong.llm.transport.urllib.request.urlopen") as mock_urlopen: lines = [ b'data: {"id": "1"}\n', - b': this is a comment\n', - b'::: another comment\n', + b": this is a comment\n", + b"::: another comment\n", b'data: {"id": "2"}\n', ] response = mock.MagicMock() @@ -379,8 +379,8 @@ def test_request_stream_skips_lines_without_data_prefix(self): with mock.patch("plainsong.llm.transport.urllib.request.urlopen") as mock_urlopen: lines = [ b'data: {"id": "1"}\n', - b'event: message\n', - b'id: 123\n', + b"event: message\n", + b"id: 123\n", b'data: {"id": "2"}\n', ] response = mock.MagicMock() @@ -397,7 +397,7 @@ def test_request_stream_stops_at_done_marker(self): lines = [ b'data: {"id": "1"}\n', b'data: {"id": "2"}\n', - b'data: [DONE]\n', + b"data: [DONE]\n", b'data: {"id": "3"}\n', # Should not be yielded ] response = mock.MagicMock() @@ -414,7 +414,7 @@ def test_request_stream_skips_malformed_json_lines(self): with mock.patch("plainsong.llm.transport.urllib.request.urlopen") as mock_urlopen: lines = [ b'data: {"id": "1"}\n', - b'data: {not valid json}\n', + b"data: {not valid json}\n", b'data: {"id": "2"}\n', ] response = mock.MagicMock() diff --git a/tests/test_voicing.py b/tests/test_voicing.py index ddc2d8a3..fae6ac65 100644 --- a/tests/test_voicing.py +++ b/tests/test_voicing.py @@ -50,7 +50,7 @@ class TestWhatGetsGivenUp(unittest.TestCase): def test_the_fifth_goes_first(self): # C13 is C E G Bb D A. Six notes into four voices: the G leaves. self.assertNotIn("G", played("C13")) - self.assertIn("E", played("C13")) # third + self.assertIn("E", played("C13")) # third self.assertIn("Bb", played("C13")) # seventh def test_the_root_goes_second(self): @@ -66,7 +66,8 @@ def test_the_guide_tones_never_go(self): for degree in (3, 7): if degree in chord.degrees: self.assertIn( - (chord.root_pc + chord.degrees[degree]) % 12, notes, + (chord.root_pc + chord.degrees[degree]) % 12, + notes, f"{symbol} lost its {degree}", ) @@ -89,8 +90,8 @@ def test_a_slash_bass_no_longer_costs_the_chord_a_note(self): # The old cap counted the bass note against the chord, so `Am7/G` lost # its seventh to make room for its own bass and sounded like `Am/G`. notes = played("Am7/G") - self.assertEqual(notes[0], "G") # the bass, below - self.assertIn("G", notes[1:]) # and the seventh, still there + self.assertEqual(notes[0], "G") # the bass, below + self.assertIn("G", notes[1:]) # and the seventh, still there self.assertEqual(len(notes), 5) def test_a_chord_with_no_degree_map_still_voices(self): @@ -148,9 +149,7 @@ def _compile(self, strategy=None, *, render=None): if render is not None: config.data.setdefault("render", {})["voicing"] = render result = pipeline.compile_text(self.SONG, config=config) - pitches = sorted( - note.pitch for track in result.arrangement.tracks for note in track.notes - ) + pitches = sorted(note.pitch for track in result.arrangement.tracks for note in track.notes) return [p % 12 for p in pitches], result.diagnostics def test_core_voicing_selects_the_strategy(self): @@ -159,16 +158,14 @@ def test_core_voicing_selects_the_strategy(self): self.assertNotEqual(guide, stack) # D9 written D F# A C E. `guide` gives up the fifth to keep the ninth; # `stack` is the pre-1.0.0 rendering, which is a D7. - self.assertEqual(guide, [2, 6, 0, 4]) # D F# C E - self.assertEqual(stack, [2, 6, 9, 0]) # D F# A C + self.assertEqual(guide, [2, 6, 0, 4]) # D F# C E + self.assertEqual(stack, [2, 6, 9, 0]) # D F# A C def test_an_unknown_strategy_says_so(self): # Falling back in silence is indistinguishable from being honoured. _, diagnostics = self._compile("stak") messages = [d.message for d in diagnostics] - self.assertTrue( - any("unknown voicing" in m and "stak" in m for m in messages), messages - ) + self.assertTrue(any("unknown voicing" in m and "stak" in m for m in messages), messages) def test_the_name_the_1_0_0_docs_printed_still_works(self): # docs/voicing.md said `render.voicing` while nothing read either name. @@ -188,10 +185,7 @@ def test_the_default_is_unchanged_when_nothing_is_configured(self): from plainsong import pipeline default = pipeline.compile_text(self.SONG) - pitches = [ - p % 12 - for p in sorted(n.pitch for t in default.arrangement.tracks for n in t.notes) - ] + pitches = [p % 12 for p in sorted(n.pitch for t in default.arrangement.tracks for n in t.notes)] self.assertEqual(pitches, self._compile("guide")[0]) diff --git a/tests/test_web.py b/tests/test_web.py index 5735ebea..9e277802 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -431,7 +431,7 @@ def run_server(): request = Request(url, method="POST") request.add_header("Content-Type", "application/json") # Invalid UTF-8 sequence - invalid_utf8 = b'\xff\xfe invalid utf8' + invalid_utf8 = b"\xff\xfe invalid utf8" request.add_header("Content-Length", str(len(invalid_utf8))) request.data = invalid_utf8