livepilot 1.9.13 → 1.9.15

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 (105) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +51 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +7 -7
  6. package/bin/livepilot.js +32 -8
  7. package/installer/install.js +21 -2
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
  11. package/livepilot/skills/livepilot-core/SKILL.md +81 -6
  12. package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
  13. package/livepilot/skills/livepilot-core/references/overview.md +3 -3
  14. package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
  15. package/livepilot/skills/livepilot-release/SKILL.md +13 -13
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +6 -3
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/curves.py +11 -3
  20. package/mcp_server/evaluation/__init__.py +1 -0
  21. package/mcp_server/evaluation/fabric.py +575 -0
  22. package/mcp_server/evaluation/feature_extractors.py +84 -0
  23. package/mcp_server/evaluation/policy.py +67 -0
  24. package/mcp_server/evaluation/tools.py +53 -0
  25. package/mcp_server/memory/__init__.py +11 -2
  26. package/mcp_server/memory/anti_memory.py +78 -0
  27. package/mcp_server/memory/promotion.py +94 -0
  28. package/mcp_server/memory/session_memory.py +108 -0
  29. package/mcp_server/memory/taste_memory.py +158 -0
  30. package/mcp_server/memory/technique_store.py +2 -1
  31. package/mcp_server/memory/tools.py +112 -0
  32. package/mcp_server/mix_engine/__init__.py +1 -0
  33. package/mcp_server/mix_engine/critics.py +299 -0
  34. package/mcp_server/mix_engine/models.py +152 -0
  35. package/mcp_server/mix_engine/planner.py +103 -0
  36. package/mcp_server/mix_engine/state_builder.py +316 -0
  37. package/mcp_server/mix_engine/tools.py +214 -0
  38. package/mcp_server/performance_engine/__init__.py +1 -0
  39. package/mcp_server/performance_engine/models.py +148 -0
  40. package/mcp_server/performance_engine/planner.py +267 -0
  41. package/mcp_server/performance_engine/safety.py +162 -0
  42. package/mcp_server/performance_engine/tools.py +183 -0
  43. package/mcp_server/project_brain/__init__.py +6 -0
  44. package/mcp_server/project_brain/arrangement_graph.py +64 -0
  45. package/mcp_server/project_brain/automation_graph.py +72 -0
  46. package/mcp_server/project_brain/builder.py +123 -0
  47. package/mcp_server/project_brain/capability_graph.py +64 -0
  48. package/mcp_server/project_brain/models.py +282 -0
  49. package/mcp_server/project_brain/refresh.py +80 -0
  50. package/mcp_server/project_brain/role_graph.py +103 -0
  51. package/mcp_server/project_brain/session_graph.py +51 -0
  52. package/mcp_server/project_brain/tools.py +144 -0
  53. package/mcp_server/reference_engine/__init__.py +1 -0
  54. package/mcp_server/reference_engine/gap_analyzer.py +239 -0
  55. package/mcp_server/reference_engine/models.py +105 -0
  56. package/mcp_server/reference_engine/profile_builder.py +149 -0
  57. package/mcp_server/reference_engine/tactic_router.py +117 -0
  58. package/mcp_server/reference_engine/tools.py +235 -0
  59. package/mcp_server/runtime/__init__.py +1 -0
  60. package/mcp_server/runtime/action_ledger.py +117 -0
  61. package/mcp_server/runtime/action_ledger_models.py +84 -0
  62. package/mcp_server/runtime/action_tools.py +57 -0
  63. package/mcp_server/runtime/capability_state.py +218 -0
  64. package/mcp_server/runtime/safety_kernel.py +339 -0
  65. package/mcp_server/runtime/safety_tools.py +42 -0
  66. package/mcp_server/runtime/tools.py +64 -0
  67. package/mcp_server/server.py +23 -1
  68. package/mcp_server/sound_design/__init__.py +1 -0
  69. package/mcp_server/sound_design/critics.py +297 -0
  70. package/mcp_server/sound_design/models.py +147 -0
  71. package/mcp_server/sound_design/planner.py +104 -0
  72. package/mcp_server/sound_design/tools.py +297 -0
  73. package/mcp_server/tools/_agent_os_engine.py +947 -0
  74. package/mcp_server/tools/_composition_engine.py +1530 -0
  75. package/mcp_server/tools/_conductor.py +199 -0
  76. package/mcp_server/tools/_conductor_budgets.py +222 -0
  77. package/mcp_server/tools/_evaluation_contracts.py +91 -0
  78. package/mcp_server/tools/_form_engine.py +416 -0
  79. package/mcp_server/tools/_motif_engine.py +351 -0
  80. package/mcp_server/tools/_planner_engine.py +516 -0
  81. package/mcp_server/tools/_research_engine.py +542 -0
  82. package/mcp_server/tools/_research_provider.py +185 -0
  83. package/mcp_server/tools/_snapshot_normalizer.py +49 -0
  84. package/mcp_server/tools/agent_os.py +440 -0
  85. package/mcp_server/tools/analyzer.py +18 -0
  86. package/mcp_server/tools/automation.py +25 -10
  87. package/mcp_server/tools/composition.py +563 -0
  88. package/mcp_server/tools/motif.py +104 -0
  89. package/mcp_server/tools/planner.py +144 -0
  90. package/mcp_server/tools/research.py +223 -0
  91. package/mcp_server/tools/tracks.py +18 -3
  92. package/mcp_server/tools/transport.py +10 -2
  93. package/mcp_server/transition_engine/__init__.py +6 -0
  94. package/mcp_server/transition_engine/archetypes.py +167 -0
  95. package/mcp_server/transition_engine/critics.py +340 -0
  96. package/mcp_server/transition_engine/models.py +90 -0
  97. package/mcp_server/transition_engine/tools.py +291 -0
  98. package/mcp_server/translation_engine/__init__.py +5 -0
  99. package/mcp_server/translation_engine/critics.py +297 -0
  100. package/mcp_server/translation_engine/models.py +27 -0
  101. package/mcp_server/translation_engine/tools.py +74 -0
  102. package/package.json +2 -2
  103. package/remote_script/LivePilot/__init__.py +1 -1
  104. package/remote_script/LivePilot/arrangement.py +12 -2
  105. package/requirements.txt +1 -1
@@ -0,0 +1,42 @@
1
+ """MCP tool wrapper for the Safety Kernel.
2
+
3
+ Tools:
4
+ check_safety — validate a proposed action before executing
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ from fastmcp import Context
12
+
13
+ from ..server import mcp
14
+ from .safety_kernel import check_action_safety
15
+
16
+
17
+ @mcp.tool()
18
+ def check_safety(ctx: Context, action: str, scope: str = "{}") -> dict:
19
+ """Validate a proposed action against safety policies before executing.
20
+
21
+ Parameters
22
+ ----------
23
+ action : str
24
+ The tool / command name to check (e.g. "delete_track").
25
+ scope : str
26
+ JSON string describing what the action will affect.
27
+ Recognised keys: ``track_count`` (int).
28
+ Defaults to ``"{}"``.
29
+
30
+ Returns
31
+ -------
32
+ dict
33
+ SafetyCheck with keys: action, allowed, risk_level, reason,
34
+ requires_confirmation.
35
+ """
36
+ try:
37
+ scope_dict = json.loads(scope) if isinstance(scope, str) else scope
38
+ except (json.JSONDecodeError, TypeError):
39
+ scope_dict = {}
40
+
41
+ result = check_action_safety(action, scope=scope_dict)
42
+ return result.to_dict()
@@ -0,0 +1,64 @@
1
+ """MCP tool wrappers for runtime capability state.
2
+
3
+ Tools:
4
+ get_capability_state — probe session + analyzer + memory, return snapshot
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from fastmcp import Context
10
+
11
+ from ..server import mcp
12
+ from .capability_state import build_capability_state
13
+
14
+
15
+ @mcp.tool()
16
+ def get_capability_state(ctx: Context) -> dict:
17
+ """Probe the runtime and return a capability state snapshot.
18
+
19
+ Checks session connectivity, analyzer freshness, memory availability,
20
+ and reports what modes the system can operate in right now.
21
+ """
22
+ ableton = ctx.lifespan_context["ableton"]
23
+ spectral = ctx.lifespan_context.get("spectral")
24
+
25
+ # ── Probe session ───────────────────────────────────────────────
26
+ session_ok = False
27
+ try:
28
+ result = ableton.send_command("get_session_info")
29
+ session_ok = isinstance(result, dict) and "error" not in result
30
+ except Exception:
31
+ session_ok = False
32
+
33
+ # ── Probe analyzer (M4L bridge) ─────────────────────────────────
34
+ analyzer_ok = False
35
+ analyzer_fresh = False
36
+ if spectral is not None:
37
+ analyzer_ok = spectral.is_connected
38
+ if analyzer_ok:
39
+ # Check if we have recent spectrum data
40
+ snap = spectral.get("spectrum")
41
+ analyzer_fresh = snap is not None
42
+
43
+ # ── Probe memory ────────────────────────────────────────────────
44
+ memory_ok = False
45
+ try:
46
+ mem_result = ableton.send_command("memory_list", {"type": "technique"})
47
+ memory_ok = isinstance(mem_result, dict) and "error" not in mem_result
48
+ except Exception:
49
+ memory_ok = False
50
+
51
+ # ── Web / FluCoMa — not probed live, default to False ───────────
52
+ web_ok = False
53
+ flucoma_ok = False
54
+
55
+ state = build_capability_state(
56
+ session_ok=session_ok,
57
+ analyzer_ok=analyzer_ok,
58
+ analyzer_fresh=analyzer_fresh,
59
+ memory_ok=memory_ok,
60
+ web_ok=web_ok,
61
+ flucoma_ok=flucoma_ok,
62
+ )
63
+
64
+ return state.to_dict()
@@ -103,6 +103,23 @@ from .tools import generative # noqa: F401, E402
103
103
  from .tools import harmony # noqa: F401, E402
104
104
  from .tools import midi_io # noqa: F401, E402
105
105
  from .tools import perception # noqa: F401, E402
106
+ from .tools import agent_os # noqa: F401, E402
107
+ from .tools import composition # noqa: F401, E402
108
+ from .tools import motif # noqa: F401, E402
109
+ from .tools import research # noqa: F401, E402
110
+ from .tools import planner # noqa: F401, E402
111
+ from .project_brain import tools as project_brain_tools # noqa: F401, E402
112
+ from .runtime import tools as runtime_tools # noqa: F401, E402
113
+ from .runtime import action_tools as action_ledger_tools # noqa: F401, E402
114
+ from .evaluation import tools as evaluation_tools # noqa: F401, E402
115
+ from .memory import tools as memory_fabric_tools # noqa: F401, E402
116
+ from .mix_engine import tools as mix_engine_tools # noqa: F401, E402
117
+ from .sound_design import tools as sound_design_tools # noqa: F401, E402
118
+ from .transition_engine import tools as transition_tools # noqa: F401, E402
119
+ from .reference_engine import tools as reference_tools # noqa: F401, E402
120
+ from .translation_engine import tools as translation_tools # noqa: F401, E402
121
+ from .performance_engine import tools as performance_tools # noqa: F401, E402
122
+ from .runtime import safety_tools # noqa: F401, E402
106
123
 
107
124
 
108
125
  # ---------------------------------------------------------------------------
@@ -136,7 +153,12 @@ def _coerce_schema_property(prop: dict) -> None:
136
153
 
137
154
 
138
155
  def _get_all_tools():
139
- """Get all registered tools, compatible with FastMCP 0.x and 3.x."""
156
+ """Get all registered tools, compatible with FastMCP 0.x and 3.x.
157
+
158
+ WARNING: Accesses FastMCP private internals (_tool_manager, _local_provider).
159
+ Pinned to fastmcp>=3.0.0,<3.3.0 in requirements.txt. If upgrading FastMCP,
160
+ verify these attributes still exist or update this function.
161
+ """
140
162
  # FastMCP 0.x: mcp._tool_manager._tools (dict of name -> Tool)
141
163
  if hasattr(mcp, "_tool_manager"):
142
164
  return list(mcp._tool_manager._tools.values())
@@ -0,0 +1 @@
1
+ """Sound Design Engine V1 — timbral intelligence for LivePilot."""
@@ -0,0 +1,297 @@
1
+ """Sound Design Engine critics — detect timbral issues from state data.
2
+
3
+ Five critics: static_timbre, weak_identity, masking_role,
4
+ modulation_flatness, layer_overlap.
5
+ All pure computation, zero I/O.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass, field
11
+
12
+ from .models import (
13
+ LayerStrategy,
14
+ PatchModel,
15
+ SoundDesignState,
16
+ TimbralGoalVector,
17
+ )
18
+
19
+
20
+ # ── SoundDesignIssue ─────────────────────────────────────────────────
21
+
22
+
23
+ @dataclass
24
+ class SoundDesignIssue:
25
+ """A single detected sound-design issue."""
26
+
27
+ issue_type: str = ""
28
+ critic: str = ""
29
+ severity: float = 0.0
30
+ confidence: float = 0.0
31
+ affected_blocks: list[str] = field(default_factory=list)
32
+ evidence: str = ""
33
+ recommended_moves: list[str] = field(default_factory=list)
34
+
35
+ def to_dict(self) -> dict:
36
+ return asdict(self)
37
+
38
+
39
+ # ── Static Timbre Critic ─────────────────────────────────────────────
40
+
41
+
42
+ def run_static_timbre_critic(
43
+ patch: PatchModel,
44
+ goal: TimbralGoalVector,
45
+ ) -> list[SoundDesignIssue]:
46
+ """Detect static timbre: no modulation sources, flat/lifeless sound.
47
+
48
+ Fires when the goal asks for movement or instability but the patch
49
+ has no LFOs and no non-default envelopes (only oscillators/filters/effects).
50
+ """
51
+ issues: list[SoundDesignIssue] = []
52
+
53
+ has_lfo = any(b.block_type == "lfo" for b in patch.blocks)
54
+ has_envelope = any(b.block_type == "envelope" for b in patch.blocks)
55
+ has_modulation = has_lfo or has_envelope
56
+
57
+ # If goal wants movement or instability but patch is static
58
+ wants_movement = goal.movement > 0.1 or goal.instability > 0.1
59
+ if wants_movement and not has_modulation and len(patch.blocks) > 0:
60
+ severity = min(1.0, (abs(goal.movement) + abs(goal.instability)) / 2.0)
61
+ issues.append(SoundDesignIssue(
62
+ issue_type="static_timbre",
63
+ critic="static_timbre",
64
+ severity=round(severity, 3),
65
+ confidence=0.8,
66
+ affected_blocks=[b.device_name for b in patch.blocks],
67
+ evidence=(
68
+ f"Goal wants movement={goal.movement:.2f}, "
69
+ f"instability={goal.instability:.2f} but patch has "
70
+ f"no LFOs or modulation envelopes"
71
+ ),
72
+ recommended_moves=["modulation_injection"],
73
+ ))
74
+
75
+ # Even without explicit goal, a patch with zero modulation is flat
76
+ if not has_modulation and not wants_movement and len(patch.blocks) > 0:
77
+ issues.append(SoundDesignIssue(
78
+ issue_type="no_modulation_sources",
79
+ critic="static_timbre",
80
+ severity=0.3,
81
+ confidence=0.5,
82
+ affected_blocks=[b.device_name for b in patch.blocks],
83
+ evidence=(
84
+ f"Patch has {len(patch.blocks)} blocks but no LFOs "
85
+ f"or modulation envelopes — timbre will be static"
86
+ ),
87
+ recommended_moves=["modulation_injection"],
88
+ ))
89
+
90
+ return issues
91
+
92
+
93
+ # ── Weak Identity Critic ─────────────────────────────────────────────
94
+
95
+
96
+ def run_weak_identity_critic(
97
+ patch: PatchModel,
98
+ ) -> list[SoundDesignIssue]:
99
+ """Detect weak patch identity: too few distinct blocks, generic chain.
100
+
101
+ A patch with only one or two blocks (e.g. just an oscillator + effect)
102
+ lacks timbral character. Also flags chains with no filter or saturation,
103
+ which tend to sound generic.
104
+ """
105
+ issues: list[SoundDesignIssue] = []
106
+
107
+ controllable_blocks = [b for b in patch.blocks if b.controllable]
108
+ block_types = {b.block_type for b in controllable_blocks}
109
+
110
+ # Too few blocks for timbral identity
111
+ if len(controllable_blocks) < 2 and len(patch.device_chain) > 0:
112
+ issues.append(SoundDesignIssue(
113
+ issue_type="too_few_blocks",
114
+ critic="weak_identity",
115
+ severity=0.5,
116
+ confidence=0.6,
117
+ affected_blocks=[b.device_name for b in controllable_blocks],
118
+ evidence=(
119
+ f"Only {len(controllable_blocks)} controllable block(s) — "
120
+ f"patch lacks timbral sculpting potential"
121
+ ),
122
+ recommended_moves=["filter_contour", "source_balance"],
123
+ ))
124
+
125
+ # Generic chain: no filter or saturation for character
126
+ if len(controllable_blocks) >= 2:
127
+ has_character = "filter" in block_types or "saturation" in block_types
128
+ if not has_character:
129
+ issues.append(SoundDesignIssue(
130
+ issue_type="generic_chain",
131
+ critic="weak_identity",
132
+ severity=0.4,
133
+ confidence=0.5,
134
+ affected_blocks=[b.device_name for b in controllable_blocks],
135
+ evidence=(
136
+ f"Block types {sorted(block_types)} lack filter or "
137
+ f"saturation — chain will sound generic"
138
+ ),
139
+ recommended_moves=["filter_contour"],
140
+ ))
141
+
142
+ return issues
143
+
144
+
145
+ # ── Masking Role Critic ──────────────────────────────────────────────
146
+
147
+
148
+ def run_masking_role_critic(
149
+ patch: PatchModel,
150
+ layers: LayerStrategy,
151
+ ) -> list[SoundDesignIssue]:
152
+ """Detect layers overlapping in frequency role.
153
+
154
+ Flags when the same track is assigned as both sub_anchor and
155
+ body_layer (or other frequency-adjacent roles), or when a track's
156
+ roles suggest it covers too wide a frequency range.
157
+ """
158
+ issues: list[SoundDesignIssue] = []
159
+
160
+ # Frequency-adjacent role pairs that risk masking
161
+ adjacent_pairs = [
162
+ ("sub_anchor", "body_layer"),
163
+ ("body_layer", "transient_layer"),
164
+ ("texture_layer", "width_layer"),
165
+ ]
166
+
167
+ ti = patch.track_index
168
+ assigned_roles = []
169
+ if layers.sub_anchor == ti:
170
+ assigned_roles.append("sub_anchor")
171
+ if layers.body_layer == ti:
172
+ assigned_roles.append("body_layer")
173
+ if layers.transient_layer == ti:
174
+ assigned_roles.append("transient_layer")
175
+ if layers.texture_layer == ti:
176
+ assigned_roles.append("texture_layer")
177
+ if layers.width_layer == ti:
178
+ assigned_roles.append("width_layer")
179
+
180
+ for role_a, role_b in adjacent_pairs:
181
+ if role_a in assigned_roles and role_b in assigned_roles:
182
+ issues.append(SoundDesignIssue(
183
+ issue_type="frequency_role_overlap",
184
+ critic="masking_role",
185
+ severity=0.6,
186
+ confidence=0.7,
187
+ affected_blocks=[],
188
+ evidence=(
189
+ f"Track {ti} assigned both '{role_a}' and '{role_b}' — "
190
+ f"these frequency-adjacent roles risk masking each other"
191
+ ),
192
+ recommended_moves=["layer_split", "source_balance"],
193
+ ))
194
+
195
+ return issues
196
+
197
+
198
+ # ── Modulation Flatness Critic ───────────────────────────────────────
199
+
200
+
201
+ def run_modulation_flatness_critic(
202
+ patch: PatchModel,
203
+ ) -> list[SoundDesignIssue]:
204
+ """Detect modulation flatness: no LFOs, no envelopes beyond default.
205
+
206
+ Fires when a patch with 3+ blocks has zero dedicated modulation
207
+ sources — the patch will sound lifeless over time.
208
+ """
209
+ issues: list[SoundDesignIssue] = []
210
+
211
+ lfo_count = sum(1 for b in patch.blocks if b.block_type == "lfo")
212
+ envelope_count = sum(1 for b in patch.blocks if b.block_type == "envelope")
213
+ total_blocks = len(patch.blocks)
214
+
215
+ if total_blocks >= 3 and lfo_count == 0 and envelope_count == 0:
216
+ issues.append(SoundDesignIssue(
217
+ issue_type="no_modulation",
218
+ critic="modulation_flatness",
219
+ severity=0.5,
220
+ confidence=0.7,
221
+ affected_blocks=[b.device_name for b in patch.blocks],
222
+ evidence=(
223
+ f"Patch has {total_blocks} blocks but zero LFOs and "
224
+ f"zero modulation envelopes — sound will be static"
225
+ ),
226
+ recommended_moves=["modulation_injection", "envelope_shape"],
227
+ ))
228
+
229
+ if total_blocks >= 3 and lfo_count == 0 and envelope_count > 0:
230
+ issues.append(SoundDesignIssue(
231
+ issue_type="no_lfo_movement",
232
+ critic="modulation_flatness",
233
+ severity=0.3,
234
+ confidence=0.6,
235
+ affected_blocks=[b.device_name for b in patch.blocks if b.block_type != "envelope"],
236
+ evidence=(
237
+ f"Patch has envelopes but no LFOs — "
238
+ f"sustained notes will lack timbral movement"
239
+ ),
240
+ recommended_moves=["modulation_injection"],
241
+ ))
242
+
243
+ return issues
244
+
245
+
246
+ # ── Layer Overlap Critic ─────────────────────────────────────────────
247
+
248
+
249
+ def run_layer_overlap_critic(
250
+ layers: LayerStrategy,
251
+ ) -> list[SoundDesignIssue]:
252
+ """Detect when the same track serves multiple layer roles.
253
+
254
+ A single track trying to be both sub anchor and texture layer,
255
+ for example, will have conflicting EQ/processing needs.
256
+ """
257
+ issues: list[SoundDesignIssue] = []
258
+
259
+ role_map: dict[int, list[str]] = {}
260
+ for role_name in ("sub_anchor", "body_layer", "transient_layer",
261
+ "texture_layer", "width_layer"):
262
+ track_idx = getattr(layers, role_name)
263
+ if track_idx is not None:
264
+ role_map.setdefault(track_idx, []).append(role_name)
265
+
266
+ for track_idx, roles in role_map.items():
267
+ if len(roles) > 1:
268
+ issues.append(SoundDesignIssue(
269
+ issue_type="multi_role_track",
270
+ critic="layer_overlap",
271
+ severity=min(1.0, 0.3 * len(roles)),
272
+ confidence=0.7,
273
+ affected_blocks=[],
274
+ evidence=(
275
+ f"Track {track_idx} serves {len(roles)} layer roles: "
276
+ f"{roles} — conflicting processing needs"
277
+ ),
278
+ recommended_moves=["layer_split"],
279
+ ))
280
+
281
+ return issues
282
+
283
+
284
+ # ── Run all critics ──────────────────────────────────────────────────
285
+
286
+
287
+ def run_all_sound_design_critics(
288
+ state: SoundDesignState,
289
+ ) -> list[SoundDesignIssue]:
290
+ """Run all five critics and aggregate issues."""
291
+ issues: list[SoundDesignIssue] = []
292
+ issues.extend(run_static_timbre_critic(state.patch, state.goal))
293
+ issues.extend(run_weak_identity_critic(state.patch))
294
+ issues.extend(run_masking_role_critic(state.patch, state.layers))
295
+ issues.extend(run_modulation_flatness_critic(state.patch))
296
+ issues.extend(run_layer_overlap_critic(state.layers))
297
+ return issues
@@ -0,0 +1,147 @@
1
+ """Sound Design Engine state models — all dataclasses with to_dict().
2
+
3
+ Pure data structures representing timbral goals, patch topology,
4
+ layer strategy, and the composite SoundDesignState container.
5
+
6
+ Zero I/O.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import asdict, dataclass, field
12
+ from typing import Optional
13
+
14
+
15
+ # ── Timbral Goal Vector ──────────────────────────────────────────────
16
+
17
+
18
+ @dataclass
19
+ class TimbralGoalVector:
20
+ """Multi-dimensional timbral target compiled from natural language.
21
+
22
+ Each dimension is a float in [-1.0, 1.0] where 0.0 means "no change".
23
+ Positive values push toward more of that quality, negative toward less.
24
+ ``protect`` is a dict of dimensions that must not regress, with threshold weights.
25
+ """
26
+
27
+ brightness: float = 0.0
28
+ warmth: float = 0.0
29
+ bite: float = 0.0
30
+ softness: float = 0.0
31
+ instability: float = 0.0
32
+ width: float = 0.0
33
+ texture_density: float = 0.0
34
+ movement: float = 0.0
35
+ polish: float = 0.0
36
+ protect: dict = field(default_factory=dict)
37
+
38
+ def to_dict(self) -> dict:
39
+ return asdict(self)
40
+
41
+
42
+ # ── Patch Block ──────────────────────────────────────────────────────
43
+
44
+
45
+ VALID_BLOCK_TYPES = frozenset({
46
+ "oscillator",
47
+ "filter",
48
+ "envelope",
49
+ "lfo",
50
+ "spatial",
51
+ "saturation",
52
+ "effect",
53
+ })
54
+
55
+
56
+ @dataclass
57
+ class PatchBlock:
58
+ """A single functional block within a device chain.
59
+
60
+ ``block_type`` must be one of the VALID_BLOCK_TYPES.
61
+ ``controllable`` indicates whether the block's parameters are exposed
62
+ to automation (True for native devices, often False for opaque plugins).
63
+ """
64
+
65
+ block_type: str = "effect"
66
+ device_name: str = ""
67
+ controllable: bool = True
68
+
69
+ def __post_init__(self) -> None:
70
+ if self.block_type not in VALID_BLOCK_TYPES:
71
+ raise ValueError(
72
+ f"Invalid block_type '{self.block_type}'. "
73
+ f"Must be one of {sorted(VALID_BLOCK_TYPES)}"
74
+ )
75
+
76
+ def to_dict(self) -> dict:
77
+ return asdict(self)
78
+
79
+
80
+ # ── Patch Model ──────────────────────────────────────────────────────
81
+
82
+
83
+ @dataclass
84
+ class PatchModel:
85
+ """Structural model of an instrument/effect chain on a single track.
86
+
87
+ ``device_chain`` lists device names in signal-flow order.
88
+ ``roles`` describes the musical role(s) this patch fills
89
+ (e.g. ["lead"], ["sub_anchor", "body_layer"]).
90
+ ``blocks`` lists the controllable functional blocks.
91
+ ``opaque_blocks`` lists device names whose internals are not inspectable.
92
+ """
93
+
94
+ track_index: int = 0
95
+ device_chain: list[str] = field(default_factory=list)
96
+ roles: list[str] = field(default_factory=list)
97
+ blocks: list[PatchBlock] = field(default_factory=list)
98
+ opaque_blocks: list[str] = field(default_factory=list)
99
+
100
+ def to_dict(self) -> dict:
101
+ return {
102
+ "track_index": self.track_index,
103
+ "device_chain": list(self.device_chain),
104
+ "roles": list(self.roles),
105
+ "blocks": [b.to_dict() for b in self.blocks],
106
+ "opaque_blocks": list(self.opaque_blocks),
107
+ }
108
+
109
+
110
+ # ── Layer Strategy ───────────────────────────────────────────────────
111
+
112
+
113
+ @dataclass
114
+ class LayerStrategy:
115
+ """Describes how multiple tracks/layers divide timbral labor.
116
+
117
+ Each field is an optional track index indicating which track
118
+ owns that layer role. None means no track is assigned.
119
+ """
120
+
121
+ sub_anchor: Optional[int] = None
122
+ body_layer: Optional[int] = None
123
+ transient_layer: Optional[int] = None
124
+ texture_layer: Optional[int] = None
125
+ width_layer: Optional[int] = None
126
+
127
+ def to_dict(self) -> dict:
128
+ return asdict(self)
129
+
130
+
131
+ # ── Composite State ──────────────────────────────────────────────────
132
+
133
+
134
+ @dataclass
135
+ class SoundDesignState:
136
+ """Top-level container for sound design analysis of a track."""
137
+
138
+ goal: TimbralGoalVector = field(default_factory=TimbralGoalVector)
139
+ patch: PatchModel = field(default_factory=PatchModel)
140
+ layers: LayerStrategy = field(default_factory=LayerStrategy)
141
+
142
+ def to_dict(self) -> dict:
143
+ return {
144
+ "goal": self.goal.to_dict(),
145
+ "patch": self.patch.to_dict(),
146
+ "layers": self.layers.to_dict(),
147
+ }
@@ -0,0 +1,104 @@
1
+ """Sound Design Engine planner — rank and suggest timbral moves.
2
+
3
+ Pure computation, zero I/O. Takes critic issues and sound design state,
4
+ returns a ranked list of reversible moves.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import asdict, dataclass, field
10
+
11
+ from .critics import SoundDesignIssue
12
+ from .models import SoundDesignState
13
+
14
+
15
+ # ── SoundDesignMove ──────────────────────────────────────────────────
16
+
17
+
18
+ @dataclass
19
+ class SoundDesignMove:
20
+ """A single suggested sound-design move."""
21
+
22
+ move_type: str = ""
23
+ target_block: str = ""
24
+ description: str = ""
25
+ estimated_impact: float = 0.0
26
+ risk: float = 0.0
27
+ parameters: dict = field(default_factory=dict)
28
+
29
+ def to_dict(self) -> dict:
30
+ return asdict(self)
31
+
32
+
33
+ # Move type metadata: (scope, base_risk)
34
+ # scope: "parameter" < "block" < "chain" — prefer smallest scope
35
+ _MOVE_META: dict[str, tuple[str, float]] = {
36
+ "source_balance": ("block", 0.1),
37
+ "filter_contour": ("parameter", 0.1),
38
+ "envelope_shape": ("parameter", 0.1),
39
+ "modulation_injection": ("block", 0.2),
40
+ "spatial_separation": ("block", 0.15),
41
+ "layer_split": ("chain", 0.3),
42
+ }
43
+
44
+ _SCOPE_PENALTY: dict[str, float] = {
45
+ "parameter": 0.0,
46
+ "block": 0.05,
47
+ "chain": 0.15,
48
+ }
49
+
50
+
51
+ # ── Planner ──────────────────────────────────────────────────────────
52
+
53
+
54
+ def plan_sound_design_moves(
55
+ issues: list[SoundDesignIssue],
56
+ state: SoundDesignState,
57
+ ) -> list[SoundDesignMove]:
58
+ """Generate a ranked list of suggested moves from detected issues.
59
+
60
+ Ranking: estimated_impact * (1 - risk), highest first.
61
+ Prefers parameter-level moves over chain-level moves.
62
+
63
+ Returns an empty list if no issues are present.
64
+ """
65
+ if not issues:
66
+ return []
67
+
68
+ moves: list[SoundDesignMove] = []
69
+
70
+ for issue in issues:
71
+ for move_type in issue.recommended_moves:
72
+ scope, base_risk = _MOVE_META.get(move_type, ("block", 0.2))
73
+ risk = min(1.0, base_risk + _SCOPE_PENALTY.get(scope, 0.0))
74
+ impact = issue.severity * issue.confidence
75
+
76
+ # Pick the first affected block as target, or empty
77
+ target = issue.affected_blocks[0] if issue.affected_blocks else ""
78
+
79
+ move = SoundDesignMove(
80
+ move_type=move_type,
81
+ target_block=target,
82
+ description=(
83
+ f"{move_type} to address {issue.issue_type} "
84
+ f"({issue.critic} critic)"
85
+ ),
86
+ estimated_impact=round(impact, 3),
87
+ risk=round(risk, 3),
88
+ parameters={
89
+ "source_issue": issue.issue_type,
90
+ "source_critic": issue.critic,
91
+ "severity": issue.severity,
92
+ },
93
+ )
94
+ moves.append(move)
95
+
96
+ # Rank: impact * (1 - risk), highest first; tie-break by scope
97
+ def _rank_key(m: SoundDesignMove) -> tuple[float, float]:
98
+ score = m.estimated_impact * (1.0 - m.risk)
99
+ scope, _ = _MOVE_META.get(m.move_type, ("block", 0.2))
100
+ scope_order = _SCOPE_PENALTY.get(scope, 0.0)
101
+ return (-score, scope_order)
102
+
103
+ moves.sort(key=_rank_key)
104
+ return moves