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.
- package/CHANGELOG.md +61 -0
- package/README.md +9 -8
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/agents/livepilot-producer/AGENT.md +2 -2
- package/livepilot/commands/beat.md +3 -3
- package/livepilot/commands/evaluate.md +3 -1
- package/livepilot/commands/mix.md +2 -2
- package/livepilot/commands/sounddesign.md +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +10 -10
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +2 -1
- package/livepilot/skills/livepilot-core/references/overview.md +5 -3
- package/livepilot/skills/livepilot-core/references/sound-design.md +5 -2
- package/livepilot/skills/livepilot-creative-director/SKILL.md +43 -0
- package/livepilot/skills/livepilot-creative-director/references/move-family-diversity-rule.md +1 -1
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +15 -9
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +9 -1
- package/livepilot/skills/livepilot-mixing/SKILL.md +12 -5
- package/livepilot/skills/livepilot-release/SKILL.md +9 -7
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +25 -3
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/composer/full/brief_builder.py +9 -0
- package/mcp_server/evaluation/feature_extractors.py +152 -8
- package/mcp_server/mix_engine/state_builder.py +19 -2
- package/mcp_server/mix_engine/tools.py +22 -0
- package/mcp_server/runtime/capability_probe.py +1 -1
- package/mcp_server/runtime/capability_state.py +66 -9
- package/mcp_server/runtime/live_version.py +18 -0
- package/mcp_server/runtime/session_kernel.py +7 -0
- package/mcp_server/runtime/tools.py +324 -22
- package/mcp_server/sound_design/tools.py +33 -0
- package/mcp_server/tools/_agent_os_engine/evaluation.py +7 -44
- package/mcp_server/tools/_agent_os_engine/models.py +2 -1
- package/mcp_server/tools/_conductor.py +5 -2
- package/mcp_server/tools/_evaluation_contracts.py +1 -1
- package/mcp_server/tools/_snapshot_normalizer.py +32 -3
- package/mcp_server/tools/analyzer.py +38 -1
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/version_detect.py +3 -0
- package/requirements.txt +3 -3
- 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: **
|
|
32
|
-
Spectral/analyzer (bridge-only): **38**.
|
|
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 ("
|
|
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` → "
|
|
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
|
|
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
|
|
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 "===
|
|
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
|
|
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.
|
|
37
|
+
var VERSION = "1.27.0";
|
|
38
38
|
|
|
39
39
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
40
40
|
|
package/mcp_server/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
|
|
2
|
-
__version__ = "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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
-
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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=
|
|
215
|
-
device_loaded=
|
|
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
|
)
|