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.
- package/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +51 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +32 -8
- package/installer/install.js +21 -2
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
- package/livepilot/skills/livepilot-core/SKILL.md +81 -6
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
- package/livepilot/skills/livepilot-core/references/overview.md +3 -3
- package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
- package/livepilot/skills/livepilot-release/SKILL.md +13 -13
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +6 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/curves.py +11 -3
- package/mcp_server/evaluation/__init__.py +1 -0
- package/mcp_server/evaluation/fabric.py +575 -0
- package/mcp_server/evaluation/feature_extractors.py +84 -0
- package/mcp_server/evaluation/policy.py +67 -0
- package/mcp_server/evaluation/tools.py +53 -0
- package/mcp_server/memory/__init__.py +11 -2
- package/mcp_server/memory/anti_memory.py +78 -0
- package/mcp_server/memory/promotion.py +94 -0
- package/mcp_server/memory/session_memory.py +108 -0
- package/mcp_server/memory/taste_memory.py +158 -0
- package/mcp_server/memory/technique_store.py +2 -1
- package/mcp_server/memory/tools.py +112 -0
- package/mcp_server/mix_engine/__init__.py +1 -0
- package/mcp_server/mix_engine/critics.py +299 -0
- package/mcp_server/mix_engine/models.py +152 -0
- package/mcp_server/mix_engine/planner.py +103 -0
- package/mcp_server/mix_engine/state_builder.py +316 -0
- package/mcp_server/mix_engine/tools.py +214 -0
- package/mcp_server/performance_engine/__init__.py +1 -0
- package/mcp_server/performance_engine/models.py +148 -0
- package/mcp_server/performance_engine/planner.py +267 -0
- package/mcp_server/performance_engine/safety.py +162 -0
- package/mcp_server/performance_engine/tools.py +183 -0
- package/mcp_server/project_brain/__init__.py +6 -0
- package/mcp_server/project_brain/arrangement_graph.py +64 -0
- package/mcp_server/project_brain/automation_graph.py +72 -0
- package/mcp_server/project_brain/builder.py +123 -0
- package/mcp_server/project_brain/capability_graph.py +64 -0
- package/mcp_server/project_brain/models.py +282 -0
- package/mcp_server/project_brain/refresh.py +80 -0
- package/mcp_server/project_brain/role_graph.py +103 -0
- package/mcp_server/project_brain/session_graph.py +51 -0
- package/mcp_server/project_brain/tools.py +144 -0
- package/mcp_server/reference_engine/__init__.py +1 -0
- package/mcp_server/reference_engine/gap_analyzer.py +239 -0
- package/mcp_server/reference_engine/models.py +105 -0
- package/mcp_server/reference_engine/profile_builder.py +149 -0
- package/mcp_server/reference_engine/tactic_router.py +117 -0
- package/mcp_server/reference_engine/tools.py +235 -0
- package/mcp_server/runtime/__init__.py +1 -0
- package/mcp_server/runtime/action_ledger.py +117 -0
- package/mcp_server/runtime/action_ledger_models.py +84 -0
- package/mcp_server/runtime/action_tools.py +57 -0
- package/mcp_server/runtime/capability_state.py +218 -0
- package/mcp_server/runtime/safety_kernel.py +339 -0
- package/mcp_server/runtime/safety_tools.py +42 -0
- package/mcp_server/runtime/tools.py +64 -0
- package/mcp_server/server.py +23 -1
- package/mcp_server/sound_design/__init__.py +1 -0
- package/mcp_server/sound_design/critics.py +297 -0
- package/mcp_server/sound_design/models.py +147 -0
- package/mcp_server/sound_design/planner.py +104 -0
- package/mcp_server/sound_design/tools.py +297 -0
- package/mcp_server/tools/_agent_os_engine.py +947 -0
- package/mcp_server/tools/_composition_engine.py +1530 -0
- package/mcp_server/tools/_conductor.py +199 -0
- package/mcp_server/tools/_conductor_budgets.py +222 -0
- package/mcp_server/tools/_evaluation_contracts.py +91 -0
- package/mcp_server/tools/_form_engine.py +416 -0
- package/mcp_server/tools/_motif_engine.py +351 -0
- package/mcp_server/tools/_planner_engine.py +516 -0
- package/mcp_server/tools/_research_engine.py +542 -0
- package/mcp_server/tools/_research_provider.py +185 -0
- package/mcp_server/tools/_snapshot_normalizer.py +49 -0
- package/mcp_server/tools/agent_os.py +440 -0
- package/mcp_server/tools/analyzer.py +18 -0
- package/mcp_server/tools/automation.py +25 -10
- package/mcp_server/tools/composition.py +563 -0
- package/mcp_server/tools/motif.py +104 -0
- package/mcp_server/tools/planner.py +144 -0
- package/mcp_server/tools/research.py +223 -0
- package/mcp_server/tools/tracks.py +18 -3
- package/mcp_server/tools/transport.py +10 -2
- package/mcp_server/transition_engine/__init__.py +6 -0
- package/mcp_server/transition_engine/archetypes.py +167 -0
- package/mcp_server/transition_engine/critics.py +340 -0
- package/mcp_server/transition_engine/models.py +90 -0
- package/mcp_server/transition_engine/tools.py +291 -0
- package/mcp_server/translation_engine/__init__.py +5 -0
- package/mcp_server/translation_engine/critics.py +297 -0
- package/mcp_server/translation_engine/models.py +27 -0
- package/mcp_server/translation_engine/tools.py +74 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +12 -2
- 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()
|
package/mcp_server/server.py
CHANGED
|
@@ -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
|