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
|
@@ -0,0 +1,414 @@
|
|
|
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
|
+
BUG-B22 fix: most clips are 4-8 bar loops. In an 8-bar section with
|
|
199
|
+
4-bar clips, notes have start_time 0..16 (one clip cycle). The old
|
|
200
|
+
algorithm placed them at absolute bars section.start_bar + 0..4 only,
|
|
201
|
+
leaving bars 4..7 of the section reading as "empty" — which produced
|
|
202
|
+
note_density=0 for the second half even though Ableton loops those
|
|
203
|
+
clips to fill the section. We now infer each track's clip length
|
|
204
|
+
from its max note start_time and wrap note bars modulo that length,
|
|
205
|
+
so a looping clip's density spreads across the whole section.
|
|
206
|
+
"""
|
|
207
|
+
section_length = section.length_bars()
|
|
208
|
+
if section_length <= 0:
|
|
209
|
+
return []
|
|
210
|
+
|
|
211
|
+
# Aggregate all notes into a bar-level density map
|
|
212
|
+
bar_densities: dict[int, int] = {}
|
|
213
|
+
for bar in range(section.start_bar, section.end_bar):
|
|
214
|
+
bar_densities[bar] = 0
|
|
215
|
+
|
|
216
|
+
section_bar_count = section.end_bar - section.start_bar
|
|
217
|
+
|
|
218
|
+
for track_notes in notes_by_track.values():
|
|
219
|
+
if not track_notes:
|
|
220
|
+
continue
|
|
221
|
+
# Infer this track's clip span (in bars) from the max start_time.
|
|
222
|
+
# The clip LOOPS to fill the section, so notes at start_time=0
|
|
223
|
+
# repeat every span bars. Round UP so a 3.5-beat phrase counts
|
|
224
|
+
# as 1 bar (not 0).
|
|
225
|
+
max_start_beat = max(
|
|
226
|
+
float(n.get("start_time", 0) or 0) for n in track_notes
|
|
227
|
+
)
|
|
228
|
+
clip_span_bars = max(
|
|
229
|
+
1,
|
|
230
|
+
int((max_start_beat / beats_per_bar) + 1),
|
|
231
|
+
)
|
|
232
|
+
# If we can't determine a sensible span, fall back to section length
|
|
233
|
+
if clip_span_bars <= 0:
|
|
234
|
+
clip_span_bars = section_bar_count
|
|
235
|
+
|
|
236
|
+
for note in track_notes:
|
|
237
|
+
start_beat = float(note.get("start_time", 0) or 0)
|
|
238
|
+
clip_bar = int(start_beat / beats_per_bar)
|
|
239
|
+
# Fill-copy the note across all loop iterations that fit
|
|
240
|
+
# inside the section. For a 4-bar clip in an 8-bar section
|
|
241
|
+
# that means each note contributes to bars 0..3 AND 4..7.
|
|
242
|
+
if clip_span_bars >= section_bar_count:
|
|
243
|
+
# Clip is already section-long (or longer) — no looping
|
|
244
|
+
positions = [clip_bar]
|
|
245
|
+
else:
|
|
246
|
+
# Wrap by modulo — project across the section
|
|
247
|
+
positions = list(range(
|
|
248
|
+
clip_bar % clip_span_bars,
|
|
249
|
+
section_bar_count,
|
|
250
|
+
clip_span_bars,
|
|
251
|
+
))
|
|
252
|
+
for offset in positions:
|
|
253
|
+
note_bar = section.start_bar + offset
|
|
254
|
+
if section.start_bar <= note_bar < section.end_bar:
|
|
255
|
+
bar_densities[note_bar] = bar_densities.get(note_bar, 0) + 1
|
|
256
|
+
|
|
257
|
+
# Find phrase boundaries using density drops (gaps)
|
|
258
|
+
boundaries = [section.start_bar]
|
|
259
|
+
bars = sorted(bar_densities.keys())
|
|
260
|
+
|
|
261
|
+
for i in range(1, len(bars)):
|
|
262
|
+
prev_density = bar_densities.get(bars[i - 1], 0)
|
|
263
|
+
curr_density = bar_densities.get(bars[i], 0)
|
|
264
|
+
|
|
265
|
+
# A phrase boundary is where density drops significantly or a gap exists
|
|
266
|
+
if prev_density > 0 and curr_density == 0:
|
|
267
|
+
boundaries.append(bars[i])
|
|
268
|
+
elif (bars[i] - section.start_bar) % default_phrase_length == 0:
|
|
269
|
+
# Regular grid fallback
|
|
270
|
+
if bars[i] not in boundaries:
|
|
271
|
+
boundaries.append(bars[i])
|
|
272
|
+
|
|
273
|
+
boundaries.append(section.end_bar)
|
|
274
|
+
boundaries = sorted(set(boundaries))
|
|
275
|
+
|
|
276
|
+
# Build phrases from boundaries
|
|
277
|
+
phrases = []
|
|
278
|
+
for i in range(len(boundaries) - 1):
|
|
279
|
+
start = boundaries[i]
|
|
280
|
+
end = boundaries[i + 1]
|
|
281
|
+
if end <= start:
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
# Calculate note density for this phrase
|
|
285
|
+
total_notes = sum(bar_densities.get(b, 0) for b in range(start, end))
|
|
286
|
+
phrase_bars = end - start
|
|
287
|
+
density = total_notes / max(phrase_bars, 1)
|
|
288
|
+
|
|
289
|
+
# Cadence strength: higher if the last bar has lower density (resolution)
|
|
290
|
+
last_bar_density = bar_densities.get(end - 1, 0)
|
|
291
|
+
avg_density = density
|
|
292
|
+
cadence = max(0.0, min(1.0, 1.0 - (last_bar_density / max(avg_density, 0.1)))) if avg_density > 0 else 0.3
|
|
293
|
+
|
|
294
|
+
phrases.append(PhraseUnit(
|
|
295
|
+
phrase_id=f"{section.section_id}_phr_{i:02d}",
|
|
296
|
+
section_id=section.section_id,
|
|
297
|
+
start_bar=start,
|
|
298
|
+
end_bar=end,
|
|
299
|
+
cadence_strength=round(cadence, 3),
|
|
300
|
+
note_density=round(density, 2),
|
|
301
|
+
has_variation=False, # Computed later by phrase critic
|
|
302
|
+
))
|
|
303
|
+
|
|
304
|
+
# Mark variation: compare adjacent phrase densities
|
|
305
|
+
for i in range(1, len(phrases)):
|
|
306
|
+
density_diff = abs(phrases[i].note_density - phrases[i - 1].note_density)
|
|
307
|
+
if density_diff > 1.0:
|
|
308
|
+
phrases[i].has_variation = True
|
|
309
|
+
|
|
310
|
+
return phrases
|
|
311
|
+
|
|
312
|
+
_ROLE_NAME_HINTS: list[tuple[str, RoleType]] = [
|
|
313
|
+
(r"kick|bd|bass\s*drum", RoleType.KICK_ANCHOR),
|
|
314
|
+
(r"sub\s*bass|sub|bass", RoleType.BASS_ANCHOR),
|
|
315
|
+
(r"lead|melody|mel|hook|synth\s*lead", RoleType.LEAD),
|
|
316
|
+
(r"pad|atmosphere|atmo|ambient|drone|chord|keys", RoleType.HARMONY_BED),
|
|
317
|
+
(r"h(?:i)?[\s\-]?hat|hh|hat|perc|percussion|clap|snare|rim", RoleType.RHYTHMIC_TEXTURE),
|
|
318
|
+
(r"fx|sfx|riser|sweep|noise|texture|tape", RoleType.TEXTURE_WASH),
|
|
319
|
+
(r"resamp|bounce|bus|group|master|return", RoleType.UTILITY),
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
def infer_role_for_track(
|
|
323
|
+
track_name: str,
|
|
324
|
+
notes: list[dict],
|
|
325
|
+
device_class: str = "",
|
|
326
|
+
beats_per_bar: int = 4,
|
|
327
|
+
) -> tuple[RoleType, float, bool]:
|
|
328
|
+
"""Infer a track's role from name, notes, and device class.
|
|
329
|
+
|
|
330
|
+
Returns (role, confidence, is_foreground).
|
|
331
|
+
"""
|
|
332
|
+
# 1. Name-based inference (highest confidence)
|
|
333
|
+
lower_name = track_name.lower().strip()
|
|
334
|
+
for pattern, role in _ROLE_NAME_HINTS:
|
|
335
|
+
if re.search(pattern, lower_name):
|
|
336
|
+
foreground = role in (RoleType.LEAD, RoleType.HOOK, RoleType.KICK_ANCHOR)
|
|
337
|
+
return role, 0.80, foreground
|
|
338
|
+
|
|
339
|
+
# 2. Device-class inference
|
|
340
|
+
dc = device_class.lower()
|
|
341
|
+
if "drumgroup" in dc or "drum" in dc:
|
|
342
|
+
return RoleType.RHYTHMIC_TEXTURE, 0.70, False
|
|
343
|
+
if "simpler" in dc and not notes:
|
|
344
|
+
return RoleType.TEXTURE_WASH, 0.50, False
|
|
345
|
+
|
|
346
|
+
# 3. Note-based inference
|
|
347
|
+
if not notes:
|
|
348
|
+
return RoleType.UNKNOWN, 0.0, False
|
|
349
|
+
|
|
350
|
+
# Analyze pitch register and density
|
|
351
|
+
pitches = [n.get("pitch", 60) for n in notes]
|
|
352
|
+
durations = [n.get("duration", 0.5) for n in notes]
|
|
353
|
+
avg_pitch = sum(pitches) / len(pitches)
|
|
354
|
+
avg_duration = sum(durations) / len(durations)
|
|
355
|
+
note_count = len(notes)
|
|
356
|
+
|
|
357
|
+
# Sub-bass register (< MIDI 48 = C3)
|
|
358
|
+
if avg_pitch < 48:
|
|
359
|
+
return RoleType.BASS_ANCHOR, 0.65, False
|
|
360
|
+
|
|
361
|
+
# Very long sustained notes → harmony bed
|
|
362
|
+
if avg_duration > 4.0:
|
|
363
|
+
return RoleType.HARMONY_BED, 0.60, False
|
|
364
|
+
|
|
365
|
+
# Dense short notes → rhythmic or lead
|
|
366
|
+
if avg_duration < 0.5 and note_count > 8:
|
|
367
|
+
if avg_pitch > 60:
|
|
368
|
+
return RoleType.LEAD, 0.55, True
|
|
369
|
+
return RoleType.RHYTHMIC_TEXTURE, 0.55, False
|
|
370
|
+
|
|
371
|
+
# Medium density, mid register → could be hook or lead
|
|
372
|
+
if 55 <= avg_pitch <= 80 and 0.5 <= avg_duration <= 2.0:
|
|
373
|
+
return RoleType.HOOK, 0.45, True
|
|
374
|
+
|
|
375
|
+
return RoleType.UNKNOWN, 0.3, False
|
|
376
|
+
|
|
377
|
+
def build_role_graph(
|
|
378
|
+
sections: list[SectionNode],
|
|
379
|
+
track_data: list[dict],
|
|
380
|
+
notes_by_section_track: dict[str, dict[int, list[dict]]],
|
|
381
|
+
) -> list[RoleNode]:
|
|
382
|
+
"""Build role graph: what each track does in each section.
|
|
383
|
+
|
|
384
|
+
track_data: [{index, name, devices: [{class_name, ...}]}]
|
|
385
|
+
notes_by_section_track: {section_id: {track_index: [notes]}}
|
|
386
|
+
"""
|
|
387
|
+
roles = []
|
|
388
|
+
for section in sections:
|
|
389
|
+
for track in track_data:
|
|
390
|
+
t_idx = track.get("index", 0)
|
|
391
|
+
if t_idx not in section.tracks_active:
|
|
392
|
+
continue
|
|
393
|
+
|
|
394
|
+
t_name = track.get("name", "")
|
|
395
|
+
devices = track.get("devices", [])
|
|
396
|
+
device_class = devices[0].get("class_name", "") if devices else ""
|
|
397
|
+
|
|
398
|
+
section_notes = notes_by_section_track.get(section.section_id, {}).get(t_idx, [])
|
|
399
|
+
|
|
400
|
+
role, confidence, foreground = infer_role_for_track(
|
|
401
|
+
t_name, section_notes, device_class,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
roles.append(RoleNode(
|
|
405
|
+
track_index=t_idx,
|
|
406
|
+
track_name=t_name,
|
|
407
|
+
section_id=section.section_id,
|
|
408
|
+
role=role,
|
|
409
|
+
confidence=confidence,
|
|
410
|
+
foreground=foreground,
|
|
411
|
+
))
|
|
412
|
+
|
|
413
|
+
return roles
|
|
414
|
+
|
|
@@ -195,13 +195,45 @@ def find_shortest_path(
|
|
|
195
195
|
# ---------------------------------------------------------------------------
|
|
196
196
|
|
|
197
197
|
def classify_transform_sequence(chords: list[tuple[int, str]]) -> list[str]:
|
|
198
|
-
"""Identify the
|
|
199
|
-
|
|
200
|
-
Tries
|
|
201
|
-
|
|
198
|
+
"""Identify the neo-Riemannian transform between each consecutive pair.
|
|
199
|
+
|
|
200
|
+
Tries (in order):
|
|
201
|
+
1. Single transforms: P, L, R
|
|
202
|
+
2. 2-step compounds: PL, PR, LP, LR, RP, RL, PP, LL, RR
|
|
203
|
+
3. 3-step compounds: PLR, PRL, LPR, LRP, RLP, RPL, PLP, PRP, LPL, …
|
|
204
|
+
(BUG-B24: needed for progressions that step through mediants)
|
|
205
|
+
4. Diatonic-step primitives: S2↑ / S2↓ (whole-step root motion,
|
|
206
|
+
same quality) and S1↑ / S1↓ (half-step root motion, same
|
|
207
|
+
quality). These aren't classical neo-Riemannian transforms —
|
|
208
|
+
but Gm → Am (D minor iv → v) IS a valid progression, so the
|
|
209
|
+
transform alphabet needs SOME label for it. Marking it with a
|
|
210
|
+
dedicated symbol is cleaner than returning "?", which cascades
|
|
211
|
+
into misleading "diatonic cycle fragment" classifications
|
|
212
|
+
downstream.
|
|
202
213
|
"""
|
|
203
|
-
|
|
204
|
-
|
|
214
|
+
_TWO_STEP = ["PL", "PR", "LP", "LR", "RP", "RL",
|
|
215
|
+
"PP", "LL", "RR"]
|
|
216
|
+
_THREE_STEP = [
|
|
217
|
+
"PLR", "PRL", "LPR", "LRP", "RLP", "RPL",
|
|
218
|
+
"PLP", "LPL", "PRP", "RPR", "LRL", "RLR",
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
def _try_primitive_step(a: tuple[int, str], b: tuple[int, str]) -> str:
|
|
222
|
+
"""Detect same-quality step motion → S1/S2 primitive."""
|
|
223
|
+
if a[1] != b[1]:
|
|
224
|
+
return "?"
|
|
225
|
+
interval = (b[0] - a[0]) % 12
|
|
226
|
+
# Prefer the signed direction symbol for readability
|
|
227
|
+
if interval == 1:
|
|
228
|
+
return "S1u" # semitone up
|
|
229
|
+
if interval == 11:
|
|
230
|
+
return "S1d" # semitone down
|
|
231
|
+
if interval == 2:
|
|
232
|
+
return "S2u" # whole-step up (Gm → Am in Dm)
|
|
233
|
+
if interval == 10:
|
|
234
|
+
return "S2d" # whole-step down
|
|
235
|
+
return "?"
|
|
236
|
+
|
|
205
237
|
result = []
|
|
206
238
|
for i in range(len(chords) - 1):
|
|
207
239
|
found = "?"
|
|
@@ -210,15 +242,27 @@ def classify_transform_sequence(chords: list[tuple[int, str]]) -> list[str]:
|
|
|
210
242
|
if fn(*chords[i]) == chords[i + 1]:
|
|
211
243
|
found = label
|
|
212
244
|
break
|
|
213
|
-
# Try 2-step
|
|
245
|
+
# Try 2-step compounds
|
|
246
|
+
if found == "?":
|
|
247
|
+
for compound in _TWO_STEP:
|
|
248
|
+
try:
|
|
249
|
+
if apply_transforms(*chords[i], compound) == chords[i + 1]:
|
|
250
|
+
found = compound
|
|
251
|
+
break
|
|
252
|
+
except (ValueError, KeyError):
|
|
253
|
+
continue
|
|
254
|
+
# Try 3-step compounds (BUG-B24)
|
|
214
255
|
if found == "?":
|
|
215
|
-
for compound in
|
|
256
|
+
for compound in _THREE_STEP:
|
|
216
257
|
try:
|
|
217
258
|
if apply_transforms(*chords[i], compound) == chords[i + 1]:
|
|
218
259
|
found = compound
|
|
219
260
|
break
|
|
220
261
|
except (ValueError, KeyError):
|
|
221
262
|
continue
|
|
263
|
+
# Final fallback: same-quality step motion (BUG-B24)
|
|
264
|
+
if found == "?":
|
|
265
|
+
found = _try_primitive_step(chords[i], chords[i + 1])
|
|
222
266
|
result.append(found)
|
|
223
267
|
return result
|
|
224
268
|
|
|
@@ -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 {
|