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.
- package/CHANGELOG.md +327 -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 +32 -0
- package/mcp_server/branches/types.py +230 -0
- package/mcp_server/composer/__init__.py +10 -1
- package/mcp_server/composer/branch_producer.py +229 -0
- package/mcp_server/evaluation/policy.py +129 -2
- package/mcp_server/experiment/engine.py +47 -11
- package/mcp_server/experiment/models.py +72 -7
- package/mcp_server/experiment/tools.py +231 -35
- package/mcp_server/m4l_bridge.py +488 -13
- package/mcp_server/memory/taste_graph.py +84 -11
- package/mcp_server/persistence/taste_store.py +21 -5
- package/mcp_server/runtime/execution_router.py +7 -0
- package/mcp_server/runtime/mcp_dispatch.py +32 -0
- package/mcp_server/runtime/remote_commands.py +54 -0
- package/mcp_server/runtime/session_kernel.py +46 -0
- package/mcp_server/runtime/tools.py +29 -3
- package/mcp_server/sample_engine/slice_classifier.py +169 -0
- package/mcp_server/server.py +11 -3
- 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 +167 -0
- package/mcp_server/synthesis_brain/adapters/base.py +86 -0
- package/mcp_server/synthesis_brain/adapters/drift.py +166 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +151 -0
- package/mcp_server/synthesis_brain/adapters/operator.py +169 -0
- package/mcp_server/synthesis_brain/adapters/wavetable.py +228 -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/tools/_conductor.py +144 -0
- package/mcp_server/tools/analyzer.py +187 -7
- package/mcp_server/tools/clips.py +65 -0
- package/mcp_server/tools/devices.py +517 -5
- package/mcp_server/tools/diagnostics.py +42 -0
- package/mcp_server/tools/follow_actions.py +202 -0
- package/mcp_server/tools/grooves.py +142 -0
- package/mcp_server/tools/miditool.py +280 -0
- package/mcp_server/tools/scales.py +126 -0
- package/mcp_server/tools/take_lanes.py +135 -0
- package/mcp_server/tools/tracks.py +46 -3
- package/mcp_server/tools/transport.py +62 -1
- 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 +8 -4
- package/remote_script/LivePilot/clips.py +62 -0
- package/remote_script/LivePilot/devices.py +444 -0
- package/remote_script/LivePilot/diagnostics.py +52 -1
- package/remote_script/LivePilot/follow_actions.py +235 -0
- package/remote_script/LivePilot/grooves.py +185 -0
- package/remote_script/LivePilot/scales.py +138 -0
- package/remote_script/LivePilot/take_lanes.py +175 -0
- package/remote_script/LivePilot/tracks.py +59 -1
- package/remote_script/LivePilot/transport.py +90 -1
- package/remote_script/LivePilot/version_detect.py +9 -0
- package/server.json +3 -3
package/mcp_server/server.py
CHANGED
|
@@ -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
|
-
|
|
178
|
-
|
|
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}"
|