livepilot 1.10.5 → 1.10.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.mcp.json.disabled +9 -0
- package/.mcpbignore +3 -0
- package/AGENTS.md +3 -3
- package/BUGS.md +1570 -0
- package/CHANGELOG.md +92 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +28 -8
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +4 -4
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +8 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
- package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
- package/m4l_device/livepilot_bridge.js +226 -3
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +93 -26
- package/mcp_server/composer/sample_resolver.py +10 -6
- package/mcp_server/composer/tools.py +10 -6
- package/mcp_server/connection.py +6 -1
- package/mcp_server/creative_constraints/tools.py +214 -40
- package/mcp_server/experiment/engine.py +16 -14
- package/mcp_server/experiment/tools.py +9 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +74 -18
- package/mcp_server/m4l_bridge.py +32 -6
- package/mcp_server/memory/taste_graph.py +7 -2
- package/mcp_server/mix_engine/tools.py +8 -3
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/musical_intelligence/tools.py +15 -10
- package/mcp_server/performance_engine/tools.py +117 -30
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +43 -21
- package/mcp_server/project_brain/automation_graph.py +71 -19
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/tools.py +73 -15
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +54 -11
- package/mcp_server/runtime/capability_probe.py +10 -4
- package/mcp_server/runtime/execution_router.py +50 -0
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +4 -2
- package/mcp_server/runtime/tools.py +8 -2
- package/mcp_server/sample_engine/analyzer.py +131 -4
- package/mcp_server/sample_engine/critics.py +29 -8
- package/mcp_server/sample_engine/models.py +20 -1
- package/mcp_server/sample_engine/tools.py +74 -31
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +5 -1
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +78 -11
- package/mcp_server/services/motif_service.py +9 -3
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tools.py +7 -3
- package/mcp_server/session_continuity/tracker.py +23 -9
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +94 -25
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +19 -6
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +49 -5
- package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
- package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
- package/mcp_server/tools/_agent_os_engine/models.py +132 -0
- package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
- package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
- package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
- package/mcp_server/tools/_composition_engine/__init__.py +67 -0
- package/mcp_server/tools/_composition_engine/analysis.py +174 -0
- package/mcp_server/tools/_composition_engine/critics.py +522 -0
- package/mcp_server/tools/_composition_engine/gestures.py +230 -0
- package/mcp_server/tools/_composition_engine/harmony.py +160 -0
- package/mcp_server/tools/_composition_engine/models.py +193 -0
- package/mcp_server/tools/_composition_engine/sections.py +414 -0
- package/mcp_server/tools/_harmony_engine.py +52 -8
- package/mcp_server/tools/_perception_engine.py +18 -11
- package/mcp_server/tools/_research_engine.py +98 -19
- package/mcp_server/tools/_theory_engine.py +138 -9
- package/mcp_server/tools/agent_os.py +43 -18
- package/mcp_server/tools/analyzer.py +105 -8
- package/mcp_server/tools/automation.py +6 -1
- package/mcp_server/tools/clips.py +45 -0
- package/mcp_server/tools/composition.py +90 -38
- package/mcp_server/tools/devices.py +32 -7
- package/mcp_server/tools/harmony.py +115 -14
- package/mcp_server/tools/midi_io.py +13 -1
- package/mcp_server/tools/mixing.py +35 -1
- package/mcp_server/tools/motif.py +56 -5
- package/mcp_server/tools/planner.py +6 -2
- package/mcp_server/tools/research.py +37 -10
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/transition_engine/tools.py +6 -1
- package/mcp_server/translation_engine/tools.py +8 -6
- package/mcp_server/wonder_mode/engine.py +8 -3
- package/mcp_server/wonder_mode/tools.py +29 -21
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +57 -2
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/router.py +13 -1
- package/scripts/generate_tool_catalog.py +13 -38
- package/scripts/sync_metadata.py +231 -14
- package/mcp_server/tools/_agent_os_engine.py +0 -947
- package/mcp_server/tools/_composition_engine.py +0 -1530
package/mcp_server/server.py
CHANGED
|
@@ -33,20 +33,83 @@ def _identify_port_holder(port: int) -> str | None:
|
|
|
33
33
|
text=True, timeout=2,
|
|
34
34
|
).strip()
|
|
35
35
|
return f"{pid} ({cmdline[:60]})"
|
|
36
|
-
except (subprocess.CalledProcessError,
|
|
36
|
+
except (subprocess.CalledProcessError,
|
|
37
|
+
subprocess.TimeoutExpired,
|
|
38
|
+
FileNotFoundError):
|
|
37
39
|
return str(pid)
|
|
38
40
|
return None
|
|
39
|
-
except (subprocess.CalledProcessError,
|
|
41
|
+
except (subprocess.CalledProcessError,
|
|
42
|
+
subprocess.TimeoutExpired,
|
|
43
|
+
FileNotFoundError,
|
|
44
|
+
ValueError):
|
|
45
|
+
# TimeoutExpired catches the busy-system case where lsof exceeds
|
|
46
|
+
# the 3-second budget; we treat it as "can't identify" and return
|
|
47
|
+
# None so startup never stalls for slow host diagnostics.
|
|
40
48
|
return None
|
|
41
49
|
|
|
42
50
|
|
|
51
|
+
def _check_remote_script_version(ableton: AbletonConnection) -> None:
|
|
52
|
+
"""BUG-A1: detect stale Remote Script installs at startup.
|
|
53
|
+
|
|
54
|
+
The installed Remote Script is loaded by Ableton at its own launch time
|
|
55
|
+
and cached in Python's module system — source-tree edits don't take
|
|
56
|
+
effect until the user reinstalls + restarts Live. When the installed
|
|
57
|
+
copy lags behind the MCP-server source, commands added after the install
|
|
58
|
+
date (e.g. ``insert_device`` in v1.10.6) return "Unknown command type".
|
|
59
|
+
|
|
60
|
+
This check pings the Remote Script, compares its reported version to
|
|
61
|
+
the MCP server version, and logs a loud warning on mismatch. We don't
|
|
62
|
+
abort — the server should still work for whatever handlers the older
|
|
63
|
+
Remote Script does support — but we make the drift visible.
|
|
64
|
+
"""
|
|
65
|
+
import sys
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
from . import __version__ as mcp_version
|
|
69
|
+
except ImportError:
|
|
70
|
+
mcp_version = "unknown"
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
pong = ableton.send_command("ping")
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
import logging as _logging
|
|
76
|
+
_logging.getLogger(__name__).debug(
|
|
77
|
+
"Remote Script version check failed: %s", exc,
|
|
78
|
+
)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if not isinstance(pong, dict):
|
|
82
|
+
return
|
|
83
|
+
rs_version = pong.get("remote_script_version")
|
|
84
|
+
if rs_version is None:
|
|
85
|
+
# Remote Script is old enough that it doesn't even embed its version
|
|
86
|
+
# in ping responses — definitely stale.
|
|
87
|
+
msg = (
|
|
88
|
+
"LivePilot: Remote Script is out of date (pre-version-handshake). "
|
|
89
|
+
"Run 'npx livepilot --install' and restart Ableton Live to fix "
|
|
90
|
+
"'Unknown command type' errors for newer tools (insert_device, "
|
|
91
|
+
"set_clip_pitch, etc)."
|
|
92
|
+
)
|
|
93
|
+
print(msg, file=sys.stderr)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
if str(rs_version) != str(mcp_version):
|
|
97
|
+
msg = (
|
|
98
|
+
f"LivePilot: Remote Script version {rs_version} does not match "
|
|
99
|
+
f"MCP server version {mcp_version}. Newer tools may fail with "
|
|
100
|
+
f"'Unknown command type'. Run 'npx livepilot --install' and "
|
|
101
|
+
f"restart Ableton Live to resync."
|
|
102
|
+
)
|
|
103
|
+
print(msg, file=sys.stderr)
|
|
104
|
+
|
|
105
|
+
|
|
43
106
|
def _master_has_livepilot_analyzer(ableton: AbletonConnection) -> bool:
|
|
44
107
|
"""Check whether the analyzer device is currently on the master track."""
|
|
45
108
|
try:
|
|
46
109
|
track = ableton.send_command("get_master_track")
|
|
47
|
-
except Exception:
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
logger.debug("_master_has_livepilot_analyzer failed: %s", exc)
|
|
48
112
|
return False
|
|
49
|
-
|
|
50
113
|
devices = track.get("devices", []) if isinstance(track, dict) else []
|
|
51
114
|
for device in devices:
|
|
52
115
|
normalized = " ".join(
|
|
@@ -92,7 +155,8 @@ async def lifespan(server):
|
|
|
92
155
|
splice_client = SpliceGRPCClient()
|
|
93
156
|
try:
|
|
94
157
|
await splice_client.connect()
|
|
95
|
-
except Exception:
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
logger.debug("lifespan failed: %s", exc)
|
|
96
160
|
pass # client remains in disconnected state
|
|
97
161
|
|
|
98
162
|
# Start UDP listener for incoming M4L spectral data (port 9880)
|
|
@@ -127,6 +191,9 @@ async def lifespan(server):
|
|
|
127
191
|
}
|
|
128
192
|
|
|
129
193
|
try:
|
|
194
|
+
# BUG-A1: detect stale Remote Script installs early so the user
|
|
195
|
+
# sees a clear message instead of cryptic "Unknown command type" errors.
|
|
196
|
+
_check_remote_script_version(ableton)
|
|
130
197
|
if bridge_state["transport"] is not None:
|
|
131
198
|
await _warm_analyzer_bridge(ableton, spectral)
|
|
132
199
|
yield {
|
|
@@ -144,10 +211,8 @@ async def lifespan(server):
|
|
|
144
211
|
ableton.disconnect()
|
|
145
212
|
try:
|
|
146
213
|
await splice_client.disconnect()
|
|
147
|
-
except Exception:
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
logger.debug("lifespan failed: %s", exc)
|
|
151
216
|
mcp = FastMCP("LivePilot", lifespan=lifespan)
|
|
152
217
|
|
|
153
218
|
# Import tool modules so they register with `mcp`
|
|
@@ -199,7 +264,9 @@ from .device_forge import tools as device_forge_tools # noqa: F401, E40
|
|
|
199
264
|
from .sample_engine import tools as sample_engine_tools # noqa: F401, E402
|
|
200
265
|
from .atlas import tools as atlas_tools # noqa: F401, E402
|
|
201
266
|
from .composer import tools as composer_tools # noqa: F401, E402
|
|
267
|
+
import logging
|
|
202
268
|
|
|
269
|
+
logger = logging.getLogger(__name__)
|
|
203
270
|
|
|
204
271
|
# ---------------------------------------------------------------------------
|
|
205
272
|
# Schema coercion patch — accept strings for numeric parameters
|
|
@@ -213,6 +280,7 @@ from .composer import tools as composer_tools # noqa: F401, E40
|
|
|
213
280
|
# "5" → 5 and "0.75" → 0.75 automatically, so no tool code changes needed.
|
|
214
281
|
# ---------------------------------------------------------------------------
|
|
215
282
|
|
|
283
|
+
|
|
216
284
|
def _coerce_schema_property(prop: dict) -> None:
|
|
217
285
|
"""Widen a single JSON Schema property to also accept strings."""
|
|
218
286
|
if prop.get("type") in ("integer", "number") and "anyOf" not in prop:
|
|
@@ -253,6 +321,7 @@ def _get_all_tools():
|
|
|
253
321
|
if hasattr(mcp, "_local_provider") and hasattr(mcp._local_provider, "_components"):
|
|
254
322
|
return list(mcp._local_provider._components.values())
|
|
255
323
|
import sys
|
|
324
|
+
|
|
256
325
|
print(
|
|
257
326
|
"LivePilot: WARNING — could not access FastMCP tool registry, "
|
|
258
327
|
"string-to-number schema coercion will not work",
|
|
@@ -273,7 +342,6 @@ def _patch_tool_schemas() -> None:
|
|
|
273
342
|
if isinstance(definition, dict):
|
|
274
343
|
_coerce_schema_property(definition)
|
|
275
344
|
|
|
276
|
-
|
|
277
345
|
_patch_tool_schemas()
|
|
278
346
|
|
|
279
347
|
|
|
@@ -281,6 +349,5 @@ def main():
|
|
|
281
349
|
"""Run the MCP server over stdio."""
|
|
282
350
|
mcp.run(transport="stdio")
|
|
283
351
|
|
|
284
|
-
|
|
285
352
|
if __name__ == "__main__":
|
|
286
353
|
main()
|
|
@@ -9,6 +9,9 @@ Pure computation — no I/O. Callers provide pre-fetched data.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
from typing import Optional
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
def get_motif_data(
|
|
@@ -28,13 +31,15 @@ def get_motif_data(
|
|
|
28
31
|
|
|
29
32
|
try:
|
|
30
33
|
from ..tools import _motif_engine as motif_engine
|
|
34
|
+
|
|
31
35
|
motifs = motif_engine.detect_motifs(notes_by_track)
|
|
32
36
|
return {
|
|
33
37
|
"motifs": [m.to_dict() for m in motifs],
|
|
34
38
|
"motif_count": len(motifs),
|
|
35
39
|
"tracks_analyzed": len(notes_by_track),
|
|
36
40
|
}
|
|
37
|
-
except Exception:
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
logger.debug("get_motif_data failed: %s", exc)
|
|
38
43
|
return {"motifs": [], "motif_count": 0, "tracks_analyzed": 0}
|
|
39
44
|
|
|
40
45
|
|
|
@@ -60,8 +65,9 @@ def fetch_notes_from_ableton(ableton, tracks: list[dict], max_clips: int = 8) ->
|
|
|
60
65
|
"clip_index": clip_idx,
|
|
61
66
|
})
|
|
62
67
|
track_notes.extend(result.get("notes", []))
|
|
63
|
-
except Exception:
|
|
64
|
-
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
logger.debug("fetch_notes_from_ableton failed: %s", exc)
|
|
70
|
+
|
|
65
71
|
if track_notes:
|
|
66
72
|
notes_by_track[t_idx] = track_notes
|
|
67
73
|
return notes_by_track
|
|
@@ -50,6 +50,9 @@ class SessionStory:
|
|
|
50
50
|
"""The narrative of the current session."""
|
|
51
51
|
|
|
52
52
|
song_id: str = ""
|
|
53
|
+
# BUG-B16: link back to the SongBrain snapshot that generated the
|
|
54
|
+
# identity_summary so callers can tell which brain was used.
|
|
55
|
+
song_brain_id: str = ""
|
|
53
56
|
identity_summary: str = ""
|
|
54
57
|
what_changed_last: str = ""
|
|
55
58
|
what_still_feels_open: list[str] = field(default_factory=list)
|
|
@@ -60,6 +63,7 @@ class SessionStory:
|
|
|
60
63
|
def to_dict(self) -> dict:
|
|
61
64
|
return {
|
|
62
65
|
"song_id": self.song_id,
|
|
66
|
+
"song_brain_id": self.song_brain_id,
|
|
63
67
|
"identity_summary": self.identity_summary,
|
|
64
68
|
"what_changed_last": self.what_changed_last,
|
|
65
69
|
"what_still_feels_open": self.what_still_feels_open,
|
|
@@ -15,6 +15,9 @@ from fastmcp import Context
|
|
|
15
15
|
|
|
16
16
|
from ..server import mcp
|
|
17
17
|
from . import tracker
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
@mcp.tool()
|
|
@@ -200,7 +203,6 @@ def explain_preference_vs_identity(
|
|
|
200
203
|
"note": "Identity has stronger weight inside a session (0.65 vs 0.35)",
|
|
201
204
|
}
|
|
202
205
|
|
|
203
|
-
|
|
204
206
|
# ── Helpers ───────────────────────────────────────────────────────
|
|
205
207
|
|
|
206
208
|
|
|
@@ -222,9 +224,11 @@ def _get_taste_graph(ctx: Context) -> dict:
|
|
|
222
224
|
from ..memory.taste_graph import build_taste_graph
|
|
223
225
|
from ..memory.taste_memory import TasteMemoryStore
|
|
224
226
|
from ..memory.anti_memory import AntiMemoryStore
|
|
227
|
+
|
|
225
228
|
taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
|
|
226
229
|
anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
|
|
227
230
|
return build_taste_graph(taste_store=taste_store, anti_store=anti_store).to_dict()
|
|
228
|
-
except Exception:
|
|
229
|
-
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
logger.debug("_get_taste_graph failed: %s", exc)
|
|
233
|
+
|
|
230
234
|
return {}
|
|
@@ -7,6 +7,7 @@ Separates taste (cross-session) from identity (in-song) ranking.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import hashlib
|
|
10
|
+
import logging
|
|
10
11
|
import time
|
|
11
12
|
from typing import Optional
|
|
12
13
|
|
|
@@ -17,6 +18,8 @@ from .models import (
|
|
|
17
18
|
TurnResolution,
|
|
18
19
|
)
|
|
19
20
|
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
20
23
|
|
|
21
24
|
# ── In-memory state ───────────────────────────────────────────────
|
|
22
25
|
|
|
@@ -47,10 +50,23 @@ def reset_story() -> None:
|
|
|
47
50
|
def get_session_story(
|
|
48
51
|
song_brain: Optional[dict] = None,
|
|
49
52
|
) -> SessionStory:
|
|
50
|
-
"""Get the current session story with identity summary.
|
|
53
|
+
"""Get the current session story with identity summary.
|
|
54
|
+
|
|
55
|
+
BUG-B16: now also populates song_brain_id from the passed brain so
|
|
56
|
+
callers can tell which brain generated the identity_summary.
|
|
57
|
+
Previously the field was empty and users got a half-populated
|
|
58
|
+
response that read as "something's wrong" even though the partial
|
|
59
|
+
data was correct for a fresh session.
|
|
60
|
+
"""
|
|
51
61
|
song_brain = song_brain or {}
|
|
52
62
|
|
|
53
63
|
_story.identity_summary = song_brain.get("identity_core", "")
|
|
64
|
+
_story.song_brain_id = str(song_brain.get("brain_id", "") or "")
|
|
65
|
+
# Carry song_id through when present on the brain — fresh sessions
|
|
66
|
+
# leave this empty, which is documented below.
|
|
67
|
+
if not _story.song_id and song_brain.get("song_id"):
|
|
68
|
+
_story.song_id = str(song_brain.get("song_id"))
|
|
69
|
+
|
|
54
70
|
_story.threads = [t for t in _threads.values() if t.status == "open"]
|
|
55
71
|
_story.turns = _turns
|
|
56
72
|
_story.what_still_feels_open = [
|
|
@@ -129,9 +145,8 @@ def record_turn_resolution(
|
|
|
129
145
|
if _project_store is not None:
|
|
130
146
|
try:
|
|
131
147
|
_project_store.save_turn(turn.to_dict())
|
|
132
|
-
except Exception:
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
logger.debug("record_turn_resolution failed: %s", exc)
|
|
135
150
|
return turn
|
|
136
151
|
|
|
137
152
|
|
|
@@ -158,9 +173,8 @@ def open_thread(description: str, domain: str = "", priority: float = 0.5) -> Cr
|
|
|
158
173
|
if _project_store is not None:
|
|
159
174
|
try:
|
|
160
175
|
_project_store.save_thread(thread.to_dict())
|
|
161
|
-
except Exception:
|
|
162
|
-
|
|
163
|
-
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
logger.debug("open_thread failed: %s", exc)
|
|
164
178
|
return thread
|
|
165
179
|
|
|
166
180
|
|
|
@@ -173,8 +187,8 @@ def resolve_thread(thread_id: str) -> Optional[CreativeThread]:
|
|
|
173
187
|
if _project_store is not None:
|
|
174
188
|
try:
|
|
175
189
|
_project_store.save_thread(thread.to_dict())
|
|
176
|
-
except Exception:
|
|
177
|
-
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
logger.debug("resolve_thread failed: %s", exc)
|
|
178
192
|
return thread
|
|
179
193
|
|
|
180
194
|
|
|
@@ -127,6 +127,14 @@ def _infer_identity_core(
|
|
|
127
127
|
"""Infer the single strongest defining idea in the session.
|
|
128
128
|
|
|
129
129
|
Returns (description, confidence).
|
|
130
|
+
|
|
131
|
+
BUG-B10 fix: the old logic picked "Dominant texture: drums" at
|
|
132
|
+
confidence 0.5 for almost every session — because drum tracks
|
|
133
|
+
typically have the most notes. We now consider richer signals:
|
|
134
|
+
featured vocals, scene-name aesthetics, tempo+key context, and
|
|
135
|
+
single-instrument dominance. When multiple low-confidence signals
|
|
136
|
+
align (e.g. "dust" aesthetic + vocal hook + D minor key), we
|
|
137
|
+
combine them into a compound identity string.
|
|
130
138
|
"""
|
|
131
139
|
candidates: list[tuple[str, float]] = []
|
|
132
140
|
|
|
@@ -144,7 +152,7 @@ def _infer_identity_core(
|
|
|
144
152
|
if arc_type:
|
|
145
153
|
candidates.append((f"Emotional arc: {arc_type}", 0.6))
|
|
146
154
|
|
|
147
|
-
# From role graph — dominant texture
|
|
155
|
+
# From role graph — dominant texture (kept but gently deranked)
|
|
148
156
|
# role_graph format: {track_name: {index: int, role: str}}
|
|
149
157
|
if role_graph:
|
|
150
158
|
role_counts = Counter(
|
|
@@ -153,9 +161,15 @@ def _infer_identity_core(
|
|
|
153
161
|
if isinstance(info, dict)
|
|
154
162
|
)
|
|
155
163
|
role_counts.pop("unknown", None)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
164
|
+
# B10 fix: drums being the "dominant texture" is almost never
|
|
165
|
+
# what the song is ABOUT — it's just that drum tracks have the
|
|
166
|
+
# most notes. Skip drums/perc from this candidate stream.
|
|
167
|
+
_BORING_DOMINANT = {"drums", "percussion", "kick", "snare", "hat"}
|
|
168
|
+
for role, _ in role_counts.most_common(3):
|
|
169
|
+
if role.lower() in _BORING_DOMINANT:
|
|
170
|
+
continue
|
|
171
|
+
candidates.append((f"Dominant texture: {role}", 0.55))
|
|
172
|
+
break
|
|
159
173
|
|
|
160
174
|
# From track analysis — genre/style cues
|
|
161
175
|
track_names = [t.get("name", "").lower() for t in tracks]
|
|
@@ -163,12 +177,65 @@ def _infer_identity_core(
|
|
|
163
177
|
if genre_cues:
|
|
164
178
|
candidates.append((f"Style: {genre_cues}", 0.4))
|
|
165
179
|
|
|
180
|
+
# BUG-B10: featured instrument — a named vocal/pad/lead track with
|
|
181
|
+
# an explicit function is usually more identity-defining than
|
|
182
|
+
# "dominant texture: drums".
|
|
183
|
+
_FEATURED_TOKENS = (
|
|
184
|
+
("vocal", "vocal hook", 0.75),
|
|
185
|
+
("vox", "vocal hook", 0.72),
|
|
186
|
+
("pad", "pad-led atmosphere", 0.55),
|
|
187
|
+
("lead", "lead synth melody", 0.65),
|
|
188
|
+
("rhodes", "rhodes-keys texture", 0.60),
|
|
189
|
+
("piano", "piano-led harmony", 0.60),
|
|
190
|
+
("guitar", "guitar-led", 0.60),
|
|
191
|
+
("saxophone", "saxophone solo", 0.65),
|
|
192
|
+
("brass", "brass section", 0.55),
|
|
193
|
+
)
|
|
194
|
+
for name in track_names:
|
|
195
|
+
for token, label, conf in _FEATURED_TOKENS:
|
|
196
|
+
if token in name:
|
|
197
|
+
candidates.append((f"Featured element: {label}", conf))
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
# BUG-B10: scene-name aesthetic cues. A scene named "Intro Dust" /
|
|
201
|
+
# "Outro Dust" signals a deliberate dust/lo-fi aesthetic; "Sun Peak"
|
|
202
|
+
# / "Peak" signals a climax-oriented structure. Pull these from the
|
|
203
|
+
# composition-analysis section list if present.
|
|
204
|
+
_AESTHETIC_TOKENS = (
|
|
205
|
+
("dust", "dust-toned lo-fi"),
|
|
206
|
+
("sun", "warm/sun-peaked"),
|
|
207
|
+
("fog", "foggy/dreamy"),
|
|
208
|
+
("glass", "brittle/glass-like"),
|
|
209
|
+
("void", "void/ambient-spatial"),
|
|
210
|
+
("haze", "hazy/nostalgic"),
|
|
211
|
+
("bloom", "blooming/evolving"),
|
|
212
|
+
)
|
|
213
|
+
sections = composition.get("sections", []) or []
|
|
214
|
+
section_names = " ".join(
|
|
215
|
+
str(s.get("name", "") or s.get("label", "")).lower()
|
|
216
|
+
for s in sections
|
|
217
|
+
)
|
|
218
|
+
for token, label in _AESTHETIC_TOKENS:
|
|
219
|
+
if token in section_names:
|
|
220
|
+
candidates.append((f"Aesthetic: {label}", 0.55))
|
|
221
|
+
break
|
|
222
|
+
|
|
166
223
|
if not candidates:
|
|
167
224
|
# Fallback: describe by track count and tempo
|
|
168
225
|
return ("Emerging piece — identity not yet established", 0.2)
|
|
169
226
|
|
|
170
|
-
|
|
171
|
-
|
|
227
|
+
# BUG-B10: when no single candidate is confident (>0.6), blend the
|
|
228
|
+
# top 2 into a compound identity — captures "vocal hook + dust
|
|
229
|
+
# aesthetic" style identity rather than picking one weak signal.
|
|
230
|
+
candidates.sort(key=lambda c: c[1], reverse=True)
|
|
231
|
+
top = candidates[0]
|
|
232
|
+
if top[1] >= 0.6 or len(candidates) < 2:
|
|
233
|
+
return top
|
|
234
|
+
# Blend top 2
|
|
235
|
+
second = candidates[1]
|
|
236
|
+
blended_desc = f"{top[0]} + {second[0].lower()}"
|
|
237
|
+
blended_conf = min(0.85, (top[1] + second[1]) / 2 + 0.1)
|
|
238
|
+
return (blended_desc, blended_conf)
|
|
172
239
|
|
|
173
240
|
|
|
174
241
|
def _detect_genre_cues(track_names: list[str]) -> str:
|
|
@@ -260,6 +327,12 @@ def _detect_sacred_elements(
|
|
|
260
327
|
# ── Section purposes ──────────────────────────────────────────────
|
|
261
328
|
|
|
262
329
|
|
|
330
|
+
# Section intents that imply this is a "payoff" / arrival moment.
|
|
331
|
+
# Used by _infer_section_purposes to derive is_payoff consistently
|
|
332
|
+
# when composition returns an intent label without the explicit flag.
|
|
333
|
+
_PAYOFF_INTENTS = frozenset({"payoff", "drop", "chorus", "hook"})
|
|
334
|
+
|
|
335
|
+
|
|
263
336
|
def _infer_section_purposes(
|
|
264
337
|
scenes: list[dict],
|
|
265
338
|
composition: dict,
|
|
@@ -271,12 +344,27 @@ def _infer_section_purposes(
|
|
|
271
344
|
comp_sections = composition.get("sections", [])
|
|
272
345
|
if comp_sections:
|
|
273
346
|
for sec in comp_sections:
|
|
347
|
+
name = str(sec.get("name", ""))
|
|
348
|
+
# BUG-B12: skip empty placeholder sections that pollute the
|
|
349
|
+
# energy_arc and section_purposes list. A section with no name
|
|
350
|
+
# AND zero energy corresponds to an unnamed empty scene slot.
|
|
351
|
+
if not name.strip() and not sec.get("energy", 0):
|
|
352
|
+
continue
|
|
353
|
+
intent = sec.get("intent", sec.get("purpose", "")) or ""
|
|
354
|
+
# BUG-B11: derive is_payoff from the intent label when the
|
|
355
|
+
# explicit flag isn't set. Composition engine returns
|
|
356
|
+
# intent="drop"/"chorus"/"hook"/"payoff" — these all mean the
|
|
357
|
+
# section IS a payoff, so is_payoff must reflect that.
|
|
358
|
+
is_payoff = bool(
|
|
359
|
+
sec.get("is_payoff", False)
|
|
360
|
+
or intent.lower() in _PAYOFF_INTENTS
|
|
361
|
+
)
|
|
274
362
|
sections.append(SectionPurpose(
|
|
275
|
-
section_id=sec.get("id",
|
|
276
|
-
label=sec.get("label",
|
|
277
|
-
emotional_intent=
|
|
363
|
+
section_id=sec.get("id", name),
|
|
364
|
+
label=sec.get("label", name),
|
|
365
|
+
emotional_intent=intent,
|
|
278
366
|
energy_level=sec.get("energy", 0.5),
|
|
279
|
-
is_payoff=
|
|
367
|
+
is_payoff=is_payoff,
|
|
280
368
|
confidence=0.7,
|
|
281
369
|
))
|
|
282
370
|
return sections
|
|
@@ -284,6 +372,10 @@ def _infer_section_purposes(
|
|
|
284
372
|
# Fallback: infer from scene names
|
|
285
373
|
for i, scene in enumerate(scenes):
|
|
286
374
|
name = scene.get("name", f"Scene {i}")
|
|
375
|
+
# BUG-B12 (fallback path): skip empty scenes so they don't pollute
|
|
376
|
+
# the output even when no composition data is available.
|
|
377
|
+
if not str(name).strip():
|
|
378
|
+
continue
|
|
287
379
|
label, intent, energy, is_payoff = _classify_scene_name(name, i, len(scenes))
|
|
288
380
|
sections.append(SectionPurpose(
|
|
289
381
|
section_id=f"scene_{i}",
|
|
@@ -389,8 +481,14 @@ def _detect_open_questions(
|
|
|
389
481
|
))
|
|
390
482
|
|
|
391
483
|
# Missing sections (common gaps)
|
|
392
|
-
|
|
393
|
-
|
|
484
|
+
# BUG-B14: check substrings across labels AND emotional intents
|
|
485
|
+
# (case-insensitive) so scene names like "Intro Dust" or intent "intro"
|
|
486
|
+
# both satisfy the check. Exact-match on the label set missed those.
|
|
487
|
+
signal_text = " ".join(
|
|
488
|
+
f"{s.label} {s.emotional_intent}".lower() for s in sections
|
|
489
|
+
)
|
|
490
|
+
has_intro = any(kw in signal_text for kw in ("intro", "opening", "opener"))
|
|
491
|
+
if len(sections) > 3 and not has_intro:
|
|
394
492
|
questions.append(OpenQuestion(
|
|
395
493
|
question="No intro section — does the track need an opening?",
|
|
396
494
|
domain="arrangement",
|