livepilot 1.12.2 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +219 -0
- package/README.md +7 -7
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/branches/__init__.py +34 -0
- package/mcp_server/branches/types.py +286 -0
- package/mcp_server/composer/__init__.py +10 -1
- package/mcp_server/composer/branch_producer.py +349 -0
- package/mcp_server/composer/tools.py +58 -1
- package/mcp_server/evaluation/policy.py +227 -2
- package/mcp_server/experiment/engine.py +47 -11
- package/mcp_server/experiment/models.py +112 -8
- package/mcp_server/experiment/tools.py +502 -38
- package/mcp_server/memory/taste_graph.py +84 -11
- package/mcp_server/persistence/taste_store.py +21 -5
- package/mcp_server/runtime/session_kernel.py +46 -0
- package/mcp_server/runtime/tools.py +29 -3
- package/mcp_server/server.py +1 -0
- package/mcp_server/synthesis_brain/__init__.py +53 -0
- package/mcp_server/synthesis_brain/adapters/__init__.py +34 -0
- package/mcp_server/synthesis_brain/adapters/analog.py +273 -0
- package/mcp_server/synthesis_brain/adapters/base.py +86 -0
- package/mcp_server/synthesis_brain/adapters/drift.py +271 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +261 -0
- package/mcp_server/synthesis_brain/adapters/operator.py +292 -0
- package/mcp_server/synthesis_brain/adapters/wavetable.py +364 -0
- package/mcp_server/synthesis_brain/engine.py +91 -0
- package/mcp_server/synthesis_brain/models.py +121 -0
- package/mcp_server/synthesis_brain/timbre.py +194 -0
- package/mcp_server/synthesis_brain/tools.py +231 -0
- package/mcp_server/tools/_conductor.py +144 -0
- package/mcp_server/wonder_mode/engine.py +324 -0
- package/mcp_server/wonder_mode/tools.py +153 -1
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/server.json +3 -3
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Meld adapter — Ableton 12's newest FM/granular hybrid.
|
|
2
|
+
|
|
3
|
+
Meld pairs two "Engines" with per-engine algorithms and a shared
|
|
4
|
+
modulation / amp / filter section. PR10 ships one canned proposer:
|
|
5
|
+
engine_algo_swap — changes Engine 1's algorithm to produce a
|
|
6
|
+
materially different core timbre without disturbing the envelope
|
|
7
|
+
or filter. Later PRs add engine-blend, unison, and modulation-matrix
|
|
8
|
+
variants.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from ...branches import BranchSeed, freeform_seed
|
|
17
|
+
from ..models import (
|
|
18
|
+
SynthProfile,
|
|
19
|
+
TimbralFingerprint,
|
|
20
|
+
ModulationGraph,
|
|
21
|
+
ArticulationProfile,
|
|
22
|
+
NATIVE,
|
|
23
|
+
)
|
|
24
|
+
from .base import register_adapter
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_KNOWN_PARAMS = {
|
|
28
|
+
"Engine 1 Algorithm",
|
|
29
|
+
"Engine 2 Algorithm",
|
|
30
|
+
"Engine 1 Level",
|
|
31
|
+
"Engine 2 Level",
|
|
32
|
+
"Engine 1 Morph",
|
|
33
|
+
"Engine 2 Morph",
|
|
34
|
+
"Filter Freq",
|
|
35
|
+
"Filter Res",
|
|
36
|
+
"Amp A",
|
|
37
|
+
"Amp D",
|
|
38
|
+
"Amp S",
|
|
39
|
+
"Amp R",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@register_adapter
|
|
44
|
+
class MeldAdapter:
|
|
45
|
+
device_name: str = "Meld"
|
|
46
|
+
|
|
47
|
+
def extract_profile(
|
|
48
|
+
self,
|
|
49
|
+
track_index: int,
|
|
50
|
+
device_index: int,
|
|
51
|
+
parameter_state: dict,
|
|
52
|
+
display_values: Optional[dict] = None,
|
|
53
|
+
role_hint: str = "",
|
|
54
|
+
) -> SynthProfile:
|
|
55
|
+
notes: list[str] = []
|
|
56
|
+
|
|
57
|
+
e1_algo = parameter_state.get("Engine 1 Algorithm")
|
|
58
|
+
e2_algo = parameter_state.get("Engine 2 Algorithm")
|
|
59
|
+
if e1_algo is not None and e2_algo is not None and e1_algo == e2_algo:
|
|
60
|
+
notes.append(
|
|
61
|
+
"Both Engines on same algorithm — consider differentiating for depth"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
articulation = ArticulationProfile(
|
|
65
|
+
attack_ms=float(parameter_state.get("Amp A", 0.0) or 0.0),
|
|
66
|
+
release_ms=float(parameter_state.get("Amp R", 0.0) or 0.0),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
mod = ModulationGraph()
|
|
70
|
+
# Meld has many internal mod routes; PR10 just records engine levels
|
|
71
|
+
# as rough "sources" so downstream can see the mix balance.
|
|
72
|
+
e1_level = parameter_state.get("Engine 1 Level", 0.0)
|
|
73
|
+
e2_level = parameter_state.get("Engine 2 Level", 0.0)
|
|
74
|
+
if e1_level and e1_level > 0:
|
|
75
|
+
mod.routes.append({"source": "Engine 1", "target": "output", "amount": e1_level})
|
|
76
|
+
if e2_level and e2_level > 0:
|
|
77
|
+
mod.routes.append({"source": "Engine 2", "target": "output", "amount": e2_level})
|
|
78
|
+
|
|
79
|
+
focused_state = {k: v for k, v in parameter_state.items() if k in _KNOWN_PARAMS}
|
|
80
|
+
focused_display = (
|
|
81
|
+
{k: v for k, v in (display_values or {}).items() if k in _KNOWN_PARAMS}
|
|
82
|
+
if display_values else {}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return SynthProfile(
|
|
86
|
+
device_name=self.device_name,
|
|
87
|
+
opacity=NATIVE,
|
|
88
|
+
track_index=track_index,
|
|
89
|
+
device_index=device_index,
|
|
90
|
+
parameter_state=focused_state,
|
|
91
|
+
display_values=focused_display,
|
|
92
|
+
role_hint=role_hint,
|
|
93
|
+
modulation=mod,
|
|
94
|
+
articulation=articulation,
|
|
95
|
+
notes=notes,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def propose_branches(
|
|
99
|
+
self,
|
|
100
|
+
profile: SynthProfile,
|
|
101
|
+
target: TimbralFingerprint,
|
|
102
|
+
kernel: Optional[dict] = None,
|
|
103
|
+
) -> list[tuple[BranchSeed, dict]]:
|
|
104
|
+
kernel = kernel or {}
|
|
105
|
+
results: list[tuple[BranchSeed, dict]] = []
|
|
106
|
+
for strategy_fn in (_strategy_engine_algo_swap, _strategy_engine_mix_shift):
|
|
107
|
+
try:
|
|
108
|
+
maybe = strategy_fn(profile, target, kernel, adapter=self)
|
|
109
|
+
except Exception:
|
|
110
|
+
continue
|
|
111
|
+
if maybe is not None:
|
|
112
|
+
results.append(maybe)
|
|
113
|
+
return results
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── Strategy registry ────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _strategy_engine_algo_swap(
|
|
120
|
+
profile: SynthProfile,
|
|
121
|
+
target: TimbralFingerprint,
|
|
122
|
+
kernel: dict,
|
|
123
|
+
adapter,
|
|
124
|
+
) -> Optional[tuple[BranchSeed, dict]]:
|
|
125
|
+
"""Shift Engine 1 Algorithm by +1 (low freshness) or +3 (high).
|
|
126
|
+
|
|
127
|
+
Always applicable — algorithm swaps are guaranteed to change tone
|
|
128
|
+
regardless of current state.
|
|
129
|
+
"""
|
|
130
|
+
freshness = float(kernel.get("freshness", 0.5) or 0.5)
|
|
131
|
+
track = profile.track_index
|
|
132
|
+
device = profile.device_index
|
|
133
|
+
current_algo = int(profile.parameter_state.get("Engine 1 Algorithm", 0) or 0)
|
|
134
|
+
shift = 1 if freshness < 0.5 else 3
|
|
135
|
+
new_algo = (current_algo + shift) % 10
|
|
136
|
+
|
|
137
|
+
seed = freeform_seed(
|
|
138
|
+
seed_id=_short_id("ml_algo", f"{track}:{device}:{new_algo}"),
|
|
139
|
+
hypothesis=(
|
|
140
|
+
f"Meld Engine 1 algorithm swap: {current_algo} → {new_algo} "
|
|
141
|
+
f"for a materially different core timbre"
|
|
142
|
+
),
|
|
143
|
+
source="synthesis",
|
|
144
|
+
novelty_label="unexpected" if shift == 3 else "strong",
|
|
145
|
+
risk_label="medium",
|
|
146
|
+
affected_scope={
|
|
147
|
+
"track_indices": [track],
|
|
148
|
+
"device_paths": [f"track/{track}/device/{device}"],
|
|
149
|
+
},
|
|
150
|
+
distinctness_reason="changes Engine 1 algorithm",
|
|
151
|
+
producer_payload={
|
|
152
|
+
"device_name": adapter.device_name,
|
|
153
|
+
"track_index": track,
|
|
154
|
+
"device_index": device,
|
|
155
|
+
"strategy": "engine_algo_swap",
|
|
156
|
+
"topology_hint": {
|
|
157
|
+
"role_hint": profile.role_hint,
|
|
158
|
+
"current_algo": current_algo,
|
|
159
|
+
"new_algo": new_algo,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
plan = {
|
|
164
|
+
"steps": [
|
|
165
|
+
{"tool": "set_device_parameter",
|
|
166
|
+
"params": {"track_index": track, "device_index": device,
|
|
167
|
+
"parameter_name": "Engine 1 Algorithm",
|
|
168
|
+
"value": new_algo}},
|
|
169
|
+
],
|
|
170
|
+
"step_count": 1,
|
|
171
|
+
"summary": f"Engine 1 Algorithm {current_algo} → {new_algo}",
|
|
172
|
+
}
|
|
173
|
+
return (seed, plan)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _strategy_engine_mix_shift(
|
|
177
|
+
profile: SynthProfile,
|
|
178
|
+
target: TimbralFingerprint,
|
|
179
|
+
kernel: dict,
|
|
180
|
+
adapter,
|
|
181
|
+
) -> Optional[tuple[BranchSeed, dict]]:
|
|
182
|
+
"""Rebalance Engine 1 / Engine 2 Level for layered character.
|
|
183
|
+
|
|
184
|
+
Gates: applicable when BOTH engines have non-zero Level (mix makes
|
|
185
|
+
no sense if one engine is silent). Shifts the balance by 0.15-0.3
|
|
186
|
+
depending on freshness.
|
|
187
|
+
"""
|
|
188
|
+
e1 = float(profile.parameter_state.get("Engine 1 Level", 0.0) or 0.0)
|
|
189
|
+
e2 = float(profile.parameter_state.get("Engine 2 Level", 0.0) or 0.0)
|
|
190
|
+
if e1 < 0.05 or e2 < 0.05:
|
|
191
|
+
return None # one engine silent — mix shift is meaningless
|
|
192
|
+
|
|
193
|
+
freshness = float(kernel.get("freshness", 0.5) or 0.5)
|
|
194
|
+
track = profile.track_index
|
|
195
|
+
device = profile.device_index
|
|
196
|
+
|
|
197
|
+
# Push toward the engine with LESS level currently — highlights the
|
|
198
|
+
# underused engine's character. When roughly equal, pick Engine 2.
|
|
199
|
+
if e1 < e2:
|
|
200
|
+
direction = "to_e1"
|
|
201
|
+
delta = 0.15 if freshness < 0.5 else 0.3
|
|
202
|
+
new_e1 = min(1.0, e1 + delta)
|
|
203
|
+
new_e2 = max(0.0, e2 - delta / 2)
|
|
204
|
+
else:
|
|
205
|
+
direction = "to_e2"
|
|
206
|
+
delta = 0.15 if freshness < 0.5 else 0.3
|
|
207
|
+
new_e2 = min(1.0, e2 + delta)
|
|
208
|
+
new_e1 = max(0.0, e1 - delta / 2)
|
|
209
|
+
|
|
210
|
+
seed = freeform_seed(
|
|
211
|
+
seed_id=_short_id(
|
|
212
|
+
"ml_mix", f"{track}:{device}:{direction}:{new_e1:.2f}:{new_e2:.2f}"
|
|
213
|
+
),
|
|
214
|
+
hypothesis=(
|
|
215
|
+
f"Meld engine mix shift {direction}: E1 {e1:.2f} → {new_e1:.2f}, "
|
|
216
|
+
f"E2 {e2:.2f} → {new_e2:.2f}"
|
|
217
|
+
),
|
|
218
|
+
source="synthesis",
|
|
219
|
+
novelty_label="strong",
|
|
220
|
+
risk_label="low",
|
|
221
|
+
affected_scope={
|
|
222
|
+
"track_indices": [track],
|
|
223
|
+
"device_paths": [f"track/{track}/device/{device}"],
|
|
224
|
+
},
|
|
225
|
+
distinctness_reason=(
|
|
226
|
+
f"Meld mix rebalance {direction}; algorithm unchanged"
|
|
227
|
+
),
|
|
228
|
+
producer_payload={
|
|
229
|
+
"device_name": adapter.device_name,
|
|
230
|
+
"track_index": track,
|
|
231
|
+
"device_index": device,
|
|
232
|
+
"strategy": f"engine_mix_shift_{direction}",
|
|
233
|
+
"topology_hint": {
|
|
234
|
+
"role_hint": profile.role_hint,
|
|
235
|
+
"current_e1": e1,
|
|
236
|
+
"current_e2": e2,
|
|
237
|
+
"new_e1": round(new_e1, 3),
|
|
238
|
+
"new_e2": round(new_e2, 3),
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
plan = {
|
|
243
|
+
"steps": [
|
|
244
|
+
{"tool": "set_device_parameter",
|
|
245
|
+
"params": {"track_index": track, "device_index": device,
|
|
246
|
+
"parameter_name": "Engine 1 Level",
|
|
247
|
+
"value": round(new_e1, 3)}},
|
|
248
|
+
{"tool": "set_device_parameter",
|
|
249
|
+
"params": {"track_index": track, "device_index": device,
|
|
250
|
+
"parameter_name": "Engine 2 Level",
|
|
251
|
+
"value": round(new_e2, 3)}},
|
|
252
|
+
],
|
|
253
|
+
"step_count": 2,
|
|
254
|
+
"summary": f"E1 {e1:.2f}→{new_e1:.2f}, E2 {e2:.2f}→{new_e2:.2f}",
|
|
255
|
+
}
|
|
256
|
+
return (seed, plan)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _short_id(prefix: str, key: str) -> str:
|
|
260
|
+
h = hashlib.sha256(f"{prefix}:{key}".encode()).hexdigest()[:10]
|
|
261
|
+
return f"{prefix}_{h}"
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Operator adapter — native-synth-aware branch production for Ableton's Operator.
|
|
2
|
+
|
|
3
|
+
FM synthesis is defined by operator ratios + algorithm topology + per-op
|
|
4
|
+
envelopes. PR9 always targeted Oscillator B because modulator role was
|
|
5
|
+
unknown; PR2/v2 decodes Algorithm → which oscillators are actually
|
|
6
|
+
carriers vs modulators, and targets the modulator with the highest Level
|
|
7
|
+
so the ratio shift produces a real timbral change rather than changing
|
|
8
|
+
an inaudible operator.
|
|
9
|
+
|
|
10
|
+
Strategy: ratio_shift_<operator>. Each seed's producer_payload captures:
|
|
11
|
+
{schema_version, device_name, track_index, device_index,
|
|
12
|
+
strategy: "ratio_shift_<op>",
|
|
13
|
+
topology_hint: {algorithm, carriers, modulators, targeted_op,
|
|
14
|
+
current_coarse, new_coarse}}
|
|
15
|
+
|
|
16
|
+
so later render-verification can confirm the shift actually altered the
|
|
17
|
+
modulated carrier's spectrum.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from ...branches import BranchSeed, freeform_seed
|
|
26
|
+
from ..models import (
|
|
27
|
+
SynthProfile,
|
|
28
|
+
TimbralFingerprint,
|
|
29
|
+
ModulationGraph,
|
|
30
|
+
ArticulationProfile,
|
|
31
|
+
NATIVE,
|
|
32
|
+
)
|
|
33
|
+
from .base import register_adapter
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_KNOWN_PARAMS = {
|
|
37
|
+
"Algorithm",
|
|
38
|
+
"Oscillator A Coarse",
|
|
39
|
+
"Oscillator B Coarse",
|
|
40
|
+
"Oscillator C Coarse",
|
|
41
|
+
"Oscillator D Coarse",
|
|
42
|
+
"Oscillator A Fine",
|
|
43
|
+
"Oscillator B Fine",
|
|
44
|
+
"Oscillator A Level",
|
|
45
|
+
"Oscillator B Level",
|
|
46
|
+
"Oscillator C Level",
|
|
47
|
+
"Oscillator D Level",
|
|
48
|
+
"Oscillator A Attack",
|
|
49
|
+
"Oscillator A Release",
|
|
50
|
+
"Filter Frequency",
|
|
51
|
+
"Filter Resonance",
|
|
52
|
+
"Time", # global envelope time
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Static topology table for Ableton Operator's 11 algorithms. Each entry
|
|
57
|
+
# names the ops that act as carriers (audible) and modulators (FM sources).
|
|
58
|
+
# Source: Ableton Operator manual, DX7-compatible topologies. The exact
|
|
59
|
+
# modulation routing within an algorithm (who modulates whom) matters less
|
|
60
|
+
# for adapter targeting than the carrier/modulator role — what we need to
|
|
61
|
+
# know is "which op's Coarse, when shifted, produces an audible timbral
|
|
62
|
+
# change?" Answer: any op acting as a modulator.
|
|
63
|
+
#
|
|
64
|
+
# Algorithm numbering follows Ableton's 0-based display order.
|
|
65
|
+
_ALGO_TOPOLOGY: dict[int, dict] = {
|
|
66
|
+
0: {"carriers": ["D"], "modulators": ["A", "B", "C"]},
|
|
67
|
+
1: {"carriers": ["B", "D"], "modulators": ["A", "C"]},
|
|
68
|
+
2: {"carriers": ["B", "C", "D"], "modulators": ["A"]},
|
|
69
|
+
3: {"carriers": ["D"], "modulators": ["A", "B", "C"]},
|
|
70
|
+
4: {"carriers": ["C", "D"], "modulators": ["A", "B"]},
|
|
71
|
+
5: {"carriers": ["A", "B", "C", "D"], "modulators": []},
|
|
72
|
+
6: {"carriers": ["B", "D"], "modulators": ["A", "C"]},
|
|
73
|
+
7: {"carriers": ["D"], "modulators": ["A", "B", "C"]},
|
|
74
|
+
8: {"carriers": ["B", "C", "D"], "modulators": ["A"]},
|
|
75
|
+
9: {"carriers": ["A", "B", "C", "D"], "modulators": []},
|
|
76
|
+
10: {"carriers": ["B", "C", "D"], "modulators": ["A"]},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _topology_for_algorithm(algorithm: int) -> dict:
|
|
81
|
+
"""Look up carrier/modulator roles for an Operator algorithm index."""
|
|
82
|
+
# Default to algorithm 0 (classic serial chain) if unknown.
|
|
83
|
+
return _ALGO_TOPOLOGY.get(int(algorithm or 0), _ALGO_TOPOLOGY[0])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _pick_target_modulator(
|
|
87
|
+
topology: dict,
|
|
88
|
+
parameter_state: dict,
|
|
89
|
+
) -> Optional[str]:
|
|
90
|
+
"""Pick the modulator with the highest Level — the best shift target.
|
|
91
|
+
|
|
92
|
+
Returns the operator letter ("A".."D") or None when the algorithm has
|
|
93
|
+
no modulators (purely additive algos 5 and 9). Level-based selection
|
|
94
|
+
ensures the Coarse shift produces an audible change; shifting an
|
|
95
|
+
op whose Level is 0 is a no-op.
|
|
96
|
+
"""
|
|
97
|
+
candidates = []
|
|
98
|
+
for op in topology.get("modulators", []):
|
|
99
|
+
level_key = f"Oscillator {op} Level"
|
|
100
|
+
level = float(parameter_state.get(level_key, 0.0) or 0.0)
|
|
101
|
+
candidates.append((level, op))
|
|
102
|
+
if not candidates:
|
|
103
|
+
return None
|
|
104
|
+
candidates.sort(reverse=True) # highest level first
|
|
105
|
+
top_level, top_op = candidates[0]
|
|
106
|
+
# If every modulator is silent, still return the first one — the shift
|
|
107
|
+
# primes the patch for a future Level bump. Better than no branch.
|
|
108
|
+
return top_op
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _fallback_carrier_target(
|
|
112
|
+
topology: dict,
|
|
113
|
+
parameter_state: dict,
|
|
114
|
+
) -> Optional[str]:
|
|
115
|
+
"""Fallback when algorithm has no modulators (additive algos).
|
|
116
|
+
|
|
117
|
+
Picks the carrier with the highest Level; shifting its Coarse changes
|
|
118
|
+
the fundamental spectrum rather than FM depth.
|
|
119
|
+
"""
|
|
120
|
+
candidates = []
|
|
121
|
+
for op in topology.get("carriers", []):
|
|
122
|
+
level_key = f"Oscillator {op} Level"
|
|
123
|
+
level = float(parameter_state.get(level_key, 0.0) or 0.0)
|
|
124
|
+
candidates.append((level, op))
|
|
125
|
+
if not candidates:
|
|
126
|
+
return None
|
|
127
|
+
candidates.sort(reverse=True)
|
|
128
|
+
return candidates[0][1]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@register_adapter
|
|
132
|
+
class OperatorAdapter:
|
|
133
|
+
"""Adapter for Ableton's native Operator."""
|
|
134
|
+
|
|
135
|
+
device_name: str = "Operator"
|
|
136
|
+
|
|
137
|
+
def extract_profile(
|
|
138
|
+
self,
|
|
139
|
+
track_index: int,
|
|
140
|
+
device_index: int,
|
|
141
|
+
parameter_state: dict,
|
|
142
|
+
display_values: Optional[dict] = None,
|
|
143
|
+
role_hint: str = "",
|
|
144
|
+
) -> SynthProfile:
|
|
145
|
+
notes: list[str] = []
|
|
146
|
+
|
|
147
|
+
algo = parameter_state.get("Algorithm", 0)
|
|
148
|
+
if algo is not None:
|
|
149
|
+
notes.append(f"Algorithm={algo} — topology governs which ops are carriers vs modulators")
|
|
150
|
+
|
|
151
|
+
# Crude modulator-detection: any oscillator with Coarse > 1 and Level > 0
|
|
152
|
+
# is acting as a modulator. Precise detection needs algorithm decoding,
|
|
153
|
+
# which lands in PR10.
|
|
154
|
+
mod_routes = []
|
|
155
|
+
for op in ("A", "B", "C", "D"):
|
|
156
|
+
coarse = parameter_state.get(f"Oscillator {op} Coarse", 1)
|
|
157
|
+
level = parameter_state.get(f"Oscillator {op} Level", 0)
|
|
158
|
+
if coarse and coarse > 1 and level and level > 0:
|
|
159
|
+
mod_routes.append({
|
|
160
|
+
"source": f"Oscillator {op}",
|
|
161
|
+
"target": "(per algorithm)",
|
|
162
|
+
"amount": level,
|
|
163
|
+
"range": None,
|
|
164
|
+
"coarse": coarse,
|
|
165
|
+
})
|
|
166
|
+
mod = ModulationGraph(routes=mod_routes)
|
|
167
|
+
|
|
168
|
+
articulation = ArticulationProfile(
|
|
169
|
+
attack_ms=float(parameter_state.get("Oscillator A Attack", 0.0) or 0.0),
|
|
170
|
+
release_ms=float(parameter_state.get("Oscillator A Release", 0.0) or 0.0),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
focused_state = {k: v for k, v in parameter_state.items() if k in _KNOWN_PARAMS}
|
|
174
|
+
focused_display = (
|
|
175
|
+
{k: v for k, v in (display_values or {}).items() if k in _KNOWN_PARAMS}
|
|
176
|
+
if display_values
|
|
177
|
+
else {}
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return SynthProfile(
|
|
181
|
+
device_name=self.device_name,
|
|
182
|
+
opacity=NATIVE,
|
|
183
|
+
track_index=track_index,
|
|
184
|
+
device_index=device_index,
|
|
185
|
+
parameter_state=focused_state,
|
|
186
|
+
display_values=focused_display,
|
|
187
|
+
role_hint=role_hint,
|
|
188
|
+
modulation=mod,
|
|
189
|
+
articulation=articulation,
|
|
190
|
+
notes=notes,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def propose_branches(
|
|
194
|
+
self,
|
|
195
|
+
profile: SynthProfile,
|
|
196
|
+
target: TimbralFingerprint,
|
|
197
|
+
kernel: Optional[dict] = None,
|
|
198
|
+
) -> list[tuple[BranchSeed, dict]]:
|
|
199
|
+
kernel = kernel or {}
|
|
200
|
+
freshness = float(kernel.get("freshness", 0.5) or 0.5)
|
|
201
|
+
track = profile.track_index
|
|
202
|
+
device = profile.device_index
|
|
203
|
+
|
|
204
|
+
results: list[tuple[BranchSeed, dict]] = []
|
|
205
|
+
|
|
206
|
+
# ── Branch A: algorithm-aware ratio_shift ────────────────────
|
|
207
|
+
# Decode the current Algorithm, pick the real modulator (highest
|
|
208
|
+
# Level among the algorithm's modulator ops), and shift its
|
|
209
|
+
# Coarse. When the algorithm is purely additive (no modulators),
|
|
210
|
+
# fall back to shifting the dominant carrier — changes the
|
|
211
|
+
# fundamental spectrum instead of FM depth.
|
|
212
|
+
algorithm = int(profile.parameter_state.get("Algorithm", 0) or 0)
|
|
213
|
+
topology = _topology_for_algorithm(algorithm)
|
|
214
|
+
targeted_op = _pick_target_modulator(topology, profile.parameter_state)
|
|
215
|
+
target_role = "modulator"
|
|
216
|
+
|
|
217
|
+
if targeted_op is None:
|
|
218
|
+
targeted_op = _fallback_carrier_target(topology, profile.parameter_state)
|
|
219
|
+
target_role = "carrier"
|
|
220
|
+
if targeted_op is None:
|
|
221
|
+
# No usable operators — skip the branch rather than emit
|
|
222
|
+
# something that can't produce a timbral change.
|
|
223
|
+
return results
|
|
224
|
+
|
|
225
|
+
coarse_key = f"Oscillator {targeted_op} Coarse"
|
|
226
|
+
current_coarse = int(profile.parameter_state.get(coarse_key, 1) or 1)
|
|
227
|
+
step = 1 if freshness < 0.5 else 2
|
|
228
|
+
new_coarse = min(24, current_coarse + step)
|
|
229
|
+
if new_coarse == current_coarse:
|
|
230
|
+
new_coarse = max(1, current_coarse - step)
|
|
231
|
+
|
|
232
|
+
strategy = f"ratio_shift_{targeted_op}"
|
|
233
|
+
topology_hint = {
|
|
234
|
+
"algorithm": algorithm,
|
|
235
|
+
"carriers": list(topology.get("carriers", [])),
|
|
236
|
+
"modulators": list(topology.get("modulators", [])),
|
|
237
|
+
"targeted_op": targeted_op,
|
|
238
|
+
"target_role": target_role,
|
|
239
|
+
"current_coarse": current_coarse,
|
|
240
|
+
"new_coarse": new_coarse,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
seed_a = freeform_seed(
|
|
244
|
+
seed_id=_short_id(
|
|
245
|
+
"op_ratio", f"{track}:{device}:{algorithm}:{targeted_op}:{new_coarse}"
|
|
246
|
+
),
|
|
247
|
+
hypothesis=(
|
|
248
|
+
f"Shift Operator Osc {targeted_op} ({target_role}) Coarse "
|
|
249
|
+
f"{current_coarse} → {new_coarse} under algorithm {algorithm} "
|
|
250
|
+
f"for a {'subtle' if step == 1 else 'significant'} FM tone change"
|
|
251
|
+
),
|
|
252
|
+
source="synthesis",
|
|
253
|
+
novelty_label="strong" if step == 1 else "unexpected",
|
|
254
|
+
risk_label="medium",
|
|
255
|
+
affected_scope={
|
|
256
|
+
"track_indices": [track],
|
|
257
|
+
"device_paths": [f"track/{track}/device/{device}"],
|
|
258
|
+
},
|
|
259
|
+
distinctness_reason=(
|
|
260
|
+
f"algorithm-{algorithm} aware shift on {target_role} Osc {targeted_op}"
|
|
261
|
+
),
|
|
262
|
+
producer_payload={
|
|
263
|
+
"device_name": self.device_name,
|
|
264
|
+
"track_index": track,
|
|
265
|
+
"device_index": device,
|
|
266
|
+
"strategy": strategy,
|
|
267
|
+
"topology_hint": topology_hint,
|
|
268
|
+
},
|
|
269
|
+
)
|
|
270
|
+
plan_a = {
|
|
271
|
+
"steps": [
|
|
272
|
+
{
|
|
273
|
+
"tool": "set_device_parameter",
|
|
274
|
+
"params": {
|
|
275
|
+
"track_index": track,
|
|
276
|
+
"device_index": device,
|
|
277
|
+
"parameter_name": coarse_key,
|
|
278
|
+
"value": new_coarse,
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
"step_count": 1,
|
|
283
|
+
"summary": f"Osc {targeted_op} Coarse {current_coarse} → {new_coarse} (algo {algorithm})",
|
|
284
|
+
}
|
|
285
|
+
results.append((seed_a, plan_a))
|
|
286
|
+
|
|
287
|
+
return results
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _short_id(prefix: str, key: str) -> str:
|
|
291
|
+
h = hashlib.sha256(f"{prefix}:{key}".encode()).hexdigest()[:10]
|
|
292
|
+
return f"{prefix}_{h}"
|