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
@@ -7,10 +7,11 @@ Tools:
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import importlib.util
11
10
  import logging
11
+ import os
12
+ from pathlib import Path
12
13
  import urllib.request
13
- from typing import Optional
14
+ from typing import Any, Optional
14
15
 
15
16
  from fastmcp import Context
16
17
 
@@ -21,6 +22,14 @@ from .capability_state import build_capability_state
21
22
  logger = logging.getLogger(__name__)
22
23
 
23
24
  _memory_store = TechniqueStore()
25
+ _FLUCOMA_STREAM_KEYS = (
26
+ "spectral_shape",
27
+ "mel_bands",
28
+ "chroma",
29
+ "onset",
30
+ "novelty",
31
+ "loudness",
32
+ )
24
33
 
25
34
 
26
35
  # ── Capability probes ──────────────────────────────────────────────────
@@ -51,21 +60,296 @@ def _probe_web(timeout: float = 0.5) -> bool:
51
60
  return False
52
61
 
53
62
 
54
- def _probe_flucoma() -> bool:
55
- """Check whether the ``flucoma`` Python package is importable.
63
+ def _flucoma_package_dirs() -> list[Path]:
64
+ """Return likely Max package locations for FluCoMa."""
65
+ home = Path.home()
66
+ if os.name == "nt":
67
+ docs = Path(os.environ.get("USERPROFILE", str(home))) / "Documents"
68
+ else:
69
+ docs = home / "Documents"
70
+ return [
71
+ docs / "Max 9" / "Packages" / "FluidCorpusManipulation",
72
+ docs / "Max 8" / "Packages" / "FluidCorpusManipulation",
73
+ ]
56
74
 
57
- Uses ``importlib.util.find_spec`` so no import side-effects fire
58
- (matching the pattern already used for optional capability probes
59
- elsewhere in the codebase). Returns False if the package is missing
60
- or if the spec lookup itself raises.
75
+
76
+ def _probe_flucoma_package() -> bool:
77
+ """Check whether the FluCoMa Max package exists on disk.
78
+
79
+ LivePilot's real-time FluCoMa support is Max-for-Live based. There is
80
+ no required Python package named ``flucoma``; probing importability
81
+ caused false ``flucoma_not_installed`` reports on healthy systems.
61
82
  """
62
83
  try:
63
- return importlib.util.find_spec("flucoma") is not None
84
+ for package_dir in _flucoma_package_dirs():
85
+ if not package_dir.exists():
86
+ continue
87
+ if (package_dir / "package-info.json").exists():
88
+ return True
89
+ externals = package_dir / "externals"
90
+ if externals.exists() and any(externals.glob("fluid.*")):
91
+ return True
92
+ return True
93
+ return False
64
94
  except Exception as exc: # noqa: BLE001
65
- logger.debug("_probe_flucoma failed: %s", exc)
95
+ logger.debug("_probe_flucoma_package failed: %s", exc)
66
96
  return False
67
97
 
68
98
 
99
+ def _probe_flucoma(spectral=None) -> dict:
100
+ """Probe the Max/bridge-backed FluCoMa runtime.
101
+
102
+ ``available`` means at least one FluCoMa stream has reached the
103
+ spectral cache. ``device_loaded`` means the external package appears
104
+ installed (or streams prove it is working from a frozen analyzer).
105
+ """
106
+ package_installed = _probe_flucoma_package()
107
+ bridge_connected = bool(
108
+ spectral is not None and getattr(spectral, "is_connected", False)
109
+ )
110
+
111
+ streams: dict[str, bool] = {}
112
+ if bridge_connected:
113
+ for key in _FLUCOMA_STREAM_KEYS:
114
+ try:
115
+ streams[key] = spectral.get(key) is not None
116
+ except Exception as exc: # noqa: BLE001
117
+ logger.debug("_probe_flucoma stream %s failed: %s", key, exc)
118
+ streams[key] = False
119
+
120
+ active_streams = sum(1 for present in streams.values() if present)
121
+ available = active_streams > 0
122
+ device_loaded = package_installed or available
123
+
124
+ reasons: list[str] = []
125
+ if not device_loaded:
126
+ reasons.append("flucoma_not_installed")
127
+ if not bridge_connected:
128
+ reasons.append("flucoma_bridge_unavailable")
129
+ elif not available:
130
+ reasons.append("flucoma_no_streams")
131
+
132
+ return {
133
+ "available": available,
134
+ "device_loaded": device_loaded,
135
+ "bridge_connected": bridge_connected,
136
+ "active_streams": active_streams,
137
+ "streams": streams,
138
+ "reasons": reasons,
139
+ }
140
+
141
+
142
+ def _normalize_flucoma_probe(raw) -> dict:
143
+ """Back-compat shim for tests/extensions that monkeypatch a bool probe."""
144
+ if isinstance(raw, bool):
145
+ return {
146
+ "available": raw,
147
+ "device_loaded": raw,
148
+ "reasons": [] if raw else ["flucoma_not_installed"],
149
+ }
150
+ if isinstance(raw, dict):
151
+ available = bool(raw.get("available", False))
152
+ return {
153
+ **raw,
154
+ "available": available,
155
+ "device_loaded": bool(raw.get("device_loaded", available)),
156
+ "reasons": list(raw.get("reasons", [])),
157
+ }
158
+ return {
159
+ "available": False,
160
+ "device_loaded": False,
161
+ "reasons": ["flucoma_probe_failed"],
162
+ }
163
+
164
+
165
+ def _run_flucoma_probe(spectral=None) -> dict:
166
+ try:
167
+ return _normalize_flucoma_probe(_probe_flucoma(spectral))
168
+ except TypeError:
169
+ # Older tests monkeypatch _probe_flucoma as a no-arg callable.
170
+ return _normalize_flucoma_probe(_probe_flucoma())
171
+
172
+
173
+ def _session_info(ableton) -> dict:
174
+ try:
175
+ info = ableton.send_command("get_session_info")
176
+ return info if isinstance(info, dict) else {}
177
+ except Exception as exc: # noqa: BLE001
178
+ logger.debug("_session_info probe failed: %s", exc)
179
+ return {}
180
+
181
+
182
+ def _live_version_from_session(session_info: dict) -> str:
183
+ version = session_info.get("live_version")
184
+ return str(version) if version else "12.0.0"
185
+
186
+
187
+ def _normalize_probe_surface(
188
+ raw: Any,
189
+ *,
190
+ default_reason: str,
191
+ default_mode: str = "manual_only",
192
+ ) -> dict:
193
+ if isinstance(raw, dict):
194
+ mode = str(raw.get("mode") or default_mode)
195
+ available = bool(raw.get("available", mode in {"readable", "routable", "callable"}))
196
+ reasons = list(raw.get("reasons") or [])
197
+ if not available and not reasons:
198
+ reasons.append(default_reason)
199
+ return {
200
+ "mode": mode,
201
+ "available": available,
202
+ "reasons": reasons,
203
+ "observed": dict(raw.get("observed") or {}),
204
+ }
205
+ return {
206
+ "mode": default_mode,
207
+ "available": False,
208
+ "reasons": [default_reason],
209
+ "observed": {},
210
+ }
211
+
212
+
213
+ def _probe_link_audio_surface(ableton, session_info: Optional[dict] = None) -> dict:
214
+ """Read-only Link Audio surface probe.
215
+
216
+ Current Live 12.4 builds expose Link Audio as product UX, but no stable
217
+ Remote Script/M4L routing contract has been proven. This helper looks for
218
+ explicit evidence if future Remote Scripts surface it; otherwise it reports
219
+ manual_only with a reason instead of claiming support from version alone.
220
+ """
221
+ info = session_info or _session_info(ableton)
222
+ observed = {
223
+ "peers_visible": bool(info.get("link_audio_peers") or info.get("link_peers")),
224
+ "inputs_visible": bool(info.get("link_audio_inputs") or info.get("audio_inputs")),
225
+ "routing_visible": bool(info.get("link_audio_routing")),
226
+ }
227
+ if observed["peers_visible"] and observed["inputs_visible"] and observed["routing_visible"]:
228
+ return {
229
+ "mode": "routable",
230
+ "available": True,
231
+ "reasons": [],
232
+ "observed": observed,
233
+ }
234
+ if observed["peers_visible"] or observed["inputs_visible"]:
235
+ return {
236
+ "mode": "readable",
237
+ "available": True,
238
+ "reasons": [],
239
+ "observed": observed,
240
+ }
241
+ return {
242
+ "mode": "manual_only",
243
+ "available": False,
244
+ "reasons": ["link_audio_not_exposed"],
245
+ "observed": observed,
246
+ }
247
+
248
+
249
+ def _probe_stem_workflow_surface(ableton, session_info: Optional[dict] = None) -> dict:
250
+ """Read-only selected-time stem workflow probe.
251
+
252
+ This intentionally does not invoke stem separation. It only reports
253
+ callable evidence if a future Remote Script/M4L surface advertises a
254
+ stable command path.
255
+ """
256
+ info = session_info or _session_info(ableton)
257
+ callable_paths = list(info.get("stem_callable_paths") or [])
258
+ if callable_paths:
259
+ return {
260
+ "mode": "callable",
261
+ "available": True,
262
+ "reasons": [],
263
+ "observed": {"callable_paths": callable_paths},
264
+ }
265
+ return {
266
+ "mode": "manual_only",
267
+ "available": False,
268
+ "reasons": ["stem_command_not_observable"],
269
+ "observed": {"callable_paths": []},
270
+ }
271
+
272
+
273
+ def _probe_live_12_4_domain(
274
+ *,
275
+ ableton,
276
+ session_info: dict,
277
+ feature_available: bool,
278
+ surface_probe,
279
+ default_reason: str,
280
+ ) -> dict:
281
+ live_version = _live_version_from_session(session_info)
282
+ if not feature_available:
283
+ return {
284
+ "ok": True,
285
+ "live_version": live_version,
286
+ "mode": "unavailable",
287
+ "available": False,
288
+ "reasons": ["live_version_below_12_4"],
289
+ "observed": {},
290
+ }
291
+ try:
292
+ surface = _normalize_probe_surface(
293
+ surface_probe(ableton, session_info=session_info),
294
+ default_reason=default_reason,
295
+ )
296
+ except Exception as exc: # noqa: BLE001
297
+ logger.debug("Live 12.4 surface probe failed: %s", exc)
298
+ surface = {
299
+ "mode": "manual_only",
300
+ "available": False,
301
+ "reasons": [f"probe_failed: {type(exc).__name__}"],
302
+ "observed": {},
303
+ }
304
+ return {
305
+ "ok": True,
306
+ "live_version": live_version,
307
+ **surface,
308
+ }
309
+
310
+
311
+ def _probe_link_audio_domain(ableton, session_info: dict) -> dict:
312
+ from .live_version import LiveVersionCapabilities
313
+
314
+ caps = LiveVersionCapabilities.from_session_info(session_info)
315
+ return _probe_live_12_4_domain(
316
+ ableton=ableton,
317
+ session_info=session_info,
318
+ feature_available=caps.has_link_audio,
319
+ surface_probe=_probe_link_audio_surface,
320
+ default_reason="link_audio_not_exposed",
321
+ )
322
+
323
+
324
+ def _probe_stem_workflow_domain(ableton, session_info: dict) -> dict:
325
+ from .live_version import LiveVersionCapabilities
326
+
327
+ caps = LiveVersionCapabilities.from_session_info(session_info)
328
+ return _probe_live_12_4_domain(
329
+ ableton=ableton,
330
+ session_info=session_info,
331
+ feature_available=caps.has_stem_time_selection,
332
+ surface_probe=_probe_stem_workflow_surface,
333
+ default_reason="stem_command_not_observable",
334
+ )
335
+
336
+
337
+ @mcp.tool()
338
+ def probe_link_audio(ctx: Context) -> dict:
339
+ """Read-only probe for Live 12.4 Link Audio MCP controllability."""
340
+ ableton = ctx.lifespan_context["ableton"]
341
+ session_info = _session_info(ableton)
342
+ return _probe_link_audio_domain(ableton, session_info)
343
+
344
+
345
+ @mcp.tool()
346
+ def probe_stem_workflow(ctx: Context) -> dict:
347
+ """Read-only probe for Live 12.4 selected-time stem workflow support."""
348
+ ableton = ctx.lifespan_context["ableton"]
349
+ session_info = _session_info(ableton)
350
+ return _probe_stem_workflow_domain(ableton, session_info)
351
+
352
+
69
353
  @mcp.tool()
70
354
  def get_capability_state(ctx: Context) -> dict:
71
355
  """Probe the runtime and return a capability state snapshot.
@@ -77,13 +361,8 @@ def get_capability_state(ctx: Context) -> dict:
77
361
  spectral = ctx.lifespan_context.get("spectral")
78
362
 
79
363
  # ── Probe session ───────────────────────────────────────────────
80
- session_ok = False
81
- try:
82
- result = ableton.send_command("get_session_info")
83
- session_ok = isinstance(result, dict) and "error" not in result
84
- except Exception as exc:
85
- logger.debug("get_capability_state failed: %s", exc)
86
- session_ok = False
364
+ session_info = _session_info(ableton)
365
+ session_ok = bool(session_info) and "error" not in session_info
87
366
 
88
367
  # ── Probe analyzer (M4L bridge) ─────────────────────────────────
89
368
  analyzer_ok = False
@@ -109,8 +388,12 @@ def get_capability_state(ctx: Context) -> dict:
109
388
  # a curated research corpus is installed (see ``research`` domain).
110
389
  web_ok = _probe_web()
111
390
 
112
- # ── FluCoMa — optional import via find_spec (no side effects) ───
113
- flucoma_ok = _probe_flucoma()
391
+ # ── FluCoMa — Max package + live bridge streams ─────────────────
392
+ flucoma_probe = _run_flucoma_probe(spectral)
393
+
394
+ # ── Live 12.4 probe-first surfaces ──────────────────────────────
395
+ link_probe = _probe_link_audio_domain(ableton, session_info)
396
+ stem_probe = _probe_stem_workflow_domain(ableton, session_info)
114
397
 
115
398
  state = build_capability_state(
116
399
  session_ok=session_ok,
@@ -118,7 +401,13 @@ def get_capability_state(ctx: Context) -> dict:
118
401
  analyzer_fresh=analyzer_fresh,
119
402
  memory_ok=memory_ok,
120
403
  web_ok=web_ok,
121
- flucoma_ok=flucoma_ok,
404
+ flucoma_ok=flucoma_probe["available"],
405
+ flucoma_device_loaded=flucoma_probe["device_loaded"],
406
+ flucoma_reasons=flucoma_probe["reasons"],
407
+ link_audio_mode=link_probe["mode"],
408
+ link_audio_reasons=link_probe["reasons"],
409
+ stem_workflow_mode=stem_probe["mode"],
410
+ stem_workflow_reasons=stem_probe["reasons"],
122
411
  )
123
412
 
124
413
  return state.to_dict()
@@ -134,6 +423,7 @@ def get_session_kernel(
134
423
  creativity_profile: str = "",
135
424
  sacred_elements: Optional[list] = None,
136
425
  synth_hints: Optional[dict] = None,
426
+ operation_profile: str = "studio_deep",
137
427
  ) -> dict:
138
428
  """Build the unified turn snapshot for V2 orchestration.
139
429
 
@@ -159,6 +449,9 @@ def get_session_kernel(
159
449
  synth_hints: focus hints for synthesis_brain; shape is open in PR2
160
450
  and firms up in PR9. Typical keys: track_indices, device_paths,
161
451
  target_timbre, preferred_devices.
452
+ operation_profile: safety/intent posture for this turn. Known values:
453
+ safe_live, studio_deep, arrangement_build, sound_design_deep,
454
+ release_audit.
162
455
 
163
456
  Returns: SessionKernel dict with kernel_id, session topology, capabilities,
164
457
  memory context, routing hints, and (if provided) creative controls.
@@ -183,7 +476,9 @@ def get_session_kernel(
183
476
  # does, and propagate through. Without this the kernel's capability view
184
477
  # lies to orchestration planners.
185
478
  web_ok = _probe_web()
186
- flucoma_ok = _probe_flucoma()
479
+ flucoma_probe = _run_flucoma_probe(spectral)
480
+ link_probe = _probe_link_audio_domain(ableton, session_info if isinstance(session_info, dict) else {})
481
+ stem_probe = _probe_stem_workflow_domain(ableton, session_info if isinstance(session_info, dict) else {})
187
482
 
188
483
  # v1.17.4: probe memory the same way too. Previously memory_ok=True was
189
484
  # hardcoded — if the store raised, the kernel still reported memory
@@ -201,7 +496,13 @@ def get_session_kernel(
201
496
  analyzer_fresh=analyzer_fresh,
202
497
  memory_ok=memory_ok,
203
498
  web_ok=web_ok,
204
- flucoma_ok=flucoma_ok,
499
+ flucoma_ok=flucoma_probe["available"],
500
+ flucoma_device_loaded=flucoma_probe["device_loaded"],
501
+ flucoma_reasons=flucoma_probe["reasons"],
502
+ link_audio_mode=link_probe["mode"],
503
+ link_audio_reasons=link_probe["reasons"],
504
+ stem_workflow_mode=stem_probe["mode"],
505
+ stem_workflow_reasons=stem_probe["reasons"],
205
506
  )
206
507
 
207
508
  # Optional subcomponents — degrade gracefully, but reach into the SAME
@@ -286,6 +587,7 @@ def get_session_kernel(
286
587
  anti_preferences=anti_prefs,
287
588
  freshness=freshness,
288
589
  creativity_profile=creativity_profile,
590
+ operation_profile=operation_profile,
289
591
  sacred_elements=sacred_elements,
290
592
  synth_hints=synth_hints,
291
593
  )
@@ -9,6 +9,8 @@ from __future__ import annotations
9
9
  from fastmcp import Context
10
10
 
11
11
  from ..server import mcp
12
+ from ..evaluation.feature_extractors import extract_character_profile
13
+ from ..tools._snapshot_normalizer import normalize_sonic_snapshot
12
14
  from .models import (
13
15
  LayerStrategy,
14
16
  PatchBlock,
@@ -189,9 +191,36 @@ def _fetch_sound_design_data(ctx: Context, track_index: int) -> dict:
189
191
  # Get devices from track_info response (already included by Remote Script)
190
192
  devices: list[dict] = track_info.get("devices", [])
191
193
 
194
+ sonic = None
195
+ try:
196
+ spectral = ctx.lifespan_context.get("spectral")
197
+ if spectral and spectral.is_connected:
198
+ sonic = {}
199
+ spec_data = spectral.get("spectrum")
200
+ if spec_data:
201
+ sonic["bands"] = spec_data["value"]
202
+ rms_snap = spectral.get("rms")
203
+ if rms_snap:
204
+ sonic["rms"] = rms_snap["value"]
205
+ peak_snap = spectral.get("peak")
206
+ if peak_snap:
207
+ sonic["peak"] = peak_snap["value"]
208
+ key_snap = spectral.get("key")
209
+ if key_snap:
210
+ sonic["detected_key"] = key_snap["value"]
211
+ for key in ("spectral_shape", "mel_bands", "chroma", "onset", "novelty", "loudness"):
212
+ snap = spectral.get(key)
213
+ if snap:
214
+ sonic[key] = snap["value"]
215
+ if not sonic:
216
+ sonic = None
217
+ except Exception:
218
+ sonic = None
219
+
192
220
  return {
193
221
  "track_info": track_info,
194
222
  "devices": devices,
223
+ "sonic_snapshot": normalize_sonic_snapshot(sonic, source="sound_design"),
195
224
  }
196
225
 
197
226
 
@@ -262,9 +291,11 @@ def analyze_sound_design(ctx: Context, track_index: int) -> dict:
262
291
  issues, data["track_info"].get("name", "")
263
292
  )
264
293
  moves = plan_sound_design_moves(issues, state)
294
+ sonic_character = extract_character_profile(data.get("sonic_snapshot") or {})
265
295
 
266
296
  return {
267
297
  "state": state.to_dict(),
298
+ "sonic_character": sonic_character,
268
299
  "issues": [i.to_dict() for i in issues],
269
300
  "suggested_moves": [m.to_dict() for m in moves],
270
301
  "issue_count": len(issues),
@@ -295,6 +326,7 @@ def get_sound_design_issues(ctx: Context, track_index: int) -> dict:
295
326
  )
296
327
 
297
328
  return {
329
+ "sonic_character": extract_character_profile(data.get("sonic_snapshot") or {}),
298
330
  "issues": [i.to_dict() for i in issues],
299
331
  "issue_count": len(issues),
300
332
  }
@@ -330,6 +362,7 @@ def plan_sound_design_move(ctx: Context, track_index: int) -> dict:
330
362
  moves = plan_sound_design_moves(issues, state)
331
363
 
332
364
  result: dict = {
365
+ "sonic_character": extract_character_profile(data.get("sonic_snapshot") or {}),
333
366
  "moves": [m.to_dict() for m in moves],
334
367
  "move_count": len(moves),
335
368
  "issue_count": len(issues),
@@ -11,6 +11,10 @@ import re
11
11
  from dataclasses import asdict, dataclass, field
12
12
  from typing import Any, Optional
13
13
 
14
+ from ...evaluation.feature_extractors import (
15
+ extract_dimension_value as _shared_extract_dimension_value,
16
+ )
17
+ from .._snapshot_normalizer import normalize_sonic_snapshot
14
18
  from .models import QUALITY_DIMENSIONS, GoalVector, WorldModel, _clamp
15
19
  from .taste import compute_taste_fit
16
20
 
@@ -29,50 +33,10 @@ def _extract_dimension_value(
29
33
  """
30
34
  if not sonic:
31
35
  return None
32
- # Accept both "spectrum" and "bands" keys — get_master_spectrum returns
33
- # {"bands": {...}} while the evaluator historically expected {"spectrum": {...}}.
34
- # Finding 2 fix: tolerate either shape so raw analyzer output works.
35
- bands = sonic.get("spectrum") or sonic.get("bands")
36
- if not bands:
37
- return None
38
- rms = sonic.get("rms")
39
- peak = sonic.get("peak")
40
-
41
- if dimension == "brightness":
42
- high = bands.get("high", 0)
43
- presence = bands.get("presence", 0)
44
- return _clamp((high + presence) / 2.0)
45
- elif dimension == "warmth":
46
- return _clamp(bands.get("low_mid", 0))
47
- elif dimension == "weight":
48
- sub = bands.get("sub", 0)
49
- low = bands.get("low", 0)
50
- return _clamp((sub + low) / 2.0)
51
- elif dimension == "clarity":
52
- low_mid = bands.get("low_mid", 0)
53
- return _clamp(1.0 - low_mid)
54
- elif dimension == "density":
55
- # Spectral flatness: geometric mean / arithmetic mean of band values.
56
- # Higher = more evenly distributed energy (noise-like).
57
- # Lower = more tonal (energy concentrated in few bands).
58
- vals = [max(v, 1e-10) for v in bands.values() if isinstance(v, (int, float))]
59
- if not vals:
60
- return None
61
- geo_mean = math.exp(sum(math.log(v) for v in vals) / len(vals))
62
- arith_mean = sum(vals) / len(vals)
63
- return _clamp(geo_mean / max(arith_mean, 1e-10))
64
- elif dimension == "energy":
65
- return _clamp(rms) if rms is not None else None
66
- elif dimension == "punch":
67
- if rms and peak and rms > 0:
68
- crest_db = 20.0 * math.log10(max(peak / rms, 1.0))
69
- # Normalize: 0 dB = 0.0, 20 dB = 1.0
70
- return _clamp(crest_db / 20.0)
71
- return None
72
- else:
73
- # Unmeasurable in Phase 1 (width, depth, motion, contrast,
74
- # groove, tension, novelty, polish, emotion, cohesion)
36
+ normalized = normalize_sonic_snapshot(sonic, source="agent_os")
37
+ if normalized is None:
75
38
  return None
39
+ return _shared_extract_dimension_value(normalized, dimension)
76
40
 
77
41
  def compute_evaluation_score(
78
42
  goal: GoalVector,
@@ -203,4 +167,3 @@ def compute_evaluation_score(
203
167
  # I5: hint for the agent to track consecutive undos
204
168
  "consecutive_undo_hint": not keep_change,
205
169
  }
206
-
@@ -33,6 +33,8 @@ MEASURABLE_PROXIES: dict[str, str] = {
33
33
  "density": "spectral flatness (geometric/arithmetic mean ratio)",
34
34
  "energy": "RMS level",
35
35
  "punch": "crest factor in dB (20*log10(peak/rms))",
36
+ "motion": "spectral novelty + onset strength",
37
+ "novelty": "FluCoMa novelty score",
36
38
  }
37
39
 
38
40
  VALID_MODES = frozenset({"observe", "improve", "explore", "finish", "diagnose"})
@@ -129,4 +131,3 @@ class TechniqueCard:
129
131
  "verification": self.verification,
130
132
  "evidence": self.evidence,
131
133
  }
132
-
@@ -70,7 +70,7 @@ class ConductorPlan:
70
70
  _ROUTING_PATTERNS: list[tuple[str, str, str, str, list[str]]] = [
71
71
  # Mix requests
72
72
  (r"clean|mud|muddy|low.?mid|eq|equaliz", "mix_engine", "mix", "analyze_mix", ["plan_mix_move", "evaluate_mix_move"]),
73
- (r"punch|punchy|transient|dynamics|compress", "mix_engine", "mix", "analyze_mix", ["plan_mix_move"]),
73
+ (r"dynamics|compress|crest|over.?compress|flat.?dynamics", "mix_engine", "mix", "analyze_mix", ["plan_mix_move"]),
74
74
  (r"wide|wider|width|stereo|narrow|mono.?compat", "mix_engine", "mix", "analyze_mix", ["plan_mix_move"]),
75
75
  (r"glue|cohes|bus.?comp|mix.?bus", "mix_engine", "mix", "analyze_mix", ["plan_mix_move"]),
76
76
  (r"balance|level|volume.?balanc|gain.?stag", "mix_engine", "mix", "analyze_mix", ["plan_mix_move"]),
@@ -87,6 +87,7 @@ _ROUTING_PATTERNS: list[tuple[str, str, str, str, list[str]]] = [
87
87
 
88
88
  # Sound design requests
89
89
  (r"synth|patch|oscillat|timbre|timbral|wavetable|operator", "sound_design", "sound_design", "analyze_sound_design", ["plan_sound_design_move"]),
90
+ (r"punch|punchy|hit.?harder|snap|attack|transient", "sound_design", "sound_design", "analyze_sound_design", ["plan_sound_design_move"]),
90
91
  (r"haunted|lush|aggressive|warm.?pad|fat.?bass|bright.?lead", "sound_design", "sound_design", "analyze_sound_design", ["plan_sound_design_move"]),
91
92
  (r"modulation|lfo|movement|evolv|texture", "sound_design", "sound_design", "get_patch_model", ["analyze_sound_design"]),
92
93
  (r"layer|sub.?layer|transient.?layer|body", "sound_design", "sound_design", "analyze_sound_design", ["plan_sound_design_move"]),
@@ -254,7 +255,7 @@ def classify_request(request: str) -> ConductorPlan:
254
255
 
255
256
  # Determine capability requirements
256
257
  caps = ["session_access"]
257
- if any(r.engine == "mix_engine" for r in routes):
258
+ if any(r.engine in ("mix_engine", "sound_design") for r in routes):
258
259
  caps.append("analyzer")
259
260
  if any(r.engine in ("reference_engine",) for r in routes):
260
261
  caps.append("offline_perception")
@@ -267,6 +268,8 @@ def classify_request(request: str) -> ConductorPlan:
267
268
  notes.append("Multi-engine task — start with get_session_kernel for shared state")
268
269
  if any(r.engine == "mix_engine" for r in routes):
269
270
  notes.append("Mix engine works best with analyzer data — check get_capability_state")
271
+ if any(r.engine == "sound_design" for r in routes):
272
+ notes.append("Sound design should use analyzer character before level or pan changes")
270
273
 
271
274
  # V2: Search semantic moves for matching intents
272
275
  semantic_moves = _find_matching_semantic_moves(lower)
@@ -22,7 +22,7 @@ from typing import Optional
22
22
  # must report confidence=0.0 for that dimension.
23
23
  MEASURABLE_DIMENSIONS: frozenset[str] = frozenset({
24
24
  "brightness", "warmth", "weight", "clarity",
25
- "density", "energy", "punch",
25
+ "density", "energy", "punch", "motion", "novelty",
26
26
  })
27
27
 
28
28
  # All valid quality dimensions (measurable + unmeasurable).