livepilot 1.9.21 → 1.9.23

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 (110) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcpbignore +40 -0
  3. package/AGENTS.md +2 -2
  4. package/CHANGELOG.md +47 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/README.md +47 -72
  7. package/bin/livepilot.js +135 -0
  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 +13 -0
  11. package/livepilot/commands/arrange.md +42 -14
  12. package/livepilot/commands/beat.md +68 -21
  13. package/livepilot/commands/evaluate.md +23 -13
  14. package/livepilot/commands/mix.md +35 -11
  15. package/livepilot/commands/perform.md +31 -19
  16. package/livepilot/commands/sounddesign.md +38 -17
  17. package/livepilot/skills/livepilot-arrangement/SKILL.md +2 -1
  18. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +2 -2
  19. package/livepilot/skills/livepilot-core/SKILL.md +60 -4
  20. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +11 -11
  21. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +25 -25
  22. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +21 -21
  23. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +13 -13
  24. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +13 -13
  25. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +5 -5
  26. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +16 -16
  27. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +40 -40
  28. package/livepilot/skills/livepilot-core/references/m4l-devices.md +3 -3
  29. package/livepilot/skills/livepilot-core/references/overview.md +4 -4
  30. package/livepilot/skills/livepilot-evaluation/SKILL.md +12 -8
  31. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +2 -2
  32. package/livepilot/skills/livepilot-mix-engine/SKILL.md +1 -1
  33. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +2 -2
  34. package/livepilot/skills/livepilot-mixing/SKILL.md +3 -1
  35. package/livepilot/skills/livepilot-notes/SKILL.md +2 -1
  36. package/livepilot/skills/livepilot-release/SKILL.md +15 -15
  37. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +2 -2
  38. package/livepilot/skills/livepilot-wonder/SKILL.md +62 -0
  39. package/livepilot.mcpb +0 -0
  40. package/m4l_device/livepilot_bridge.js +1 -1
  41. package/manifest.json +91 -0
  42. package/mcp_server/__init__.py +1 -1
  43. package/mcp_server/creative_constraints/__init__.py +6 -0
  44. package/mcp_server/creative_constraints/engine.py +277 -0
  45. package/mcp_server/creative_constraints/models.py +75 -0
  46. package/mcp_server/creative_constraints/tools.py +341 -0
  47. package/mcp_server/experiment/__init__.py +6 -0
  48. package/mcp_server/experiment/engine.py +213 -0
  49. package/mcp_server/experiment/models.py +120 -0
  50. package/mcp_server/experiment/tools.py +263 -0
  51. package/mcp_server/hook_hunter/__init__.py +5 -0
  52. package/mcp_server/hook_hunter/analyzer.py +342 -0
  53. package/mcp_server/hook_hunter/models.py +57 -0
  54. package/mcp_server/hook_hunter/tools.py +586 -0
  55. package/mcp_server/memory/taste_graph.py +261 -0
  56. package/mcp_server/memory/tools.py +88 -0
  57. package/mcp_server/mix_engine/critics.py +2 -2
  58. package/mcp_server/mix_engine/models.py +1 -1
  59. package/mcp_server/mix_engine/state_builder.py +2 -2
  60. package/mcp_server/musical_intelligence/__init__.py +8 -0
  61. package/mcp_server/musical_intelligence/detectors.py +421 -0
  62. package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
  63. package/mcp_server/musical_intelligence/tools.py +221 -0
  64. package/mcp_server/preview_studio/__init__.py +5 -0
  65. package/mcp_server/preview_studio/engine.py +280 -0
  66. package/mcp_server/preview_studio/models.py +73 -0
  67. package/mcp_server/preview_studio/tools.py +423 -0
  68. package/mcp_server/runtime/session_kernel.py +96 -0
  69. package/mcp_server/runtime/tools.py +90 -1
  70. package/mcp_server/semantic_moves/__init__.py +13 -0
  71. package/mcp_server/semantic_moves/compiler.py +116 -0
  72. package/mcp_server/semantic_moves/mix_compilers.py +291 -0
  73. package/mcp_server/semantic_moves/mix_moves.py +157 -0
  74. package/mcp_server/semantic_moves/models.py +46 -0
  75. package/mcp_server/semantic_moves/performance_compilers.py +208 -0
  76. package/mcp_server/semantic_moves/performance_moves.py +81 -0
  77. package/mcp_server/semantic_moves/registry.py +32 -0
  78. package/mcp_server/semantic_moves/resolvers.py +126 -0
  79. package/mcp_server/semantic_moves/sound_design_compilers.py +266 -0
  80. package/mcp_server/semantic_moves/sound_design_moves.py +78 -0
  81. package/mcp_server/semantic_moves/tools.py +204 -0
  82. package/mcp_server/semantic_moves/transition_compilers.py +222 -0
  83. package/mcp_server/semantic_moves/transition_moves.py +76 -0
  84. package/mcp_server/server.py +10 -0
  85. package/mcp_server/session_continuity/__init__.py +6 -0
  86. package/mcp_server/session_continuity/models.py +86 -0
  87. package/mcp_server/session_continuity/tools.py +230 -0
  88. package/mcp_server/session_continuity/tracker.py +235 -0
  89. package/mcp_server/song_brain/__init__.py +6 -0
  90. package/mcp_server/song_brain/builder.py +477 -0
  91. package/mcp_server/song_brain/models.py +132 -0
  92. package/mcp_server/song_brain/tools.py +294 -0
  93. package/mcp_server/stuckness_detector/__init__.py +5 -0
  94. package/mcp_server/stuckness_detector/detector.py +400 -0
  95. package/mcp_server/stuckness_detector/models.py +66 -0
  96. package/mcp_server/stuckness_detector/tools.py +195 -0
  97. package/mcp_server/tools/_conductor.py +104 -6
  98. package/mcp_server/tools/analyzer.py +1 -1
  99. package/mcp_server/tools/devices.py +34 -0
  100. package/mcp_server/wonder_mode/__init__.py +6 -0
  101. package/mcp_server/wonder_mode/diagnosis.py +84 -0
  102. package/mcp_server/wonder_mode/engine.py +493 -0
  103. package/mcp_server/wonder_mode/session.py +114 -0
  104. package/mcp_server/wonder_mode/tools.py +285 -0
  105. package/package.json +2 -2
  106. package/remote_script/LivePilot/__init__.py +1 -1
  107. package/remote_script/LivePilot/browser.py +4 -1
  108. package/remote_script/LivePilot/devices.py +29 -0
  109. package/remote_script/LivePilot/tracks.py +11 -4
  110. package/scripts/generate_tool_catalog.py +131 -0
@@ -0,0 +1,208 @@
1
+ """Compilers for performance-safe semantic moves.
2
+
3
+ Critical rule: NEVER compile to blocked actions (delete, create, device load).
4
+ Only volume, pan, send, and automation are allowed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .compiler import CompiledPlan, CompiledStep, register_compiler
10
+ from .models import SemanticMove
11
+ from . import resolvers
12
+
13
+
14
+ def _compile_recover_energy(move: SemanticMove, kernel: dict) -> CompiledPlan:
15
+ """Compile 'recover_energy': bring drums+bass back gradually."""
16
+ steps = []
17
+ descriptions = []
18
+
19
+ drum_tracks = resolvers.find_tracks_by_role(kernel, ["drums", "percussion"])
20
+ bass_tracks = resolvers.find_tracks_by_role(kernel, ["bass"])
21
+
22
+ for dt in drum_tracks[:1]:
23
+ steps.append(CompiledStep(
24
+ tool="set_track_volume",
25
+ params={"track_index": dt["index"], "volume": 0.70},
26
+ description=f"Restore {dt['name']} to 0.70 for energy recovery",
27
+ ))
28
+ descriptions.append(f"Restore {dt['name']} to 0.70")
29
+
30
+ for bt in bass_tracks[:1]:
31
+ steps.append(CompiledStep(
32
+ tool="set_track_volume",
33
+ params={"track_index": bt["index"], "volume": 0.60},
34
+ description=f"Restore {bt['name']} to 0.60",
35
+ ))
36
+ descriptions.append(f"Restore {bt['name']} to 0.60")
37
+
38
+ # Pull reverb back to tighten
39
+ pad_tracks = resolvers.find_tracks_by_role(kernel, ["pad", "chords"])
40
+ for pt in pad_tracks[:1]:
41
+ steps.append(CompiledStep(
42
+ tool="set_track_send",
43
+ params={"track_index": pt["index"], "send_index": 0, "value": 0.15},
44
+ description=f"Tighten reverb on {pt['name']} to 0.15",
45
+ ))
46
+ descriptions.append(f"Tighten reverb on {pt['name']}")
47
+
48
+ steps.append(CompiledStep(
49
+ tool="get_track_meters",
50
+ params={"include_stereo": True},
51
+ description="Verify energy recovered",
52
+ ))
53
+
54
+ return CompiledPlan(
55
+ move_id=move.move_id,
56
+ intent=move.intent,
57
+ steps=steps,
58
+ risk_level="low",
59
+ summary="; ".join(descriptions) if descriptions else "No rhythm tracks found",
60
+ requires_approval=False, # Performance moves execute immediately
61
+ )
62
+
63
+
64
+ def _compile_decompress_tension(move: SemanticMove, kernel: dict) -> CompiledPlan:
65
+ """Compile 'decompress_tension': pull back energy, open space."""
66
+ steps = []
67
+ descriptions = []
68
+
69
+ lead_tracks = resolvers.find_tracks_by_role(kernel, ["lead", "chords"])
70
+ pad_tracks = resolvers.find_tracks_by_role(kernel, ["pad"])
71
+
72
+ for lt in lead_tracks[:2]:
73
+ steps.append(CompiledStep(
74
+ tool="set_track_volume",
75
+ params={"track_index": lt["index"], "volume": 0.35},
76
+ description=f"Pull {lt['name']} to 0.35 for decompression",
77
+ ))
78
+ descriptions.append(f"Pull {lt['name']} to 0.35")
79
+
80
+ for pt in pad_tracks[:1]:
81
+ steps.append(CompiledStep(
82
+ tool="set_track_send",
83
+ params={"track_index": pt["index"], "send_index": 0, "value": 0.40},
84
+ description=f"Open reverb on {pt['name']} to 0.40 for spaciousness",
85
+ ))
86
+ descriptions.append(f"Open reverb on {pt['name']}")
87
+
88
+ steps.append(CompiledStep(
89
+ tool="get_track_meters",
90
+ params={"include_stereo": True},
91
+ description="Verify decompression — energy lower, space wider",
92
+ ))
93
+
94
+ return CompiledPlan(
95
+ move_id=move.move_id,
96
+ intent=move.intent,
97
+ steps=steps,
98
+ risk_level="low",
99
+ summary="; ".join(descriptions) if descriptions else "No tracks to decompress",
100
+ requires_approval=False,
101
+ )
102
+
103
+
104
+ def _compile_safe_spotlight(move: SemanticMove, kernel: dict) -> CompiledPlan:
105
+ """Compile 'safe_spotlight': pull non-spotlight tracks, push one."""
106
+ steps = []
107
+ descriptions = []
108
+ warnings = []
109
+
110
+ all_tracks = kernel.get("session_info", {}).get("tracks", [])
111
+ if not all_tracks:
112
+ warnings.append("No tracks found")
113
+ return CompiledPlan(
114
+ move_id=move.move_id, intent=move.intent, warnings=warnings,
115
+ summary="No tracks to spotlight",
116
+ )
117
+
118
+ # Spotlight the first lead or melodic track; pull everything else
119
+ lead_tracks = resolvers.find_tracks_by_role(kernel, ["lead", "chords"])
120
+ spotlight = lead_tracks[0] if lead_tracks else all_tracks[0]
121
+ spotlight_idx = spotlight.get("index", 0)
122
+ spotlight_name = spotlight.get("name", f"Track {spotlight_idx}")
123
+
124
+ # Pull non-spotlight audio tracks
125
+ for track in all_tracks:
126
+ idx = track.get("index", 0)
127
+ name = track.get("name", "")
128
+ if idx == spotlight_idx:
129
+ continue
130
+ if track.get("type") in ("return", "master"):
131
+ continue
132
+ steps.append(CompiledStep(
133
+ tool="set_track_volume",
134
+ params={"track_index": idx, "volume": 0.30},
135
+ description=f"Pull {name} to 0.30 (background)",
136
+ ))
137
+
138
+ # Push spotlight
139
+ steps.append(CompiledStep(
140
+ tool="set_track_volume",
141
+ params={"track_index": spotlight_idx, "volume": 0.82},
142
+ description=f"Push spotlight {spotlight_name} to 0.82",
143
+ ))
144
+ descriptions.append(f"Spotlight {spotlight_name}")
145
+
146
+ steps.append(CompiledStep(
147
+ tool="get_track_meters",
148
+ params={"include_stereo": True},
149
+ description="Verify spotlight dominant, others still audible",
150
+ ))
151
+
152
+ return CompiledPlan(
153
+ move_id=move.move_id,
154
+ intent=move.intent,
155
+ steps=steps,
156
+ risk_level="low",
157
+ summary="; ".join(descriptions),
158
+ requires_approval=False,
159
+ )
160
+
161
+
162
+ def _compile_emergency_simplify(move: SemanticMove, kernel: dict) -> CompiledPlan:
163
+ """Compile 'emergency_simplify': strip to drums+bass only."""
164
+ steps = []
165
+ descriptions = []
166
+
167
+ all_tracks = kernel.get("session_info", {}).get("tracks", [])
168
+ drum_tracks = resolvers.find_tracks_by_role(kernel, ["drums", "percussion"])
169
+ bass_tracks = resolvers.find_tracks_by_role(kernel, ["bass"])
170
+ keep_indices = {t["index"] for t in drum_tracks + bass_tracks}
171
+
172
+ for track in all_tracks:
173
+ idx = track.get("index", 0)
174
+ name = track.get("name", "")
175
+ if track.get("type") in ("return", "master"):
176
+ continue
177
+ if idx in keep_indices:
178
+ continue
179
+ steps.append(CompiledStep(
180
+ tool="set_track_volume",
181
+ params={"track_index": idx, "volume": 0.10},
182
+ description=f"Strip {name} to 0.10 (emergency simplify)",
183
+ ))
184
+
185
+ descriptions.append("Strip to drums+bass only")
186
+
187
+ steps.append(CompiledStep(
188
+ tool="get_track_meters",
189
+ params={"include_stereo": True},
190
+ description="Verify drums+bass dominant, others at background level",
191
+ ))
192
+
193
+ return CompiledPlan(
194
+ move_id=move.move_id,
195
+ intent=move.intent,
196
+ steps=steps,
197
+ risk_level="low",
198
+ summary="; ".join(descriptions),
199
+ requires_approval=False,
200
+ )
201
+
202
+
203
+ # ── Register ────────────────────────────────────────────────────────────────
204
+
205
+ register_compiler("recover_energy", _compile_recover_energy)
206
+ register_compiler("decompress_tension", _compile_decompress_tension)
207
+ register_compiler("safe_spotlight", _compile_safe_spotlight)
208
+ register_compiler("emergency_simplify", _compile_emergency_simplify)
@@ -0,0 +1,81 @@
1
+ """Performance-safe semantic moves — safe actions during live performance.
2
+
3
+ Every move in this family MUST compile only to safe or caution actions.
4
+ Blocked actions (delete, create, device load) are never used.
5
+ """
6
+
7
+ from .models import SemanticMove
8
+ from .registry import register
9
+
10
+ RECOVER_ENERGY = SemanticMove(
11
+ move_id="recover_energy",
12
+ family="performance",
13
+ intent="Recover energy after a breakdown — bring elements back in gradually",
14
+ targets={"energy": 0.5, "cohesion": 0.3, "motion": 0.2},
15
+ protect={"clarity": 0.7},
16
+ risk_level="low",
17
+ required_capabilities=["session"],
18
+ compile_plan=[
19
+ {"tool": "set_track_volume", "params": {"description": "Gradually restore drum volume"}, "description": "Bring drums back"},
20
+ {"tool": "set_track_volume", "params": {"description": "Restore bass volume"}, "description": "Bring bass back"},
21
+ {"tool": "set_track_send", "params": {"description": "Reduce reverb send to tighten mix"}, "description": "Tighten reverb"},
22
+ ],
23
+ verification_plan=[
24
+ {"tool": "get_track_meters", "check": "drum and bass tracks producing audio"},
25
+ ],
26
+ )
27
+
28
+ DECOMPRESS_TENSION = SemanticMove(
29
+ move_id="decompress_tension",
30
+ family="performance",
31
+ intent="Release built-up tension — open filters, reduce density, create space",
32
+ targets={"clarity": 0.4, "depth": 0.3, "width": 0.3},
33
+ protect={"cohesion": 0.6},
34
+ risk_level="low",
35
+ required_capabilities=["session"],
36
+ compile_plan=[
37
+ {"tool": "set_track_volume", "params": {"description": "Pull back high-energy elements 15-20%"}, "description": "Pull energy down"},
38
+ {"tool": "set_track_send", "params": {"description": "Increase reverb for spaciousness"}, "description": "Open space"},
39
+ ],
40
+ verification_plan=[
41
+ {"tool": "get_track_meters", "check": "all tracks still alive, overall energy decreased"},
42
+ ],
43
+ )
44
+
45
+ SAFE_SPOTLIGHT = SemanticMove(
46
+ move_id="safe_spotlight",
47
+ family="performance",
48
+ intent="Spotlight one element by pulling others back — no muting, just volume balance",
49
+ targets={"contrast": 0.5, "clarity": 0.3, "motion": 0.2},
50
+ protect={"cohesion": 0.7, "energy": 0.5},
51
+ risk_level="low",
52
+ required_capabilities=["session"],
53
+ compile_plan=[
54
+ {"tool": "set_track_volume", "params": {"description": "Pull non-spotlight tracks to 30-40%"}, "description": "Pull background"},
55
+ {"tool": "set_track_volume", "params": {"description": "Push spotlight track to 80-85%"}, "description": "Push spotlight"},
56
+ ],
57
+ verification_plan=[
58
+ {"tool": "get_track_meters", "check": "spotlight track clearly dominant, others still audible"},
59
+ ],
60
+ )
61
+
62
+ EMERGENCY_SIMPLIFY = SemanticMove(
63
+ move_id="emergency_simplify",
64
+ family="performance",
65
+ intent="Emergency simplify — pull everything back to drums+bass only for recovery",
66
+ targets={"clarity": 0.6, "cohesion": 0.4},
67
+ protect={"energy": 0.3},
68
+ risk_level="low",
69
+ required_capabilities=["session"],
70
+ compile_plan=[
71
+ {"tool": "set_track_volume", "params": {"description": "Pull all non-rhythm tracks to 10-15%"}, "description": "Strip to essentials"},
72
+ {"tool": "set_track_volume", "params": {"description": "Keep drums at current level"}, "description": "Maintain rhythm"},
73
+ ],
74
+ verification_plan=[
75
+ {"tool": "get_track_meters", "check": "drums producing audio, other tracks at low but nonzero level"},
76
+ ],
77
+ )
78
+
79
+ # Register all performance moves
80
+ for _move in [RECOVER_ENERGY, DECOMPRESS_TENSION, SAFE_SPOTLIGHT, EMERGENCY_SIMPLIFY]:
81
+ register(_move)
@@ -0,0 +1,32 @@
1
+ """Semantic move registry — stable IDs, discoverable by domain and style."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from .models import SemanticMove
8
+
9
+ _REGISTRY: dict[str, SemanticMove] = {}
10
+
11
+
12
+ def register(move: SemanticMove) -> None:
13
+ """Register a semantic move. Duplicate IDs overwrite silently."""
14
+ _REGISTRY[move.move_id] = move
15
+
16
+
17
+ def get_move(move_id: str) -> Optional[SemanticMove]:
18
+ """Get a move by ID. Returns None if not found."""
19
+ return _REGISTRY.get(move_id)
20
+
21
+
22
+ def list_moves(domain: str = "", style: str = "") -> list[dict]:
23
+ """List all registered moves, optionally filtered by domain/style."""
24
+ moves = list(_REGISTRY.values())
25
+ if domain:
26
+ moves = [m for m in moves if m.family == domain]
27
+ return [m.to_dict() for m in moves]
28
+
29
+
30
+ def count() -> int:
31
+ """Return total number of registered moves."""
32
+ return len(_REGISTRY)
@@ -0,0 +1,126 @@
1
+ """Resolvers — find tracks, devices, and parameters from a SessionKernel.
2
+
3
+ These are the "eyes" of the semantic move compiler. They inspect the kernel's
4
+ session topology to find the right targets for a musical intent.
5
+
6
+ Pure functions — no I/O, no MCP calls. They read from the kernel dict only.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional
12
+
13
+
14
+ # ── Role inference from track names ──────────────────────────────────────────
15
+
16
+ _ROLE_KEYWORDS: dict[str, list[str]] = {
17
+ "drums": ["drum", "beat", "kit", "perc", "rhythm", "hat", "kick", "snare"],
18
+ "bass": ["bass", "sub", "808", "low"],
19
+ "chords": ["chord", "rhodes", "keys", "piano", "organ", "electric", "wurli"],
20
+ "pad": ["pad", "texture", "ambient", "drone", "atmosphere"],
21
+ "lead": ["lead", "melody", "synth", "glitch", "hook", "vocal"],
22
+ "percussion": ["perc", "shaker", "tambourine", "conga", "bongo", "rim"],
23
+ "fx": ["fx", "effect", "noise", "riser", "impact", "sweep"],
24
+ }
25
+
26
+
27
+ def infer_role(track_name: str) -> str:
28
+ """Infer a musical role from a track name. Returns 'unknown' if no match."""
29
+ name_lower = track_name.lower()
30
+ for role, keywords in _ROLE_KEYWORDS.items():
31
+ for kw in keywords:
32
+ if kw in name_lower:
33
+ return role
34
+ return "unknown"
35
+
36
+
37
+ # ── Track finders ────────────────────────────────────────────────────────────
38
+
39
+ def find_tracks_by_role(kernel: dict, roles: list[str]) -> list[dict]:
40
+ """Find all tracks whose inferred role is in the given list.
41
+
42
+ Returns list of {index, name, role, volume, pan} for matched tracks.
43
+ """
44
+ tracks = kernel.get("session_info", {}).get("tracks", [])
45
+ results = []
46
+ for track in tracks:
47
+ role = infer_role(track.get("name", ""))
48
+ if role in roles:
49
+ results.append({
50
+ "index": track.get("index", 0),
51
+ "name": track.get("name", ""),
52
+ "role": role,
53
+ "volume": track.get("volume"),
54
+ "pan": track.get("pan"),
55
+ "mute": track.get("mute", False),
56
+ "solo": track.get("solo", False),
57
+ })
58
+ return results
59
+
60
+
61
+ def find_loudest_track(kernel: dict) -> Optional[dict]:
62
+ """Find the track with the highest volume setting."""
63
+ tracks = kernel.get("session_info", {}).get("tracks", [])
64
+ if not tracks:
65
+ return None
66
+ # Volume might not be in session_info tracks — return the first non-muted
67
+ non_muted = [t for t in tracks if not t.get("mute", False)]
68
+ return non_muted[0] if non_muted else tracks[0]
69
+
70
+
71
+ def find_track_by_name(kernel: dict, name_substring: str) -> Optional[dict]:
72
+ """Find a track whose name contains the given substring (case-insensitive)."""
73
+ tracks = kernel.get("session_info", {}).get("tracks", [])
74
+ name_lower = name_substring.lower()
75
+ for track in tracks:
76
+ if name_lower in track.get("name", "").lower():
77
+ return track
78
+ return None
79
+
80
+
81
+ # ── Device finders ───────────────────────────────────────────────────────────
82
+
83
+ def find_device_on_track(
84
+ kernel: dict, track_index: int, device_class: str
85
+ ) -> Optional[dict]:
86
+ """Find a device by class name on a track. Returns {device_index, name} or None.
87
+
88
+ Note: This requires device data in the kernel, which may not always be
89
+ available. Returns None if device data is missing.
90
+ """
91
+ # Device data would be in an extended kernel; for now, return None
92
+ # and let the compiler use find_and_load_device as a fallback
93
+ return None
94
+
95
+
96
+ # ── Spectral helpers ─────────────────────────────────────────────────────────
97
+
98
+ def get_spectral_balance(kernel: dict) -> Optional[dict]:
99
+ """Extract spectral band balance from the kernel's capability data.
100
+
101
+ Returns None if no spectral data is available.
102
+ """
103
+ # Spectral data isn't stored in the base kernel — it would come from
104
+ # a pre-capture. Return None for graceful degradation.
105
+ return None
106
+
107
+
108
+ def is_analyzer_available(kernel: dict) -> bool:
109
+ """Check if the M4L analyzer is connected."""
110
+ cap = kernel.get("capability_state", {})
111
+ domains = cap.get("domains", {})
112
+ analyzer = domains.get("analyzer", {})
113
+ return analyzer.get("available", False)
114
+
115
+
116
+ # ── Volume math ──────────────────────────────────────────────────────────────
117
+
118
+ def clamp_volume(vol: float) -> float:
119
+ """Clamp volume to Ableton's 0.0-1.0 range."""
120
+ return max(0.0, min(1.0, vol))
121
+
122
+
123
+ def adjust_volume(current: float, delta_percent: float) -> float:
124
+ """Adjust a volume by a percentage. delta_percent=5 means +5%."""
125
+ new = current + (delta_percent / 100.0)
126
+ return clamp_volume(new)