From 22e69767e87c509e21cbab99a57d54563a4225e9 Mon Sep 17 00:00:00 2001 From: Patrick Nilan Date: Sat, 7 Mar 2026 13:42:28 -0800 Subject: [PATCH 1/4] Add Phase 2: MIDI control with synth definitions, tools, and deps Introduce MIDI output support via python-rtmidi, synth definition loading from YAML files, PydanticAI dependency injection, and agent tools for controlling hardware synths over MIDI CC. Includes Minitaur and TB-03 synth definitions, comprehensive test coverage for all new modules. --- patchwork/agent.py | 36 ++++++- patchwork/cli.py | 64 ++++++----- patchwork/deps.py | 10 ++ patchwork/midi.py | 55 ++++++++++ patchwork/synth_definitions.py | 43 ++++++++ patchwork/tools/__init__.py | 0 patchwork/tools/midi_control.py | 110 +++++++++++++++++++ pyproject.toml | 2 + synths/moog_minitaur.yaml | 30 ++++++ synths/roland_tb03.yaml | 18 ++++ tests/test_midi.py | 74 +++++++++++++ tests/test_midi_control_tools.py | 178 +++++++++++++++++++++++++++++++ tests/test_synth_definitions.py | 123 +++++++++++++++++++++ tests/test_synth_yamls.py | 38 +++++++ uv.lock | 10 ++ 15 files changed, 763 insertions(+), 28 deletions(-) create mode 100644 patchwork/deps.py create mode 100644 patchwork/midi.py create mode 100644 patchwork/synth_definitions.py create mode 100644 patchwork/tools/__init__.py create mode 100644 patchwork/tools/midi_control.py create mode 100644 synths/moog_minitaur.yaml create mode 100644 synths/roland_tb03.yaml create mode 100644 tests/test_midi.py create mode 100644 tests/test_midi_control_tools.py create mode 100644 tests/test_synth_definitions.py create mode 100644 tests/test_synth_yamls.py diff --git a/patchwork/agent.py b/patchwork/agent.py index efca121..f8dd4c0 100644 --- a/patchwork/agent.py +++ b/patchwork/agent.py @@ -1,4 +1,13 @@ -from pydantic_ai import Agent +from pydantic_ai import Agent, RunContext + +from patchwork.deps import PatchworkDeps +from patchwork.tools.midi_control import ( + connect_midi, + list_midi_ports, + list_synths, + send_cc, + send_patch, +) SYSTEM_PROMPT = """You are Patchwork, an expert synthesizer sound design assistant. @@ -16,8 +25,15 @@ - 1010 Music Blackbox (sampler/groovebox) - Arturia Minibrute 2S (analog, semi-modular) -You will eventually control these synths via MIDI CC messages. For now, describe -settings conceptually using parameter names and values. +You have tools to control synths via MIDI: +- list_midi_ports: Show available MIDI output devices +- connect_midi: Connect to a MIDI port +- list_synths: Show loaded synth definitions and their controllable parameters +- send_cc: Send a single MIDI CC value to a synth parameter +- send_patch: Send multiple MIDI CC values at once to set a full patch + +When the user asks you to set a sound or tweak a parameter, use these tools. +Always confirm what you sent after a successful tool call. Tone: conversational but concise. Use musical and technical terminology naturally. When describing a patch, be specific enough that someone could recreate it by hand. @@ -26,5 +42,19 @@ agent = Agent( "anthropic:claude-sonnet-4-6", system_prompt=SYSTEM_PROMPT, + deps_type=PatchworkDeps, defer_model_check=True, + tools=[list_midi_ports, connect_midi, list_synths, send_cc, send_patch], ) + + +@agent.system_prompt +async def add_synth_context(ctx: RunContext[PatchworkDeps]) -> str: + """Append loaded synth definitions to the system prompt at runtime.""" + if not ctx.deps.synths: + return "No synth definitions loaded yet." + lines = [] + for synth in ctx.deps.synths.values(): + params = ", ".join(synth.cc_map.keys()) + lines.append(f"- {synth.manufacturer} {synth.name} (ch.{synth.midi_channel}): {params}") + return "Loaded synths and their controllable parameters:\n" + "\n".join(lines) diff --git a/patchwork/cli.py b/patchwork/cli.py index 17a1824..46aad84 100644 --- a/patchwork/cli.py +++ b/patchwork/cli.py @@ -4,39 +4,53 @@ from rich.console import Console from patchwork.agent import agent +from patchwork.deps import PatchworkDeps +from patchwork.midi import MidiConnection +from patchwork.synth_definitions import load_synth_definitions console = Console() async def main(): + midi = MidiConnection() + synths = load_synth_definitions() + deps = PatchworkDeps(midi=midi, synths=synths) + console.print("[bold]patchwork[/bold] — synth research agent\n") + if synths: + console.print(f"[dim]loaded {len(synths)} synth(s): {', '.join(s.name for s in synths.values())}[/dim]\n") + else: + console.print("[dim]no synth definitions found in synths/[/dim]\n") message_history = [] - while True: - try: - user_input = console.input("[bold cyan]patch>[/bold cyan] ") - except (KeyboardInterrupt, EOFError): - console.print("\n[dim]goodbye[/dim]") - break - - if not user_input.strip(): - continue - - if user_input.strip().lower() in ("quit", "exit"): - console.print("[dim]goodbye[/dim]") - break - - try: - async with agent.run_stream( - user_input, message_history=message_history - ) as result: - async for chunk in result.stream_text(delta=True): - console.print(chunk, end="", markup=False, highlight=False) - console.print() # newline after stream - - message_history = result.all_messages() - except Exception as e: - console.print(f"\n[bold red]error:[/bold red] {e}") + try: + while True: + try: + user_input = console.input("[bold cyan]patch>[/bold cyan] ") + except (KeyboardInterrupt, EOFError): + console.print("\n[dim]goodbye[/dim]") + break + + if not user_input.strip(): + continue + + if user_input.strip().lower() in ("quit", "exit"): + console.print("[dim]goodbye[/dim]") + break + + try: + async with agent.run_stream( + user_input, message_history=message_history, deps=deps + ) as result: + async for chunk in result.stream_text(delta=True): + console.print(chunk, end="", markup=False, highlight=False) + console.print() # newline after stream + + message_history = result.all_messages() + except Exception as e: + console.print(f"\n[bold red]error:[/bold red] {e}") + finally: + midi.close() def main_cli(): diff --git a/patchwork/deps.py b/patchwork/deps.py new file mode 100644 index 0000000..bb5ccc8 --- /dev/null +++ b/patchwork/deps.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from patchwork.midi import MidiConnection +from patchwork.synth_definitions import SynthDefinition + + +@dataclass +class PatchworkDeps: + midi: MidiConnection + synths: dict[str, SynthDefinition] diff --git a/patchwork/midi.py b/patchwork/midi.py new file mode 100644 index 0000000..b2f4085 --- /dev/null +++ b/patchwork/midi.py @@ -0,0 +1,55 @@ +import rtmidi + + +class MidiConnection: + def __init__(self): + self._out: rtmidi.MidiOut | None = None + self._port_name: str | None = None + + @property + def is_connected(self) -> bool: + return self._out is not None + + @property + def port_name(self) -> str | None: + return self._port_name + + def list_ports(self) -> list[str]: + """List available MIDI output port names.""" + out = rtmidi.MidiOut() + ports = out.get_ports() + del out + return ports + + def open(self, port_index: int = 0) -> str: + """Open a MIDI output port by index. Returns the port name.""" + self._out = rtmidi.MidiOut() + ports = self._out.get_ports() + if not ports: + raise RuntimeError("No MIDI output ports available") + if port_index >= len(ports): + raise ValueError(f"Port index {port_index} out of range (0-{len(ports) - 1})") + self._out.open_port(port_index) + self._port_name = ports[port_index] + return self._port_name + + def close(self): + """Close the current MIDI connection.""" + if self._out is not None: + self._out.close_port() + del self._out + self._out = None + self._port_name = None + + def send_cc(self, channel: int, cc_number: int, value: int): + """Send a MIDI CC message. Channel is 1-16 (converted to 0-15 internally).""" + if self._out is None: + raise RuntimeError("MIDI port not open — call open() first") + if not (1 <= channel <= 16): + raise ValueError(f"MIDI channel must be 1-16, got {channel}") + if not (0 <= cc_number <= 127): + raise ValueError(f"CC number must be 0-127, got {cc_number}") + if not (0 <= value <= 127): + raise ValueError(f"CC value must be 0-127, got {value}") + status = 0xB0 | (channel - 1) + self._out.send_message([status, cc_number, value]) diff --git a/patchwork/synth_definitions.py b/patchwork/synth_definitions.py new file mode 100644 index 0000000..4de7d7f --- /dev/null +++ b/patchwork/synth_definitions.py @@ -0,0 +1,43 @@ +from pathlib import Path + +import yaml +from pydantic import BaseModel, Field, model_validator + + +class CCParameter(BaseModel): + cc: int = Field(ge=0, le=127) + value_range: tuple[int, int] = (0, 127) + notes: str | None = None + + @model_validator(mode="after") + def check_range_order(self) -> "CCParameter": + low, high = self.value_range + if low > high: + raise ValueError(f"value_range low ({low}) must be <= high ({high})") + return self + + +class SynthDefinition(BaseModel): + name: str + manufacturer: str + midi_channel: int = Field(ge=1, le=16) + cc_map: dict[str, CCParameter] + + +def load_synth_definitions(synths_dir: Path = Path("synths")) -> dict[str, SynthDefinition]: + """Load and validate all synth YAML files from the given directory.""" + definitions: dict[str, SynthDefinition] = {} + if not synths_dir.exists(): + return definitions + yaml_files = sorted([*synths_dir.glob("*.yaml"), *synths_dir.glob("*.yml")]) + for yaml_file in yaml_files: + data = yaml.safe_load(yaml_file.read_text()) + synth = SynthDefinition(**data) + key = synth.name.lower() + if key in definitions: + raise ValueError( + f"Duplicate synth name '{synth.name}' " + f"(from {yaml_file.name} and existing definition)" + ) + definitions[key] = synth + return definitions diff --git a/patchwork/tools/__init__.py b/patchwork/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patchwork/tools/midi_control.py b/patchwork/tools/midi_control.py new file mode 100644 index 0000000..3da2102 --- /dev/null +++ b/patchwork/tools/midi_control.py @@ -0,0 +1,110 @@ +from pydantic_ai import RunContext + +from patchwork.deps import PatchworkDeps + + +async def list_midi_ports(ctx: RunContext[PatchworkDeps]) -> str: + """List available MIDI output ports.""" + ports = ctx.deps.midi.list_ports() + if not ports: + return "No MIDI output ports found. Is your MIDI interface connected?" + lines = [f"{i}: {name}" for i, name in enumerate(ports)] + return "Available MIDI ports:\n" + "\n".join(lines) + + +async def send_cc( + ctx: RunContext[PatchworkDeps], + synth: str, + parameter: str, + value: int, +) -> str: + """Send a MIDI CC value to a parameter on a synth. + + Args: + synth: Synth name (e.g. "minitaur", "tb-03") + parameter: Parameter name from the synth's CC map (e.g. "filter_cutoff") + value: CC value to send (0-127) + """ + synth_def = ctx.deps.synths.get(synth.lower()) + if synth_def is None: + available = ", ".join(ctx.deps.synths.keys()) + return f"Unknown synth '{synth}'. Available: {available}" + + param_key = parameter.lower().replace(" ", "_") + param = synth_def.cc_map.get(param_key) + if param is None: + available = ", ".join(synth_def.cc_map.keys()) + return f"Unknown parameter '{parameter}' for {synth_def.name}. Available: {available}" + + low, high = param.value_range + if not (low <= value <= high): + return f"Value {value} out of range for {parameter} ({low}-{high})" + + if not ctx.deps.midi.is_connected: + return f"MIDI not connected. Use list_midi_ports to see available ports, then ask me to connect." + + ctx.deps.midi.send_cc(synth_def.midi_channel, param.cc, value) + return f"Sent CC {param.cc} = {value} to {synth_def.name} ch.{synth_def.midi_channel} ({parameter})" + + +async def send_patch( + ctx: RunContext[PatchworkDeps], + synth: str, + settings: dict[str, int], +) -> str: + """Send multiple CC values at once to set a full patch on a synth. + + Args: + synth: Synth name (e.g. "minitaur", "tb-03") + settings: Dict of parameter name to CC value (e.g. {"filter_cutoff": 64, "resonance": 80}) + """ + synth_def = ctx.deps.synths.get(synth.lower()) + if synth_def is None: + available = ", ".join(ctx.deps.synths.keys()) + return f"Unknown synth '{synth}'. Available: {available}" + + if not ctx.deps.midi.is_connected: + return f"MIDI not connected. Use list_midi_ports to see available ports, then ask me to connect." + + results = [] + for param_name, value in settings.items(): + param_key = param_name.lower().replace(" ", "_") + param = synth_def.cc_map.get(param_key) + if param is None: + results.append(f" ✗ Unknown parameter '{param_name}'") + continue + low, high = param.value_range + if not (low <= value <= high): + results.append(f" ✗ {param_name}: value {value} out of range ({low}-{high})") + continue + ctx.deps.midi.send_cc(synth_def.midi_channel, param.cc, value) + results.append(f" ✓ {param_name} = {value} (CC {param.cc})") + + return f"Patch sent to {synth_def.name} ch.{synth_def.midi_channel}:\n" + "\n".join(results) + + +async def list_synths(ctx: RunContext[PatchworkDeps]) -> str: + """List all loaded synth definitions and their controllable parameters.""" + if not ctx.deps.synths: + return "No synth definitions loaded. Add YAML files to the synths/ directory." + lines = [] + for synth in ctx.deps.synths.values(): + params = ", ".join(synth.cc_map.keys()) + lines.append(f"- {synth.manufacturer} {synth.name} (ch.{synth.midi_channel}): {params}") + return "Loaded synths:\n" + "\n".join(lines) + + +async def connect_midi( + ctx: RunContext[PatchworkDeps], + port_index: int = 0, +) -> str: + """Connect to a MIDI output port by index. Use list_midi_ports first to see available ports. + + Args: + port_index: Index of the MIDI port to connect to (default: 0, the first port) + """ + try: + port_name = ctx.deps.midi.open(port_index) + return f"Connected to MIDI port: {port_name}" + except (RuntimeError, ValueError) as e: + return str(e) diff --git a/pyproject.toml b/pyproject.toml index 6dfc043..d0339f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,8 @@ dependencies = [ "pydantic-ai", "rich", "python-dotenv", + "python-rtmidi", + "pyyaml", ] [project.scripts] diff --git a/synths/moog_minitaur.yaml b/synths/moog_minitaur.yaml new file mode 100644 index 0000000..a70cd3b --- /dev/null +++ b/synths/moog_minitaur.yaml @@ -0,0 +1,30 @@ +name: Minitaur +manufacturer: Moog +midi_channel: 2 +cc_map: + filter_cutoff: + cc: 22 + filter_resonance: + cc: 23 + filter_eg_amount: + cc: 24 + osc2_frequency: + cc: 18 + osc_mix: + cc: 15 + vca_volume: + cc: 7 + eg_attack: + cc: 105 + eg_decay: + cc: 106 + eg_sustain: + cc: 107 + eg_release: + cc: 108 + glide_rate: + cc: 5 + legato_mode: + cc: 114 + value_range: [0, 127] + notes: "0=off, 127=on" diff --git a/synths/roland_tb03.yaml b/synths/roland_tb03.yaml new file mode 100644 index 0000000..0445c4a --- /dev/null +++ b/synths/roland_tb03.yaml @@ -0,0 +1,18 @@ +name: TB-03 +manufacturer: Roland +midi_channel: 3 +cc_map: + tuning: + cc: 104 + cutoff: + cc: 74 + resonance: + cc: 71 + env_mod: + cc: 12 + decay: + cc: 75 + accent: + cc: 16 + overdrive: + cc: 18 diff --git a/tests/test_midi.py b/tests/test_midi.py new file mode 100644 index 0000000..6ba9c77 --- /dev/null +++ b/tests/test_midi.py @@ -0,0 +1,74 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from patchwork.midi import MidiConnection + + +def test_midi_connection_initial_state(): + conn = MidiConnection() + assert conn.is_connected is False + assert conn.port_name is None + + +def test_send_cc_without_open(): + conn = MidiConnection() + with pytest.raises(RuntimeError, match="MIDI port not open"): + conn.send_cc(channel=1, cc_number=22, value=64) + + +def test_send_cc_channel_validation(): + conn = MidiConnection() + conn._out = MagicMock() + with pytest.raises(ValueError, match="MIDI channel must be 1-16"): + conn.send_cc(channel=0, cc_number=22, value=64) + with pytest.raises(ValueError, match="MIDI channel must be 1-16"): + conn.send_cc(channel=17, cc_number=22, value=64) + + +def test_send_cc_value_validation(): + conn = MidiConnection() + conn._out = MagicMock() + with pytest.raises(ValueError, match="CC value must be 0-127"): + conn.send_cc(channel=1, cc_number=22, value=-1) + with pytest.raises(ValueError, match="CC value must be 0-127"): + conn.send_cc(channel=1, cc_number=22, value=128) + + +def test_send_cc_number_validation(): + conn = MidiConnection() + conn._out = MagicMock() + with pytest.raises(ValueError, match="CC number must be 0-127"): + conn.send_cc(channel=1, cc_number=-1, value=64) + with pytest.raises(ValueError, match="CC number must be 0-127"): + conn.send_cc(channel=1, cc_number=128, value=64) + + +def test_send_cc_message_format(): + conn = MidiConnection() + mock_out = MagicMock() + conn._out = mock_out + conn.send_cc(channel=2, cc_number=22, value=64) + mock_out.send_message.assert_called_once_with([0xB1, 22, 64]) + + +@patch("patchwork.midi.rtmidi.MidiOut") +def test_open_no_ports(mock_midi_out_cls): + mock_instance = MagicMock() + mock_instance.get_ports.return_value = [] + mock_midi_out_cls.return_value = mock_instance + + conn = MidiConnection() + with pytest.raises(RuntimeError, match="No MIDI output ports available"): + conn.open() + + +@patch("patchwork.midi.rtmidi.MidiOut") +def test_list_ports(mock_midi_out_cls): + mock_instance = MagicMock() + mock_instance.get_ports.return_value = ["Port A", "Port B"] + mock_midi_out_cls.return_value = mock_instance + + conn = MidiConnection() + ports = conn.list_ports() + assert ports == ["Port A", "Port B"] diff --git a/tests/test_midi_control_tools.py b/tests/test_midi_control_tools.py new file mode 100644 index 0000000..f22e7df --- /dev/null +++ b/tests/test_midi_control_tools.py @@ -0,0 +1,178 @@ +from unittest.mock import MagicMock + +import pytest +from pydantic_ai import RunContext +from pydantic_ai.usage import RunUsage + +from patchwork.deps import PatchworkDeps +from patchwork.midi import MidiConnection +from patchwork.synth_definitions import CCParameter, SynthDefinition +from patchwork.tools.midi_control import ( + connect_midi, + list_midi_ports, + list_synths, + send_cc, + send_patch, +) + + +def _make_synth() -> SynthDefinition: + return SynthDefinition( + name="TestSynth", + manufacturer="TestCo", + midi_channel=1, + cc_map={ + "cutoff": CCParameter(cc=74), + "resonance": CCParameter(cc=71), + "special": CCParameter(cc=50, value_range=(0, 64)), + }, + ) + + +def _make_ctx( + midi: MidiConnection | None = None, + synths: dict[str, SynthDefinition] | None = None, +) -> RunContext[PatchworkDeps]: + if midi is None: + midi = MidiConnection() + if synths is None: + synth = _make_synth() + synths = {synth.name.lower(): synth} + deps = PatchworkDeps(midi=midi, synths=synths) + return RunContext(deps=deps, model=MagicMock(), usage=RunUsage()) + + +@pytest.mark.asyncio +async def test_send_cc_valid(): + midi = MidiConnection() + mock_out = MagicMock() + midi._out = mock_out + midi._port_name = "Test Port" + + ctx = _make_ctx(midi=midi) + result = await send_cc(ctx, synth="testsynth", parameter="cutoff", value=64) + assert "Sent CC 74 = 64" in result + mock_out.send_message.assert_called_once_with([0xB0, 74, 64]) + + +@pytest.mark.asyncio +async def test_send_cc_unknown_synth(): + ctx = _make_ctx() + result = await send_cc(ctx, synth="unknown", parameter="cutoff", value=64) + assert "Unknown synth" in result + assert "testsynth" in result + + +@pytest.mark.asyncio +async def test_send_cc_unknown_parameter(): + ctx = _make_ctx() + result = await send_cc(ctx, synth="testsynth", parameter="nonexistent", value=64) + assert "Unknown parameter" in result + assert "cutoff" in result + + +@pytest.mark.asyncio +async def test_send_cc_value_out_of_range(): + midi = MidiConnection() + midi._out = MagicMock() + midi._port_name = "Test Port" + ctx = _make_ctx(midi=midi) + result = await send_cc(ctx, synth="testsynth", parameter="special", value=100) + assert "out of range" in result + assert "0-64" in result + + +@pytest.mark.asyncio +async def test_send_cc_not_connected(): + ctx = _make_ctx() + result = await send_cc(ctx, synth="testsynth", parameter="cutoff", value=64) + assert "MIDI not connected" in result + + +@pytest.mark.asyncio +async def test_send_patch_valid(): + midi = MidiConnection() + mock_out = MagicMock() + midi._out = mock_out + midi._port_name = "Test Port" + + ctx = _make_ctx(midi=midi) + result = await send_patch(ctx, synth="testsynth", settings={"cutoff": 64, "resonance": 80}) + assert "cutoff = 64" in result + assert "resonance = 80" in result + assert mock_out.send_message.call_count == 2 + + +@pytest.mark.asyncio +async def test_send_patch_partial_failure(): + midi = MidiConnection() + mock_out = MagicMock() + midi._out = mock_out + midi._port_name = "Test Port" + + ctx = _make_ctx(midi=midi) + result = await send_patch( + ctx, + synth="testsynth", + settings={"cutoff": 64, "nonexistent": 50, "special": 100}, + ) + assert "cutoff = 64" in result + assert "Unknown parameter" in result + assert "out of range" in result + assert mock_out.send_message.call_count == 1 + + +@pytest.mark.asyncio +async def test_list_midi_ports_empty(): + midi = MidiConnection() + with MagicMock() as mock_rtmidi: + midi.list_ports = MagicMock(return_value=[]) + ctx = _make_ctx(midi=midi) + result = await list_midi_ports(ctx) + assert "No MIDI output ports found" in result + + +@pytest.mark.asyncio +async def test_list_midi_ports_with_ports(): + midi = MidiConnection() + midi.list_ports = MagicMock(return_value=["Port A", "Port B"]) + ctx = _make_ctx(midi=midi) + result = await list_midi_ports(ctx) + assert "Port A" in result + assert "Port B" in result + assert "0: Port A" in result + assert "1: Port B" in result + + +@pytest.mark.asyncio +async def test_connect_midi_success(): + midi = MidiConnection() + midi.open = MagicMock(return_value="Test Port") + ctx = _make_ctx(midi=midi) + result = await connect_midi(ctx, port_index=0) + assert "Connected to MIDI port: Test Port" in result + + +@pytest.mark.asyncio +async def test_connect_midi_no_ports(): + midi = MidiConnection() + midi.open = MagicMock(side_effect=RuntimeError("No MIDI output ports available")) + ctx = _make_ctx(midi=midi) + result = await connect_midi(ctx, port_index=0) + assert "No MIDI output ports available" in result + + +@pytest.mark.asyncio +async def test_list_synths_with_synths(): + ctx = _make_ctx() + result = await list_synths(ctx) + assert "TestCo TestSynth" in result + assert "cutoff" in result + assert "resonance" in result + + +@pytest.mark.asyncio +async def test_list_synths_empty(): + ctx = _make_ctx(synths={}) + result = await list_synths(ctx) + assert "No synth definitions loaded" in result diff --git a/tests/test_synth_definitions.py b/tests/test_synth_definitions.py new file mode 100644 index 0000000..69a3027 --- /dev/null +++ b/tests/test_synth_definitions.py @@ -0,0 +1,123 @@ +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from patchwork.synth_definitions import ( + CCParameter, + SynthDefinition, + load_synth_definitions, +) + + +def test_cc_parameter_defaults(): + param = CCParameter(cc=22) + assert param.value_range == (0, 127) + assert param.notes is None + + +def test_cc_parameter_validation(): + with pytest.raises(ValidationError): + CCParameter(cc=-1) + with pytest.raises(ValidationError): + CCParameter(cc=128) + + +def test_cc_parameter_range_order(): + with pytest.raises(ValidationError): + CCParameter(cc=22, value_range=(127, 0)) + + +def test_synth_definition_valid(): + synth = SynthDefinition( + name="TestSynth", + manufacturer="TestCo", + midi_channel=1, + cc_map={"cutoff": CCParameter(cc=74)}, + ) + assert synth.name == "TestSynth" + assert synth.manufacturer == "TestCo" + assert synth.midi_channel == 1 + assert "cutoff" in synth.cc_map + assert synth.cc_map["cutoff"].cc == 74 + + +def test_synth_definition_channel_validation(): + with pytest.raises(ValidationError): + SynthDefinition( + name="X", + manufacturer="Y", + midi_channel=0, + cc_map={}, + ) + with pytest.raises(ValidationError): + SynthDefinition( + name="X", + manufacturer="Y", + midi_channel=17, + cc_map={}, + ) + + +def test_load_synth_definitions(tmp_path): + data = { + "name": "TestSynth", + "manufacturer": "TestCo", + "midi_channel": 1, + "cc_map": { + "cutoff": {"cc": 74}, + "resonance": {"cc": 71}, + }, + } + yaml_file = tmp_path / "test_synth.yaml" + yaml_file.write_text(yaml.dump(data)) + + result = load_synth_definitions(tmp_path) + assert "testsynth" in result + assert result["testsynth"].name == "TestSynth" + assert result["testsynth"].cc_map["cutoff"].cc == 74 + + +def test_load_synth_definitions_empty_dir(tmp_path): + result = load_synth_definitions(tmp_path) + assert result == {} + + +def test_load_synth_definitions_missing_dir(tmp_path): + result = load_synth_definitions(tmp_path / "nonexistent") + assert result == {} + + +def test_load_synth_definitions_invalid_yaml(tmp_path): + yaml_file = tmp_path / "bad.yaml" + yaml_file.write_text("name: Bad\nmanufacturer: X\nmidi_channel: 999\ncc_map: {}") + with pytest.raises(ValidationError): + load_synth_definitions(tmp_path) + + +def test_load_synth_definitions_duplicate_name(tmp_path): + data = { + "name": "Dupe", + "manufacturer": "X", + "midi_channel": 1, + "cc_map": {"cutoff": {"cc": 74}}, + } + (tmp_path / "dupe1.yaml").write_text(yaml.dump(data)) + (tmp_path / "dupe2.yaml").write_text(yaml.dump(data)) + with pytest.raises(ValueError, match="Duplicate synth name"): + load_synth_definitions(tmp_path) + + +def test_load_synth_definitions_yml_extension(tmp_path): + data = { + "name": "YmlSynth", + "manufacturer": "Y", + "midi_channel": 5, + "cc_map": {"vol": {"cc": 7}}, + } + yaml_file = tmp_path / "synth.yml" + yaml_file.write_text(yaml.dump(data)) + + result = load_synth_definitions(tmp_path) + assert "ymlsynth" in result diff --git a/tests/test_synth_yamls.py b/tests/test_synth_yamls.py new file mode 100644 index 0000000..a3dc182 --- /dev/null +++ b/tests/test_synth_yamls.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import yaml + +from patchwork.synth_definitions import SynthDefinition + +SYNTHS_DIR = Path(__file__).parent.parent / "synths" + + +def test_minitaur_yaml_loads(): + data = yaml.safe_load((SYNTHS_DIR / "moog_minitaur.yaml").read_text()) + synth = SynthDefinition(**data) + assert synth.name == "Minitaur" + assert synth.manufacturer == "Moog" + assert synth.midi_channel == 2 + assert "filter_cutoff" in synth.cc_map + assert synth.cc_map["filter_cutoff"].cc == 22 + + +def test_tb03_yaml_loads(): + data = yaml.safe_load((SYNTHS_DIR / "roland_tb03.yaml").read_text()) + synth = SynthDefinition(**data) + assert synth.name == "TB-03" + assert synth.manufacturer == "Roland" + assert synth.midi_channel == 3 + assert "cutoff" in synth.cc_map + assert synth.cc_map["cutoff"].cc == 74 + + +def test_all_synth_yamls_valid(): + yaml_files = list(SYNTHS_DIR.glob("*.yaml")) + list(SYNTHS_DIR.glob("*.yml")) + assert len(yaml_files) > 0, "No synth YAML files found" + for yaml_file in yaml_files: + data = yaml.safe_load(yaml_file.read_text()) + synth = SynthDefinition(**data) + assert synth.name + assert synth.manufacturer + assert len(synth.cc_map) > 0 diff --git a/uv.lock b/uv.lock index 0ef08a3..c8b2c9a 100644 --- a/uv.lock +++ b/uv.lock @@ -1321,6 +1321,8 @@ source = { virtual = "." } dependencies = [ { name = "pydantic-ai" }, { name = "python-dotenv" }, + { name = "python-rtmidi" }, + { name = "pyyaml" }, { name = "rich" }, ] @@ -1334,6 +1336,8 @@ dev = [ requires-dist = [ { name = "pydantic-ai" }, { name = "python-dotenv" }, + { name = "python-rtmidi" }, + { name = "pyyaml" }, { name = "rich" }, ] @@ -1781,6 +1785,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] +[[package]] +name = "python-rtmidi" +version = "1.5.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/ee/0f91965dcc471714c69df21e5ca3d94dc81411b7dee2d31ff1184bea07c9/python_rtmidi-1.5.8.tar.gz", hash = "sha256:7f9ade68b068ae09000ecb562ae9521da3a234361ad5449e83fc734544d004fa", size = 368130, upload-time = "2023-11-20T21:55:02.192Z" } + [[package]] name = "pywin32" version = "311" From 803d7f43dbf9984ec018f814dd3fd650e2049b4f Mon Sep 17 00:00:00 2001 From: Patrick Nilan Date: Sat, 7 Mar 2026 13:49:03 -0800 Subject: [PATCH 2/4] Fix MIDI connection leak, port index validation, and tool check order - Close existing connection before opening a new one in MidiConnection.open() - Guard against negative port_index with 0 <= port_index < len(ports) - Anchor default synths_dir to project root via __file__ instead of CWD - Move MIDI connection check before parameter validation in send_cc for consistency with send_patch - Remove unnecessary f-string prefixes on non-interpolated strings --- patchwork/midi.py | 3 ++- patchwork/synth_definitions.py | 5 ++++- patchwork/tools/midi_control.py | 8 ++++---- tests/test_midi_control_tools.py | 5 ++++- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/patchwork/midi.py b/patchwork/midi.py index b2f4085..5776b28 100644 --- a/patchwork/midi.py +++ b/patchwork/midi.py @@ -23,11 +23,12 @@ def list_ports(self) -> list[str]: def open(self, port_index: int = 0) -> str: """Open a MIDI output port by index. Returns the port name.""" + self.close() self._out = rtmidi.MidiOut() ports = self._out.get_ports() if not ports: raise RuntimeError("No MIDI output ports available") - if port_index >= len(ports): + if not (0 <= port_index < len(ports)): raise ValueError(f"Port index {port_index} out of range (0-{len(ports) - 1})") self._out.open_port(port_index) self._port_name = ports[port_index] diff --git a/patchwork/synth_definitions.py b/patchwork/synth_definitions.py index 4de7d7f..82ea1d2 100644 --- a/patchwork/synth_definitions.py +++ b/patchwork/synth_definitions.py @@ -24,7 +24,10 @@ class SynthDefinition(BaseModel): cc_map: dict[str, CCParameter] -def load_synth_definitions(synths_dir: Path = Path("synths")) -> dict[str, SynthDefinition]: +_DEFAULT_SYNTHS_DIR = Path(__file__).resolve().parent.parent / "synths" + + +def load_synth_definitions(synths_dir: Path = _DEFAULT_SYNTHS_DIR) -> dict[str, SynthDefinition]: """Load and validate all synth YAML files from the given directory.""" definitions: dict[str, SynthDefinition] = {} if not synths_dir.exists(): diff --git a/patchwork/tools/midi_control.py b/patchwork/tools/midi_control.py index 3da2102..63992c9 100644 --- a/patchwork/tools/midi_control.py +++ b/patchwork/tools/midi_control.py @@ -30,6 +30,9 @@ async def send_cc( available = ", ".join(ctx.deps.synths.keys()) return f"Unknown synth '{synth}'. Available: {available}" + if not ctx.deps.midi.is_connected: + return "MIDI not connected. Use list_midi_ports to see available ports, then ask me to connect." + param_key = parameter.lower().replace(" ", "_") param = synth_def.cc_map.get(param_key) if param is None: @@ -40,9 +43,6 @@ async def send_cc( if not (low <= value <= high): return f"Value {value} out of range for {parameter} ({low}-{high})" - if not ctx.deps.midi.is_connected: - return f"MIDI not connected. Use list_midi_ports to see available ports, then ask me to connect." - ctx.deps.midi.send_cc(synth_def.midi_channel, param.cc, value) return f"Sent CC {param.cc} = {value} to {synth_def.name} ch.{synth_def.midi_channel} ({parameter})" @@ -64,7 +64,7 @@ async def send_patch( return f"Unknown synth '{synth}'. Available: {available}" if not ctx.deps.midi.is_connected: - return f"MIDI not connected. Use list_midi_ports to see available ports, then ask me to connect." + return "MIDI not connected. Use list_midi_ports to see available ports, then ask me to connect." results = [] for param_name, value in settings.items(): diff --git a/tests/test_midi_control_tools.py b/tests/test_midi_control_tools.py index f22e7df..687c161 100644 --- a/tests/test_midi_control_tools.py +++ b/tests/test_midi_control_tools.py @@ -65,7 +65,10 @@ async def test_send_cc_unknown_synth(): @pytest.mark.asyncio async def test_send_cc_unknown_parameter(): - ctx = _make_ctx() + midi = MidiConnection() + midi._out = MagicMock() + midi._port_name = "Test Port" + ctx = _make_ctx(midi=midi) result = await send_cc(ctx, synth="testsynth", parameter="nonexistent", value=64) assert "Unknown parameter" in result assert "cutoff" in result From 96a2e39e70fa2331006a7666265da5b8dc1de0c4 Mon Sep 17 00:00:00 2001 From: Patrick Nilan Date: Sat, 7 Mar 2026 13:52:08 -0800 Subject: [PATCH 3/4] Add ruff for linting and formatting - Add ruff as dev dependency with rules: E, W, F, I, UP, B, SIM, RUF - Fix all lint issues (line length, unused imports, unused vars, quoted annotations) - Run ruff format across codebase - Add lint job to CI workflow (ruff check + ruff format --check) - Rename workflow from "Tests" to "CI" --- .github/workflows/test.yml | 13 ++++++++++++- patchwork/cli.py | 5 +++-- patchwork/synth_definitions.py | 2 +- patchwork/tools/midi_control.py | 15 ++++++++++++--- pyproject.toml | 18 +++++++++++++++++- tests/test_agent.py | 15 +++++++++++---- tests/test_midi_control_tools.py | 3 +-- tests/test_synth_definitions.py | 2 -- uv.lock | 27 +++++++++++++++++++++++++++ 9 files changed, 84 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0fef313..f14073d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Tests +name: CI on: push: @@ -7,6 +7,17 @@ on: branches: [main] jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + python-version: "3.14" + - run: uv sync + - run: uv run ruff check . + - run: uv run ruff format --check . + test: runs-on: ubuntu-latest steps: diff --git a/patchwork/cli.py b/patchwork/cli.py index 46aad84..5bf560d 100644 --- a/patchwork/cli.py +++ b/patchwork/cli.py @@ -18,7 +18,8 @@ async def main(): console.print("[bold]patchwork[/bold] — synth research agent\n") if synths: - console.print(f"[dim]loaded {len(synths)} synth(s): {', '.join(s.name for s in synths.values())}[/dim]\n") + synth_names = ", ".join(s.name for s in synths.values()) + console.print(f"[dim]loaded {len(synths)} synth(s): {synth_names}[/dim]\n") else: console.print("[dim]no synth definitions found in synths/[/dim]\n") message_history = [] @@ -27,7 +28,7 @@ async def main(): while True: try: user_input = console.input("[bold cyan]patch>[/bold cyan] ") - except (KeyboardInterrupt, EOFError): + except KeyboardInterrupt, EOFError: console.print("\n[dim]goodbye[/dim]") break diff --git a/patchwork/synth_definitions.py b/patchwork/synth_definitions.py index 82ea1d2..812536b 100644 --- a/patchwork/synth_definitions.py +++ b/patchwork/synth_definitions.py @@ -10,7 +10,7 @@ class CCParameter(BaseModel): notes: str | None = None @model_validator(mode="after") - def check_range_order(self) -> "CCParameter": + def check_range_order(self) -> CCParameter: low, high = self.value_range if low > high: raise ValueError(f"value_range low ({low}) must be <= high ({high})") diff --git a/patchwork/tools/midi_control.py b/patchwork/tools/midi_control.py index 63992c9..9688770 100644 --- a/patchwork/tools/midi_control.py +++ b/patchwork/tools/midi_control.py @@ -31,7 +31,10 @@ async def send_cc( return f"Unknown synth '{synth}'. Available: {available}" if not ctx.deps.midi.is_connected: - return "MIDI not connected. Use list_midi_ports to see available ports, then ask me to connect." + return ( + "MIDI not connected. Use list_midi_ports to see available ports," + " then ask me to connect." + ) param_key = parameter.lower().replace(" ", "_") param = synth_def.cc_map.get(param_key) @@ -44,7 +47,10 @@ async def send_cc( return f"Value {value} out of range for {parameter} ({low}-{high})" ctx.deps.midi.send_cc(synth_def.midi_channel, param.cc, value) - return f"Sent CC {param.cc} = {value} to {synth_def.name} ch.{synth_def.midi_channel} ({parameter})" + return ( + f"Sent CC {param.cc} = {value} to {synth_def.name}" + f" ch.{synth_def.midi_channel} ({parameter})" + ) async def send_patch( @@ -64,7 +70,10 @@ async def send_patch( return f"Unknown synth '{synth}'. Available: {available}" if not ctx.deps.midi.is_connected: - return "MIDI not connected. Use list_midi_ports to see available ports, then ask me to connect." + return ( + "MIDI not connected. Use list_midi_ports to see available ports," + " then ask me to connect." + ) results = [] for param_name, value in settings.items(): diff --git a/pyproject.toml b/pyproject.toml index d0339f1..24c9ec9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,4 +16,20 @@ dependencies = [ patchwork = "patchwork.cli:main_cli" [dependency-groups] -dev = ["pytest", "pytest-asyncio"] +dev = ["pytest", "pytest-asyncio", "ruff"] + +[tool.ruff] +target-version = "py314" +line-length = 100 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "RUF", # ruff-specific rules +] diff --git a/tests/test_agent.py b/tests/test_agent.py index cf71d6d..8cc8b63 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -15,7 +15,16 @@ def test_agent_model(): def test_system_prompt_contains_synth_context(): - for synth in ["Minitaur", "TB-03", "Minilogue XD", "Roland S-1", "Blackbox", "Digitakt", "Minibrute 2S"]: + synths = [ + "Minitaur", + "TB-03", + "Minilogue XD", + "Roland S-1", + "Blackbox", + "Digitakt", + "Minibrute 2S", + ] + for synth in synths: assert synth in SYSTEM_PROMPT, f"System prompt missing {synth}" @@ -23,9 +32,7 @@ def test_system_prompt_mentions_midi(): assert "MIDI CC" in SYSTEM_PROMPT -@pytest.mark.skipif( - not os.environ.get("ANTHROPIC_API_KEY"), reason="no API key" -) +@pytest.mark.skipif(not os.environ.get("ANTHROPIC_API_KEY"), reason="no API key") @pytest.mark.asyncio async def test_agent_responds(): result = await agent.run("What synths do I have?") diff --git a/tests/test_midi_control_tools.py b/tests/test_midi_control_tools.py index 687c161..63a3635 100644 --- a/tests/test_midi_control_tools.py +++ b/tests/test_midi_control_tools.py @@ -128,8 +128,7 @@ async def test_send_patch_partial_failure(): @pytest.mark.asyncio async def test_list_midi_ports_empty(): midi = MidiConnection() - with MagicMock() as mock_rtmidi: - midi.list_ports = MagicMock(return_value=[]) + midi.list_ports = MagicMock(return_value=[]) ctx = _make_ctx(midi=midi) result = await list_midi_ports(ctx) assert "No MIDI output ports found" in result diff --git a/tests/test_synth_definitions.py b/tests/test_synth_definitions.py index 69a3027..abb07b7 100644 --- a/tests/test_synth_definitions.py +++ b/tests/test_synth_definitions.py @@ -1,5 +1,3 @@ -from pathlib import Path - import pytest import yaml from pydantic import ValidationError diff --git a/uv.lock b/uv.lock index c8b2c9a..7dab9ba 100644 --- a/uv.lock +++ b/uv.lock @@ -1330,6 +1330,7 @@ dependencies = [ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "ruff" }, ] [package.metadata] @@ -1345,6 +1346,7 @@ requires-dist = [ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "ruff" }, ] [[package]] @@ -1979,6 +1981,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, ] +[[package]] +name = "ruff" +version = "0.15.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, + { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, + { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +] + [[package]] name = "s3transfer" version = "0.16.0" From e44742d23c111bec92a21b9838a364fa06b694b2 Mon Sep 17 00:00:00 2001 From: Patrick Nilan Date: Sat, 7 Mar 2026 13:54:03 -0800 Subject: [PATCH 4/4] Fix CI: install system deps for python-rtmidi build --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f14073d..1d4046c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,8 @@ jobs: - uses: astral-sh/setup-uv@v5 with: python-version: "3.14" + - name: Install system dependencies for python-rtmidi + run: sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-jackd2-dev - run: uv sync - run: uv run ruff check . - run: uv run ruff format --check . @@ -25,5 +27,7 @@ jobs: - uses: astral-sh/setup-uv@v5 with: python-version: "3.14" + - name: Install system dependencies for python-rtmidi + run: sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-jackd2-dev - run: uv sync - run: uv run pytest .