livepilot 1.10.9 → 1.13.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 (61) hide show
  1. package/CHANGELOG.md +327 -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 +32 -0
  7. package/mcp_server/branches/types.py +230 -0
  8. package/mcp_server/composer/__init__.py +10 -1
  9. package/mcp_server/composer/branch_producer.py +229 -0
  10. package/mcp_server/evaluation/policy.py +129 -2
  11. package/mcp_server/experiment/engine.py +47 -11
  12. package/mcp_server/experiment/models.py +72 -7
  13. package/mcp_server/experiment/tools.py +231 -35
  14. package/mcp_server/m4l_bridge.py +488 -13
  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/execution_router.py +7 -0
  18. package/mcp_server/runtime/mcp_dispatch.py +32 -0
  19. package/mcp_server/runtime/remote_commands.py +54 -0
  20. package/mcp_server/runtime/session_kernel.py +46 -0
  21. package/mcp_server/runtime/tools.py +29 -3
  22. package/mcp_server/sample_engine/slice_classifier.py +169 -0
  23. package/mcp_server/server.py +11 -3
  24. package/mcp_server/synthesis_brain/__init__.py +53 -0
  25. package/mcp_server/synthesis_brain/adapters/__init__.py +34 -0
  26. package/mcp_server/synthesis_brain/adapters/analog.py +167 -0
  27. package/mcp_server/synthesis_brain/adapters/base.py +86 -0
  28. package/mcp_server/synthesis_brain/adapters/drift.py +166 -0
  29. package/mcp_server/synthesis_brain/adapters/meld.py +151 -0
  30. package/mcp_server/synthesis_brain/adapters/operator.py +169 -0
  31. package/mcp_server/synthesis_brain/adapters/wavetable.py +228 -0
  32. package/mcp_server/synthesis_brain/engine.py +91 -0
  33. package/mcp_server/synthesis_brain/models.py +121 -0
  34. package/mcp_server/synthesis_brain/timbre.py +194 -0
  35. package/mcp_server/tools/_conductor.py +144 -0
  36. package/mcp_server/tools/analyzer.py +187 -7
  37. package/mcp_server/tools/clips.py +65 -0
  38. package/mcp_server/tools/devices.py +517 -5
  39. package/mcp_server/tools/diagnostics.py +42 -0
  40. package/mcp_server/tools/follow_actions.py +202 -0
  41. package/mcp_server/tools/grooves.py +142 -0
  42. package/mcp_server/tools/miditool.py +280 -0
  43. package/mcp_server/tools/scales.py +126 -0
  44. package/mcp_server/tools/take_lanes.py +135 -0
  45. package/mcp_server/tools/tracks.py +46 -3
  46. package/mcp_server/tools/transport.py +62 -1
  47. package/mcp_server/wonder_mode/engine.py +324 -0
  48. package/mcp_server/wonder_mode/tools.py +153 -1
  49. package/package.json +2 -2
  50. package/remote_script/LivePilot/__init__.py +8 -4
  51. package/remote_script/LivePilot/clips.py +62 -0
  52. package/remote_script/LivePilot/devices.py +444 -0
  53. package/remote_script/LivePilot/diagnostics.py +52 -1
  54. package/remote_script/LivePilot/follow_actions.py +235 -0
  55. package/remote_script/LivePilot/grooves.py +185 -0
  56. package/remote_script/LivePilot/scales.py +138 -0
  57. package/remote_script/LivePilot/take_lanes.py +175 -0
  58. package/remote_script/LivePilot/tracks.py +59 -1
  59. package/remote_script/LivePilot/transport.py +90 -1
  60. package/remote_script/LivePilot/version_detect.py +9 -0
  61. package/server.json +3 -3
@@ -9,7 +9,7 @@ import subprocess
9
9
  from fastmcp import FastMCP, Context # noqa: F401
10
10
 
11
11
  from .connection import AbletonConnection
12
- from .m4l_bridge import SpectralCache, SpectralReceiver, M4LBridge
12
+ from .m4l_bridge import SpectralCache, SpectralReceiver, M4LBridge, MidiToolCache
13
13
 
14
14
  # Logger must be defined before any function uses it — several module-level
15
15
  # helpers below (e.g. _master_has_livepilot_analyzer) call logger.debug on
@@ -174,8 +174,9 @@ async def lifespan(server):
174
174
 
175
175
  ableton = AbletonConnection()
176
176
  spectral = SpectralCache()
177
- receiver = SpectralReceiver(spectral)
178
- m4l = M4LBridge(spectral, receiver)
177
+ miditool = MidiToolCache()
178
+ receiver = SpectralReceiver(spectral, miditool_cache=miditool)
179
+ m4l = M4LBridge(spectral, receiver, miditool_cache=miditool)
179
180
  mcp_dispatch = build_mcp_dispatch_registry()
180
181
 
181
182
  # Splice gRPC client — graceful degradation if Splice desktop isn't
@@ -234,6 +235,7 @@ async def lifespan(server):
234
235
  yield {
235
236
  "ableton": ableton,
236
237
  "spectral": spectral,
238
+ "miditool": miditool,
237
239
  "m4l": m4l,
238
240
  "_bridge_state": bridge_state,
239
241
  "mcp_dispatch": mcp_dispatch,
@@ -257,6 +259,10 @@ from .tools import clips # noqa: F401, E402
257
259
  from .tools import notes # noqa: F401, E402
258
260
  from .tools import devices # noqa: F401, E402
259
261
  from .tools import scenes # noqa: F401, E402
262
+ from .tools import scales # noqa: F401, E402
263
+ from .tools import follow_actions # noqa: F401, E402
264
+ from .tools import grooves # noqa: F401, E402
265
+ from .tools import take_lanes # noqa: F401, E402
260
266
  from .tools import mixing # noqa: F401, E402
261
267
  from .tools import browser # noqa: F401, E402
262
268
  from .tools import arrangement # noqa: F401, E402
@@ -299,6 +305,8 @@ from .device_forge import tools as device_forge_tools # noqa: F401, E40
299
305
  from .sample_engine import tools as sample_engine_tools # noqa: F401, E402
300
306
  from .atlas import tools as atlas_tools # noqa: F401, E402
301
307
  from .composer import tools as composer_tools # noqa: F401, E402
308
+ from .tools import diagnostics # noqa: F401, E402
309
+ from .tools import miditool # noqa: F401, E402
302
310
 
303
311
  # ---------------------------------------------------------------------------
304
312
  # Schema coercion patch — accept strings for numeric parameters
@@ -0,0 +1,53 @@
1
+ """Synthesis brain — native-synth-aware branch production.
2
+
3
+ Parallel subsystem to mcp_server.sound_design. Where sound_design reasons
4
+ at the block-type level (oscillator / filter / envelope / ...), the
5
+ synthesis brain reasons at the native-device level with per-adapter
6
+ knowledge of Wavetable / Operator / Analog / Drift / Meld parameter
7
+ spaces, modulation graphs, and articulation profiles.
8
+
9
+ Each adapter implements the SynthAdapter protocol:
10
+ - device_name: the Ableton device name it claims
11
+ - extract_profile(device_parameters): read a SynthProfile from live params
12
+ - propose_branches(profile, target, kernel): emit BranchSeed objects plus
13
+ pre-compiled plans (set_device_parameter / batch_set_parameters steps)
14
+
15
+ PR9 ships Wavetable and Operator adapters. PR10 adds Analog, Drift, Meld
16
+ and render-based timbre extraction on top of ``capture_audio``.
17
+
18
+ No MCP @tool() decorators in this PR — the subsystem is callable from
19
+ Python (Wonder's generate_branch_seeds, composer, etc.). PR12 wires
20
+ dedicated MCP tools and does the tool-count metadata sweep in one pass.
21
+ """
22
+
23
+ from .models import (
24
+ SynthProfile,
25
+ TimbralFingerprint,
26
+ ModulationGraph,
27
+ ArticulationProfile,
28
+ OPAQUE,
29
+ NATIVE,
30
+ )
31
+ from .adapters import get_adapter, SynthAdapter
32
+ from .engine import (
33
+ analyze_synth_patch,
34
+ propose_synth_branches,
35
+ supported_devices,
36
+ )
37
+ from .timbre import extract_timbre_fingerprint, diff_fingerprint
38
+
39
+ __all__ = [
40
+ "SynthProfile",
41
+ "TimbralFingerprint",
42
+ "ModulationGraph",
43
+ "ArticulationProfile",
44
+ "OPAQUE",
45
+ "NATIVE",
46
+ "SynthAdapter",
47
+ "get_adapter",
48
+ "analyze_synth_patch",
49
+ "propose_synth_branches",
50
+ "supported_devices",
51
+ "extract_timbre_fingerprint",
52
+ "diff_fingerprint",
53
+ ]
@@ -0,0 +1,34 @@
1
+ """Synth adapter registry.
2
+
3
+ Each adapter is a Python class implementing the SynthAdapter protocol.
4
+ Registration is explicit — adapters add themselves to ``_REGISTRY`` at
5
+ module import time. ``get_adapter(device_name)`` returns the registered
6
+ adapter or None.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .base import SynthAdapter, _REGISTRY, register_adapter
12
+ from . import wavetable as _wavetable # noqa: F401 — import for registration
13
+ from . import operator as _operator # noqa: F401
14
+ from . import analog as _analog # noqa: F401
15
+ from . import drift as _drift # noqa: F401
16
+ from . import meld as _meld # noqa: F401
17
+
18
+
19
+ def get_adapter(device_name: str) -> SynthAdapter | None:
20
+ """Return the adapter for a given Ableton device name, or None."""
21
+ return _REGISTRY.get(device_name)
22
+
23
+
24
+ def registered_devices() -> list[str]:
25
+ """List device names this package has an adapter for."""
26
+ return sorted(_REGISTRY.keys())
27
+
28
+
29
+ __all__ = [
30
+ "SynthAdapter",
31
+ "get_adapter",
32
+ "register_adapter",
33
+ "registered_devices",
34
+ ]
@@ -0,0 +1,167 @@
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
+ kernel = kernel or {}
108
+ freshness = float(kernel.get("freshness", 0.5) or 0.5)
109
+ track = profile.track_index
110
+ device = profile.device_index
111
+
112
+ # Skip proposal if already plucky — avoids doubling-down on the
113
+ # same treatment.
114
+ if any("Already plucky" in n for n in profile.notes):
115
+ return []
116
+
117
+ current_env = float(profile.parameter_state.get("F1 Env Amount", 0.0) or 0.0)
118
+ # Target filter-env amount scales with freshness.
119
+ new_env = min(1.0, max(current_env, 0.45 if freshness < 0.5 else 0.65))
120
+ current_decay = float(profile.parameter_state.get("F1 Env D", 0.5) or 0.5)
121
+ new_decay = min(current_decay, 0.25 if freshness < 0.5 else 0.15)
122
+
123
+ seed = freeform_seed(
124
+ seed_id=_short_id("an_plk", f"{track}:{device}:{new_env:.2f}:{new_decay:.2f}"),
125
+ hypothesis=(
126
+ f"Analog filter-pluck: Env Amount → {new_env:.2f}, "
127
+ f"Decay → {new_decay:.2f} for attack character"
128
+ ),
129
+ source="synthesis",
130
+ novelty_label="strong" if freshness < 0.7 else "unexpected",
131
+ risk_label="low",
132
+ affected_scope={
133
+ "track_indices": [track],
134
+ "device_paths": [f"track/{track}/device/{device}"],
135
+ },
136
+ distinctness_reason="only Analog seed that couples Filter Env + Decay",
137
+ )
138
+ plan = {
139
+ "steps": [
140
+ {
141
+ "tool": "set_device_parameter",
142
+ "params": {
143
+ "track_index": track,
144
+ "device_index": device,
145
+ "parameter_name": "F1 Env Amount",
146
+ "value": round(new_env, 3),
147
+ },
148
+ },
149
+ {
150
+ "tool": "set_device_parameter",
151
+ "params": {
152
+ "track_index": track,
153
+ "device_index": device,
154
+ "parameter_name": "F1 Env D",
155
+ "value": round(new_decay, 3),
156
+ },
157
+ },
158
+ ],
159
+ "step_count": 2,
160
+ "summary": f"F1 Env Amount → {new_env:.2f}, F1 Env D → {new_decay:.2f}",
161
+ }
162
+ return [(seed, plan)]
163
+
164
+
165
+ def _short_id(prefix: str, key: str) -> str:
166
+ h = hashlib.sha256(f"{prefix}:{key}".encode()).hexdigest()[:10]
167
+ 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,166 @@
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
+ freshness = float(kernel.get("freshness", 0.5) or 0.5)
109
+ track = profile.track_index
110
+ device = profile.device_index
111
+
112
+ current_char = float(profile.parameter_state.get("Character", 0.0) or 0.0)
113
+ current_sub = float(profile.parameter_state.get("Sub Level", 0.0) or 0.0)
114
+
115
+ # Toggle character in the opposite direction to create contrast.
116
+ new_char = min(1.0, max(current_char + 0.3, 0.3)) if current_char <= 0.5 else max(0.0, current_char - 0.3)
117
+ # Move sub slightly toward 0.3 if we're starting outside 0.2-0.4
118
+ target_sub = 0.3
119
+ new_sub = current_sub + (target_sub - current_sub) * (0.5 if freshness < 0.5 else 0.8)
120
+ new_sub = round(max(0.0, min(1.0, new_sub)), 3)
121
+
122
+ seed = freeform_seed(
123
+ seed_id=_short_id("dr_chr", f"{track}:{device}:{new_char:.2f}:{new_sub:.2f}"),
124
+ hypothesis=(
125
+ f"Drift character blend: Character → {new_char:.2f}, "
126
+ f"Sub Level → {new_sub:.2f} for a different core tone"
127
+ ),
128
+ source="synthesis",
129
+ novelty_label="strong",
130
+ risk_label="low",
131
+ affected_scope={
132
+ "track_indices": [track],
133
+ "device_paths": [f"track/{track}/device/{device}"],
134
+ },
135
+ distinctness_reason="only Drift seed that shifts Character + Sub balance",
136
+ )
137
+ plan = {
138
+ "steps": [
139
+ {
140
+ "tool": "set_device_parameter",
141
+ "params": {
142
+ "track_index": track,
143
+ "device_index": device,
144
+ "parameter_name": "Character",
145
+ "value": round(new_char, 3),
146
+ },
147
+ },
148
+ {
149
+ "tool": "set_device_parameter",
150
+ "params": {
151
+ "track_index": track,
152
+ "device_index": device,
153
+ "parameter_name": "Sub Level",
154
+ "value": new_sub,
155
+ },
156
+ },
157
+ ],
158
+ "step_count": 2,
159
+ "summary": f"Character → {new_char:.2f}, Sub Level → {new_sub:.2f}",
160
+ }
161
+ return [(seed, plan)]
162
+
163
+
164
+ def _short_id(prefix: str, key: str) -> str:
165
+ h = hashlib.sha256(f"{prefix}:{key}".encode()).hexdigest()[:10]
166
+ return f"{prefix}_{h}"