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.
- 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 +42 -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-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 +214 -2
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +93 -26
- package/mcp_server/creative_constraints/tools.py +206 -33
- package/mcp_server/experiment/engine.py +7 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +60 -9
- package/mcp_server/m4l_bridge.py +21 -6
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/performance_engine/tools.py +112 -29
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +22 -6
- 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 +55 -5
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +47 -6
- 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/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 +48 -14
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +68 -2
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tracker.py +14 -1
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +77 -13
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +41 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
- package/mcp_server/tools/_composition_engine/__init__.py +2 -2
- package/mcp_server/tools/_composition_engine/harmony.py +90 -0
- package/mcp_server/tools/_composition_engine/sections.py +47 -4
- package/mcp_server/tools/_harmony_engine.py +52 -8
- 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 +20 -3
- package/mcp_server/tools/analyzer.py +98 -0
- package/mcp_server/tools/clips.py +45 -0
- package/mcp_server/tools/composition.py +66 -23
- package/mcp_server/tools/devices.py +22 -1
- 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 +49 -3
- package/mcp_server/tools/research.py +24 -0
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/transition_engine/critics.py +18 -11
- 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
|
@@ -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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
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={
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
for
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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":
|
|
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
|
-
|
|
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(
|