File size: 12,450 Bytes
5459c43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
"""Diagnosis β†’ executable Ableton Live moves.

This Space is the ears. It has no route into your Live set, and pretending
otherwise would be a lie. What it emits instead is a plan in the exact shape
the Ableton MCP server expects, so an agent with that server connected
locally can execute it verbatim.

Parameter names differ between Live versions and device presets, so every
step that sets a parameter is preceded by a `get_device_parameters` probe and
carries a `match` hint. The executing agent resolves the real name rather
than trusting a hardcoded string.
"""

from __future__ import annotations

import json
from typing import Any

from .knowledge import Diagnosis

# Browser paths as they appear in Live's own device tree. `load_instrument_or_effect`
# wants a browser URI, which the agent resolves with `get_browser_items_at_path`.
DEVICE_PATHS = {
    "EQ Eight": "Audio Effects/EQ Eight",
    "EQ Three": "Audio Effects/EQ Three",
    "Utility": "Audio Effects/Utility",
    "Glue Compressor": "Audio Effects/Glue Compressor",
    "Compressor": "Audio Effects/Compressor",
    "Multiband Dynamics": "Audio Effects/Multiband Dynamics",
    "Limiter": "Audio Effects/Limiter",
    "Saturator": "Audio Effects/Saturator",
    "Drum Buss": "Audio Effects/Drum Buss",
    "Roar": "Audio Effects/Roar",
    "Dynamic Tube": "Audio Effects/Dynamic Tube",
    "Auto Filter": "Audio Effects/Auto Filter",
    "Hybrid Reverb": "Audio Effects/Hybrid Reverb",
    "Spectrum": "Audio Effects/Spectrum",
    "Operator": "Instruments/Operator",
}


def _load(track: Any, device: str, note: str = "") -> dict:
    return {
        "tool": "load_instrument_or_effect",
        "args": {"track_index": track, "uri": DEVICE_PATHS.get(device, device)},
        "device": device,
        "resolve": "call get_browser_tree / get_browser_items_at_path to turn "
                   "this path into a concrete browser URI",
        "skip_if": f"{device} already exists on this track",
        "note": note,
    }


def _probe(track: Any, device: str) -> dict:
    return {
        "tool": "get_device_parameters",
        "args": {"track_index": track, "device_index": "$LAST"},
        "purpose": f"resolve the real parameter names for {device} in this Live version",
    }


def _set(track: Any, match: str, value: Any, why: str) -> dict:
    return {
        "tool": "set_device_parameter",
        "args": {"track_index": track, "device_index": "$LAST",
                 "parameter_name": match, "value": value},
        "match": match,
        "why": why,
    }


# --------------------------------------------------------------------------
# per-diagnosis recipes
# --------------------------------------------------------------------------

def _recipe(d: Diagnosis, track: Any) -> list[dict] | None:
    if d.id in ("clipping", "no_headroom"):
        return [
            _load(track, "Limiter", "master chain, last in the signal path"),
            _probe(track, "Limiter"),
            _set(track, "Ceiling", -1.0, "true peak target of -1.0 dBTP survives lossy encoding"),
            _set(track, "Release", 200.0, "slow enough not to pump on sustained low end"),
        ]

    if d.id == "too_loud":
        return [
            _probe(track, "Limiter"),
            _set(track, "Gain", round(-d.amount, 1),
                 f"back off {d.amount:.1f} dB of limiter drive"),
            _load(track, "Saturator", "recover density without more ceiling"),
            _probe(track, "Saturator"),
            _set(track, "Drive", 3.0, "harmonic weight instead of gain"),
            _set(track, "Dry/Wet", 35.0, "parallel amount"),
        ]

    if d.id == "over_limited":
        return [
            _probe(track, "Limiter"),
            _set(track, "Gain", -2.5, "give the transients back 2.5 dB"),
            _load(track, "Glue Compressor", "parallel glue on the drum bus, not the master"),
            _probe(track, "Glue Compressor"),
            _set(track, "Ratio", 4.0, "parallel crush setting"),
            _set(track, "Attack", 30.0, "let the stick through before gain reduction"),
            _set(track, "Release", 0.4, "follows the groove"),
            _set(track, "Dry/Wet", 30.0, "parallel, keeps the original transient"),
        ]

    if d.id in ("mud", "hollow"):
        gain = -abs(d.amount) if d.id == "mud" else 2.0
        return [
            _load(track, "EQ Eight", f"surgical work at {d.freq:.0f} Hz"),
            _probe(track, "EQ Eight"),
            _set(track, "2 Filter Type A", "Bell", "band 2 as a bell"),
            _set(track, "2 Frequency A", round(d.freq, 1), f"centre on the problem at {d.freq:.0f} Hz"),
            _set(track, "2 Gain A", round(gain, 1), d.headline),
            _set(track, "2 Resonance A", 1.4 if d.id == "mud" else 0.7,
                 "narrow to cut, wide to add"),
        ]

    if d.id == "harsh":
        return [
            _load(track, "Multiband Dynamics", "dynamic control at 3 kHz, not a static cut"),
            _probe(track, "Multiband Dynamics"),
            _set(track, "Low Crossover", 1500.0, "isolate 1.5–4k as the mid band"),
            _set(track, "High Crossover", 4000.0, "isolate 1.5–4k as the mid band"),
            _set(track, "Above Threshold (Mid)", -18.0, "engage only on peaks"),
            _set(track, "Above Ratio (Mid)", 3.0, f"{d.amount:.1f} dB of movement at the top"),
        ]

    if d.id in ("dull", "brittle"):
        gain = 2.0 if d.id == "dull" else -2.0
        return [
            _load(track, "EQ Eight", "high shelf"),
            _probe(track, "EQ Eight"),
            _set(track, "8 Filter Type A", "High Shelf", "band 8 as a shelf"),
            _set(track, "8 Frequency A", round(d.freq, 1), f"shelf from {d.freq/1000:.0f} kHz up"),
            _set(track, "8 Gain A", gain, d.headline),
        ]

    if d.id in ("stereo_sub", "out_of_phase"):
        return [
            _load(track, "Utility", "before the limiter, after everything else"),
            _probe(track, "Utility"),
            _set(track, "Bass Mono", 1, "collapse the low end to mono"),
            _set(track, "Bass Mono Frequency", 120.0, "everything under 120 Hz centred"),
        ]

    if d.id == "sub_heavy":
        return [
            _load(track, "EQ Eight", "control the shelf under 60 Hz"),
            _probe(track, "EQ Eight"),
            _set(track, "1 Filter Type A", "High Pass 48", "steep high-pass"),
            _set(track, "1 Frequency A", 29.0, "kill infrasonic energy the system cannot use"),
            _set(track, "2 Filter Type A", "Low Shelf", "tame the remaining sub"),
            _set(track, "2 Frequency A", 60.0, "shelf at the sub/bass boundary"),
            _set(track, "2 Gain A", round(-abs(d.amount), 1), d.headline),
        ]

    if d.id == "no_sub":
        return [
            {"tool": "create_midi_track", "args": {"index": -1},
             "why": "dedicated sub track β€” layering it on the bass channel means "
                    "the sub inherits the bass processing, which is what killed it"},
            _load("$NEW", "Operator", "single sine partial"),
            _probe("$NEW", "Operator"),
            _set("$NEW", "Oscillator A Coarse", 1, "fundamental only"),
            _load("$NEW", "Utility", "force mono"),
            _set("$NEW", "Width", 0.0, "sub is mono, always"),
        ]

    if d.id == "over_wide":
        return [
            _load(track, "Utility", "pull the image back in"),
            _probe(track, "Utility"),
            _set(track, "Width", 110.0, "down from whatever the widener is doing"),
            _set(track, "Bass Mono", 1, "and keep the low end centred regardless"),
        ]

    if d.id == "narrow":
        return [
            _load(track, "Utility", "apply to reverb returns and pads, NOT bass or kick"),
            _probe(track, "Utility"),
            _set(track, "Width", 130.0, "widen only the elements that can afford it"),
        ]

    if d.id == "flat_drums":
        return [
            _load(track, "Drum Buss", "transient shaping plus drive in one device"),
            _probe(track, "Drum Buss"),
            _set(track, "Transients", 35.0, "bring the attack back"),
            _set(track, "Drive", 15.0, "density without the limiter"),
            _set(track, "Crunch", 10.0, "top-end bite"),
        ]

    if d.id == "sibilance":
        return [
            _load(track, "Multiband Dynamics", "de-ess band"),
            _probe(track, "Multiband Dynamics"),
            _set(track, "High Crossover", 6500.0, "isolate the sibilant band"),
            _set(track, "Above Threshold (High)", -22.0, "catch only the esses"),
            _set(track, "Above Ratio (High)", 4.0, "3 dB of range on peaks"),
        ]

    if d.id == "loud_range":
        return [
            _load(track, "Glue Compressor", "slow master glue"),
            _probe(track, "Glue Compressor"),
            _set(track, "Ratio", 2.0, "gentle"),
            _set(track, "Attack", 30.0, "let transients through"),
            _set(track, "Release", 0.6, "follows sections, not hits"),
            _set(track, "Threshold", -14.0, "aim for 1–2 dB of gain reduction"),
        ]

    return None


# --------------------------------------------------------------------------
# plan assembly
# --------------------------------------------------------------------------

def build_plan(
    diags: list[Diagnosis],
    *,
    track_index: Any = "$MASTER",
    genre: str = "",
    bpm: float = 0.0,
    max_steps: int = 4,
) -> dict:
    """Turn the top diagnoses into an Ableton MCP call sequence."""
    steps = []
    for d in diags:
        if d.severity == "good":
            continue
        calls = _recipe(d, track_index)
        if not calls:
            continue
        steps.append({
            "step": len(steps) + 1,
            "severity": d.severity,
            "intent": d.headline,
            "evidence": d.evidence,
            "expected_result": d.move,
            "calls": calls,
        })
        if len(steps) >= max_steps:
            break

    preflight = [
        {"tool": "get_session_info", "args": {},
         "purpose": "confirm the set is reachable and read the track count"},
        {"tool": "get_track_info", "args": {"track_index": track_index},
         "purpose": "read the existing device chain so steps can be skipped "
                    "when the device is already there"},
    ]
    if bpm > 0:
        preflight.append({
            "tool": "set_tempo", "args": {"tempo": round(bpm)},
            "purpose": f"detected {bpm:.1f} BPM in the incoming audio",
            "confirm_first": True,
        })

    return {
        "target": {"track_index": track_index, "genre": genre},
        "contract": (
            "Execute against a locally connected Ableton MCP server. "
            "$MASTER = the master track index, $LAST = the device index returned "
            "by the preceding load call, $NEW = the track index returned by the "
            "preceding create call. Run every get_device_parameters probe and "
            "match parameter names by substring before calling "
            "set_device_parameter β€” names differ across Live versions."
        ),
        "preflight": preflight,
        "steps": steps,
        "rollback": "Every step is additive. To undo, delete_device on the "
                    "devices added by this plan, newest first.",
    }


def plan_to_markdown(plan: dict) -> str:
    if not plan.get("steps"):
        return "No corrective moves needed β€” nothing measurable is wrong."

    lines = ["**Preflight**", ""]
    for c in plan["preflight"]:
        lines.append(f"- `{c['tool']}({json.dumps(c['args'])})` β€” {c['purpose']}")
    lines.append("")

    for step in plan["steps"]:
        badge = {"critical": "πŸ”΄", "warn": "🟠", "note": "πŸ”΅"}.get(step["severity"], "Β·")
        lines.append(f"**{badge} Step {step['step']} β€” {step['intent']}**")
        lines.append("")
        lines.append(f"> {step['evidence']}")
        lines.append("")
        for c in step["calls"]:
            args = json.dumps(c["args"], ensure_ascii=False)
            reason = c.get("why") or c.get("purpose") or c.get("note") or ""
            lines.append(f"- `{c['tool']}({args})`" + (f" β€” {reason}" if reason else ""))
        lines.append("")
        lines.append(f"*What you should hear:* {step['expected_result']}")
        lines.append("")

    return "\n".join(lines)