Skip to content

Commit 13ad660

Browse files
pnilanclaude
andcommitted
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. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9738a3d commit 13ad660

15 files changed

Lines changed: 763 additions & 28 deletions

patchwork/agent.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
from pydantic_ai import Agent
1+
from pydantic_ai import Agent, RunContext
2+
3+
from patchwork.deps import PatchworkDeps
4+
from patchwork.tools.midi_control import (
5+
connect_midi,
6+
list_midi_ports,
7+
list_synths,
8+
send_cc,
9+
send_patch,
10+
)
211

312
SYSTEM_PROMPT = """You are Patchwork, an expert synthesizer sound design assistant.
413
@@ -16,8 +25,15 @@
1625
- 1010 Music Blackbox (sampler/groovebox)
1726
- Arturia Minibrute 2S (analog, semi-modular)
1827
19-
You will eventually control these synths via MIDI CC messages. For now, describe
20-
settings conceptually using parameter names and values.
28+
You have tools to control synths via MIDI:
29+
- list_midi_ports: Show available MIDI output devices
30+
- connect_midi: Connect to a MIDI port
31+
- list_synths: Show loaded synth definitions and their controllable parameters
32+
- send_cc: Send a single MIDI CC value to a synth parameter
33+
- send_patch: Send multiple MIDI CC values at once to set a full patch
34+
35+
When the user asks you to set a sound or tweak a parameter, use these tools.
36+
Always confirm what you sent after a successful tool call.
2137
2238
Tone: conversational but concise. Use musical and technical terminology naturally.
2339
When describing a patch, be specific enough that someone could recreate it by hand.
@@ -26,5 +42,19 @@
2642
agent = Agent(
2743
"anthropic:claude-sonnet-4-6",
2844
system_prompt=SYSTEM_PROMPT,
45+
deps_type=PatchworkDeps,
2946
defer_model_check=True,
47+
tools=[list_midi_ports, connect_midi, list_synths, send_cc, send_patch],
3048
)
49+
50+
51+
@agent.system_prompt
52+
async def add_synth_context(ctx: RunContext[PatchworkDeps]) -> str:
53+
"""Append loaded synth definitions to the system prompt at runtime."""
54+
if not ctx.deps.synths:
55+
return "No synth definitions loaded yet."
56+
lines = []
57+
for synth in ctx.deps.synths.values():
58+
params = ", ".join(synth.cc_map.keys())
59+
lines.append(f"- {synth.manufacturer} {synth.name} (ch.{synth.midi_channel}): {params}")
60+
return "Loaded synths and their controllable parameters:\n" + "\n".join(lines)

patchwork/cli.py

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,39 +4,53 @@
44
from rich.console import Console
55

66
from patchwork.agent import agent
7+
from patchwork.deps import PatchworkDeps
8+
from patchwork.midi import MidiConnection
9+
from patchwork.synth_definitions import load_synth_definitions
710

811
console = Console()
912

1013

1114
async def main():
15+
midi = MidiConnection()
16+
synths = load_synth_definitions()
17+
deps = PatchworkDeps(midi=midi, synths=synths)
18+
1219
console.print("[bold]patchwork[/bold] — synth research agent\n")
20+
if synths:
21+
console.print(f"[dim]loaded {len(synths)} synth(s): {', '.join(s.name for s in synths.values())}[/dim]\n")
22+
else:
23+
console.print("[dim]no synth definitions found in synths/[/dim]\n")
1324
message_history = []
1425

15-
while True:
16-
try:
17-
user_input = console.input("[bold cyan]patch>[/bold cyan] ")
18-
except (KeyboardInterrupt, EOFError):
19-
console.print("\n[dim]goodbye[/dim]")
20-
break
21-
22-
if not user_input.strip():
23-
continue
24-
25-
if user_input.strip().lower() in ("quit", "exit"):
26-
console.print("[dim]goodbye[/dim]")
27-
break
28-
29-
try:
30-
async with agent.run_stream(
31-
user_input, message_history=message_history
32-
) as result:
33-
async for chunk in result.stream_text(delta=True):
34-
console.print(chunk, end="", markup=False, highlight=False)
35-
console.print() # newline after stream
36-
37-
message_history = result.all_messages()
38-
except Exception as e:
39-
console.print(f"\n[bold red]error:[/bold red] {e}")
26+
try:
27+
while True:
28+
try:
29+
user_input = console.input("[bold cyan]patch>[/bold cyan] ")
30+
except (KeyboardInterrupt, EOFError):
31+
console.print("\n[dim]goodbye[/dim]")
32+
break
33+
34+
if not user_input.strip():
35+
continue
36+
37+
if user_input.strip().lower() in ("quit", "exit"):
38+
console.print("[dim]goodbye[/dim]")
39+
break
40+
41+
try:
42+
async with agent.run_stream(
43+
user_input, message_history=message_history, deps=deps
44+
) as result:
45+
async for chunk in result.stream_text(delta=True):
46+
console.print(chunk, end="", markup=False, highlight=False)
47+
console.print() # newline after stream
48+
49+
message_history = result.all_messages()
50+
except Exception as e:
51+
console.print(f"\n[bold red]error:[/bold red] {e}")
52+
finally:
53+
midi.close()
4054

4155

4256
def main_cli():

patchwork/deps.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from dataclasses import dataclass
2+
3+
from patchwork.midi import MidiConnection
4+
from patchwork.synth_definitions import SynthDefinition
5+
6+
7+
@dataclass
8+
class PatchworkDeps:
9+
midi: MidiConnection
10+
synths: dict[str, SynthDefinition]

patchwork/midi.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import rtmidi
2+
3+
4+
class MidiConnection:
5+
def __init__(self):
6+
self._out: rtmidi.MidiOut | None = None
7+
self._port_name: str | None = None
8+
9+
@property
10+
def is_connected(self) -> bool:
11+
return self._out is not None
12+
13+
@property
14+
def port_name(self) -> str | None:
15+
return self._port_name
16+
17+
def list_ports(self) -> list[str]:
18+
"""List available MIDI output port names."""
19+
out = rtmidi.MidiOut()
20+
ports = out.get_ports()
21+
del out
22+
return ports
23+
24+
def open(self, port_index: int = 0) -> str:
25+
"""Open a MIDI output port by index. Returns the port name."""
26+
self._out = rtmidi.MidiOut()
27+
ports = self._out.get_ports()
28+
if not ports:
29+
raise RuntimeError("No MIDI output ports available")
30+
if port_index >= len(ports):
31+
raise ValueError(f"Port index {port_index} out of range (0-{len(ports) - 1})")
32+
self._out.open_port(port_index)
33+
self._port_name = ports[port_index]
34+
return self._port_name
35+
36+
def close(self):
37+
"""Close the current MIDI connection."""
38+
if self._out is not None:
39+
self._out.close_port()
40+
del self._out
41+
self._out = None
42+
self._port_name = None
43+
44+
def send_cc(self, channel: int, cc_number: int, value: int):
45+
"""Send a MIDI CC message. Channel is 1-16 (converted to 0-15 internally)."""
46+
if self._out is None:
47+
raise RuntimeError("MIDI port not open — call open() first")
48+
if not (1 <= channel <= 16):
49+
raise ValueError(f"MIDI channel must be 1-16, got {channel}")
50+
if not (0 <= cc_number <= 127):
51+
raise ValueError(f"CC number must be 0-127, got {cc_number}")
52+
if not (0 <= value <= 127):
53+
raise ValueError(f"CC value must be 0-127, got {value}")
54+
status = 0xB0 | (channel - 1)
55+
self._out.send_message([status, cc_number, value])

patchwork/synth_definitions.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from pathlib import Path
2+
3+
import yaml
4+
from pydantic import BaseModel, Field, model_validator
5+
6+
7+
class CCParameter(BaseModel):
8+
cc: int = Field(ge=0, le=127)
9+
value_range: tuple[int, int] = (0, 127)
10+
notes: str | None = None
11+
12+
@model_validator(mode="after")
13+
def check_range_order(self) -> "CCParameter":
14+
low, high = self.value_range
15+
if low > high:
16+
raise ValueError(f"value_range low ({low}) must be <= high ({high})")
17+
return self
18+
19+
20+
class SynthDefinition(BaseModel):
21+
name: str
22+
manufacturer: str
23+
midi_channel: int = Field(ge=1, le=16)
24+
cc_map: dict[str, CCParameter]
25+
26+
27+
def load_synth_definitions(synths_dir: Path = Path("synths")) -> dict[str, SynthDefinition]:
28+
"""Load and validate all synth YAML files from the given directory."""
29+
definitions: dict[str, SynthDefinition] = {}
30+
if not synths_dir.exists():
31+
return definitions
32+
yaml_files = sorted([*synths_dir.glob("*.yaml"), *synths_dir.glob("*.yml")])
33+
for yaml_file in yaml_files:
34+
data = yaml.safe_load(yaml_file.read_text())
35+
synth = SynthDefinition(**data)
36+
key = synth.name.lower()
37+
if key in definitions:
38+
raise ValueError(
39+
f"Duplicate synth name '{synth.name}' "
40+
f"(from {yaml_file.name} and existing definition)"
41+
)
42+
definitions[key] = synth
43+
return definitions

patchwork/tools/__init__.py

Whitespace-only changes.

patchwork/tools/midi_control.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from pydantic_ai import RunContext
2+
3+
from patchwork.deps import PatchworkDeps
4+
5+
6+
async def list_midi_ports(ctx: RunContext[PatchworkDeps]) -> str:
7+
"""List available MIDI output ports."""
8+
ports = ctx.deps.midi.list_ports()
9+
if not ports:
10+
return "No MIDI output ports found. Is your MIDI interface connected?"
11+
lines = [f"{i}: {name}" for i, name in enumerate(ports)]
12+
return "Available MIDI ports:\n" + "\n".join(lines)
13+
14+
15+
async def send_cc(
16+
ctx: RunContext[PatchworkDeps],
17+
synth: str,
18+
parameter: str,
19+
value: int,
20+
) -> str:
21+
"""Send a MIDI CC value to a parameter on a synth.
22+
23+
Args:
24+
synth: Synth name (e.g. "minitaur", "tb-03")
25+
parameter: Parameter name from the synth's CC map (e.g. "filter_cutoff")
26+
value: CC value to send (0-127)
27+
"""
28+
synth_def = ctx.deps.synths.get(synth.lower())
29+
if synth_def is None:
30+
available = ", ".join(ctx.deps.synths.keys())
31+
return f"Unknown synth '{synth}'. Available: {available}"
32+
33+
param_key = parameter.lower().replace(" ", "_")
34+
param = synth_def.cc_map.get(param_key)
35+
if param is None:
36+
available = ", ".join(synth_def.cc_map.keys())
37+
return f"Unknown parameter '{parameter}' for {synth_def.name}. Available: {available}"
38+
39+
low, high = param.value_range
40+
if not (low <= value <= high):
41+
return f"Value {value} out of range for {parameter} ({low}-{high})"
42+
43+
if not ctx.deps.midi.is_connected:
44+
return f"MIDI not connected. Use list_midi_ports to see available ports, then ask me to connect."
45+
46+
ctx.deps.midi.send_cc(synth_def.midi_channel, param.cc, value)
47+
return f"Sent CC {param.cc} = {value} to {synth_def.name} ch.{synth_def.midi_channel} ({parameter})"
48+
49+
50+
async def send_patch(
51+
ctx: RunContext[PatchworkDeps],
52+
synth: str,
53+
settings: dict[str, int],
54+
) -> str:
55+
"""Send multiple CC values at once to set a full patch on a synth.
56+
57+
Args:
58+
synth: Synth name (e.g. "minitaur", "tb-03")
59+
settings: Dict of parameter name to CC value (e.g. {"filter_cutoff": 64, "resonance": 80})
60+
"""
61+
synth_def = ctx.deps.synths.get(synth.lower())
62+
if synth_def is None:
63+
available = ", ".join(ctx.deps.synths.keys())
64+
return f"Unknown synth '{synth}'. Available: {available}"
65+
66+
if not ctx.deps.midi.is_connected:
67+
return f"MIDI not connected. Use list_midi_ports to see available ports, then ask me to connect."
68+
69+
results = []
70+
for param_name, value in settings.items():
71+
param_key = param_name.lower().replace(" ", "_")
72+
param = synth_def.cc_map.get(param_key)
73+
if param is None:
74+
results.append(f" ✗ Unknown parameter '{param_name}'")
75+
continue
76+
low, high = param.value_range
77+
if not (low <= value <= high):
78+
results.append(f" ✗ {param_name}: value {value} out of range ({low}-{high})")
79+
continue
80+
ctx.deps.midi.send_cc(synth_def.midi_channel, param.cc, value)
81+
results.append(f" ✓ {param_name} = {value} (CC {param.cc})")
82+
83+
return f"Patch sent to {synth_def.name} ch.{synth_def.midi_channel}:\n" + "\n".join(results)
84+
85+
86+
async def list_synths(ctx: RunContext[PatchworkDeps]) -> str:
87+
"""List all loaded synth definitions and their controllable parameters."""
88+
if not ctx.deps.synths:
89+
return "No synth definitions loaded. Add YAML files to the synths/ directory."
90+
lines = []
91+
for synth in ctx.deps.synths.values():
92+
params = ", ".join(synth.cc_map.keys())
93+
lines.append(f"- {synth.manufacturer} {synth.name} (ch.{synth.midi_channel}): {params}")
94+
return "Loaded synths:\n" + "\n".join(lines)
95+
96+
97+
async def connect_midi(
98+
ctx: RunContext[PatchworkDeps],
99+
port_index: int = 0,
100+
) -> str:
101+
"""Connect to a MIDI output port by index. Use list_midi_ports first to see available ports.
102+
103+
Args:
104+
port_index: Index of the MIDI port to connect to (default: 0, the first port)
105+
"""
106+
try:
107+
port_name = ctx.deps.midi.open(port_index)
108+
return f"Connected to MIDI port: {port_name}"
109+
except (RuntimeError, ValueError) as e:
110+
return str(e)

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ dependencies = [
88
"pydantic-ai",
99
"rich",
1010
"python-dotenv",
11+
"python-rtmidi",
12+
"pyyaml",
1113
]
1214

1315
[project.scripts]

synths/moog_minitaur.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: Minitaur
2+
manufacturer: Moog
3+
midi_channel: 2
4+
cc_map:
5+
filter_cutoff:
6+
cc: 22
7+
filter_resonance:
8+
cc: 23
9+
filter_eg_amount:
10+
cc: 24
11+
osc2_frequency:
12+
cc: 18
13+
osc_mix:
14+
cc: 15
15+
vca_volume:
16+
cc: 7
17+
eg_attack:
18+
cc: 105
19+
eg_decay:
20+
cc: 106
21+
eg_sustain:
22+
cc: 107
23+
eg_release:
24+
cc: 108
25+
glide_rate:
26+
cc: 5
27+
legato_mode:
28+
cc: 114
29+
value_range: [0, 127]
30+
notes: "0=off, 127=on"

0 commit comments

Comments
 (0)