livepilot 1.10.6 → 1.10.8
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/CHANGELOG.md +168 -0
- package/README.md +12 -10
- package/bin/livepilot.js +168 -30
- package/installer/install.js +117 -11
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +215 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +132 -33
- package/mcp_server/atlas/tools.py +56 -15
- package/mcp_server/composer/layer_planner.py +27 -0
- package/mcp_server/composer/prompt_parser.py +15 -6
- package/mcp_server/connection.py +11 -3
- package/mcp_server/corpus/__init__.py +14 -4
- 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 +68 -12
- 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 +66 -2
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +10 -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 +42 -4
- package/mcp_server/sample_engine/tools.py +48 -14
- package/mcp_server/semantic_moves/__init__.py +1 -0
- package/mcp_server/semantic_moves/compiler.py +9 -1
- package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
- package/mcp_server/semantic_moves/mix_compilers.py +170 -0
- package/mcp_server/semantic_moves/mix_moves.py +1 -1
- package/mcp_server/semantic_moves/models.py +5 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +15 -4
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +75 -5
- package/mcp_server/services/singletons.py +68 -0
- 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/splice_client/client.py +29 -8
- 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 +105 -6
- package/mcp_server/tools/clips.py +46 -1
- 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 +23 -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/tools/tracks.py +1 -1
- package/mcp_server/tools/transport.py +1 -1
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/translation_engine/tools.py +8 -4
- package/package.json +25 -3
- package/remote_script/LivePilot/__init__.py +77 -2
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/remote_script/LivePilot/browser.py +16 -6
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/devices.py +10 -5
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/notes.py +13 -2
- package/remote_script/LivePilot/router.py +13 -1
- package/remote_script/LivePilot/server.py +51 -13
- package/remote_script/LivePilot/version_detect.py +7 -4
- package/server.json +20 -0
- package/.claude-plugin/marketplace.json +0 -21
- package/.mcpbignore +0 -57
- package/AGENTS.md +0 -46
- package/CODE_OF_CONDUCT.md +0 -27
- package/CONTRIBUTING.md +0 -131
- package/SECURITY.md +0 -48
- package/livepilot/.Codex-plugin/plugin.json +0 -8
- package/livepilot/.claude-plugin/plugin.json +0 -8
- package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
- package/livepilot/commands/arrange.md +0 -47
- package/livepilot/commands/beat.md +0 -77
- package/livepilot/commands/evaluate.md +0 -49
- package/livepilot/commands/memory.md +0 -22
- package/livepilot/commands/mix.md +0 -44
- package/livepilot/commands/perform.md +0 -42
- package/livepilot/commands/session.md +0 -13
- package/livepilot/commands/sounddesign.md +0 -43
- package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
- package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
- package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
- package/livepilot/skills/livepilot-core/SKILL.md +0 -184
- package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
- package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
- package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
- package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
- package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
- package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
- package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
- package/livepilot/skills/livepilot-core/references/overview.md +0 -290
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
- package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
- package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
- package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
- package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
- package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
- package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
- package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
- package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
- package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
- package/livepilot/skills/livepilot-release/SKILL.md +0 -130
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
- package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
- package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
- package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
- package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
- package/manifest.json +0 -91
- package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
- package/scripts/generate_tool_catalog.py +0 -131
- package/scripts/sync_metadata.py +0 -132
|
@@ -187,6 +187,77 @@ def build_song_brain(ctx: Context) -> dict:
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
|
|
190
|
+
def classify_energy_shape(arc: list[float]) -> dict:
|
|
191
|
+
"""Classify the shape of an energy arc for user-facing explanation.
|
|
192
|
+
|
|
193
|
+
BUG-B13 fix: the old single-max-position classifier labeled
|
|
194
|
+
[0.7, 0.9, 0.9, 0.5, 0.6, 0.9, 0.4] (peaks at 1-2 AND 5) as
|
|
195
|
+
"front-loaded" because max()'s first occurrence is at index 1.
|
|
196
|
+
We now find ALL peaks above a dynamic threshold and classify by
|
|
197
|
+
count + distribution.
|
|
198
|
+
|
|
199
|
+
Returns {"shape": str, "peak_positions": list[int] | None}.
|
|
200
|
+
"""
|
|
201
|
+
arc = [x for x in (arc or []) if x is not None]
|
|
202
|
+
if len(arc) < 3:
|
|
203
|
+
return {"shape": "short form — limited arc data", "peak_positions": None}
|
|
204
|
+
|
|
205
|
+
max_energy = max(arc)
|
|
206
|
+
arc_min = min(arc)
|
|
207
|
+
dynamic_mid = (arc_min + max_energy) / 2.0
|
|
208
|
+
peak_threshold = max(max_energy * 0.9, dynamic_mid)
|
|
209
|
+
peak_indices = [i for i, v in enumerate(arc) if v >= peak_threshold]
|
|
210
|
+
|
|
211
|
+
# Collapse runs of adjacent peak indices into their starting index —
|
|
212
|
+
# [1, 2, 5] has peaks at "position ~1" and "position 5", NOT three
|
|
213
|
+
# distinct peaks. Without this, front-loaded arcs where bars 0 and 1
|
|
214
|
+
# are both above threshold would misfire the dual-peak branch.
|
|
215
|
+
distinct_peaks: list[int] = []
|
|
216
|
+
for idx in peak_indices:
|
|
217
|
+
if not distinct_peaks or idx - distinct_peaks[-1] > 1:
|
|
218
|
+
distinct_peaks.append(idx)
|
|
219
|
+
|
|
220
|
+
n = len(arc)
|
|
221
|
+
first_third = {i for i in range(0, n // 3 + 1)}
|
|
222
|
+
last_third = {i for i in range(2 * n // 3, n)}
|
|
223
|
+
in_first = any(i in first_third for i in peak_indices)
|
|
224
|
+
in_last = any(i in last_third for i in peak_indices)
|
|
225
|
+
in_middle = any(
|
|
226
|
+
i not in first_third and i not in last_third
|
|
227
|
+
for i in peak_indices
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Plateau FIRST — when the dynamic range is narrow (<0.3) and most
|
|
231
|
+
# of the arc sits at/near the max, it's a plateau, not a multi-peak
|
|
232
|
+
# shape. Has to win over dual-peak so [0.7, 0.8, 0.8, 0.75, 0.8, …]
|
|
233
|
+
# doesn't get labeled "dual-peak at 2 and 6" when it's clearly flat.
|
|
234
|
+
if len(peak_indices) >= max(n - 2, 2) and (
|
|
235
|
+
max_energy - arc_min < 0.3
|
|
236
|
+
):
|
|
237
|
+
shape = "plateau — sustained energy with limited dynamic range"
|
|
238
|
+
# Multi-peak: at least 2 DISTINCT peaks (after collapsing adjacent runs),
|
|
239
|
+
# separated by >= n/3 positions. Adjacent peaks are a single plateau, not two.
|
|
240
|
+
elif len(distinct_peaks) >= 2 and (
|
|
241
|
+
max(distinct_peaks) - min(distinct_peaks) >= max(n // 3, 2)
|
|
242
|
+
):
|
|
243
|
+
shape = (
|
|
244
|
+
f"dual-peak — energy peaks at positions "
|
|
245
|
+
f"{distinct_peaks[0]+1} and {distinct_peaks[-1]+1}"
|
|
246
|
+
)
|
|
247
|
+
elif in_first and not in_middle and not in_last:
|
|
248
|
+
shape = "front-loaded — peaks early"
|
|
249
|
+
elif in_last and not in_first and not in_middle:
|
|
250
|
+
shape = "slow burn — builds to late peak"
|
|
251
|
+
elif in_middle and not in_first and not in_last:
|
|
252
|
+
shape = "centered arc — peaks in the middle"
|
|
253
|
+
else:
|
|
254
|
+
shape = (
|
|
255
|
+
f"mixed — peaks at positions "
|
|
256
|
+
f"{', '.join(str(i+1) for i in peak_indices)}"
|
|
257
|
+
)
|
|
258
|
+
return {"shape": shape, "peak_positions": peak_indices}
|
|
259
|
+
|
|
260
|
+
|
|
190
261
|
@mcp.tool()
|
|
191
262
|
def explain_song_identity(ctx: Context) -> dict:
|
|
192
263
|
"""Explain the current song's identity in human musical language.
|
|
@@ -228,20 +299,13 @@ def explain_song_identity(ctx: Context) -> dict:
|
|
|
228
299
|
for s in brain.section_purposes
|
|
229
300
|
]
|
|
230
301
|
|
|
231
|
-
# Energy shape
|
|
302
|
+
# Energy shape — BUG-B13 fix: dual-peak detection. See
|
|
303
|
+
# classify_energy_shape() for logic.
|
|
232
304
|
if brain.energy_arc:
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if peak_pct < 0.3:
|
|
238
|
-
explanation["energy_shape"] = "front-loaded — peaks early"
|
|
239
|
-
elif peak_pct > 0.7:
|
|
240
|
-
explanation["energy_shape"] = "slow burn — builds to late peak"
|
|
241
|
-
else:
|
|
242
|
-
explanation["energy_shape"] = "centered arc — peaks in the middle"
|
|
243
|
-
else:
|
|
244
|
-
explanation["energy_shape"] = "short form — limited arc data"
|
|
305
|
+
shape_info = classify_energy_shape(brain.energy_arc)
|
|
306
|
+
explanation["energy_shape"] = shape_info["shape"]
|
|
307
|
+
if shape_info["peak_positions"] is not None:
|
|
308
|
+
explanation["peak_positions"] = shape_info["peak_positions"]
|
|
245
309
|
|
|
246
310
|
# Open questions
|
|
247
311
|
if brain.open_questions:
|
|
@@ -195,6 +195,46 @@ def _fetch_sound_design_data(ctx: Context, track_index: int) -> dict:
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
|
|
198
|
+
# BUG-B35: some roles are SUPPOSED to be simple. A kick, snare, or sub-bass
|
|
199
|
+
# patch with one block + a saturator is textbook electronic drum design —
|
|
200
|
+
# not "weak identity". Gate the "too_few_blocks" + "no_modulation_sources"
|
|
201
|
+
# critics on track role so we don't pester users about their perfectly
|
|
202
|
+
# serviceable DS Kick patches.
|
|
203
|
+
_SIMPLE_ROLE_TOKENS = (
|
|
204
|
+
"kick", "snare", "clap", "rim", "hat", "hihat", "hi-hat",
|
|
205
|
+
"drum", "drums", "perc", "percussion", "conga", "shaker",
|
|
206
|
+
"tambourine", "cowbell", "tom", "crash", "ride", "cymbal",
|
|
207
|
+
"808", "sub", "sub bass", "sub_bass",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _is_simple_role_track(track_name: str) -> bool:
|
|
212
|
+
"""True when the track name matches a role where simple patches are
|
|
213
|
+
the correct creative choice (kick/snare/drums/sub)."""
|
|
214
|
+
if not track_name:
|
|
215
|
+
return False
|
|
216
|
+
lowered = str(track_name).lower()
|
|
217
|
+
return any(tok in lowered for tok in _SIMPLE_ROLE_TOKENS)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
_ROLE_SUPPRESSIBLE_ISSUES = frozenset({
|
|
221
|
+
"too_few_blocks",
|
|
222
|
+
"no_modulation_sources",
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _filter_role_appropriate_issues(
|
|
227
|
+
issues: list,
|
|
228
|
+
track_name: str,
|
|
229
|
+
) -> list:
|
|
230
|
+
"""Drop issues whose type is in _ROLE_SUPPRESSIBLE_ISSUES when the
|
|
231
|
+
track role (inferred from name) is one where simplicity is expected.
|
|
232
|
+
Issues pass through unchanged for pad / lead / synth / bass roles."""
|
|
233
|
+
if not _is_simple_role_track(track_name):
|
|
234
|
+
return issues
|
|
235
|
+
return [i for i in issues if i.issue_type not in _ROLE_SUPPRESSIBLE_ISSUES]
|
|
236
|
+
|
|
237
|
+
|
|
198
238
|
# ── MCP Tools ────────────────────────────────────────────────────────
|
|
199
239
|
|
|
200
240
|
|
|
@@ -217,6 +257,10 @@ def analyze_sound_design(ctx: Context, track_index: int) -> dict:
|
|
|
217
257
|
layers=layers,
|
|
218
258
|
)
|
|
219
259
|
issues = run_all_sound_design_critics(state)
|
|
260
|
+
# BUG-B35: gate role-sensitive critics by track name
|
|
261
|
+
issues = _filter_role_appropriate_issues(
|
|
262
|
+
issues, data["track_info"].get("name", "")
|
|
263
|
+
)
|
|
220
264
|
moves = plan_sound_design_moves(issues, state)
|
|
221
265
|
|
|
222
266
|
return {
|
|
@@ -246,6 +290,9 @@ def get_sound_design_issues(ctx: Context, track_index: int) -> dict:
|
|
|
246
290
|
layers=layers,
|
|
247
291
|
)
|
|
248
292
|
issues = run_all_sound_design_critics(state)
|
|
293
|
+
issues = _filter_role_appropriate_issues(
|
|
294
|
+
issues, data["track_info"].get("name", "")
|
|
295
|
+
)
|
|
249
296
|
|
|
250
297
|
return {
|
|
251
298
|
"issues": [i.to_dict() for i in issues],
|
|
@@ -260,6 +307,11 @@ def plan_sound_design_move(ctx: Context, track_index: int) -> dict:
|
|
|
260
307
|
Runs critics and planner, returns sorted moves with
|
|
261
308
|
estimated impact and risk scores.
|
|
262
309
|
|
|
310
|
+
BUG-B36 fix: when zero sound-design issues but sibling mix/
|
|
311
|
+
composition engines flag problems on the same track, returns a
|
|
312
|
+
`cross_engine_hint` pointing the user to the right tool instead
|
|
313
|
+
of silently reporting empty.
|
|
314
|
+
|
|
263
315
|
Args:
|
|
264
316
|
track_index: Index of the track to analyze.
|
|
265
317
|
"""
|
|
@@ -272,14 +324,73 @@ def plan_sound_design_move(ctx: Context, track_index: int) -> dict:
|
|
|
272
324
|
layers=layers,
|
|
273
325
|
)
|
|
274
326
|
issues = run_all_sound_design_critics(state)
|
|
327
|
+
issues = _filter_role_appropriate_issues(
|
|
328
|
+
issues, data["track_info"].get("name", "")
|
|
329
|
+
)
|
|
275
330
|
moves = plan_sound_design_moves(issues, state)
|
|
276
331
|
|
|
277
|
-
|
|
332
|
+
result: dict = {
|
|
278
333
|
"moves": [m.to_dict() for m in moves],
|
|
279
334
|
"move_count": len(moves),
|
|
280
335
|
"issue_count": len(issues),
|
|
281
336
|
}
|
|
282
337
|
|
|
338
|
+
# BUG-B36: when nothing to do on the sound-design side, probe sibling
|
|
339
|
+
# engines for issues on this track and emit a discoverability hint.
|
|
340
|
+
if not moves:
|
|
341
|
+
cross_hint = _cross_engine_hint_for_track(ctx, track_index)
|
|
342
|
+
if cross_hint:
|
|
343
|
+
result["cross_engine_hint"] = cross_hint
|
|
344
|
+
|
|
345
|
+
return result
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _cross_engine_hint_for_track(
|
|
349
|
+
ctx: Context, track_index: int,
|
|
350
|
+
) -> Optional[str]:
|
|
351
|
+
"""Look at mix issues for this track and emit a hint pointing to
|
|
352
|
+
the right sibling tool when sound-design has nothing to say.
|
|
353
|
+
|
|
354
|
+
Best-effort — any failure returns None so plan_sound_design_move
|
|
355
|
+
never breaks on telemetry hiccups. Calls into the mix-engine's
|
|
356
|
+
pure function directly (not an MCP round-trip) to avoid the
|
|
357
|
+
one-client-per-port contention.
|
|
358
|
+
"""
|
|
359
|
+
try:
|
|
360
|
+
from ..mix_engine.critics import run_all_mix_critics
|
|
361
|
+
from ..mix_engine.state_builder import build_mix_state
|
|
362
|
+
from ..mix_engine.tools import _fetch_mix_data
|
|
363
|
+
data = _fetch_mix_data(ctx)
|
|
364
|
+
mix_state = build_mix_state(
|
|
365
|
+
session_info=data.get("session_info", {}),
|
|
366
|
+
track_infos=data.get("track_infos", []),
|
|
367
|
+
spectrum=data.get("spectrum"),
|
|
368
|
+
rms_data=data.get("rms_data"),
|
|
369
|
+
)
|
|
370
|
+
mix_issues = run_all_mix_critics(mix_state)
|
|
371
|
+
except Exception:
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
if not mix_issues:
|
|
375
|
+
return None
|
|
376
|
+
track_issues = [
|
|
377
|
+
i for i in mix_issues
|
|
378
|
+
if getattr(i, "track_index", None) == track_index
|
|
379
|
+
]
|
|
380
|
+
if not track_issues:
|
|
381
|
+
return None
|
|
382
|
+
top = max(
|
|
383
|
+
track_issues,
|
|
384
|
+
key=lambda i: float(getattr(i, "severity", 0) or 0),
|
|
385
|
+
)
|
|
386
|
+
issue_type = str(getattr(top, "issue_type", None) or getattr(top, "type", "issue"))
|
|
387
|
+
sev = float(getattr(top, "severity", 0) or 0)
|
|
388
|
+
return (
|
|
389
|
+
f"No sound-design issues on this track, but mix critic flagged "
|
|
390
|
+
f"'{issue_type}' (severity {sev:.2f}). "
|
|
391
|
+
f"Try plan_mix_move for the same track_index."
|
|
392
|
+
)
|
|
393
|
+
|
|
283
394
|
|
|
284
395
|
@mcp.tool()
|
|
285
396
|
def get_patch_model(ctx: Context, track_index: int) -> dict:
|
|
@@ -27,6 +27,16 @@ _SPLICE_APP_SUPPORT = os.path.expanduser(
|
|
|
27
27
|
# Credit safety floor — never drain below this
|
|
28
28
|
CREDIT_HARD_FLOOR = 5
|
|
29
29
|
|
|
30
|
+
# Per-call gRPC timeouts. The previous implementation passed no timeout, so
|
|
31
|
+
# a hung Splice process could block the MCP event loop until gRPC's default
|
|
32
|
+
# (often infinite) deadline fired. Keep generous enough for cold searches
|
|
33
|
+
# but bounded enough that a dead socket fails the tool call, not the server.
|
|
34
|
+
SEARCH_TIMEOUT = 10.0
|
|
35
|
+
INFO_TIMEOUT = 5.0
|
|
36
|
+
CREDITS_TIMEOUT = 5.0
|
|
37
|
+
SYNC_TIMEOUT = 30.0
|
|
38
|
+
DOWNLOAD_TRIGGER_TIMEOUT = 5.0
|
|
39
|
+
|
|
30
40
|
|
|
31
41
|
def _try_import_grpc():
|
|
32
42
|
"""Import grpcio lazily — graceful degradation if not installed."""
|
|
@@ -148,7 +158,7 @@ class SpliceGRPCClient:
|
|
|
148
158
|
Page=page,
|
|
149
159
|
Purchased=purchased,
|
|
150
160
|
)
|
|
151
|
-
response = await self.stub.SearchSamples(request)
|
|
161
|
+
response = await self.stub.SearchSamples(request, timeout=SEARCH_TIMEOUT)
|
|
152
162
|
return self._parse_search_response(response)
|
|
153
163
|
except Exception as exc:
|
|
154
164
|
logger.warning(f"Splice search failed: {exc}")
|
|
@@ -213,7 +223,8 @@ class SpliceGRPCClient:
|
|
|
213
223
|
try:
|
|
214
224
|
# Trigger download
|
|
215
225
|
await self.stub.DownloadSample(
|
|
216
|
-
pb2.DownloadSampleRequest(FileHash=file_hash)
|
|
226
|
+
pb2.DownloadSampleRequest(FileHash=file_hash),
|
|
227
|
+
timeout=DOWNLOAD_TRIGGER_TIMEOUT,
|
|
217
228
|
)
|
|
218
229
|
# Wait for file to appear on disk
|
|
219
230
|
return await self._wait_for_download(file_hash, timeout)
|
|
@@ -226,11 +237,16 @@ class SpliceGRPCClient:
|
|
|
226
237
|
) -> Optional[str]:
|
|
227
238
|
"""Poll SampleInfo until LocalPath is populated."""
|
|
228
239
|
pb2 = self._pb2
|
|
229
|
-
|
|
230
|
-
|
|
240
|
+
# asyncio.get_event_loop() is deprecated when called inside an
|
|
241
|
+
# already-running coroutine on Python 3.10+. Use get_running_loop()
|
|
242
|
+
# which is the documented replacement.
|
|
243
|
+
loop = asyncio.get_running_loop()
|
|
244
|
+
deadline = loop.time() + timeout
|
|
245
|
+
while loop.time() < deadline:
|
|
231
246
|
try:
|
|
232
247
|
response = await self.stub.SampleInfo(
|
|
233
|
-
pb2.SampleInfoRequest(FileHash=file_hash)
|
|
248
|
+
pb2.SampleInfoRequest(FileHash=file_hash),
|
|
249
|
+
timeout=INFO_TIMEOUT,
|
|
234
250
|
)
|
|
235
251
|
if response.Sample.LocalPath:
|
|
236
252
|
return response.Sample.LocalPath
|
|
@@ -250,7 +266,8 @@ class SpliceGRPCClient:
|
|
|
250
266
|
pb2 = self._pb2
|
|
251
267
|
try:
|
|
252
268
|
response = await self.stub.SampleInfo(
|
|
253
|
-
pb2.SampleInfoRequest(FileHash=file_hash)
|
|
269
|
+
pb2.SampleInfoRequest(FileHash=file_hash),
|
|
270
|
+
timeout=INFO_TIMEOUT,
|
|
254
271
|
)
|
|
255
272
|
s = response.Sample
|
|
256
273
|
return SpliceSample(
|
|
@@ -282,7 +299,8 @@ class SpliceGRPCClient:
|
|
|
282
299
|
pb2 = self._pb2
|
|
283
300
|
try:
|
|
284
301
|
response = await self.stub.ValidateLogin(
|
|
285
|
-
pb2.ValidateLoginRequest()
|
|
302
|
+
pb2.ValidateLoginRequest(),
|
|
303
|
+
timeout=CREDITS_TIMEOUT,
|
|
286
304
|
)
|
|
287
305
|
return SpliceCredits(
|
|
288
306
|
credits=response.User.Credits,
|
|
@@ -315,7 +333,10 @@ class SpliceGRPCClient:
|
|
|
315
333
|
return False
|
|
316
334
|
pb2 = self._pb2
|
|
317
335
|
try:
|
|
318
|
-
await self.stub.SyncSounds(
|
|
336
|
+
await self.stub.SyncSounds(
|
|
337
|
+
pb2.SyncSoundsRequest(),
|
|
338
|
+
timeout=SYNC_TIMEOUT,
|
|
339
|
+
)
|
|
319
340
|
return True
|
|
320
341
|
except Exception as exc:
|
|
321
342
|
logger.debug("sync_sounds failed: %s", exc)
|
|
@@ -20,14 +20,30 @@ def detect_stuckness(
|
|
|
20
20
|
session_info: Optional[dict] = None,
|
|
21
21
|
song_brain: Optional[dict] = None,
|
|
22
22
|
section_count: int = 0,
|
|
23
|
+
state_signals: Optional[dict] = None,
|
|
23
24
|
) -> StucknessReport:
|
|
24
25
|
"""Detect whether the session is stuck.
|
|
25
26
|
|
|
26
27
|
Analyzes action history for repeated undos, local tweaking,
|
|
27
28
|
long loops without structural edits, and other stuckness signals.
|
|
29
|
+
|
|
30
|
+
BUG-B6 / B20 fix: also accepts `state_signals` — a dict of
|
|
31
|
+
current-session-state indicators that may reveal stuckness even
|
|
32
|
+
when the action ledger is empty. Known keys (all optional):
|
|
33
|
+
fatigue_level: float 0-1 (from detect_repetition_fatigue)
|
|
34
|
+
motif_overuse_count: int (motifs exceeding overuse threshold)
|
|
35
|
+
emotional_arc_issues: list of issue-type strings
|
|
36
|
+
transition_issues: int
|
|
37
|
+
support_too_loud: bool (from analyze_mix)
|
|
38
|
+
automation_density: float (0 = no clip automation anywhere)
|
|
39
|
+
|
|
40
|
+
When state_signals are provided, they contribute to confidence
|
|
41
|
+
alongside ledger-based signals (ledger weighting is still
|
|
42
|
+
dominant — ledger signals indicate active-user-is-stuck behavior).
|
|
28
43
|
"""
|
|
29
44
|
session_info = session_info or {}
|
|
30
45
|
song_brain = song_brain or {}
|
|
46
|
+
state_signals = state_signals or {}
|
|
31
47
|
signals: list[StucknessSignal] = []
|
|
32
48
|
|
|
33
49
|
# 1. Repeated undos
|
|
@@ -60,6 +76,10 @@ def detect_stuckness(
|
|
|
60
76
|
if identity_signal:
|
|
61
77
|
signals.append(identity_signal)
|
|
62
78
|
|
|
79
|
+
# 7. BUG-B6 / B20 — state critics
|
|
80
|
+
state_derived = _state_signals_to_signal_list(state_signals)
|
|
81
|
+
signals.extend(state_derived)
|
|
82
|
+
|
|
63
83
|
# Compute overall confidence
|
|
64
84
|
if not signals:
|
|
65
85
|
return StucknessReport(confidence=0.0, level="flowing")
|
|
@@ -98,6 +118,76 @@ def detect_stuckness(
|
|
|
98
118
|
# ── Signal checkers ───────────────────────────────────────────────
|
|
99
119
|
|
|
100
120
|
|
|
121
|
+
def _state_signals_to_signal_list(state: dict) -> list[StucknessSignal]:
|
|
122
|
+
"""Convert a state_signals dict (from sibling critics) into
|
|
123
|
+
StucknessSignal entries. BUG-B6 / B20: previously the detector
|
|
124
|
+
ignored session state entirely — so a session with fatigue_level=0.93
|
|
125
|
+
but an empty action ledger always reported "flowing". Now we surface
|
|
126
|
+
state-based stuckness but keep the signals at a slightly lower
|
|
127
|
+
weight than ledger signals (ledger = active-user-is-stuck behavior,
|
|
128
|
+
state = project-shape-is-stuck).
|
|
129
|
+
"""
|
|
130
|
+
out: list[StucknessSignal] = []
|
|
131
|
+
if not state:
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
# Fatigue / repetition
|
|
135
|
+
fatigue = state.get("fatigue_level")
|
|
136
|
+
if isinstance(fatigue, (int, float)) and fatigue >= 0.6:
|
|
137
|
+
out.append(StucknessSignal(
|
|
138
|
+
signal_type="state_repetition_fatigue",
|
|
139
|
+
# Scale from 0.6-1.0 → 0.5-0.85 (sub-ledger weight)
|
|
140
|
+
strength=min(0.85, 0.5 + (fatigue - 0.6) * 0.875),
|
|
141
|
+
evidence=(
|
|
142
|
+
f"repetition fatigue at {fatigue:.2f} — clips/sections "
|
|
143
|
+
f"overused"
|
|
144
|
+
),
|
|
145
|
+
))
|
|
146
|
+
|
|
147
|
+
motif_overuse = state.get("motif_overuse_count", 0)
|
|
148
|
+
if isinstance(motif_overuse, int) and motif_overuse >= 3:
|
|
149
|
+
out.append(StucknessSignal(
|
|
150
|
+
signal_type="state_motif_overuse",
|
|
151
|
+
strength=min(0.7, 0.3 + motif_overuse * 0.1),
|
|
152
|
+
evidence=f"{motif_overuse} motifs flagged as overused",
|
|
153
|
+
))
|
|
154
|
+
|
|
155
|
+
arc_issues = state.get("emotional_arc_issues") or []
|
|
156
|
+
if isinstance(arc_issues, (list, tuple)) and arc_issues:
|
|
157
|
+
out.append(StucknessSignal(
|
|
158
|
+
signal_type="state_emotional_arc",
|
|
159
|
+
strength=min(0.7, 0.3 + len(arc_issues) * 0.1),
|
|
160
|
+
evidence=(
|
|
161
|
+
f"emotional-arc issues: {', '.join(str(i) for i in arc_issues[:3])}"
|
|
162
|
+
),
|
|
163
|
+
))
|
|
164
|
+
|
|
165
|
+
transition_issues = state.get("transition_issues", 0)
|
|
166
|
+
if isinstance(transition_issues, int) and transition_issues >= 3:
|
|
167
|
+
out.append(StucknessSignal(
|
|
168
|
+
signal_type="state_transition_issues",
|
|
169
|
+
strength=min(0.7, 0.25 + transition_issues * 0.08),
|
|
170
|
+
evidence=f"{transition_issues} transition issues detected",
|
|
171
|
+
))
|
|
172
|
+
|
|
173
|
+
if state.get("support_too_loud"):
|
|
174
|
+
out.append(StucknessSignal(
|
|
175
|
+
signal_type="state_mix_imbalance",
|
|
176
|
+
strength=0.35,
|
|
177
|
+
evidence="mix critic flagged a support element as too loud",
|
|
178
|
+
))
|
|
179
|
+
|
|
180
|
+
auto_density = state.get("automation_density")
|
|
181
|
+
if isinstance(auto_density, (int, float)) and auto_density <= 0.05:
|
|
182
|
+
out.append(StucknessSignal(
|
|
183
|
+
signal_type="state_no_automation",
|
|
184
|
+
strength=0.35,
|
|
185
|
+
evidence="no clip automation detected — arrangement is static",
|
|
186
|
+
))
|
|
187
|
+
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
101
191
|
def _check_repeated_undos(history: list[dict]) -> Optional[StucknessSignal]:
|
|
102
192
|
"""Check for repeated undone moves (kept=False in ledger entries)."""
|
|
103
193
|
recent = history[-20:] if len(history) > 20 else history
|
|
@@ -62,6 +62,43 @@ def _get_session_and_brain(ctx: Context) -> tuple[dict, dict, int]:
|
|
|
62
62
|
return session_info, song_brain, section_count
|
|
63
63
|
|
|
64
64
|
|
|
65
|
+
def _gather_state_signals(ctx: Context, song_brain: dict) -> dict:
|
|
66
|
+
"""BUG-B6 / B20: collect current-session-state stuckness indicators
|
|
67
|
+
that the detector can merge with ledger-based signals.
|
|
68
|
+
|
|
69
|
+
All lookups are best-effort — if a sibling module isn't available
|
|
70
|
+
or its data is stale, we omit the signal (don't guess).
|
|
71
|
+
"""
|
|
72
|
+
signals: dict = {}
|
|
73
|
+
|
|
74
|
+
# Repetition fatigue from musical_intelligence.detectors
|
|
75
|
+
try:
|
|
76
|
+
from ..musical_intelligence.tools import _current_fatigue_cache # type: ignore
|
|
77
|
+
if isinstance(_current_fatigue_cache, dict):
|
|
78
|
+
fl = _current_fatigue_cache.get("fatigue_level")
|
|
79
|
+
if isinstance(fl, (int, float)):
|
|
80
|
+
signals["fatigue_level"] = float(fl)
|
|
81
|
+
overuse = _current_fatigue_cache.get("motif_overuse_count")
|
|
82
|
+
if isinstance(overuse, int):
|
|
83
|
+
signals["motif_overuse_count"] = overuse
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
logger.debug("_gather_state_signals fatigue fetch failed: %s", exc)
|
|
86
|
+
|
|
87
|
+
# Emotional-arc issues directly from song brain (already fetched)
|
|
88
|
+
arc_issues = []
|
|
89
|
+
if isinstance(song_brain, dict):
|
|
90
|
+
oqs = song_brain.get("open_questions") or []
|
|
91
|
+
for q in oqs:
|
|
92
|
+
if isinstance(q, dict):
|
|
93
|
+
qtype = q.get("question_type") or q.get("type") or ""
|
|
94
|
+
if "arc" in str(qtype).lower() or "payoff" in str(qtype).lower():
|
|
95
|
+
arc_issues.append(qtype)
|
|
96
|
+
if arc_issues:
|
|
97
|
+
signals["emotional_arc_issues"] = arc_issues
|
|
98
|
+
|
|
99
|
+
return signals
|
|
100
|
+
|
|
101
|
+
|
|
65
102
|
@mcp.tool()
|
|
66
103
|
def detect_stuckness(ctx: Context) -> dict:
|
|
67
104
|
"""Detect whether the session is losing momentum.
|
|
@@ -79,12 +116,14 @@ def detect_stuckness(ctx: Context) -> dict:
|
|
|
79
116
|
"""
|
|
80
117
|
history = _get_action_history(ctx)
|
|
81
118
|
session_info, song_brain, section_count = _get_session_and_brain(ctx)
|
|
119
|
+
state_signals = _gather_state_signals(ctx, song_brain)
|
|
82
120
|
|
|
83
121
|
report = detector.detect_stuckness(
|
|
84
122
|
action_history=history,
|
|
85
123
|
session_info=session_info,
|
|
86
124
|
song_brain=song_brain,
|
|
87
125
|
section_count=section_count,
|
|
126
|
+
state_signals=state_signals,
|
|
88
127
|
)
|
|
89
128
|
|
|
90
129
|
return report.to_dict()
|
|
@@ -110,12 +149,14 @@ def suggest_momentum_rescue(
|
|
|
110
149
|
|
|
111
150
|
history = _get_action_history(ctx)
|
|
112
151
|
session_info, song_brain, section_count = _get_session_and_brain(ctx)
|
|
152
|
+
state_signals = _gather_state_signals(ctx, song_brain)
|
|
113
153
|
|
|
114
154
|
report = detector.detect_stuckness(
|
|
115
155
|
action_history=history,
|
|
116
156
|
session_info=session_info,
|
|
117
157
|
song_brain=song_brain,
|
|
118
158
|
section_count=section_count,
|
|
159
|
+
state_signals=state_signals,
|
|
119
160
|
)
|
|
120
161
|
|
|
121
162
|
if report.level == "flowing":
|
|
@@ -37,6 +37,30 @@ def run_sonic_critic(
|
|
|
37
37
|
peak = sonic.get("peak")
|
|
38
38
|
target_dims = set(goal.targets.keys())
|
|
39
39
|
|
|
40
|
+
# BUG-B42: if every spectrum band is zero AND rms is zero, playback
|
|
41
|
+
# is stopped (or nothing is routing to master). Spectrum-based
|
|
42
|
+
# critics (weak_foundation, harsh_highs, low_mid_congestion, etc.)
|
|
43
|
+
# would fire on zero data, reporting "no bass!" when the real cause
|
|
44
|
+
# is "no audio". Short-circuit to a playback_required advisory so
|
|
45
|
+
# callers don't chase phantom mix issues during static inspection.
|
|
46
|
+
_all_bands = all(float(bands.get(b, 0) or 0) == 0 for b in
|
|
47
|
+
("sub", "low", "low_mid", "mid", "high_mid", "high",
|
|
48
|
+
"presence", "air"))
|
|
49
|
+
_silent = _all_bands and (rms is None or float(rms or 0) == 0)
|
|
50
|
+
if _silent:
|
|
51
|
+
return [Issue(
|
|
52
|
+
type="playback_required",
|
|
53
|
+
critic="sonic",
|
|
54
|
+
severity=0.1,
|
|
55
|
+
confidence=1.0,
|
|
56
|
+
affected_dimensions=list(MEASURABLE_PROXIES.keys()),
|
|
57
|
+
evidence=["spectrum and RMS both zero — playback stopped or no signal"],
|
|
58
|
+
recommended_actions=[
|
|
59
|
+
"Start playback before calling build_world_model / "
|
|
60
|
+
"analyze_mix so spectrum-based critics can evaluate.",
|
|
61
|
+
],
|
|
62
|
+
)]
|
|
63
|
+
|
|
40
64
|
# 1. Mud detection: low_mid congestion
|
|
41
65
|
low_mid = bands.get("low_mid", 0)
|
|
42
66
|
if low_mid > 0.7 and {"clarity", "weight", "warmth"} & target_dims:
|
|
@@ -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,
|