livepilot 1.26.1 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +9 -8
  3. package/livepilot/.Codex-plugin/plugin.json +2 -2
  4. package/livepilot/.claude-plugin/plugin.json +2 -2
  5. package/livepilot/agents/livepilot-producer/AGENT.md +2 -2
  6. package/livepilot/commands/beat.md +3 -3
  7. package/livepilot/commands/evaluate.md +3 -1
  8. package/livepilot/commands/mix.md +2 -2
  9. package/livepilot/commands/sounddesign.md +2 -2
  10. package/livepilot/skills/livepilot-core/SKILL.md +10 -10
  11. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +2 -1
  12. package/livepilot/skills/livepilot-core/references/overview.md +5 -3
  13. package/livepilot/skills/livepilot-core/references/sound-design.md +5 -2
  14. package/livepilot/skills/livepilot-creative-director/SKILL.md +43 -0
  15. package/livepilot/skills/livepilot-creative-director/references/move-family-diversity-rule.md +1 -1
  16. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +15 -9
  17. package/livepilot/skills/livepilot-mix-engine/SKILL.md +9 -1
  18. package/livepilot/skills/livepilot-mixing/SKILL.md +12 -5
  19. package/livepilot/skills/livepilot-release/SKILL.md +9 -7
  20. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +25 -3
  21. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  22. package/m4l_device/livepilot_bridge.js +1 -1
  23. package/mcp_server/__init__.py +1 -1
  24. package/mcp_server/composer/full/brief_builder.py +9 -0
  25. package/mcp_server/evaluation/feature_extractors.py +152 -8
  26. package/mcp_server/mix_engine/state_builder.py +19 -2
  27. package/mcp_server/mix_engine/tools.py +22 -0
  28. package/mcp_server/runtime/capability_probe.py +1 -1
  29. package/mcp_server/runtime/capability_state.py +66 -9
  30. package/mcp_server/runtime/live_version.py +18 -0
  31. package/mcp_server/runtime/session_kernel.py +7 -0
  32. package/mcp_server/runtime/tools.py +324 -22
  33. package/mcp_server/sound_design/tools.py +33 -0
  34. package/mcp_server/tools/_agent_os_engine/evaluation.py +7 -44
  35. package/mcp_server/tools/_agent_os_engine/models.py +2 -1
  36. package/mcp_server/tools/_conductor.py +5 -2
  37. package/mcp_server/tools/_evaluation_contracts.py +1 -1
  38. package/mcp_server/tools/_snapshot_normalizer.py +32 -3
  39. package/mcp_server/tools/analyzer.py +38 -1
  40. package/package.json +2 -2
  41. package/remote_script/LivePilot/__init__.py +1 -1
  42. package/remote_script/LivePilot/version_detect.py +3 -0
  43. package/requirements.txt +3 -3
  44. package/server.json +3 -3
@@ -28,20 +28,20 @@ Run this checklist EVERY time the user says "update everything", "push", "releas
28
28
 
29
29
  ## 2. Tool Count (must ALL match)
30
30
 
31
- Current: **465 tools across 56 domains**.
32
- Spectral/analyzer (bridge-only): **38**. All others: **292** (work without bridge, some with graceful fallbacks). Backed by 32 bridge commands.
31
+ Current: **467 tools across 56 domains**.
32
+ Spectral/analyzer (bridge-only): **38**. The remaining tool surface works without the bridge or degrades gracefully. Backed by 32 bridge commands.
33
33
 
34
34
  Verify: `grep -rc "@mcp.tool" mcp_server/tools/ | grep -v ":0" | awk -F: '{sum+=$2} END{print sum}'`
35
35
 
36
36
  Files that reference tool count:
37
- - [ ] `README.md` — header ("465 tools. 56 domains"), bridge section ("38 spectral/analyzer tools require bridge")
37
+ - [ ] `README.md` — header ("467 tools. 56 domains"), bridge section ("38 spectral/analyzer tools require bridge")
38
38
  - [ ] `package.json` → `"description"`
39
39
  - [ ] `server.json` → `"description"`
40
40
  - [ ] `manifest.json` → `"description"`
41
41
  - [ ] `livepilot/.Codex-plugin/plugin.json` → `"description"` (primary Codex manifest)
42
42
  - [ ] `livepilot/.claude-plugin/plugin.json` → `"description"` (must match Codex plugin)
43
43
  - [ ] `.claude-plugin/marketplace.json` → `"description"`
44
- - [ ] `CLAUDE.md` → "465 tools across 56 domains"
44
+ - [ ] `CLAUDE.md` → "467 tools across 56 domains"
45
45
  - [ ] `CONTRIBUTING.md` → tool count in intro
46
46
  - [ ] `livepilot/skills/livepilot-core/SKILL.md` — tool/domain count
47
47
  - [ ] `livepilot/skills/livepilot-core/references/overview.md` — tool/domain count
@@ -64,7 +64,7 @@ Files that reference tool count:
64
64
  Current: **56 domains**: transport, tracks, clips, notes, devices, scenes, mixing, browser, arrangement, memory, analyzer, automation, theory, generative, harmony, midi_io, perception, agent_os, composition, motif, research, planner, project_brain, runtime, evaluation, mix_engine, sound_design, transition_engine, reference_engine, translation_engine, performance_engine, song_brain, preview_studio, hook_hunter, stuckness_detector, wonder_mode, session_continuity, creative_constraints, device_forge, sample_engine, atlas, composer, experiment, musical_intelligence, semantic_moves, diagnostics, follow_actions, grooves, scales, take_lanes, miditool, synthesis_brain, creative_director, user_corpus, audit, grader.
65
65
 
66
66
  - [ ] All files that mention domain count say "56 domains"
67
- - [ ] Domain lists include ALL 45 (especially newer domains — they're the most often omitted)
67
+ - [ ] Domain lists include ALL 56 (especially newer domains — they're the most often omitted)
68
68
 
69
69
  ## 4. npm Registry
70
70
 
@@ -85,6 +85,8 @@ Current: **56 domains**: transport, tracks, clips, notes, devices, scenes, mixin
85
85
 
86
86
  ## 6. Plugin Cache
87
87
 
88
+ - [ ] `python3 scripts/verify_codex_plugin_sync.py` passes after `npx livepilot --install-codex-plugin`
89
+ - [ ] `python3 scripts/verify_plugin_sync.py` passes after Claude plugin cache/mirror sync
88
90
  - [ ] `~/.claude/plugins/cache/dreamrec-LivePilot/livepilot/` has current version directory
89
91
  - [ ] Old version directories removed
90
92
  - [ ] `~/.claude/plugins/installed_plugins.json` → `livepilot@dreamrec-LivePilot` entry: update `version`, `installPath`, `gitCommitSha`, `lastUpdated`
@@ -98,7 +100,7 @@ Current: **56 domains**: transport, tracks, clips, notes, devices, scenes, mixin
98
100
 
99
101
  - [ ] `README.md` — features match current capabilities, "Coming" section is accurate
100
102
  - [ ] `docs/manual/getting-started.md` — install instructions current
101
- - [ ] `docs/manual/tool-reference.md` — all 56 domains listed, all 465 tools present
103
+ - [ ] `docs/manual/tool-reference.md` — all 56 domains listed, all 467 tools present
102
104
  - [ ] `docs/M4L_BRIDGE.md` — architecture accurate, core/bridge counts correct
103
105
 
104
106
  ## 9. Derived Artifacts
@@ -147,5 +149,5 @@ Current: **56 domains**: transport, tracks, clips, notes, devices, scenes, mixin
147
149
  ## Quick Verify Command
148
150
 
149
151
  ```bash
150
- echo "=== Versions ===" && grep -h '"version"' package.json server.json manifest.json livepilot/.Codex-plugin/plugin.json livepilot/.claude-plugin/plugin.json .claude-plugin/marketplace.json | head -8 && grep __version__ mcp_server/__init__.py remote_script/LivePilot/__init__.py && echo "=== Tool count ===" && grep -rc "@mcp.tool" mcp_server/ | grep -v ":0" | awk -F: '{sum+=$2} END{print "Total:", sum}' && echo "=== Tests ===" && python3 -m pytest tests/ -q 2>&1 | tail -1
152
+ echo "=== Versions ===" && grep -h '"version"' package.json server.json manifest.json livepilot/.Codex-plugin/plugin.json livepilot/.claude-plugin/plugin.json .claude-plugin/marketplace.json | head -8 && grep __version__ mcp_server/__init__.py remote_script/LivePilot/__init__.py && echo "=== Metadata ===" && python3 scripts/sync_metadata.py --check && echo "=== Plugin sync ===" && python3 scripts/verify_codex_plugin_sync.py --quiet && python3 scripts/verify_plugin_sync.py --quiet && echo "=== Tests ===" && python3 -m pytest tests/ -q 2>&1 | tail -1
151
153
  ```
@@ -7,6 +7,28 @@ description: This skill should be used when the user asks to "design a sound", "
7
7
 
8
8
  The sound design engine analyzes synth patches, identifies timbral weaknesses, and iteratively refines them through a measured critic loop. Every change is evaluated against the before state.
9
9
 
10
+ ## Analyzer Character Is the Main Signal
11
+
12
+ For broad quality requests, this skill is the primary route. "More punch", "warmer", "darker", "brighter", "less flat", "more alive", "more texture", and "more character" should become source/device/parameter decisions before they become volume moves.
13
+
14
+ When the analyzer is available, read character, not just level:
15
+
16
+ - `get_master_spectrum` for the 9-band contour
17
+ - `get_spectral_shape` for centroid, flatness, crest, rolloff, and brightness/noise shape
18
+ - `get_mel_spectrum` when EQ or source choice needs perceptual detail
19
+ - `get_onsets` for transient/envelope decisions
20
+ - `get_novelty` for movement/staticness decisions
21
+ - `get_momentary_loudness` only for safety/headroom/loudness context
22
+
23
+ Translate those measurements into musical moves:
24
+
25
+ - Bright/harsh character → filter contour, softer source, de-harshing, saturation tone; do not merely lower volume.
26
+ - Dark/dull character → oscillator/filter opening, excitation, air-band source, tasteful saturation; do not merely raise volume.
27
+ - Static/low novelty → modulation, envelope drift, LFO, generative device, granular/vector source.
28
+ - Weak punch → envelope/transient shaping, source layering, attack/release work; volume push is last.
29
+ - Flat/noisy spectrum → source substitution, subtractive filtering, simpler spectral role.
30
+ - Weak weight → instrument/register/source decision before master or track gain.
31
+
10
32
  ## Atlas-first reflex (v1.23.x+, MANDATORY before any creative move)
11
33
 
12
34
  Before producing ANY creative response, query the user's atlas overlays. The corpus contains 337 entries across 3 namespaces, plus 3,917 parameter-level JSON sidecars — far richer than anything inferable from training data alone.
@@ -97,7 +119,7 @@ Move vocabulary:
97
119
  ### Step 4 — Capture Before
98
120
 
99
121
  1. Call `get_device_parameters(track_index, device_index)` — save current parameter state
100
- 2. Call `get_master_spectrum` — save spectral snapshot (if analyzer available)
122
+ 2. Call `get_master_spectrum` plus the relevant character streams above — save spectral snapshot (if analyzer available)
101
123
 
102
124
  ### Step 5 — Execute
103
125
 
@@ -116,7 +138,7 @@ Execute one move at a time. Verify before continuing.
116
138
  Repeat the same measurements:
117
139
 
118
140
  1. Call `get_device_parameters(track_index, device_index)` — confirm the change took effect
119
- 2. Call `get_master_spectrum` — save post-change spectral snapshot
141
+ 2. Call `get_master_spectrum` plus the same character streams used before — save post-change spectral snapshot
120
142
 
121
143
  ### Step 7 — Evaluate
122
144
 
@@ -135,7 +157,7 @@ If `keep_change` is `true`, report the improvement. If score > 0.7, consider cal
135
157
 
136
158
  ### Step 9 — Repeat
137
159
 
138
- Return to Step 2. Re-analyze after each kept change. The critic list updates as issues are resolved. Continue until the user is satisfied or no high-severity issues remain.
160
+ Return to Step 2 only when the user asked for a deep refinement pass. In normal mode, stop after one meaningful character-improving move and summarize what changed plus the next optional direction. Avoid long loops of small parameter nudges.
139
161
 
140
162
  ## Working with Opaque Plugins
141
163
 
Binary file
@@ -34,7 +34,7 @@ outlets = 2; // 0: to udpsend (responses), 1: to buffer~/status
34
34
  // Single source of truth for the bridge version — bumped alongside the
35
35
  // rest of the release manifest. Surfaced in the UI via messnamed("livepilot_version", ...)
36
36
  // so the frozen .amxd visibly reports which build it was last exported from.
37
- var VERSION = "1.26.1";
37
+ var VERSION = "1.27.0";
38
38
 
39
39
  // ── State ──────────────────────────────────────────────────────────────────
40
40
 
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.26.1"
2
+ __version__ = "1.27.0"
@@ -47,6 +47,15 @@ _DESIGN_TARGETS = (
47
47
  "moves to schedule at chosen phrase boundaries. For niche style references "
48
48
  "in research_hooks, run WebSearch to ground your form choices in the "
49
49
  "actual conventions of that subgenre.\n\n"
50
+ "CHARACTER-FIRST NORMAL MODE:\n"
51
+ "Do not spend full mode on long level-balancing loops. Producers can adjust "
52
+ "simple volume by ear; your high-value job is to choose instruments, sources, "
53
+ "device chains, macro states, envelopes, filters, saturation, modulation, "
54
+ "and structural reveals that fit the requested character. Use analyzer/mix "
55
+ "feedback as evidence and safety, but prefer timbral/source decisions over "
56
+ "`set_track_volume`, `set_track_pan`, or broad send tweaking unless the "
57
+ "brief explicitly asks for mix balance, loudness, headroom, stereo translation, "
58
+ "or masking repair.\n\n"
50
59
  "INSTRUMENT SELECTION (v1.25 hybrid knowledge surface — MANDATED FOUR-SOURCE SEARCH):\n"
51
60
  "The brief's `atlas_anchors` is ONE source. Before committing any role pick "
52
61
  "you MUST also query the other three sources below. Factory-atlas-only picks "
@@ -9,7 +9,7 @@ All returned values are clamped to 0.0-1.0 for consistent scoring.
9
9
  from __future__ import annotations
10
10
 
11
11
  import math
12
- from typing import Optional
12
+ from typing import Any, Optional
13
13
 
14
14
  from ..tools._evaluation_contracts import MEASURABLE_DIMENSIONS
15
15
 
@@ -19,6 +19,42 @@ def _clamp(value: float, lo: float = 0.0, hi: float = 1.0) -> float:
19
19
  return max(lo, min(hi, value))
20
20
 
21
21
 
22
+ def _number(value: Any) -> Optional[float]:
23
+ """Best-effort numeric coercion for analyzer payloads."""
24
+ if isinstance(value, bool):
25
+ return float(value)
26
+ if isinstance(value, (int, float)):
27
+ return float(value)
28
+ return None
29
+
30
+
31
+ def _nested_number(payload: Any, *keys: str) -> Optional[float]:
32
+ """Read a numeric value from a dict payload using candidate keys."""
33
+ if isinstance(payload, dict):
34
+ for key in keys:
35
+ value = _number(payload.get(key))
36
+ if value is not None:
37
+ return value
38
+ return _number(payload)
39
+
40
+
41
+ def _centroid_to_unit(centroid: float) -> float:
42
+ """Map centroid-like values to 0..1.
43
+
44
+ FluCoMa deployments may report centroid in Hz or normalized units.
45
+ Values <= 1 are treated as normalized. Larger values are mapped across
46
+ a practical musical range where 150 Hz is very dark and 8 kHz is bright.
47
+ """
48
+ if centroid <= 1.0:
49
+ return _clamp(centroid)
50
+ return _clamp((centroid - 150.0) / (8000.0 - 150.0))
51
+
52
+
53
+ def _lufs_to_unit(lufs: float) -> float:
54
+ """Map momentary LUFS to a rough 0..1 energy proxy."""
55
+ return _clamp((lufs + 60.0) / 60.0)
56
+
57
+
22
58
  def extract_dimension_value(
23
59
  snapshot: dict,
24
60
  dimension: str,
@@ -37,31 +73,49 @@ def extract_dimension_value(
37
73
  if not snapshot or not isinstance(snapshot, dict):
38
74
  return None
39
75
 
40
- bands = snapshot.get("spectrum")
41
- if not bands:
42
- return None
76
+ bands = snapshot.get("spectrum") or {}
77
+ spectral_shape = snapshot.get("spectral_shape") or {}
78
+ onset = snapshot.get("onset") or {}
79
+ novelty = snapshot.get("novelty") or {}
80
+ loudness = snapshot.get("loudness") or {}
43
81
 
44
82
  rms = snapshot.get("rms")
45
83
  peak = snapshot.get("peak")
46
84
 
47
85
  if dimension == "brightness":
86
+ centroid = _nested_number(spectral_shape, "centroid", "centroid_hz")
87
+ if centroid is not None:
88
+ return _centroid_to_unit(centroid)
89
+ if not bands:
90
+ return None
48
91
  high = bands.get("high", 0)
49
92
  presence = bands.get("presence", 0)
50
93
  return _clamp((high + presence) / 2.0)
51
94
 
52
95
  elif dimension == "warmth":
96
+ if not bands:
97
+ return None
53
98
  return _clamp(bands.get("low_mid", 0))
54
99
 
55
100
  elif dimension == "weight":
56
- sub = bands.get("sub", 0)
101
+ if not bands:
102
+ return None
103
+ sub = bands.get("sub_low", bands.get("sub", 0))
57
104
  low = bands.get("low", 0)
58
105
  return _clamp((sub + low) / 2.0)
59
106
 
60
107
  elif dimension == "clarity":
108
+ if not bands:
109
+ return None
61
110
  low_mid = bands.get("low_mid", 0)
62
111
  return _clamp(1.0 - low_mid)
63
112
 
64
113
  elif dimension == "density":
114
+ flatness = _nested_number(spectral_shape, "flatness", "spectral_flatness")
115
+ if flatness is not None:
116
+ return _clamp(flatness)
117
+ if not bands:
118
+ return None
65
119
  vals = [max(v, 1e-10) for v in bands.values()
66
120
  if isinstance(v, (int, float))]
67
121
  if not vals:
@@ -71,14 +125,104 @@ def extract_dimension_value(
71
125
  return _clamp(geo_mean / max(arith_mean, 1e-10))
72
126
 
73
127
  elif dimension == "energy":
74
- return _clamp(rms) if rms is not None else None
128
+ rms_value = _number(rms)
129
+ if rms_value is not None:
130
+ return _clamp(rms_value)
131
+ lufs = _nested_number(loudness, "momentary_lufs", "lufs", "integrated_lufs")
132
+ if lufs is not None:
133
+ return _lufs_to_unit(lufs)
134
+ return None
75
135
 
76
136
  elif dimension == "punch":
77
- if rms and peak and rms > 0:
78
- crest_db = 20.0 * math.log10(max(peak / rms, 1.0))
137
+ rms_value = _number(rms)
138
+ peak_value = _number(peak)
139
+ if rms_value and peak_value and rms_value > 0:
140
+ crest_db = 20.0 * math.log10(max(peak_value / rms_value, 1.0))
79
141
  return _clamp(crest_db / 20.0)
142
+ onset_strength = _nested_number(onset, "strength", "onset")
143
+ if onset_strength is not None:
144
+ return _clamp(onset_strength)
145
+ spectral_crest = _nested_number(spectral_shape, "crest")
146
+ if spectral_crest is not None:
147
+ return _clamp(spectral_crest)
148
+ return None
149
+
150
+ elif dimension == "novelty":
151
+ novelty_score = _nested_number(novelty, "score", "novelty", "value")
152
+ return _clamp(novelty_score) if novelty_score is not None else None
153
+
154
+ elif dimension == "motion":
155
+ novelty_score = _nested_number(novelty, "score", "novelty", "value")
156
+ onset_strength = _nested_number(onset, "strength", "onset")
157
+ vals = [v for v in (novelty_score, onset_strength) if v is not None]
158
+ if vals:
159
+ return _clamp(sum(vals) / len(vals))
80
160
  return None
81
161
 
82
162
  else:
83
163
  # Unmeasurable dimension
84
164
  return None
165
+
166
+
167
+ def _label_low_mid_high(value: float, low: str, mid: str, high: str) -> str:
168
+ if value < 0.33:
169
+ return low
170
+ if value > 0.67:
171
+ return high
172
+ return mid
173
+
174
+
175
+ def extract_character_profile(snapshot: dict) -> dict:
176
+ """Summarize analyzer data as a production-oriented character profile.
177
+
178
+ This is intentionally descriptive, not prescriptive. Engines can attach
179
+ the profile to their analysis response so the agent chooses sound-source,
180
+ device, and parameter moves before reaching for generic level changes.
181
+ """
182
+ if not snapshot or not isinstance(snapshot, dict):
183
+ return {"available": False, "values": {}, "labels": {}, "biases": []}
184
+
185
+ dimensions = (
186
+ "brightness", "warmth", "weight", "clarity", "density",
187
+ "energy", "punch", "motion", "novelty",
188
+ )
189
+ values = {
190
+ dim: round(val, 4)
191
+ for dim in dimensions
192
+ if (val := extract_dimension_value(snapshot, dim)) is not None
193
+ }
194
+
195
+ labels: dict[str, str] = {}
196
+ if "brightness" in values:
197
+ labels["brightness"] = _label_low_mid_high(values["brightness"], "dark", "balanced", "bright")
198
+ if "warmth" in values:
199
+ labels["warmth"] = _label_low_mid_high(values["warmth"], "lean", "warm", "thick")
200
+ if "weight" in values:
201
+ labels["weight"] = _label_low_mid_high(values["weight"], "light", "grounded", "heavy")
202
+ if "density" in values:
203
+ labels["density"] = _label_low_mid_high(values["density"], "peaked", "shaped", "flat/noisy")
204
+ if "punch" in values:
205
+ labels["punch"] = _label_low_mid_high(values["punch"], "soft", "defined", "spiky")
206
+ if "motion" in values:
207
+ labels["motion"] = _label_low_mid_high(values["motion"], "static", "moving", "busy")
208
+
209
+ biases: list[str] = []
210
+ if values.get("brightness", 0.5) > 0.72:
211
+ biases.append("prefer filter tone, source choice, or de-harshing over lowering track volume")
212
+ if values.get("brightness", 0.5) < 0.28:
213
+ biases.append("prefer oscillator/filter opening, excitation, or air-band source choice over level boosts")
214
+ if values.get("motion", 0.5) < 0.25:
215
+ biases.append("prefer modulation, envelope drift, or evolving devices before static mix moves")
216
+ if values.get("punch", 0.5) < 0.25:
217
+ biases.append("prefer envelope/transient shaping or source layering before pushing volume")
218
+ if values.get("density", 0.0) > 0.75:
219
+ biases.append("prefer subtractive filtering or simpler source selection when the spectrum is flat/noisy")
220
+ if values.get("weight", 0.5) < 0.25:
221
+ biases.append("prefer instrument/register/source changes for low-end weight before master gain")
222
+
223
+ return {
224
+ "available": bool(values),
225
+ "values": values,
226
+ "labels": labels,
227
+ "biases": biases,
228
+ }
@@ -68,6 +68,19 @@ def _name_signals_non_anchor(track_name: str) -> bool:
68
68
  # Frequency bands where masking is most problematic.
69
69
  _MASKING_BANDS = ("sub", "low", "low_mid", "mid", "high_mid", "presence", "high")
70
70
 
71
+ _MASKING_ROLE_ALIASES = {
72
+ "sub_bass": "bass",
73
+ "hihat": "percussion",
74
+ "hat": "percussion",
75
+ "clap": "percussion",
76
+ "snare": "percussion",
77
+ }
78
+
79
+
80
+ def _masking_role(role: str) -> str:
81
+ """Normalize detailed track roles into the collision-rule vocabulary."""
82
+ return _MASKING_ROLE_ALIASES.get(role, role)
83
+
71
84
 
72
85
  # ── Balance ─────────────────────────────────────────────────────────
73
86
 
@@ -164,7 +177,7 @@ def build_masking_map(
164
177
  # Build role->indices mapping
165
178
  role_to_indices: dict[str, list[int]] = {}
166
179
  for idx, role in track_roles.items():
167
- role_to_indices.setdefault(role, []).append(idx)
180
+ role_to_indices.setdefault(_masking_role(role), []).append(idx)
168
181
 
169
182
  # Known problematic role pairs and their collision bands
170
183
  collision_rules: list[tuple[str, str, str, float]] = [
@@ -267,7 +280,11 @@ def build_mix_state(
267
280
  role_hints = role_hints or {}
268
281
 
269
282
  balance = build_balance_state(track_infos, role_hints)
270
- masking = build_masking_map(spectrum, role_hints)
283
+ inferred_roles = {
284
+ track.track_index: role_hints.get(track.track_index, track.role)
285
+ for track in balance.track_states
286
+ }
287
+ masking = build_masking_map(spectrum, inferred_roles)
271
288
 
272
289
  # Extract peak from spectrum if available
273
290
  peak = None
@@ -11,6 +11,7 @@ from fastmcp import Context
11
11
  from ..server import mcp
12
12
  from ..tools._evaluation_contracts import EvaluationRequest
13
13
  from ..tools._snapshot_normalizer import normalize_sonic_snapshot
14
+ from ..evaluation.feature_extractors import extract_character_profile
14
15
  from ..evaluation.fabric import evaluate_sonic_move
15
16
  from .state_builder import build_mix_state
16
17
  from .critics import run_all_mix_critics
@@ -56,6 +57,18 @@ def _fetch_mix_data(ctx: Context) -> dict:
56
57
  rms_snap = spectral.get("rms")
57
58
  if rms_snap:
58
59
  rms_data = rms_snap["value"] if isinstance(rms_snap["value"], dict) else rms_snap["value"]
60
+ if spectrum is not None:
61
+ spectrum["rms"] = rms_data.get("rms") if isinstance(rms_data, dict) else rms_data
62
+ peak_snap = spectral.get("peak")
63
+ if peak_snap and spectrum is not None:
64
+ spectrum["peak"] = peak_snap["value"]
65
+
66
+ for key in ("spectral_shape", "mel_bands", "chroma", "onset", "novelty", "loudness"):
67
+ snap = spectral.get(key)
68
+ if snap:
69
+ if spectrum is None:
70
+ spectrum = {}
71
+ spectrum[key] = snap["value"]
59
72
  except Exception as exc:
60
73
  logger.debug("_fetch_mix_data failed: %s", exc)
61
74
 
@@ -86,9 +99,12 @@ def analyze_mix(ctx: Context) -> dict:
86
99
  )
87
100
  issues = run_all_mix_critics(mix_state)
88
101
  moves = plan_mix_moves(issues, mix_state)
102
+ sonic_snapshot = normalize_sonic_snapshot(data["spectrum"], source="mix_engine")
103
+ sonic_character = extract_character_profile(sonic_snapshot or {})
89
104
 
90
105
  return {
91
106
  "mix_state": mix_state.to_dict(),
107
+ "sonic_character": sonic_character,
92
108
  "issues": [i.to_dict() for i in issues],
93
109
  "suggested_moves": [m.to_dict() for m in moves],
94
110
  "issue_count": len(issues),
@@ -110,8 +126,10 @@ def get_mix_issues(ctx: Context) -> dict:
110
126
  rms_data=data["rms_data"],
111
127
  )
112
128
  issues = run_all_mix_critics(mix_state)
129
+ sonic_snapshot = normalize_sonic_snapshot(data["spectrum"], source="mix_engine")
113
130
 
114
131
  return {
132
+ "sonic_character": extract_character_profile(sonic_snapshot or {}),
115
133
  "issues": [i.to_dict() for i in issues],
116
134
  "issue_count": len(issues),
117
135
  }
@@ -133,8 +151,10 @@ def plan_mix_move(ctx: Context) -> dict:
133
151
  )
134
152
  issues = run_all_mix_critics(mix_state)
135
153
  moves = plan_mix_moves(issues, mix_state)
154
+ sonic_snapshot = normalize_sonic_snapshot(data["spectrum"], source="mix_engine")
136
155
 
137
156
  return {
157
+ "sonic_character": extract_character_profile(sonic_snapshot or {}),
138
158
  "moves": [m.to_dict() for m in moves],
139
159
  "move_count": len(moves),
140
160
  "issue_count": len(issues),
@@ -212,10 +232,12 @@ def get_mix_summary(ctx: Context) -> dict:
212
232
  rms_data=data["rms_data"],
213
233
  )
214
234
  issues = run_all_mix_critics(mix_state)
235
+ sonic_snapshot = normalize_sonic_snapshot(data["spectrum"], source="mix_engine")
215
236
 
216
237
  return {
217
238
  "track_count": len(mix_state.balance.track_states),
218
239
  "issue_count": len(issues),
240
+ "sonic_character": extract_character_profile(sonic_snapshot or {}),
219
241
  "dynamics": mix_state.dynamics.to_dict(),
220
242
  "stereo": mix_state.stereo.to_dict(),
221
243
  "depth": mix_state.depth.to_dict(),
@@ -73,7 +73,7 @@ def probe_capabilities(
73
73
  bridge_ok = bridge is not None and spectral is not None and getattr(spectral, "is_connected", False)
74
74
  report["m4l_bridge"] = {
75
75
  "status": "ok" if bridge_ok else "unavailable",
76
- "detail": "UDP 9880 / OSC 9881 active" if bridge_ok else "Not connected — 30 analyzer tools unavailable",
76
+ "detail": "UDP 9880 / OSC 9881 active" if bridge_ok else "Not connected — 38 analyzer tools unavailable",
77
77
  }
78
78
 
79
79
  # 4. Offline perception
@@ -118,6 +118,12 @@ def build_capability_state(
118
118
  memory_ok: bool = False,
119
119
  web_ok: bool = False,
120
120
  flucoma_ok: bool = False,
121
+ flucoma_device_loaded: Optional[bool] = None,
122
+ flucoma_reasons: Optional[list[str]] = None,
123
+ link_audio_mode: str = "manual_only",
124
+ link_audio_reasons: Optional[list[str]] = None,
125
+ stem_workflow_mode: str = "manual_only",
126
+ stem_workflow_reasons: Optional[list[str]] = None,
121
127
  ) -> CapabilityState:
122
128
  """Build a CapabilityState from simple boolean probes.
123
129
 
@@ -200,19 +206,70 @@ def build_capability_state(
200
206
  )
201
207
 
202
208
  # ── flucoma ──────────────────────────────────────────────────────
203
- # Optional dependency (the ``flucoma`` Python package). Emitted
204
- # unconditionally so consumers can distinguish "probed and missing"
205
- # from "probe not run yet".
206
- flucoma_reasons: list[str] = []
207
- if not flucoma_ok:
208
- flucoma_reasons.append("flucoma_not_installed")
209
+ # Max/FluCoMa real-time streams. Emitted unconditionally so consumers
210
+ # can distinguish "not installed" from "installed but bridge/streams
211
+ # are currently unavailable".
212
+ resolved_flucoma_device_loaded = (
213
+ flucoma_ok if flucoma_device_loaded is None else flucoma_device_loaded
214
+ )
215
+ resolved_flucoma_reasons = list(flucoma_reasons or [])
216
+ if not flucoma_ok and not resolved_flucoma_reasons:
217
+ resolved_flucoma_reasons.append(
218
+ "flucoma_no_streams"
219
+ if resolved_flucoma_device_loaded
220
+ else "flucoma_not_installed"
221
+ )
209
222
  domains["flucoma"] = CapabilityDomain(
210
223
  name="flucoma",
211
224
  available=flucoma_ok,
212
- confidence=0.9 if flucoma_ok else 0.0,
225
+ confidence=0.9 if flucoma_ok else (0.2 if resolved_flucoma_device_loaded else 0.0),
213
226
  mode="available" if flucoma_ok else "unavailable",
214
- reasons=flucoma_reasons,
215
- device_loaded=flucoma_ok,
227
+ reasons=resolved_flucoma_reasons,
228
+ device_loaded=resolved_flucoma_device_loaded,
229
+ )
230
+
231
+ # ── link_audio ────────────────────────────────────────────────────
232
+ # Live 12.4 exposes Link Audio in the product UX, but LivePilot must
233
+ # not claim automation support from the version number alone. This
234
+ # domain only becomes available when runtime probing observes a stable
235
+ # readable/routable surface.
236
+ link_mode = link_audio_mode if session_ok else "unavailable"
237
+ link_reasons = list(link_audio_reasons or [])
238
+ if not link_reasons:
239
+ if not session_ok:
240
+ link_reasons.append("session_unavailable")
241
+ elif link_mode == "manual_only":
242
+ link_reasons.append("link_audio_unprobed")
243
+ elif link_mode == "unavailable":
244
+ link_reasons.append("link_audio_not_exposed")
245
+ link_available = link_mode in {"readable", "routable"}
246
+ domains["link_audio"] = CapabilityDomain(
247
+ name="link_audio",
248
+ available=link_available,
249
+ confidence=0.8 if link_available else (0.2 if session_ok else 0.0),
250
+ mode=link_mode,
251
+ reasons=link_reasons,
252
+ )
253
+
254
+ # ── stem_workflow ─────────────────────────────────────────────────
255
+ # Selected-time stem separation / merge are also probe-first. The
256
+ # safe default is manual_only; callable only after concrete evidence.
257
+ stem_mode = stem_workflow_mode if session_ok else "unavailable"
258
+ stem_reasons = list(stem_workflow_reasons or [])
259
+ if not stem_reasons:
260
+ if not session_ok:
261
+ stem_reasons.append("session_unavailable")
262
+ elif stem_mode == "manual_only":
263
+ stem_reasons.append("stem_workflow_unprobed")
264
+ elif stem_mode == "unavailable":
265
+ stem_reasons.append("stem_workflow_not_exposed")
266
+ stem_available = stem_mode == "callable"
267
+ domains["stem_workflow"] = CapabilityDomain(
268
+ name="stem_workflow",
269
+ available=stem_available,
270
+ confidence=0.8 if stem_available else (0.2 if session_ok else 0.0),
271
+ mode=stem_mode,
272
+ reasons=stem_reasons,
216
273
  )
217
274
 
218
275
  # ── research (composite) ────────────────────────────────────────
@@ -72,6 +72,21 @@ class LiveVersionCapabilities:
72
72
  """Stem separation via MFL — 12.3+"""
73
73
  return self._version_tuple >= (12, 3, 0)
74
74
 
75
+ @property
76
+ def has_link_audio(self) -> bool:
77
+ """Live 12.4 Link Audio UX exists; MCP control remains probe-gated."""
78
+ return self._version_tuple >= (12, 4, 0)
79
+
80
+ @property
81
+ def has_stem_time_selection(self) -> bool:
82
+ """Live 12.4 selected-time stem separation UX exists; probe before use."""
83
+ return self._version_tuple >= (12, 4, 0)
84
+
85
+ @property
86
+ def has_stem_merge_selected(self) -> bool:
87
+ """Live 12.4 merge-selected-stems UX exists; probe before use."""
88
+ return self._version_tuple >= (12, 4, 0)
89
+
75
90
  @property
76
91
  def has_replace_sample_native(self) -> bool:
77
92
  """SimplerDevice.replace_sample(path) — 12.4+"""
@@ -100,5 +115,8 @@ class LiveVersionCapabilities:
100
115
  "drum_rack_construction": self.has_drum_rack_construction,
101
116
  "take_lanes": self.has_take_lanes,
102
117
  "stem_separation": self.has_stem_separation,
118
+ "link_audio": self.has_link_audio,
119
+ "stem_time_selection": self.has_stem_time_selection,
120
+ "stem_merge_selected": self.has_stem_merge_selected,
103
121
  "replace_sample_native": self.has_replace_sample_native,
104
122
  }
@@ -60,6 +60,11 @@ class SessionKernel:
60
60
  # may add "sculptor". Empty string = producer picks a default.
61
61
  creativity_profile: str = ""
62
62
 
63
+ # v1.27 operation posture. This is intentionally descriptive at the
64
+ # kernel layer; engines can opt into stricter behavior without forcing
65
+ # every tool to know every profile immediately.
66
+ operation_profile: str = "studio_deep"
67
+
63
68
  # Caller-asserted sacred elements. Normally sacred elements come from
64
69
  # song_brain; this lets the user or a skill override. Shape matches
65
70
  # song_brain.sacred_elements entries: {element_type, description, salience}.
@@ -94,6 +99,7 @@ def build_session_kernel(
94
99
  # PR2 — creative controls. All optional; legacy callers unaffected.
95
100
  freshness: float = 0.5,
96
101
  creativity_profile: str = "",
102
+ operation_profile: str = "studio_deep",
97
103
  sacred_elements: Optional[list] = None,
98
104
  synth_hints: Optional[dict] = None,
99
105
  ) -> SessionKernel:
@@ -137,6 +143,7 @@ def build_session_kernel(
137
143
  protected_dimensions=protected_dimensions or {},
138
144
  freshness=freshness,
139
145
  creativity_profile=creativity_profile,
146
+ operation_profile=operation_profile,
140
147
  sacred_elements=sacred_elements or [],
141
148
  synth_hints=synth_hints or {},
142
149
  )