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.
Files changed (111) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcp.json.disabled +9 -0
  3. package/.mcpbignore +3 -0
  4. package/AGENTS.md +3 -3
  5. package/BUGS.md +1570 -0
  6. package/CHANGELOG.md +92 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/README.md +7 -7
  9. package/bin/livepilot.js +28 -8
  10. package/livepilot/.Codex-plugin/plugin.json +2 -2
  11. package/livepilot/.claude-plugin/plugin.json +2 -2
  12. package/livepilot/skills/livepilot-core/SKILL.md +4 -4
  13. package/livepilot/skills/livepilot-core/references/overview.md +2 -2
  14. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  15. package/livepilot/skills/livepilot-release/SKILL.md +8 -8
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
  18. package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
  19. package/m4l_device/livepilot_bridge.js +226 -3
  20. package/manifest.json +3 -3
  21. package/mcp_server/__init__.py +1 -1
  22. package/mcp_server/atlas/__init__.py +93 -26
  23. package/mcp_server/composer/sample_resolver.py +10 -6
  24. package/mcp_server/composer/tools.py +10 -6
  25. package/mcp_server/connection.py +6 -1
  26. package/mcp_server/creative_constraints/tools.py +214 -40
  27. package/mcp_server/experiment/engine.py +16 -14
  28. package/mcp_server/experiment/tools.py +9 -9
  29. package/mcp_server/hook_hunter/analyzer.py +62 -9
  30. package/mcp_server/hook_hunter/tools.py +74 -18
  31. package/mcp_server/m4l_bridge.py +32 -6
  32. package/mcp_server/memory/taste_graph.py +7 -2
  33. package/mcp_server/mix_engine/tools.py +8 -3
  34. package/mcp_server/musical_intelligence/detectors.py +32 -0
  35. package/mcp_server/musical_intelligence/tools.py +15 -10
  36. package/mcp_server/performance_engine/tools.py +117 -30
  37. package/mcp_server/preview_studio/engine.py +89 -8
  38. package/mcp_server/preview_studio/tools.py +43 -21
  39. package/mcp_server/project_brain/automation_graph.py +71 -19
  40. package/mcp_server/project_brain/builder.py +2 -0
  41. package/mcp_server/project_brain/tools.py +73 -15
  42. package/mcp_server/reference_engine/profile_builder.py +129 -3
  43. package/mcp_server/reference_engine/tools.py +54 -11
  44. package/mcp_server/runtime/capability_probe.py +10 -4
  45. package/mcp_server/runtime/execution_router.py +50 -0
  46. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  47. package/mcp_server/runtime/remote_commands.py +4 -2
  48. package/mcp_server/runtime/tools.py +8 -2
  49. package/mcp_server/sample_engine/analyzer.py +131 -4
  50. package/mcp_server/sample_engine/critics.py +29 -8
  51. package/mcp_server/sample_engine/models.py +20 -1
  52. package/mcp_server/sample_engine/tools.py +74 -31
  53. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  54. package/mcp_server/semantic_moves/tools.py +5 -1
  55. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  56. package/mcp_server/server.py +78 -11
  57. package/mcp_server/services/motif_service.py +9 -3
  58. package/mcp_server/session_continuity/models.py +4 -0
  59. package/mcp_server/session_continuity/tools.py +7 -3
  60. package/mcp_server/session_continuity/tracker.py +23 -9
  61. package/mcp_server/song_brain/builder.py +110 -12
  62. package/mcp_server/song_brain/tools.py +94 -25
  63. package/mcp_server/sound_design/tools.py +112 -1
  64. package/mcp_server/splice_client/client.py +19 -6
  65. package/mcp_server/stuckness_detector/detector.py +90 -0
  66. package/mcp_server/stuckness_detector/tools.py +49 -5
  67. package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
  68. package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
  69. package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
  70. package/mcp_server/tools/_agent_os_engine/models.py +132 -0
  71. package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
  72. package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
  73. package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
  74. package/mcp_server/tools/_composition_engine/__init__.py +67 -0
  75. package/mcp_server/tools/_composition_engine/analysis.py +174 -0
  76. package/mcp_server/tools/_composition_engine/critics.py +522 -0
  77. package/mcp_server/tools/_composition_engine/gestures.py +230 -0
  78. package/mcp_server/tools/_composition_engine/harmony.py +160 -0
  79. package/mcp_server/tools/_composition_engine/models.py +193 -0
  80. package/mcp_server/tools/_composition_engine/sections.py +414 -0
  81. package/mcp_server/tools/_harmony_engine.py +52 -8
  82. package/mcp_server/tools/_perception_engine.py +18 -11
  83. package/mcp_server/tools/_research_engine.py +98 -19
  84. package/mcp_server/tools/_theory_engine.py +138 -9
  85. package/mcp_server/tools/agent_os.py +43 -18
  86. package/mcp_server/tools/analyzer.py +105 -8
  87. package/mcp_server/tools/automation.py +6 -1
  88. package/mcp_server/tools/clips.py +45 -0
  89. package/mcp_server/tools/composition.py +90 -38
  90. package/mcp_server/tools/devices.py +32 -7
  91. package/mcp_server/tools/harmony.py +115 -14
  92. package/mcp_server/tools/midi_io.py +13 -1
  93. package/mcp_server/tools/mixing.py +35 -1
  94. package/mcp_server/tools/motif.py +56 -5
  95. package/mcp_server/tools/planner.py +6 -2
  96. package/mcp_server/tools/research.py +37 -10
  97. package/mcp_server/tools/theory.py +108 -16
  98. package/mcp_server/transition_engine/critics.py +18 -11
  99. package/mcp_server/transition_engine/tools.py +6 -1
  100. package/mcp_server/translation_engine/tools.py +8 -6
  101. package/mcp_server/wonder_mode/engine.py +8 -3
  102. package/mcp_server/wonder_mode/tools.py +29 -21
  103. package/package.json +2 -2
  104. package/remote_script/LivePilot/__init__.py +57 -2
  105. package/remote_script/LivePilot/clips.py +69 -0
  106. package/remote_script/LivePilot/mixing.py +117 -0
  107. package/remote_script/LivePilot/router.py +13 -1
  108. package/scripts/generate_tool_catalog.py +13 -38
  109. package/scripts/sync_metadata.py +231 -14
  110. package/mcp_server/tools/_agent_os_engine.py +0 -947
  111. 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 PRL transform between each consecutive pair of chords.
199
-
200
- Tries single transforms (P, L, R) first, then 2-step compound
201
- transforms (PL, PR, LP, LR, RP, RL) for richer classification.
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
- _COMPOUNDS = ["PL", "PR", "LP", "LR", "RP", "RL",
204
- "PP", "LL", "RR"]
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 compound transforms
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 _COMPOUNDS:
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
- pass
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 {