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.
Files changed (37) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/README.md +7 -7
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/branches/__init__.py +34 -0
  7. package/mcp_server/branches/types.py +286 -0
  8. package/mcp_server/composer/__init__.py +10 -1
  9. package/mcp_server/composer/branch_producer.py +349 -0
  10. package/mcp_server/composer/tools.py +58 -1
  11. package/mcp_server/evaluation/policy.py +227 -2
  12. package/mcp_server/experiment/engine.py +47 -11
  13. package/mcp_server/experiment/models.py +112 -8
  14. package/mcp_server/experiment/tools.py +502 -38
  15. package/mcp_server/memory/taste_graph.py +84 -11
  16. package/mcp_server/persistence/taste_store.py +21 -5
  17. package/mcp_server/runtime/session_kernel.py +46 -0
  18. package/mcp_server/runtime/tools.py +29 -3
  19. package/mcp_server/server.py +1 -0
  20. package/mcp_server/synthesis_brain/__init__.py +53 -0
  21. package/mcp_server/synthesis_brain/adapters/__init__.py +34 -0
  22. package/mcp_server/synthesis_brain/adapters/analog.py +273 -0
  23. package/mcp_server/synthesis_brain/adapters/base.py +86 -0
  24. package/mcp_server/synthesis_brain/adapters/drift.py +271 -0
  25. package/mcp_server/synthesis_brain/adapters/meld.py +261 -0
  26. package/mcp_server/synthesis_brain/adapters/operator.py +292 -0
  27. package/mcp_server/synthesis_brain/adapters/wavetable.py +364 -0
  28. package/mcp_server/synthesis_brain/engine.py +91 -0
  29. package/mcp_server/synthesis_brain/models.py +121 -0
  30. package/mcp_server/synthesis_brain/timbre.py +194 -0
  31. package/mcp_server/synthesis_brain/tools.py +231 -0
  32. package/mcp_server/tools/_conductor.py +144 -0
  33. package/mcp_server/wonder_mode/engine.py +324 -0
  34. package/mcp_server/wonder_mode/tools.py +153 -1
  35. package/package.json +2 -2
  36. package/remote_script/LivePilot/__init__.py +1 -1
  37. package/server.json +3 -3
@@ -0,0 +1,273 @@
1
+ """Analog adapter — Ableton's classic two-oscillator subtractive synth.
2
+
3
+ PR10 ships one canned proposer: filter_envelope_variant — pushes Filter
4
+ Envelope Amount while shortening the Filter Decay, producing the
5
+ characteristic "plucked" attack that Analog excels at. Later PRs add
6
+ detune/unison variants and dual-filter variants.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ from typing import Optional
13
+
14
+ from ...branches import BranchSeed, freeform_seed
15
+ from ..models import (
16
+ SynthProfile,
17
+ TimbralFingerprint,
18
+ ModulationGraph,
19
+ ArticulationProfile,
20
+ NATIVE,
21
+ )
22
+ from .base import register_adapter
23
+
24
+
25
+ _KNOWN_PARAMS = {
26
+ "Osc1 Shape",
27
+ "Osc2 Shape",
28
+ "Osc1 Tune",
29
+ "Osc2 Tune",
30
+ "F1 Freq",
31
+ "F1 Reso",
32
+ "F1 Env Amount",
33
+ "F1 Env A",
34
+ "F1 Env D",
35
+ "F1 Env S",
36
+ "F1 Env R",
37
+ "A1 Attack",
38
+ "A1 Decay",
39
+ "A1 Sustain",
40
+ "A1 Release",
41
+ "Glide Mode",
42
+ "Glide Time",
43
+ }
44
+
45
+
46
+ @register_adapter
47
+ class AnalogAdapter:
48
+ device_name: str = "Analog"
49
+
50
+ def extract_profile(
51
+ self,
52
+ track_index: int,
53
+ device_index: int,
54
+ parameter_state: dict,
55
+ display_values: Optional[dict] = None,
56
+ role_hint: str = "",
57
+ ) -> SynthProfile:
58
+ notes: list[str] = []
59
+
60
+ # Filter-env coupling summary
61
+ env_amount = parameter_state.get("F1 Env Amount", 0.0)
62
+ env_decay = parameter_state.get("F1 Env D", 0.0)
63
+ if env_amount and abs(env_amount) > 0.3 and env_decay and env_decay < 0.3:
64
+ notes.append(
65
+ f"Already plucky: F1 Env Amount={env_amount:.2f}, Decay={env_decay:.2f}"
66
+ )
67
+
68
+ articulation = ArticulationProfile(
69
+ attack_ms=float(parameter_state.get("A1 Attack", 0.0) or 0.0),
70
+ release_ms=float(parameter_state.get("A1 Release", 0.0) or 0.0),
71
+ )
72
+
73
+ mod = ModulationGraph()
74
+ if env_amount and abs(env_amount) > 0.01:
75
+ mod.routes.append({
76
+ "source": "Filter Env",
77
+ "target": "F1 Freq",
78
+ "amount": env_amount,
79
+ "range": None,
80
+ })
81
+
82
+ focused_state = {k: v for k, v in parameter_state.items() if k in _KNOWN_PARAMS}
83
+ focused_display = (
84
+ {k: v for k, v in (display_values or {}).items() if k in _KNOWN_PARAMS}
85
+ if display_values else {}
86
+ )
87
+
88
+ return SynthProfile(
89
+ device_name=self.device_name,
90
+ opacity=NATIVE,
91
+ track_index=track_index,
92
+ device_index=device_index,
93
+ parameter_state=focused_state,
94
+ display_values=focused_display,
95
+ role_hint=role_hint,
96
+ modulation=mod,
97
+ articulation=articulation,
98
+ notes=notes,
99
+ )
100
+
101
+ def propose_branches(
102
+ self,
103
+ profile: SynthProfile,
104
+ target: TimbralFingerprint,
105
+ kernel: Optional[dict] = None,
106
+ ) -> list[tuple[BranchSeed, dict]]:
107
+ # Strategy registry — each candidate strategy's ``applicable()``
108
+ # gates on profile+role+target, and ``build()`` emits (seed, plan).
109
+ # Adapter returns ALL applicable strategies' proposals so Wonder /
110
+ # create_experiment can offer them as branches. Callers cap total
111
+ # via max_seeds.
112
+ kernel = kernel or {}
113
+ results: list[tuple[BranchSeed, dict]] = []
114
+ for strategy_fn in (_strategy_filter_pluck, _strategy_detune_warmth):
115
+ try:
116
+ maybe = strategy_fn(profile, target, kernel, adapter=self)
117
+ except Exception:
118
+ # Never let one strategy's crash kill the rest.
119
+ continue
120
+ if maybe is not None:
121
+ results.append(maybe)
122
+ return results
123
+
124
+
125
+ # ── Strategy registry ────────────────────────────────────────────────
126
+ #
127
+ # Each strategy is a pure function (profile, target, kernel, adapter) →
128
+ # Optional[(BranchSeed, plan_dict)]. A strategy returns None when not
129
+ # applicable to the current profile+target+role combination. This lets
130
+ # the adapter stay thin while the intelligence lives in the strategies.
131
+
132
+
133
+ def _strategy_filter_pluck(
134
+ profile: SynthProfile,
135
+ target: TimbralFingerprint,
136
+ kernel: dict,
137
+ adapter,
138
+ ) -> Optional[tuple[BranchSeed, dict]]:
139
+ """Couple Filter Env Amount up + Filter Decay down → attack pluck.
140
+
141
+ Gates: skip when profile already flags 'Already plucky'. Most useful
142
+ when role_hint is "bass", "pluck", "lead", or when target.bite > 0.
143
+ """
144
+ if any("Already plucky" in n for n in profile.notes):
145
+ return None
146
+
147
+ role = (profile.role_hint or "").lower()
148
+ want_bite = target.bite > 0.1 or role in {"bass", "pluck", "lead", "stab"}
149
+ if not want_bite and role in {"pad", "drone"}:
150
+ # Sustained roles actively fight this strategy.
151
+ return None
152
+
153
+ freshness = float(kernel.get("freshness", 0.5) or 0.5)
154
+ track = profile.track_index
155
+ device = profile.device_index
156
+ current_env = float(profile.parameter_state.get("F1 Env Amount", 0.0) or 0.0)
157
+ new_env = min(1.0, max(current_env, 0.45 if freshness < 0.5 else 0.65))
158
+ current_decay = float(profile.parameter_state.get("F1 Env D", 0.5) or 0.5)
159
+ new_decay = min(current_decay, 0.25 if freshness < 0.5 else 0.15)
160
+
161
+ seed = freeform_seed(
162
+ seed_id=_short_id("an_plk", f"{track}:{device}:{new_env:.2f}:{new_decay:.2f}"),
163
+ hypothesis=(
164
+ f"Analog filter-pluck: Env Amount → {new_env:.2f}, "
165
+ f"Decay → {new_decay:.2f} for attack character"
166
+ ),
167
+ source="synthesis",
168
+ novelty_label="strong" if freshness < 0.7 else "unexpected",
169
+ risk_label="low",
170
+ affected_scope={
171
+ "track_indices": [track],
172
+ "device_paths": [f"track/{track}/device/{device}"],
173
+ },
174
+ distinctness_reason="couples Filter Env Amount + Decay for attack character",
175
+ producer_payload={
176
+ "device_name": adapter.device_name,
177
+ "track_index": track,
178
+ "device_index": device,
179
+ "strategy": "filter_pluck",
180
+ "topology_hint": {
181
+ "role_hint": profile.role_hint,
182
+ "current_env": current_env,
183
+ "new_env": new_env,
184
+ "current_decay": current_decay,
185
+ "new_decay": new_decay,
186
+ },
187
+ },
188
+ )
189
+ plan = {
190
+ "steps": [
191
+ {"tool": "set_device_parameter",
192
+ "params": {"track_index": track, "device_index": device,
193
+ "parameter_name": "F1 Env Amount",
194
+ "value": round(new_env, 3)}},
195
+ {"tool": "set_device_parameter",
196
+ "params": {"track_index": track, "device_index": device,
197
+ "parameter_name": "F1 Env D",
198
+ "value": round(new_decay, 3)}},
199
+ ],
200
+ "step_count": 2,
201
+ "summary": f"F1 Env Amount → {new_env:.2f}, F1 Env D → {new_decay:.2f}",
202
+ }
203
+ return (seed, plan)
204
+
205
+
206
+ def _strategy_detune_warmth(
207
+ profile: SynthProfile,
208
+ target: TimbralFingerprint,
209
+ kernel: dict,
210
+ adapter,
211
+ ) -> Optional[tuple[BranchSeed, dict]]:
212
+ """Detune Osc2 slightly + lean warmer tone.
213
+
214
+ Gates: applicable when role_hint is "pad" / "lead" / "stab" / "drone"
215
+ or target.warmth > 0. Skip on "pluck"/"bass" to avoid woofiness.
216
+ """
217
+ role = (profile.role_hint or "").lower()
218
+ if role in {"bass", "pluck", "kick"}:
219
+ return None
220
+ want_warm = target.warmth > 0.1 or role in {"pad", "lead", "stab", "drone"}
221
+ if not want_warm:
222
+ return None
223
+
224
+ freshness = float(kernel.get("freshness", 0.5) or 0.5)
225
+ track = profile.track_index
226
+ device = profile.device_index
227
+ current_detune = float(profile.parameter_state.get("Osc2 Tune", 0.0) or 0.0)
228
+ # Detune is in semitones by convention on Analog; keep shifts musical.
229
+ step = 0.04 if freshness < 0.5 else 0.09
230
+ new_detune = round(current_detune + step, 3)
231
+
232
+ seed = freeform_seed(
233
+ seed_id=_short_id("an_det", f"{track}:{device}:{new_detune:.3f}"),
234
+ hypothesis=(
235
+ f"Analog detune warmth: Osc2 Tune {current_detune:.3f} → "
236
+ f"{new_detune:.3f} semitones for a wider, lusher body"
237
+ ),
238
+ source="synthesis",
239
+ novelty_label="safe",
240
+ risk_label="low",
241
+ affected_scope={
242
+ "track_indices": [track],
243
+ "device_paths": [f"track/{track}/device/{device}"],
244
+ },
245
+ distinctness_reason="slight Osc2 detune for body, no filter changes",
246
+ producer_payload={
247
+ "device_name": adapter.device_name,
248
+ "track_index": track,
249
+ "device_index": device,
250
+ "strategy": "detune_warmth",
251
+ "topology_hint": {
252
+ "role_hint": profile.role_hint,
253
+ "current_detune": current_detune,
254
+ "new_detune": new_detune,
255
+ },
256
+ },
257
+ )
258
+ plan = {
259
+ "steps": [
260
+ {"tool": "set_device_parameter",
261
+ "params": {"track_index": track, "device_index": device,
262
+ "parameter_name": "Osc2 Tune",
263
+ "value": new_detune}},
264
+ ],
265
+ "step_count": 1,
266
+ "summary": f"Osc2 Tune → {new_detune:.3f}",
267
+ }
268
+ return (seed, plan)
269
+
270
+
271
+ def _short_id(prefix: str, key: str) -> str:
272
+ h = hashlib.sha256(f"{prefix}:{key}".encode()).hexdigest()[:10]
273
+ return f"{prefix}_{h}"
@@ -0,0 +1,86 @@
1
+ """SynthAdapter base protocol + registry infrastructure.
2
+
3
+ Adapters are plain classes that expose:
4
+ - device_name (class attribute)
5
+ - extract_profile(track_index, device_index, parameter_state, display_values,
6
+ role_hint) -> SynthProfile
7
+ - propose_branches(profile, target, kernel) -> list[tuple[BranchSeed, dict]]
8
+ (each tuple: (seed, compiled_plan_dict))
9
+
10
+ Adapters register themselves via ``register_adapter(cls)`` as a class
11
+ decorator at module-import time, so the registry is populated when
12
+ the adapters package is imported.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Protocol, runtime_checkable, Optional
18
+
19
+ from ...branches import BranchSeed
20
+ from ..models import SynthProfile, TimbralFingerprint
21
+
22
+
23
+ # Adapter registry — populated by register_adapter at import time.
24
+ _REGISTRY: dict[str, "SynthAdapter"] = {}
25
+
26
+
27
+ @runtime_checkable
28
+ class SynthAdapter(Protocol):
29
+ """Contract every native-synth adapter must satisfy."""
30
+
31
+ device_name: str
32
+
33
+ def extract_profile(
34
+ self,
35
+ track_index: int,
36
+ device_index: int,
37
+ parameter_state: dict,
38
+ display_values: Optional[dict] = None,
39
+ role_hint: str = "",
40
+ ) -> SynthProfile:
41
+ """Build a SynthProfile from raw device parameter state.
42
+
43
+ Must be pure — no I/O. The caller has already fetched parameters
44
+ via get_device_parameters / get_display_values and hands them here.
45
+ """
46
+ ...
47
+
48
+ def propose_branches(
49
+ self,
50
+ profile: SynthProfile,
51
+ target: TimbralFingerprint,
52
+ kernel: Optional[dict] = None,
53
+ ) -> list[tuple[BranchSeed, dict]]:
54
+ """Emit seed + pre-compiled plan pairs.
55
+
56
+ Each pair: (BranchSeed with source="synthesis", compiled_plan dict
57
+ ready for execution_router.execute_plan_steps_async).
58
+
59
+ The kernel dict may carry freshness / creativity_profile / synth_hints
60
+ (see SessionKernel PR2 additions); adapters read these to bias
61
+ proposals. Pre-PR10, adapters ship canned proposers; later PRs
62
+ extend with render-based verification.
63
+ """
64
+ ...
65
+
66
+
67
+ def register_adapter(cls):
68
+ """Class decorator — register an adapter under its device_name.
69
+
70
+ Raises ValueError if the class lacks device_name or duplicates an
71
+ existing registration.
72
+ """
73
+ device_name = getattr(cls, "device_name", None)
74
+ if not device_name:
75
+ raise ValueError(
76
+ f"Adapter {cls.__name__} must define a class attribute "
77
+ f"'device_name' to register"
78
+ )
79
+ if device_name in _REGISTRY:
80
+ raise ValueError(
81
+ f"Duplicate synth adapter registration for device '{device_name}' "
82
+ f"(existing: {type(_REGISTRY[device_name]).__name__}, "
83
+ f"new: {cls.__name__})"
84
+ )
85
+ _REGISTRY[device_name] = cls()
86
+ return cls
@@ -0,0 +1,271 @@
1
+ """Drift adapter — Ableton 12's modern subtractive synth.
2
+
3
+ Drift has a cleaner parameter set than Analog and pairs oscillator
4
+ shapes with a character wave + sub + noise blend. PR10 ships one
5
+ canned proposer: character_blend — shifts the oscillator wave + sub
6
+ balance to change core tone without touching the filter. Later PRs
7
+ add tuning-table variants and LFO-routing variants.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ from typing import Optional
14
+
15
+ from ...branches import BranchSeed, freeform_seed
16
+ from ..models import (
17
+ SynthProfile,
18
+ TimbralFingerprint,
19
+ ModulationGraph,
20
+ ArticulationProfile,
21
+ NATIVE,
22
+ )
23
+ from .base import register_adapter
24
+
25
+
26
+ _KNOWN_PARAMS = {
27
+ "Wave",
28
+ "Character",
29
+ "Tune",
30
+ "Sub Level",
31
+ "Sub Tone",
32
+ "Noise Level",
33
+ "Noise Color",
34
+ "Filter Freq",
35
+ "Filter Res",
36
+ "Filter Env",
37
+ "LFO Rate",
38
+ "LFO Amount",
39
+ "Amp Env A",
40
+ "Amp Env D",
41
+ "Amp Env S",
42
+ "Amp Env R",
43
+ }
44
+
45
+
46
+ @register_adapter
47
+ class DriftAdapter:
48
+ device_name: str = "Drift"
49
+
50
+ def extract_profile(
51
+ self,
52
+ track_index: int,
53
+ device_index: int,
54
+ parameter_state: dict,
55
+ display_values: Optional[dict] = None,
56
+ role_hint: str = "",
57
+ ) -> SynthProfile:
58
+ notes: list[str] = []
59
+
60
+ sub = float(parameter_state.get("Sub Level", 0.0) or 0.0)
61
+ noise = float(parameter_state.get("Noise Level", 0.0) or 0.0)
62
+ if sub > 0.5 and role_hint in ("lead", "stab"):
63
+ notes.append(f"Sub Level {sub:.2f} is high for a {role_hint} — check bass clash")
64
+ if noise > 0.5:
65
+ notes.append(f"Noise Level {noise:.2f} — significant noise content")
66
+
67
+ articulation = ArticulationProfile(
68
+ attack_ms=float(parameter_state.get("Amp Env A", 0.0) or 0.0),
69
+ release_ms=float(parameter_state.get("Amp Env R", 0.0) or 0.0),
70
+ )
71
+
72
+ mod = ModulationGraph()
73
+ lfo_amount = parameter_state.get("LFO Amount", 0.0)
74
+ if lfo_amount and abs(lfo_amount) > 0.01:
75
+ mod.routes.append({
76
+ "source": "LFO",
77
+ "target": "(inferred)",
78
+ "amount": lfo_amount,
79
+ "range": None,
80
+ })
81
+
82
+ focused_state = {k: v for k, v in parameter_state.items() if k in _KNOWN_PARAMS}
83
+ focused_display = (
84
+ {k: v for k, v in (display_values or {}).items() if k in _KNOWN_PARAMS}
85
+ if display_values else {}
86
+ )
87
+
88
+ return SynthProfile(
89
+ device_name=self.device_name,
90
+ opacity=NATIVE,
91
+ track_index=track_index,
92
+ device_index=device_index,
93
+ parameter_state=focused_state,
94
+ display_values=focused_display,
95
+ role_hint=role_hint,
96
+ modulation=mod,
97
+ articulation=articulation,
98
+ notes=notes,
99
+ )
100
+
101
+ def propose_branches(
102
+ self,
103
+ profile: SynthProfile,
104
+ target: TimbralFingerprint,
105
+ kernel: Optional[dict] = None,
106
+ ) -> list[tuple[BranchSeed, dict]]:
107
+ kernel = kernel or {}
108
+ results: list[tuple[BranchSeed, dict]] = []
109
+ for strategy_fn in (_strategy_character_blend, _strategy_filter_sweep):
110
+ try:
111
+ maybe = strategy_fn(profile, target, kernel, adapter=self)
112
+ except Exception:
113
+ continue
114
+ if maybe is not None:
115
+ results.append(maybe)
116
+ return results
117
+
118
+
119
+ # ── Strategy registry ────────────────────────────────────────────────
120
+
121
+
122
+ def _strategy_character_blend(
123
+ profile: SynthProfile,
124
+ target: TimbralFingerprint,
125
+ kernel: dict,
126
+ adapter,
127
+ ) -> Optional[tuple[BranchSeed, dict]]:
128
+ """Shift Character + Sub balance. Always applicable."""
129
+ freshness = float(kernel.get("freshness", 0.5) or 0.5)
130
+ track = profile.track_index
131
+ device = profile.device_index
132
+ current_char = float(profile.parameter_state.get("Character", 0.0) or 0.0)
133
+ current_sub = float(profile.parameter_state.get("Sub Level", 0.0) or 0.0)
134
+
135
+ new_char = (
136
+ min(1.0, max(current_char + 0.3, 0.3))
137
+ if current_char <= 0.5
138
+ else max(0.0, current_char - 0.3)
139
+ )
140
+ target_sub = 0.3
141
+ new_sub = current_sub + (target_sub - current_sub) * (
142
+ 0.5 if freshness < 0.5 else 0.8
143
+ )
144
+ new_sub = round(max(0.0, min(1.0, new_sub)), 3)
145
+
146
+ seed = freeform_seed(
147
+ seed_id=_short_id("dr_chr", f"{track}:{device}:{new_char:.2f}:{new_sub:.2f}"),
148
+ hypothesis=(
149
+ f"Drift character blend: Character → {new_char:.2f}, "
150
+ f"Sub Level → {new_sub:.2f} for a different core tone"
151
+ ),
152
+ source="synthesis",
153
+ novelty_label="strong",
154
+ risk_label="low",
155
+ affected_scope={
156
+ "track_indices": [track],
157
+ "device_paths": [f"track/{track}/device/{device}"],
158
+ },
159
+ distinctness_reason="shifts Character + Sub balance",
160
+ producer_payload={
161
+ "device_name": adapter.device_name,
162
+ "track_index": track,
163
+ "device_index": device,
164
+ "strategy": "character_blend",
165
+ "topology_hint": {
166
+ "role_hint": profile.role_hint,
167
+ "current_char": current_char,
168
+ "new_char": round(new_char, 3),
169
+ "current_sub": current_sub,
170
+ "new_sub": new_sub,
171
+ },
172
+ },
173
+ )
174
+ plan = {
175
+ "steps": [
176
+ {"tool": "set_device_parameter",
177
+ "params": {"track_index": track, "device_index": device,
178
+ "parameter_name": "Character",
179
+ "value": round(new_char, 3)}},
180
+ {"tool": "set_device_parameter",
181
+ "params": {"track_index": track, "device_index": device,
182
+ "parameter_name": "Sub Level",
183
+ "value": new_sub}},
184
+ ],
185
+ "step_count": 2,
186
+ "summary": f"Character → {new_char:.2f}, Sub Level → {new_sub:.2f}",
187
+ }
188
+ return (seed, plan)
189
+
190
+
191
+ def _strategy_filter_sweep(
192
+ profile: SynthProfile,
193
+ target: TimbralFingerprint,
194
+ kernel: dict,
195
+ adapter,
196
+ ) -> Optional[tuple[BranchSeed, dict]]:
197
+ """Sweep Filter Freq toward target brightness.
198
+
199
+ Gates: applicable when target.brightness != 0 OR role_hint suggests
200
+ motion ("lead", "pad", "drone"). Skip when role is "bass" (sub roles
201
+ want a stable low-pass, not a sweep).
202
+ """
203
+ role = (profile.role_hint or "").lower()
204
+ if role in {"bass", "sub", "kick"}:
205
+ return None
206
+ want_motion = abs(target.brightness) > 0.1 or role in {"lead", "pad", "drone"}
207
+ if not want_motion:
208
+ return None
209
+
210
+ freshness = float(kernel.get("freshness", 0.5) or 0.5)
211
+ track = profile.track_index
212
+ device = profile.device_index
213
+ # Drift's filter freq is normalized 0-1 in the API; display is Hz.
214
+ current_freq = float(profile.parameter_state.get("Filter Freq", 0.5) or 0.5)
215
+ if target.brightness > 0:
216
+ # Open filter toward bright.
217
+ new_freq = min(1.0, current_freq + (0.15 if freshness < 0.5 else 0.3))
218
+ direction = "open"
219
+ else:
220
+ # Close toward warm.
221
+ new_freq = max(0.0, current_freq - (0.12 if freshness < 0.5 else 0.25))
222
+ direction = "close"
223
+
224
+ if abs(new_freq - current_freq) < 0.03:
225
+ return None # barely any change — skip
226
+
227
+ seed = freeform_seed(
228
+ seed_id=_short_id(
229
+ "dr_flt", f"{track}:{device}:{direction}:{new_freq:.2f}"
230
+ ),
231
+ hypothesis=(
232
+ f"Drift filter sweep: Filter Freq {current_freq:.2f} → "
233
+ f"{new_freq:.2f} ({direction}) for a {direction}d voice"
234
+ ),
235
+ source="synthesis",
236
+ novelty_label="strong" if freshness < 0.7 else "unexpected",
237
+ risk_label="low",
238
+ affected_scope={
239
+ "track_indices": [track],
240
+ "device_paths": [f"track/{track}/device/{device}"],
241
+ },
242
+ distinctness_reason=f"filter {direction} without touching core oscillator",
243
+ producer_payload={
244
+ "device_name": adapter.device_name,
245
+ "track_index": track,
246
+ "device_index": device,
247
+ "strategy": f"filter_sweep_{direction}",
248
+ "topology_hint": {
249
+ "role_hint": profile.role_hint,
250
+ "target_brightness": target.brightness,
251
+ "current_freq": current_freq,
252
+ "new_freq": round(new_freq, 3),
253
+ },
254
+ },
255
+ )
256
+ plan = {
257
+ "steps": [
258
+ {"tool": "set_device_parameter",
259
+ "params": {"track_index": track, "device_index": device,
260
+ "parameter_name": "Filter Freq",
261
+ "value": round(new_freq, 3)}},
262
+ ],
263
+ "step_count": 1,
264
+ "summary": f"Filter Freq {current_freq:.2f} → {new_freq:.2f}",
265
+ }
266
+ return (seed, plan)
267
+
268
+
269
+ def _short_id(prefix: str, key: str) -> str:
270
+ h = hashlib.sha256(f"{prefix}:{key}".encode()).hexdigest()[:10]
271
+ return f"{prefix}_{h}"