livepilot 1.10.6 → 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 (78) 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 +42 -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-release/SKILL.md +8 -8
  15. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
  17. package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
  18. package/m4l_device/livepilot_bridge.js +214 -2
  19. package/manifest.json +3 -3
  20. package/mcp_server/__init__.py +1 -1
  21. package/mcp_server/atlas/__init__.py +93 -26
  22. package/mcp_server/creative_constraints/tools.py +206 -33
  23. package/mcp_server/experiment/engine.py +7 -9
  24. package/mcp_server/hook_hunter/analyzer.py +62 -9
  25. package/mcp_server/hook_hunter/tools.py +60 -9
  26. package/mcp_server/m4l_bridge.py +21 -6
  27. package/mcp_server/musical_intelligence/detectors.py +32 -0
  28. package/mcp_server/performance_engine/tools.py +112 -29
  29. package/mcp_server/preview_studio/engine.py +89 -8
  30. package/mcp_server/preview_studio/tools.py +22 -6
  31. package/mcp_server/project_brain/automation_graph.py +71 -19
  32. package/mcp_server/project_brain/builder.py +2 -0
  33. package/mcp_server/project_brain/tools.py +55 -5
  34. package/mcp_server/reference_engine/profile_builder.py +129 -3
  35. package/mcp_server/reference_engine/tools.py +47 -6
  36. package/mcp_server/runtime/execution_router.py +50 -0
  37. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  38. package/mcp_server/runtime/remote_commands.py +4 -2
  39. package/mcp_server/sample_engine/analyzer.py +131 -4
  40. package/mcp_server/sample_engine/critics.py +29 -8
  41. package/mcp_server/sample_engine/models.py +20 -1
  42. package/mcp_server/sample_engine/tools.py +48 -14
  43. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  44. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  45. package/mcp_server/server.py +68 -2
  46. package/mcp_server/session_continuity/models.py +4 -0
  47. package/mcp_server/session_continuity/tracker.py +14 -1
  48. package/mcp_server/song_brain/builder.py +110 -12
  49. package/mcp_server/song_brain/tools.py +77 -13
  50. package/mcp_server/sound_design/tools.py +112 -1
  51. package/mcp_server/stuckness_detector/detector.py +90 -0
  52. package/mcp_server/stuckness_detector/tools.py +41 -0
  53. package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
  54. package/mcp_server/tools/_composition_engine/__init__.py +2 -2
  55. package/mcp_server/tools/_composition_engine/harmony.py +90 -0
  56. package/mcp_server/tools/_composition_engine/sections.py +47 -4
  57. package/mcp_server/tools/_harmony_engine.py +52 -8
  58. package/mcp_server/tools/_research_engine.py +98 -19
  59. package/mcp_server/tools/_theory_engine.py +138 -9
  60. package/mcp_server/tools/agent_os.py +20 -3
  61. package/mcp_server/tools/analyzer.py +98 -0
  62. package/mcp_server/tools/clips.py +45 -0
  63. package/mcp_server/tools/composition.py +66 -23
  64. package/mcp_server/tools/devices.py +22 -1
  65. package/mcp_server/tools/harmony.py +115 -14
  66. package/mcp_server/tools/midi_io.py +13 -1
  67. package/mcp_server/tools/mixing.py +35 -1
  68. package/mcp_server/tools/motif.py +49 -3
  69. package/mcp_server/tools/research.py +24 -0
  70. package/mcp_server/tools/theory.py +108 -16
  71. package/mcp_server/transition_engine/critics.py +18 -11
  72. package/package.json +2 -2
  73. package/remote_script/LivePilot/__init__.py +57 -2
  74. package/remote_script/LivePilot/clips.py +69 -0
  75. package/remote_script/LivePilot/mixing.py +117 -0
  76. package/remote_script/LivePilot/router.py +13 -1
  77. package/scripts/generate_tool_catalog.py +13 -38
  78. package/scripts/sync_metadata.py +231 -14
@@ -44,7 +44,7 @@ from .gestures import (
44
44
  plan_gesture,
45
45
  resolve_gesture_template,
46
46
  )
47
- from .harmony import build_harmony_field
47
+ from .harmony import build_harmony_field, harmonic_score
48
48
  from .analysis import (
49
49
  COMPOSITION_DIMENSIONS,
50
50
  analyze_section_outcomes,
@@ -61,7 +61,7 @@ __all__ = [
61
61
  "run_form_critic", "run_section_identity_critic", "run_phrase_critic",
62
62
  "run_transition_critic", "run_emotional_arc_critic", "run_cross_section_critic",
63
63
  "GESTURE_TEMPLATES", "plan_gesture", "resolve_gesture_template",
64
- "build_harmony_field",
64
+ "build_harmony_field", "harmonic_score",
65
65
  "COMPOSITION_DIMENSIONS", "analyze_section_outcomes",
66
66
  "evaluate_composition_move", "build_composition_taste_model",
67
67
  ]
@@ -14,6 +14,96 @@ from typing import Any, Optional
14
14
 
15
15
  from .models import HarmonyField
16
16
 
17
+
18
+ # ── BUG-E3: harmonic-ness scoring ────────────────────────────────────────
19
+ # get_harmony_field used to take the FIRST track in section.tracks_active
20
+ # that had notes and lock in its key detection. When that track was
21
+ # percussion (all notes at a single pitch, staccato), detect_key would
22
+ # return a bogus "C major" for the whole section. The fix: score every
23
+ # track's notes for harmonic-ness, aggregate notes from tracks that pass
24
+ # a threshold, and run key detection on the combined pool.
25
+
26
+ _PERC_NAME_TOKENS = (
27
+ "kick", "snare", "clap", "hat", "hihat", "hi-hat", "hh", "drum",
28
+ "drums", "perc", "percussion", "rim", "crash", "ride", "tom",
29
+ "cymbal", "shaker", "tambourine", "cowbell", "808", "909",
30
+ "breakbeat", "break", "stick", "click",
31
+ )
32
+ _HARMONIC_NAME_TOKENS = (
33
+ "pad", "pads", "bass", "sub", "lead", "chord", "chords", "keys",
34
+ "synth", "piano", "rhodes", "organ", "lush", "string", "strings",
35
+ "brass", "pluck", "arp", "melody", "harmony", "voice", "choir",
36
+ )
37
+
38
+
39
+ def harmonic_score(notes: list[dict], track_name: str = "") -> float:
40
+ """Rate how likely a track's notes carry harmonic content (0.0 - 1.0).
41
+
42
+ Used by get_harmony_field to decide which tracks to include in
43
+ aggregate key detection. Percussion (single-pitch, staccato) scores
44
+ near 0. Sustained chordal/melodic material scores near 1.
45
+
46
+ Signals combined:
47
+ - unique pitch classes (chords vary, kicks don't)
48
+ - median note duration (sustain vs staccato)
49
+ - pitch range (melody moves, drums don't)
50
+ - minimum pitch (above the GM drum range)
51
+ - track-name hint tokens (soft nudge)
52
+
53
+ Returns a score in [0.0, 1.0]. Callers typically threshold at 0.3 or 0.4.
54
+ """
55
+ if not notes:
56
+ return 0.0
57
+
58
+ pitches = [int(n.get("pitch", 60)) for n in notes]
59
+ durations = [float(n.get("duration", 0.0)) for n in notes]
60
+ pcs = set(p % 12 for p in pitches)
61
+
62
+ # Use statistics.median for a more stable middle value. Falling back
63
+ # to a manual median keeps this file free of an extra import.
64
+ sorted_durs = sorted(durations)
65
+ median_dur = sorted_durs[len(sorted_durs) // 2] if sorted_durs else 0.0
66
+ unique_pcs = len(pcs)
67
+ pitch_range = max(pitches) - min(pitches) if pitches else 0
68
+ min_pitch = min(pitches) if pitches else 0
69
+
70
+ score = 0.0
71
+ # Unique pitch-class diversity: 3+ distinct pcs is a strong harmonic signal
72
+ if unique_pcs >= 4:
73
+ score += 0.45
74
+ elif unique_pcs >= 3:
75
+ score += 0.35
76
+ elif unique_pcs >= 2:
77
+ score += 0.15
78
+
79
+ # Duration: sustained notes carry harmony; staccato is rhythmic
80
+ if median_dur >= 1.0:
81
+ score += 0.30
82
+ elif median_dur >= 0.5:
83
+ score += 0.25
84
+ elif median_dur >= 0.25:
85
+ score += 0.10
86
+
87
+ # Pitch range: melody spans more than an octave often; drums don't
88
+ if pitch_range >= 12:
89
+ score += 0.20
90
+ elif pitch_range >= 5:
91
+ score += 0.10
92
+
93
+ # Minimum pitch out of the GM drum-map range (35–51) suggests melody
94
+ if min_pitch >= 48:
95
+ score += 0.10
96
+
97
+ # Track-name hints — mild nudges either way
98
+ name_lower = str(track_name or "").lower()
99
+ if any(tok in name_lower for tok in _PERC_NAME_TOKENS):
100
+ score -= 0.45
101
+ if any(tok in name_lower for tok in _HARMONIC_NAME_TOKENS):
102
+ score += 0.20
103
+
104
+ return max(0.0, min(1.0, score))
105
+
106
+
17
107
  def build_harmony_field(
18
108
  section_id: str,
19
109
  harmony_analysis: Optional[dict] = None,
@@ -194,6 +194,15 @@ def detect_phrases(
194
194
 
195
195
  Uses note density changes and gap detection to find phrase boundaries.
196
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.
197
206
  """
198
207
  section_length = section.length_bars()
199
208
  if section_length <= 0:
@@ -204,12 +213,46 @@ def detect_phrases(
204
213
  for bar in range(section.start_bar, section.end_bar):
205
214
  bar_densities[bar] = 0
206
215
 
216
+ section_bar_count = section.end_bar - section.start_bar
217
+
207
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
+
208
236
  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
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
213
256
 
214
257
  # Find phrase boundaries using density drops (gaps)
215
258
  boundaries = [section.start_bar]
@@ -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
 
@@ -162,15 +162,52 @@ def targeted_research(
162
162
  findings: list[ResearchFinding] = []
163
163
  sources_searched = []
164
164
 
165
- # 1. Device atlas findings
165
+ # 1. Device atlas findings.
166
+ # BUG-B43 fix: device_atlas_results is a list of search_browser
167
+ # RESPONSES (each with {path, items: [...], count, ...}) — NOT a
168
+ # list of device entries. The old code read response.get("name")
169
+ # which always returned "" because the response has no top-level
170
+ # name. Every finding came back as "Device: Unknown". We now
171
+ # flatten the responses, look up each item's real atlas metadata,
172
+ # and build one finding per resolved device.
166
173
  if device_atlas_results:
167
174
  sources_searched.append("device_atlas")
168
- for entry in device_atlas_results:
175
+ flattened_entries: list[dict] = []
176
+ for response in device_atlas_results:
177
+ if not isinstance(response, dict):
178
+ continue
179
+ # Accept old shape (single device dict) for forward compat
180
+ if response.get("name") and not response.get("items"):
181
+ flattened_entries.append(response)
182
+ continue
183
+ items = response.get("items") or response.get("results") or []
184
+ for item in items:
185
+ if not isinstance(item, dict):
186
+ continue
187
+ flattened_entries.append({
188
+ "name": item.get("name", ""),
189
+ "uri": item.get("uri", ""),
190
+ "category": response.get("path", ""),
191
+ "is_loadable": item.get("is_loadable", False),
192
+ })
193
+
194
+ for entry in flattened_entries:
169
195
  name = entry.get("name", "")
196
+ if not name:
197
+ continue # skip phantom empties
198
+ # Try to enrich with atlas lookup — gives real description,
199
+ # character_tags, genres.
200
+ try:
201
+ from ..atlas import _atlas_instance as _atlas
202
+ if _atlas is not None:
203
+ full = _atlas.lookup(name)
204
+ if full:
205
+ entry = {**full, **entry}
206
+ except Exception:
207
+ pass
208
+
170
209
  text = _format_device_finding(entry)
171
210
  relevance = _score_finding_relevance(text, query_info["keywords"])
172
-
173
- # Boost relevance if device was in our predicted list
174
211
  if name in query_info["likely_devices"]:
175
212
  relevance = min(1.0, relevance + 0.3)
176
213
 
@@ -179,7 +216,10 @@ def targeted_research(
179
216
  source_id=name,
180
217
  relevance=round(relevance, 3),
181
218
  content=text,
182
- metadata={"device_name": name, "category": entry.get("category", "")},
219
+ metadata={
220
+ "device_name": name,
221
+ "category": entry.get("category", ""),
222
+ },
183
223
  ))
184
224
 
185
225
  # 2. Memory findings (technique cards, outcomes, research notes)
@@ -522,21 +562,60 @@ def get_style_tactics(
522
562
  if any(query in p.lower() for p in tactic.arrangement_patterns):
523
563
  results.append(tactic)
524
564
 
525
- # Search user memory tactics
565
+ # Search user memory tactics.
566
+ # BUG-B18 fix: TechniqueStore.search() strips the payload from
567
+ # summaries, so `mem.get("payload", {})` was always empty and the
568
+ # old match-by-payload.artist_or_genre code never fired. Users who
569
+ # saved 3 "Prefuse73" techniques via memory_learn got back 0
570
+ # tactics from get_style_tactics("prefuse73"). We now match on
571
+ # name + tags + qualities.summary + payload (if present) and
572
+ # adapt the memory-entry to a StyleTactic regardless of whether
573
+ # the caller formatted the payload in the exact style_tactic shape.
526
574
  if memory_tactics:
527
575
  for mem in memory_tactics:
528
- payload = mem.get("payload", {})
529
- if isinstance(payload, dict):
530
- mem_genre = payload.get("artist_or_genre", "").lower()
531
- mem_name = payload.get("tactic_name", "").lower()
532
- if query in mem_genre or query in mem_name:
533
- results.append(StyleTactic(
534
- artist_or_genre=payload.get("artist_or_genre", ""),
535
- tactic_name=payload.get("tactic_name", ""),
536
- arrangement_patterns=payload.get("arrangement_patterns", []),
537
- device_chain=payload.get("device_chain", []),
538
- automation_gestures=payload.get("automation_gestures", []),
539
- verification=payload.get("verification", []),
540
- ))
576
+ if not isinstance(mem, dict):
577
+ continue
578
+ # Build the searchable text from whichever shape the memory
579
+ # entry uses. This is lenient on purpose — saved techniques
580
+ # don't need to pre-commit to a schema to surface here.
581
+ name = str(mem.get("name", ""))
582
+ tags = mem.get("tags", []) or []
583
+ qualities = mem.get("qualities", {}) or {}
584
+ summary = str(qualities.get("summary", "") or "")
585
+ payload = mem.get("payload", {}) or {}
586
+
587
+ searchable = " ".join([
588
+ name.lower(), summary.lower(),
589
+ " ".join(str(t).lower() for t in tags),
590
+ str(payload.get("artist_or_genre", "")).lower(),
591
+ str(payload.get("tactic_name", "")).lower(),
592
+ ])
593
+ if query not in searchable:
594
+ continue
595
+
596
+ # Adapt to StyleTactic — prefer payload fields when present,
597
+ # fall back to the summary for arrangement_patterns, etc.
598
+ artist_or_genre = str(
599
+ payload.get("artist_or_genre")
600
+ or next((t for t in tags if query in str(t).lower()), "")
601
+ or query
602
+ )
603
+ tactic_name = str(payload.get("tactic_name") or name or summary[:40])
604
+ arrangement_patterns = (
605
+ payload.get("arrangement_patterns")
606
+ or [summary] if summary else []
607
+ )
608
+ device_chain = payload.get("device_chain", []) or []
609
+ automation_gestures = payload.get("automation_gestures", []) or []
610
+ verification = payload.get("verification", []) or []
611
+
612
+ results.append(StyleTactic(
613
+ artist_or_genre=artist_or_genre,
614
+ tactic_name=tactic_name,
615
+ arrangement_patterns=arrangement_patterns,
616
+ device_chain=device_chain,
617
+ automation_gestures=automation_gestures,
618
+ verification=verification,
619
+ ))
541
620
 
542
621
  return results
@@ -218,16 +218,98 @@ def detect_key(notes: list[dict], mode_detection: bool = True) -> dict:
218
218
 
219
219
 
220
220
  def chord_name(midi_pitches: list[int]) -> str:
221
- """Identify chord from MIDI pitches -> 'C-major triad'."""
222
- pcs = sorted(set(p % 12 for p in midi_pitches))
223
- if not pcs:
221
+ """Identify chord from MIDI pitches -> 'C-major triad'.
222
+
223
+ Root-selection rules (BUG-B2 / BUG-B5):
224
+ 1) Prefer the bass note (lowest MIDI pitch) as root — matches
225
+ musical convention and handles partial voicings correctly.
226
+ 2) Accept exact CHORD_PATTERNS match first.
227
+ 3) If no exact match, allow subset match (partial chord e.g.
228
+ Dm7(no5)) and superset match (add-tone e.g. Gm7(add11)).
229
+ 4) Only fall back to the pitch-class-0 guess when nothing else
230
+ works — previous code always returned pcs[0] on miss, which
231
+ mis-labeled Dm7 as a "C chord" just because C has pc=0.
232
+ """
233
+ if not midi_pitches:
224
234
  return "unknown"
225
- # Try each pitch class as potential root
226
- for root in pcs:
227
- intervals = tuple(sorted((pc - root) % 12 for pc in pcs))
235
+ pcs_set = set(p % 12 for p in midi_pitches)
236
+ pcs_sorted_pc = sorted(pcs_set) # numerical pc order, used for fallback only
237
+ bass_pc = min(midi_pitches) % 12
238
+
239
+ # Ordered candidate roots: bass first, then remaining pcs (low→high).
240
+ # This gives musical priority to the bass note without ignoring cases
241
+ # where a non-bass root yields a cleaner exact match (2nd-pass scoring).
242
+ ordered_roots: list[int] = [bass_pc] + [pc for pc in pcs_sorted_pc if pc != bass_pc]
243
+
244
+ # ── Pass 1: exact CHORD_PATTERNS match ─────────────────────────────
245
+ exact_matches: list[tuple[int, tuple[int, ...]]] = []
246
+ for root in ordered_roots:
247
+ intervals = tuple(sorted((pc - root) % 12 for pc in pcs_set))
228
248
  if intervals in CHORD_PATTERNS:
229
- return f"{NOTE_NAMES[root]}-{CHORD_PATTERNS[intervals]}"
230
- return f"{NOTE_NAMES[pcs[0]]} chord"
249
+ exact_matches.append((root, intervals))
250
+
251
+ if exact_matches:
252
+ # Prefer the match where root == bass_pc
253
+ for root, intervals in exact_matches:
254
+ if root == bass_pc:
255
+ return f"{NOTE_NAMES[root]}-{CHORD_PATTERNS[intervals]}"
256
+ # Otherwise the first (bass-first iteration) wins
257
+ root, intervals = exact_matches[0]
258
+ return f"{NOTE_NAMES[root]}-{CHORD_PATTERNS[intervals]}"
259
+
260
+ # ── Pass 2: subset match (partial chord — missing tones) ───────────
261
+ # e.g. D-F-C → (0, 3, 10) is a subset of (0, 3, 7, 10) = minor seventh.
262
+ # Report the canonical name with "(no X)" where X is the missing interval.
263
+ interval_names = {
264
+ 0: "root", 2: "2", 3: "♭3", 4: "3", 5: "4", 6: "♭5",
265
+ 7: "5", 8: "♭6", 9: "6", 10: "♭7", 11: "7",
266
+ }
267
+ # Prefer bass-pc first.
268
+ for root in ordered_roots:
269
+ intervals_set = set((pc - root) % 12 for pc in pcs_set)
270
+ if 0 not in intervals_set:
271
+ continue
272
+ for pattern, label in CHORD_PATTERNS.items():
273
+ pattern_set = set(pattern)
274
+ if intervals_set.issubset(pattern_set) and intervals_set != pattern_set:
275
+ missing = sorted(pattern_set - intervals_set)
276
+ missing_names = ",".join(interval_names.get(m, str(m)) for m in missing)
277
+ return f"{NOTE_NAMES[root]}-{label} (no {missing_names})"
278
+
279
+ # ── Pass 3: superset match (extended chord — added tensions) ───────
280
+ # e.g. G-Bb-D-F-A → pattern minor-seventh (0,3,7,10) is a subset of
281
+ # {0,2,3,7,10}; the extra 2 is an added 9 (or 11 depending on voicing).
282
+ # Report "Gm7(add2)".
283
+ add_interval_names = {
284
+ 1: "♭9", 2: "9", 4: "♯9", 5: "11", 6: "♯11",
285
+ 8: "♭13", 9: "13", 11: "maj7",
286
+ }
287
+ for root in ordered_roots:
288
+ intervals_set = set((pc - root) % 12 for pc in pcs_set)
289
+ if 0 not in intervals_set:
290
+ continue
291
+ # Try each pattern as the core, extras as tensions. Track the
292
+ # chosen pattern size so we prefer 7ths over triads (the bug in
293
+ # the first draft was using len(set(label_string)) — chars, not
294
+ # intervals — which broke the tie-break for BUG-B2.
295
+ best_superset: tuple[str, list[int], int] | None = None
296
+ for pattern, label in CHORD_PATTERNS.items():
297
+ pattern_set = set(pattern)
298
+ if pattern_set.issubset(intervals_set) and pattern_set != intervals_set:
299
+ extras = sorted(intervals_set - pattern_set)
300
+ # Prefer the longest pattern (seventh chords win over triads)
301
+ if best_superset is None or len(pattern_set) > best_superset[2]:
302
+ best_superset = (label, extras, len(pattern_set))
303
+ if best_superset is not None:
304
+ label, extras, _ = best_superset
305
+ add_names = ",".join(add_interval_names.get(e, str(e)) for e in extras)
306
+ return f"{NOTE_NAMES[root]}-{label} (add {add_names})"
307
+
308
+ # ── Pass 4: fallback — name by bass note, not pcs[0] ───────────────
309
+ # Previous behavior returned NOTE_NAMES[pcs[0]] (numerically lowest pc,
310
+ # which put C first for any chord containing C). Bass-note is musically
311
+ # correct — if we can't identify the quality, at least the root is right.
312
+ return f"{NOTE_NAMES[bass_pc]} chord"
231
313
 
232
314
 
233
315
  def roman_numeral(chord_pcs: list[int], tonic: int, mode: str) -> dict:
@@ -399,8 +481,18 @@ def roman_figure_to_pitches(figure: str, tonic: int, mode: str) -> dict:
399
481
  p = base_midi + ((pc - root_pc) % 12)
400
482
  midi.append(p)
401
483
 
484
+ # BUG-B23: the original figure string's case can disagree with the
485
+ # resolved quality (e.g. "IV" resolving to a minor triad on the 4th
486
+ # scale degree of D minor). Convention says uppercase numerals encode
487
+ # major/augmented and lowercase encode minor/diminished. Return a
488
+ # normalized figure so callers receive internally consistent data;
489
+ # the raw input figure stays reflected in 'figure_requested' for
490
+ # debugging / compatibility.
491
+ normalized_figure = _normalize_figure_case(figure, quality)
492
+
402
493
  return {
403
- "figure": figure,
494
+ "figure": normalized_figure,
495
+ "figure_requested": figure,
404
496
  "root_pc": root_pc,
405
497
  "pitches": [pitch_name(m) for m in midi],
406
498
  "midi_pitches": midi,
@@ -408,6 +500,43 @@ def roman_figure_to_pitches(figure: str, tonic: int, mode: str) -> dict:
408
500
  }
409
501
 
410
502
 
503
+ def _normalize_figure_case(figure: str, quality: str) -> str:
504
+ """Return *figure* with its numeral's case matched to *quality*.
505
+
506
+ Uppercase numerals (I, II, …, VII) conventionally encode major-family
507
+ qualities; lowercase (i, ii, …, vii) encode minor-family. We preserve
508
+ any leading accidentals (b/#) and trailing suffix (7, maj7, °, etc.)
509
+ and only flip the numeral itself. Safe on edge cases: if the figure
510
+ doesn't start with a recognized numeral, it's returned unchanged.
511
+ """
512
+ if not figure:
513
+ return figure
514
+ # Split off leading accidentals
515
+ prefix = ""
516
+ remaining = figure
517
+ while remaining and remaining[0] in ("b", "#"):
518
+ prefix += remaining[0]
519
+ remaining = remaining[1:]
520
+ # Match numeral greedily (longest first so VII wins over VI wins over V)
521
+ numeral = ""
522
+ for rn in ["VII", "VI", "IV", "III", "II", "V", "I"]:
523
+ if remaining.upper().startswith(rn):
524
+ numeral = rn
525
+ break
526
+ if not numeral:
527
+ return figure
528
+ suffix = remaining[len(numeral):]
529
+ # Minor family: lowercase. Major family: uppercase.
530
+ q = quality.lower() if isinstance(quality, str) else ""
531
+ is_minor_family = (
532
+ q.startswith("minor")
533
+ or q.startswith("half-diminished")
534
+ or q.startswith("diminished")
535
+ )
536
+ new_numeral = numeral.lower() if is_minor_family else numeral
537
+ return prefix + new_numeral + suffix
538
+
539
+
411
540
  def check_voice_leading(prev_pitches: list[int], curr_pitches: list[int]) -> list[dict]:
412
541
  """Check voice leading issues between two chords."""
413
542
  issues = []
@@ -151,11 +151,28 @@ def build_world_model(ctx: Context) -> dict:
151
151
  if key_data:
152
152
  detected_key = key_data["value"] if isinstance(key_data["value"], dict) else {"key": key_data["value"]}
153
153
 
154
+ # BUG-E6 fix: derive flucoma_available from the same 6-stream probe
155
+ # that check_flucoma uses. Previously we read a dedicated
156
+ # "flucoma_status" key that the M4L bridge doesn't emit, so the
157
+ # fallback `{"flucoma_available": False}` always won even when all
158
+ # 6 FluCoMa streams were actively delivering data.
159
+ _flu_streams = ("spectral_shape", "mel_bands", "chroma",
160
+ "onset", "novelty", "loudness")
161
+ active = sum(1 for k in _flu_streams if spectral.get(k) is not None)
162
+ flucoma_status = {
163
+ "flucoma_available": active > 0,
164
+ "active_streams": active,
165
+ }
166
+ # Keep any explicit flucoma_status payload the bridge may emit
167
+ # alongside as extra metadata — without letting it override the
168
+ # stream-based truth.
154
169
  flucoma_data = spectral.get("flucoma_status")
155
- if flucoma_data:
156
- flucoma_status = flucoma_data["value"] if isinstance(flucoma_data["value"], dict) else {}
170
+ if flucoma_data and isinstance(flucoma_data.get("value"), dict):
171
+ extras = {k: v for k, v in flucoma_data["value"].items()
172
+ if k not in flucoma_status}
173
+ flucoma_status.update(extras)
157
174
  else:
158
- flucoma_status = {"flucoma_available": False}
175
+ flucoma_status = {"flucoma_available": False, "active_streams": 0}
159
176
 
160
177
  # Build model
161
178
  wm = engine.build_world_model_from_data(