livepilot 1.10.4 → 1.10.6
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/AGENTS.md +3 -3
- package/CHANGELOG.md +148 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +6 -6
- 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 +3 -3
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +5 -5
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +12 -1
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- 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 +9 -8
- package/mcp_server/experiment/engine.py +9 -5
- package/mcp_server/experiment/tools.py +9 -9
- package/mcp_server/hook_hunter/tools.py +14 -9
- package/mcp_server/m4l_bridge.py +11 -0
- package/mcp_server/memory/taste_graph.py +7 -2
- package/mcp_server/mix_engine/tools.py +8 -3
- package/mcp_server/musical_intelligence/tools.py +15 -10
- package/mcp_server/performance_engine/tools.py +6 -2
- package/mcp_server/preview_studio/tools.py +21 -15
- package/mcp_server/project_brain/tools.py +18 -10
- package/mcp_server/reference_engine/tools.py +7 -5
- package/mcp_server/runtime/capability_probe.py +10 -4
- package/mcp_server/runtime/tools.py +8 -2
- package/mcp_server/sample_engine/tools.py +394 -33
- package/mcp_server/semantic_moves/tools.py +5 -1
- package/mcp_server/server.py +10 -9
- package/mcp_server/services/motif_service.py +9 -3
- package/mcp_server/session_continuity/tools.py +7 -3
- package/mcp_server/session_continuity/tracker.py +9 -8
- package/mcp_server/song_brain/tools.py +17 -12
- package/mcp_server/splice_client/client.py +19 -6
- package/mcp_server/stuckness_detector/tools.py +8 -5
- package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +134 -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 +70 -0
- package/mcp_server/tools/_composition_engine/models.py +193 -0
- package/mcp_server/tools/_composition_engine/sections.py +371 -0
- package/mcp_server/tools/_perception_engine.py +18 -11
- package/mcp_server/tools/agent_os.py +23 -15
- package/mcp_server/tools/analyzer.py +166 -7
- package/mcp_server/tools/automation.py +6 -1
- package/mcp_server/tools/composition.py +25 -16
- package/mcp_server/tools/devices.py +10 -6
- package/mcp_server/tools/motif.py +7 -2
- package/mcp_server/tools/planner.py +6 -2
- package/mcp_server/tools/research.py +13 -10
- 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 +1 -1
- package/requirements.txt +6 -0
- package/livepilot.mcpb +0 -0
- package/mcp_server/tools/_agent_os_engine.py +0 -947
- package/mcp_server/tools/_composition_engine.py +0 -1530
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Part of the _composition_engine package — extracted from the single-file engine.
|
|
2
|
+
|
|
3
|
+
Pure-computation core, no external deps. Callers should import from the package
|
|
4
|
+
facade (e.g. `from mcp_server.tools._composition_engine import X`), which
|
|
5
|
+
re-exports everything from these sub-modules.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from .models import SectionType, RoleType, SectionNode, PhraseUnit, RoleNode
|
|
16
|
+
|
|
17
|
+
_SECTION_NAME_PATTERNS: list[tuple[str, SectionType]] = [
|
|
18
|
+
(r"intro", SectionType.INTRO),
|
|
19
|
+
(r"verse|vrs", SectionType.VERSE),
|
|
20
|
+
(r"pre[\s\-]?chorus", SectionType.PRE_CHORUS),
|
|
21
|
+
(r"chorus|hook|chrs", SectionType.CHORUS),
|
|
22
|
+
(r"build|riser|tension", SectionType.BUILD),
|
|
23
|
+
(r"drop|main|peak", SectionType.DROP),
|
|
24
|
+
(r"bridge|brg", SectionType.BRIDGE),
|
|
25
|
+
(r"break(?:down)?|strip", SectionType.BREAKDOWN),
|
|
26
|
+
(r"outro|end|fade", SectionType.OUTRO),
|
|
27
|
+
(r"loop", SectionType.LOOP),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
def _infer_section_type_from_name(name: str) -> tuple[SectionType, float]:
|
|
31
|
+
"""Infer section type from a scene or clip name. Returns (type, confidence)."""
|
|
32
|
+
lower = name.lower().strip()
|
|
33
|
+
for pattern, stype in _SECTION_NAME_PATTERNS:
|
|
34
|
+
if re.search(pattern, lower):
|
|
35
|
+
return stype, 0.85
|
|
36
|
+
return SectionType.UNKNOWN, 0.0
|
|
37
|
+
|
|
38
|
+
def _infer_section_type_from_energy(
|
|
39
|
+
energy: float, density: float, position_ratio: float, total_sections: int,
|
|
40
|
+
) -> tuple[SectionType, float]:
|
|
41
|
+
"""Infer section type from energy/density/position heuristics."""
|
|
42
|
+
# Position-based heuristics
|
|
43
|
+
if position_ratio < 0.1 and density < 0.4:
|
|
44
|
+
return SectionType.INTRO, 0.6
|
|
45
|
+
if position_ratio > 0.9 and density < 0.4:
|
|
46
|
+
return SectionType.OUTRO, 0.6
|
|
47
|
+
|
|
48
|
+
# Energy-based heuristics
|
|
49
|
+
if energy > 0.8 and density > 0.7:
|
|
50
|
+
return SectionType.DROP, 0.5
|
|
51
|
+
if energy < 0.3 and density < 0.3:
|
|
52
|
+
return SectionType.BREAKDOWN, 0.5
|
|
53
|
+
if 0.4 <= energy <= 0.7:
|
|
54
|
+
return SectionType.VERSE, 0.4
|
|
55
|
+
|
|
56
|
+
return SectionType.UNKNOWN, 0.0
|
|
57
|
+
|
|
58
|
+
def build_section_graph_from_scenes(
|
|
59
|
+
scenes: list[dict],
|
|
60
|
+
clip_matrix: list[list[dict]],
|
|
61
|
+
track_count: int,
|
|
62
|
+
beats_per_bar: int = 4,
|
|
63
|
+
) -> list[SectionNode]:
|
|
64
|
+
"""Build section graph from session view scenes.
|
|
65
|
+
|
|
66
|
+
scenes: list of {index, name, tempo, color_index}
|
|
67
|
+
clip_matrix: [scene_index][track_index] = {state, name, ...} or None
|
|
68
|
+
"""
|
|
69
|
+
sections = []
|
|
70
|
+
# Estimate bar positions: each scene is a section, assume 8-bar default
|
|
71
|
+
# unless clips provide length info
|
|
72
|
+
current_bar = 0
|
|
73
|
+
|
|
74
|
+
for i, scene in enumerate(scenes):
|
|
75
|
+
scene_name = scene.get("name", "")
|
|
76
|
+
if not scene_name.strip():
|
|
77
|
+
continue # Skip unnamed empty scenes
|
|
78
|
+
|
|
79
|
+
# Count active tracks in this scene
|
|
80
|
+
active_tracks = []
|
|
81
|
+
if i < len(clip_matrix):
|
|
82
|
+
for t_idx in range(min(track_count, len(clip_matrix[i]))):
|
|
83
|
+
slot = clip_matrix[i][t_idx]
|
|
84
|
+
if slot and slot.get("state") in ("playing", "stopped", "triggered"):
|
|
85
|
+
if slot.get("has_clip", True):
|
|
86
|
+
active_tracks.append(t_idx)
|
|
87
|
+
|
|
88
|
+
density = len(active_tracks) / max(track_count, 1)
|
|
89
|
+
|
|
90
|
+
# Estimate section length (default 32 beats = 8 bars)
|
|
91
|
+
section_length_bars = 8
|
|
92
|
+
start_bar = current_bar
|
|
93
|
+
end_bar = start_bar + section_length_bars
|
|
94
|
+
|
|
95
|
+
# Infer type from name first, then energy/position
|
|
96
|
+
stype, confidence = _infer_section_type_from_name(scene_name)
|
|
97
|
+
if stype == SectionType.UNKNOWN:
|
|
98
|
+
total = len([s for s in scenes if s.get("name", "").strip()])
|
|
99
|
+
position_ratio = i / max(total - 1, 1) if total > 1 else 0.5
|
|
100
|
+
stype, confidence = _infer_section_type_from_energy(
|
|
101
|
+
energy=density, density=density,
|
|
102
|
+
position_ratio=position_ratio, total_sections=total,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
sections.append(SectionNode(
|
|
106
|
+
section_id=f"sec_{i:02d}",
|
|
107
|
+
start_bar=start_bar,
|
|
108
|
+
end_bar=end_bar,
|
|
109
|
+
section_type=stype,
|
|
110
|
+
confidence=confidence,
|
|
111
|
+
energy=density, # density as energy proxy
|
|
112
|
+
density=density,
|
|
113
|
+
tracks_active=active_tracks,
|
|
114
|
+
name=scene_name,
|
|
115
|
+
))
|
|
116
|
+
current_bar = end_bar
|
|
117
|
+
|
|
118
|
+
return sections
|
|
119
|
+
|
|
120
|
+
def build_section_graph_from_arrangement(
|
|
121
|
+
arrangement_clips: dict[int, list[dict]],
|
|
122
|
+
track_count: int,
|
|
123
|
+
beats_per_bar: int = 4,
|
|
124
|
+
) -> list[SectionNode]:
|
|
125
|
+
"""Build section graph from arrangement view clips.
|
|
126
|
+
|
|
127
|
+
arrangement_clips: {track_index: [{start_time, end_time, length, name}, ...]}
|
|
128
|
+
"""
|
|
129
|
+
if not arrangement_clips:
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
# Collect all time boundaries
|
|
133
|
+
boundaries: set[float] = set()
|
|
134
|
+
for clips in arrangement_clips.values():
|
|
135
|
+
for clip in clips:
|
|
136
|
+
boundaries.add(clip.get("start_time", 0))
|
|
137
|
+
boundaries.add(clip.get("end_time", clip.get("start_time", 0) + clip.get("length", 0)))
|
|
138
|
+
|
|
139
|
+
sorted_bounds = sorted(boundaries)
|
|
140
|
+
if len(sorted_bounds) < 2:
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
sections = []
|
|
144
|
+
for i in range(len(sorted_bounds) - 1):
|
|
145
|
+
start_beat = sorted_bounds[i]
|
|
146
|
+
end_beat = sorted_bounds[i + 1]
|
|
147
|
+
if end_beat - start_beat < beats_per_bar:
|
|
148
|
+
continue # Skip very short segments
|
|
149
|
+
|
|
150
|
+
start_bar = int(start_beat / beats_per_bar)
|
|
151
|
+
end_bar = int(end_beat / beats_per_bar)
|
|
152
|
+
if end_bar <= start_bar:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
# Count active tracks in this time range
|
|
156
|
+
active_tracks = []
|
|
157
|
+
for t_idx, clips in arrangement_clips.items():
|
|
158
|
+
for clip in clips:
|
|
159
|
+
clip_start = clip.get("start_time", 0)
|
|
160
|
+
clip_end = clip.get("end_time", clip_start + clip.get("length", 0))
|
|
161
|
+
if clip_start < end_beat and clip_end > start_beat:
|
|
162
|
+
active_tracks.append(t_idx)
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
density = len(active_tracks) / max(track_count, 1)
|
|
166
|
+
total_sections = len(sorted_bounds) - 1
|
|
167
|
+
position_ratio = i / max(total_sections - 1, 1) if total_sections > 1 else 0.5
|
|
168
|
+
|
|
169
|
+
stype, confidence = _infer_section_type_from_energy(
|
|
170
|
+
energy=density, density=density,
|
|
171
|
+
position_ratio=position_ratio, total_sections=total_sections,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
sections.append(SectionNode(
|
|
175
|
+
section_id=f"arr_{i:02d}",
|
|
176
|
+
start_bar=start_bar,
|
|
177
|
+
end_bar=end_bar,
|
|
178
|
+
section_type=stype,
|
|
179
|
+
confidence=confidence,
|
|
180
|
+
energy=density,
|
|
181
|
+
density=density,
|
|
182
|
+
tracks_active=active_tracks,
|
|
183
|
+
))
|
|
184
|
+
|
|
185
|
+
return sections
|
|
186
|
+
|
|
187
|
+
def detect_phrases(
|
|
188
|
+
section: SectionNode,
|
|
189
|
+
notes_by_track: dict[int, list[dict]],
|
|
190
|
+
default_phrase_length: int = 4,
|
|
191
|
+
beats_per_bar: int = 4,
|
|
192
|
+
) -> list[PhraseUnit]:
|
|
193
|
+
"""Detect phrase boundaries within a section from note data.
|
|
194
|
+
|
|
195
|
+
Uses note density changes and gap detection to find phrase boundaries.
|
|
196
|
+
Falls back to regular grid (4 or 8 bar phrases).
|
|
197
|
+
"""
|
|
198
|
+
section_length = section.length_bars()
|
|
199
|
+
if section_length <= 0:
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
# Aggregate all notes into a bar-level density map
|
|
203
|
+
bar_densities: dict[int, int] = {}
|
|
204
|
+
for bar in range(section.start_bar, section.end_bar):
|
|
205
|
+
bar_densities[bar] = 0
|
|
206
|
+
|
|
207
|
+
for track_notes in notes_by_track.values():
|
|
208
|
+
for note in track_notes:
|
|
209
|
+
start_beat = note.get("start_time", 0)
|
|
210
|
+
note_bar = section.start_bar + int(start_beat / beats_per_bar)
|
|
211
|
+
if section.start_bar <= note_bar < section.end_bar:
|
|
212
|
+
bar_densities[note_bar] = bar_densities.get(note_bar, 0) + 1
|
|
213
|
+
|
|
214
|
+
# Find phrase boundaries using density drops (gaps)
|
|
215
|
+
boundaries = [section.start_bar]
|
|
216
|
+
bars = sorted(bar_densities.keys())
|
|
217
|
+
|
|
218
|
+
for i in range(1, len(bars)):
|
|
219
|
+
prev_density = bar_densities.get(bars[i - 1], 0)
|
|
220
|
+
curr_density = bar_densities.get(bars[i], 0)
|
|
221
|
+
|
|
222
|
+
# A phrase boundary is where density drops significantly or a gap exists
|
|
223
|
+
if prev_density > 0 and curr_density == 0:
|
|
224
|
+
boundaries.append(bars[i])
|
|
225
|
+
elif (bars[i] - section.start_bar) % default_phrase_length == 0:
|
|
226
|
+
# Regular grid fallback
|
|
227
|
+
if bars[i] not in boundaries:
|
|
228
|
+
boundaries.append(bars[i])
|
|
229
|
+
|
|
230
|
+
boundaries.append(section.end_bar)
|
|
231
|
+
boundaries = sorted(set(boundaries))
|
|
232
|
+
|
|
233
|
+
# Build phrases from boundaries
|
|
234
|
+
phrases = []
|
|
235
|
+
for i in range(len(boundaries) - 1):
|
|
236
|
+
start = boundaries[i]
|
|
237
|
+
end = boundaries[i + 1]
|
|
238
|
+
if end <= start:
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
# Calculate note density for this phrase
|
|
242
|
+
total_notes = sum(bar_densities.get(b, 0) for b in range(start, end))
|
|
243
|
+
phrase_bars = end - start
|
|
244
|
+
density = total_notes / max(phrase_bars, 1)
|
|
245
|
+
|
|
246
|
+
# Cadence strength: higher if the last bar has lower density (resolution)
|
|
247
|
+
last_bar_density = bar_densities.get(end - 1, 0)
|
|
248
|
+
avg_density = density
|
|
249
|
+
cadence = max(0.0, min(1.0, 1.0 - (last_bar_density / max(avg_density, 0.1)))) if avg_density > 0 else 0.3
|
|
250
|
+
|
|
251
|
+
phrases.append(PhraseUnit(
|
|
252
|
+
phrase_id=f"{section.section_id}_phr_{i:02d}",
|
|
253
|
+
section_id=section.section_id,
|
|
254
|
+
start_bar=start,
|
|
255
|
+
end_bar=end,
|
|
256
|
+
cadence_strength=round(cadence, 3),
|
|
257
|
+
note_density=round(density, 2),
|
|
258
|
+
has_variation=False, # Computed later by phrase critic
|
|
259
|
+
))
|
|
260
|
+
|
|
261
|
+
# Mark variation: compare adjacent phrase densities
|
|
262
|
+
for i in range(1, len(phrases)):
|
|
263
|
+
density_diff = abs(phrases[i].note_density - phrases[i - 1].note_density)
|
|
264
|
+
if density_diff > 1.0:
|
|
265
|
+
phrases[i].has_variation = True
|
|
266
|
+
|
|
267
|
+
return phrases
|
|
268
|
+
|
|
269
|
+
_ROLE_NAME_HINTS: list[tuple[str, RoleType]] = [
|
|
270
|
+
(r"kick|bd|bass\s*drum", RoleType.KICK_ANCHOR),
|
|
271
|
+
(r"sub\s*bass|sub|bass", RoleType.BASS_ANCHOR),
|
|
272
|
+
(r"lead|melody|mel|hook|synth\s*lead", RoleType.LEAD),
|
|
273
|
+
(r"pad|atmosphere|atmo|ambient|drone|chord|keys", RoleType.HARMONY_BED),
|
|
274
|
+
(r"h(?:i)?[\s\-]?hat|hh|hat|perc|percussion|clap|snare|rim", RoleType.RHYTHMIC_TEXTURE),
|
|
275
|
+
(r"fx|sfx|riser|sweep|noise|texture|tape", RoleType.TEXTURE_WASH),
|
|
276
|
+
(r"resamp|bounce|bus|group|master|return", RoleType.UTILITY),
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
def infer_role_for_track(
|
|
280
|
+
track_name: str,
|
|
281
|
+
notes: list[dict],
|
|
282
|
+
device_class: str = "",
|
|
283
|
+
beats_per_bar: int = 4,
|
|
284
|
+
) -> tuple[RoleType, float, bool]:
|
|
285
|
+
"""Infer a track's role from name, notes, and device class.
|
|
286
|
+
|
|
287
|
+
Returns (role, confidence, is_foreground).
|
|
288
|
+
"""
|
|
289
|
+
# 1. Name-based inference (highest confidence)
|
|
290
|
+
lower_name = track_name.lower().strip()
|
|
291
|
+
for pattern, role in _ROLE_NAME_HINTS:
|
|
292
|
+
if re.search(pattern, lower_name):
|
|
293
|
+
foreground = role in (RoleType.LEAD, RoleType.HOOK, RoleType.KICK_ANCHOR)
|
|
294
|
+
return role, 0.80, foreground
|
|
295
|
+
|
|
296
|
+
# 2. Device-class inference
|
|
297
|
+
dc = device_class.lower()
|
|
298
|
+
if "drumgroup" in dc or "drum" in dc:
|
|
299
|
+
return RoleType.RHYTHMIC_TEXTURE, 0.70, False
|
|
300
|
+
if "simpler" in dc and not notes:
|
|
301
|
+
return RoleType.TEXTURE_WASH, 0.50, False
|
|
302
|
+
|
|
303
|
+
# 3. Note-based inference
|
|
304
|
+
if not notes:
|
|
305
|
+
return RoleType.UNKNOWN, 0.0, False
|
|
306
|
+
|
|
307
|
+
# Analyze pitch register and density
|
|
308
|
+
pitches = [n.get("pitch", 60) for n in notes]
|
|
309
|
+
durations = [n.get("duration", 0.5) for n in notes]
|
|
310
|
+
avg_pitch = sum(pitches) / len(pitches)
|
|
311
|
+
avg_duration = sum(durations) / len(durations)
|
|
312
|
+
note_count = len(notes)
|
|
313
|
+
|
|
314
|
+
# Sub-bass register (< MIDI 48 = C3)
|
|
315
|
+
if avg_pitch < 48:
|
|
316
|
+
return RoleType.BASS_ANCHOR, 0.65, False
|
|
317
|
+
|
|
318
|
+
# Very long sustained notes → harmony bed
|
|
319
|
+
if avg_duration > 4.0:
|
|
320
|
+
return RoleType.HARMONY_BED, 0.60, False
|
|
321
|
+
|
|
322
|
+
# Dense short notes → rhythmic or lead
|
|
323
|
+
if avg_duration < 0.5 and note_count > 8:
|
|
324
|
+
if avg_pitch > 60:
|
|
325
|
+
return RoleType.LEAD, 0.55, True
|
|
326
|
+
return RoleType.RHYTHMIC_TEXTURE, 0.55, False
|
|
327
|
+
|
|
328
|
+
# Medium density, mid register → could be hook or lead
|
|
329
|
+
if 55 <= avg_pitch <= 80 and 0.5 <= avg_duration <= 2.0:
|
|
330
|
+
return RoleType.HOOK, 0.45, True
|
|
331
|
+
|
|
332
|
+
return RoleType.UNKNOWN, 0.3, False
|
|
333
|
+
|
|
334
|
+
def build_role_graph(
|
|
335
|
+
sections: list[SectionNode],
|
|
336
|
+
track_data: list[dict],
|
|
337
|
+
notes_by_section_track: dict[str, dict[int, list[dict]]],
|
|
338
|
+
) -> list[RoleNode]:
|
|
339
|
+
"""Build role graph: what each track does in each section.
|
|
340
|
+
|
|
341
|
+
track_data: [{index, name, devices: [{class_name, ...}]}]
|
|
342
|
+
notes_by_section_track: {section_id: {track_index: [notes]}}
|
|
343
|
+
"""
|
|
344
|
+
roles = []
|
|
345
|
+
for section in sections:
|
|
346
|
+
for track in track_data:
|
|
347
|
+
t_idx = track.get("index", 0)
|
|
348
|
+
if t_idx not in section.tracks_active:
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
t_name = track.get("name", "")
|
|
352
|
+
devices = track.get("devices", [])
|
|
353
|
+
device_class = devices[0].get("class_name", "") if devices else ""
|
|
354
|
+
|
|
355
|
+
section_notes = notes_by_section_track.get(section.section_id, {}).get(t_idx, [])
|
|
356
|
+
|
|
357
|
+
role, confidence, foreground = infer_role_for_track(
|
|
358
|
+
t_name, section_notes, device_class,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
roles.append(RoleNode(
|
|
362
|
+
track_index=t_idx,
|
|
363
|
+
track_name=t_name,
|
|
364
|
+
section_id=section.section_id,
|
|
365
|
+
role=role,
|
|
366
|
+
confidence=confidence,
|
|
367
|
+
foreground=foreground,
|
|
368
|
+
))
|
|
369
|
+
|
|
370
|
+
return roles
|
|
371
|
+
|
|
@@ -11,7 +11,9 @@ import tempfile
|
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
13
|
import numpy as np
|
|
14
|
+
import logging
|
|
14
15
|
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
15
17
|
|
|
16
18
|
# ---------------------------------------------------------------------------
|
|
17
19
|
# Constants
|
|
@@ -34,11 +36,11 @@ BAND_EDGES: dict[str, tuple[float, float]] = {
|
|
|
34
36
|
"air_16khz": (8000.0, 20000.0),
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
|
|
38
39
|
# ---------------------------------------------------------------------------
|
|
39
40
|
# Internal helpers
|
|
40
41
|
# ---------------------------------------------------------------------------
|
|
41
42
|
|
|
43
|
+
|
|
42
44
|
def _load_audio(file_path: str) -> tuple[np.ndarray, int]:
|
|
43
45
|
"""Load an audio file as (ndarray, sample_rate). Ensures stereo output."""
|
|
44
46
|
if not os.path.exists(file_path):
|
|
@@ -67,7 +69,8 @@ def _normalize_to_lufs(
|
|
|
67
69
|
os.close(tmp_fd)
|
|
68
70
|
try:
|
|
69
71
|
sf.write(tmp_path, normalized, sr)
|
|
70
|
-
except Exception:
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
logger.debug("_normalize_to_lufs failed: %s", exc)
|
|
71
74
|
# Clean up on failure to avoid orphan files
|
|
72
75
|
try:
|
|
73
76
|
os.unlink(tmp_path)
|
|
@@ -76,11 +79,11 @@ def _normalize_to_lufs(
|
|
|
76
79
|
raise
|
|
77
80
|
return tmp_path
|
|
78
81
|
|
|
79
|
-
|
|
80
82
|
# ---------------------------------------------------------------------------
|
|
81
83
|
# True-peak helper
|
|
82
84
|
# ---------------------------------------------------------------------------
|
|
83
85
|
|
|
86
|
+
|
|
84
87
|
def _true_peak_dbtp(data: np.ndarray, sr: int) -> float:
|
|
85
88
|
"""Estimate EBU R128 true peak via 4x oversampling.
|
|
86
89
|
|
|
@@ -95,11 +98,11 @@ def _true_peak_dbtp(data: np.ndarray, sr: int) -> float:
|
|
|
95
98
|
peak_linear = float(np.max(np.abs(oversampled)))
|
|
96
99
|
return float(20.0 * np.log10(max(peak_linear, 1e-10)))
|
|
97
100
|
|
|
98
|
-
|
|
99
101
|
# ---------------------------------------------------------------------------
|
|
100
102
|
# compute_loudness
|
|
101
103
|
# ---------------------------------------------------------------------------
|
|
102
104
|
|
|
105
|
+
|
|
103
106
|
def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
|
|
104
107
|
"""Analyze integrated loudness (LUFS), true peak, RMS, LRA, and streaming compliance.
|
|
105
108
|
|
|
@@ -151,7 +154,8 @@ def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
|
|
|
151
154
|
try:
|
|
152
155
|
st = meter.integrated_loudness(window)
|
|
153
156
|
short_term_raw.append(float(st) if np.isfinite(st) else SILENCE_FLOOR)
|
|
154
|
-
except Exception:
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
logger.debug("compute_loudness failed: %s", exc)
|
|
155
159
|
short_term_raw.append(SILENCE_FLOOR)
|
|
156
160
|
pos += hop_samples
|
|
157
161
|
|
|
@@ -197,11 +201,11 @@ def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
|
|
|
197
201
|
|
|
198
202
|
return result
|
|
199
203
|
|
|
200
|
-
|
|
201
204
|
# ---------------------------------------------------------------------------
|
|
202
205
|
# compute_spectral
|
|
203
206
|
# ---------------------------------------------------------------------------
|
|
204
207
|
|
|
208
|
+
|
|
205
209
|
def compute_spectral(
|
|
206
210
|
file_path: str,
|
|
207
211
|
n_fft: int = 2048,
|
|
@@ -289,11 +293,11 @@ def compute_spectral(
|
|
|
289
293
|
"band_balance": band_balance,
|
|
290
294
|
}
|
|
291
295
|
|
|
292
|
-
|
|
293
296
|
# ---------------------------------------------------------------------------
|
|
294
297
|
# compare_to_reference
|
|
295
298
|
# ---------------------------------------------------------------------------
|
|
296
299
|
|
|
300
|
+
|
|
297
301
|
def compare_to_reference(
|
|
298
302
|
mix_path: str,
|
|
299
303
|
reference_path: str,
|
|
@@ -412,11 +416,11 @@ def compare_to_reference(
|
|
|
412
416
|
"suggestions": suggestions,
|
|
413
417
|
}
|
|
414
418
|
|
|
415
|
-
|
|
416
419
|
# ---------------------------------------------------------------------------
|
|
417
420
|
# read_audio_metadata
|
|
418
421
|
# ---------------------------------------------------------------------------
|
|
419
422
|
|
|
423
|
+
|
|
420
424
|
def read_audio_metadata(file_path: str) -> dict[str, Any]:
|
|
421
425
|
"""Read audio file metadata using mutagen (tags) and soundfile (format info).
|
|
422
426
|
|
|
@@ -451,6 +455,7 @@ def read_audio_metadata(file_path: str) -> dict[str, Any]:
|
|
|
451
455
|
has_artwork = False
|
|
452
456
|
try:
|
|
453
457
|
import mutagen
|
|
458
|
+
|
|
454
459
|
audio = mutagen.File(file_path)
|
|
455
460
|
if audio is not None:
|
|
456
461
|
for key, value in audio.tags.items() if audio.tags else []:
|
|
@@ -459,8 +464,9 @@ def read_audio_metadata(file_path: str) -> dict[str, Any]:
|
|
|
459
464
|
str_val = str(value)
|
|
460
465
|
if len(str_val) < 2048:
|
|
461
466
|
tags[str(key)] = str_val
|
|
462
|
-
except Exception:
|
|
463
|
-
|
|
467
|
+
except Exception as exc:
|
|
468
|
+
logger.debug("read_audio_metadata failed: %s", exc)
|
|
469
|
+
|
|
464
470
|
# Detect artwork (common tag names)
|
|
465
471
|
artwork_keys = {"APIC", "covr", "METADATA_BLOCK_PICTURE", "artwork"}
|
|
466
472
|
if audio.tags:
|
|
@@ -468,7 +474,8 @@ def read_audio_metadata(file_path: str) -> dict[str, Any]:
|
|
|
468
474
|
if any(k in str(key) for k in artwork_keys):
|
|
469
475
|
has_artwork = True
|
|
470
476
|
break
|
|
471
|
-
except Exception:
|
|
477
|
+
except Exception as exc:
|
|
478
|
+
logger.debug("read_audio_metadata failed: %s", exc)
|
|
472
479
|
pass # mutagen can't parse — use soundfile info only
|
|
473
480
|
|
|
474
481
|
return {
|
|
@@ -10,6 +10,7 @@ These tools power the Agent OS cyclical loop:
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
|
+
import logging
|
|
13
14
|
from typing import Optional
|
|
14
15
|
|
|
15
16
|
from fastmcp import Context
|
|
@@ -18,6 +19,8 @@ from ..server import mcp
|
|
|
18
19
|
from ..memory.technique_store import TechniqueStore
|
|
19
20
|
from . import _agent_os_engine as engine
|
|
20
21
|
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
21
24
|
_memory_store = TechniqueStore()
|
|
22
25
|
|
|
23
26
|
|
|
@@ -125,8 +128,9 @@ def build_world_model(ctx: Context) -> dict:
|
|
|
125
128
|
"track_index": track["index"]
|
|
126
129
|
})
|
|
127
130
|
track_infos.append(ti)
|
|
128
|
-
except Exception:
|
|
129
|
-
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
# Skip tracks that fail — don't block world model build
|
|
133
|
+
logger.debug("world-model track %s skipped: %s", track.get("index"), exc)
|
|
130
134
|
|
|
131
135
|
# Fetch spectral data (may be unavailable)
|
|
132
136
|
spectrum = None
|
|
@@ -184,13 +188,14 @@ def build_world_model(ctx: Context) -> dict:
|
|
|
184
188
|
try:
|
|
185
189
|
matrix_data = ableton.send_command("get_scene_matrix")
|
|
186
190
|
clip_matrix = matrix_data.get("matrix", [])
|
|
187
|
-
except Exception:
|
|
188
|
-
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
logger.debug("scene_matrix fetch for structural critic skipped: %s", exc)
|
|
189
193
|
|
|
190
194
|
sections = comp_engine.build_section_graph_from_scenes(scenes, clip_matrix, track_count)
|
|
191
195
|
structural_issues = comp_engine.run_form_critic(sections)
|
|
192
|
-
except Exception:
|
|
193
|
-
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
# Composition engine unavailable — degrade gracefully
|
|
198
|
+
logger.warning("structural critic unavailable: %s", exc)
|
|
194
199
|
|
|
195
200
|
result = wm.to_dict()
|
|
196
201
|
result["issues"] = {
|
|
@@ -276,7 +281,8 @@ def analyze_outcomes(
|
|
|
276
281
|
techniques = _memory_store.list_techniques(
|
|
277
282
|
type_filter="outcome", sort_by="updated_at", limit=limit,
|
|
278
283
|
)
|
|
279
|
-
except Exception:
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
logger.warning("analyze_outcomes list_techniques failed: %s", exc)
|
|
280
286
|
techniques = []
|
|
281
287
|
|
|
282
288
|
# Extract payloads from full technique records
|
|
@@ -288,8 +294,8 @@ def analyze_outcomes(
|
|
|
288
294
|
payload = full.get("payload", {})
|
|
289
295
|
if isinstance(payload, dict):
|
|
290
296
|
outcomes.append(payload)
|
|
291
|
-
except Exception:
|
|
292
|
-
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
logger.debug("outcome payload %s skipped: %s", t.get("id"), exc)
|
|
293
299
|
|
|
294
300
|
return engine.analyze_outcome_history(outcomes)
|
|
295
301
|
|
|
@@ -316,7 +322,8 @@ def get_technique_card(
|
|
|
316
322
|
techniques = _memory_store.search(
|
|
317
323
|
query=query, type_filter="technique_card", limit=limit,
|
|
318
324
|
)
|
|
319
|
-
except Exception:
|
|
325
|
+
except Exception as exc:
|
|
326
|
+
logger.warning("technique_card search(%r) failed: %s", query, exc)
|
|
320
327
|
techniques = []
|
|
321
328
|
|
|
322
329
|
cards = []
|
|
@@ -333,8 +340,8 @@ def get_technique_card(
|
|
|
333
340
|
"rating": t.get("rating", 0),
|
|
334
341
|
"replay_count": t.get("replay_count", 0),
|
|
335
342
|
})
|
|
336
|
-
except Exception:
|
|
337
|
-
|
|
343
|
+
except Exception as exc:
|
|
344
|
+
logger.debug("technique_card %s payload skipped: %s", t.get("id"), exc)
|
|
338
345
|
|
|
339
346
|
return {
|
|
340
347
|
"query": query,
|
|
@@ -367,7 +374,8 @@ def get_taste_profile(
|
|
|
367
374
|
techniques = _memory_store.list_techniques(
|
|
368
375
|
type_filter="outcome", sort_by="updated_at", limit=limit,
|
|
369
376
|
)
|
|
370
|
-
except Exception:
|
|
377
|
+
except Exception as exc:
|
|
378
|
+
logger.warning("taste_profile list_techniques failed: %s", exc)
|
|
371
379
|
techniques = []
|
|
372
380
|
|
|
373
381
|
outcomes = []
|
|
@@ -377,8 +385,8 @@ def get_taste_profile(
|
|
|
377
385
|
payload = full.get("payload", {})
|
|
378
386
|
if isinstance(payload, dict):
|
|
379
387
|
outcomes.append(payload)
|
|
380
|
-
except Exception:
|
|
381
|
-
|
|
388
|
+
except Exception as exc:
|
|
389
|
+
logger.debug("taste_profile outcome %s skipped: %s", t.get("id"), exc)
|
|
382
390
|
|
|
383
391
|
return engine.get_taste_profile(outcomes)
|
|
384
392
|
|