livepilot 1.10.8 → 1.12.2
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 +373 -0
- package/README.md +16 -16
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/evaluation/fabric.py +62 -1
- package/mcp_server/m4l_bridge.py +503 -18
- package/mcp_server/project_brain/automation_graph.py +23 -1
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/models.py +20 -1
- package/mcp_server/project_brain/tools.py +10 -3
- package/mcp_server/runtime/execution_router.py +7 -0
- package/mcp_server/runtime/mcp_dispatch.py +32 -0
- package/mcp_server/runtime/remote_commands.py +54 -0
- package/mcp_server/sample_engine/slice_classifier.py +169 -0
- package/mcp_server/semantic_moves/tools.py +139 -31
- package/mcp_server/server.py +151 -17
- package/mcp_server/session_continuity/models.py +13 -0
- package/mcp_server/session_continuity/tools.py +2 -0
- package/mcp_server/session_continuity/tracker.py +93 -0
- package/mcp_server/tools/_analyzer_engine/__init__.py +39 -0
- package/mcp_server/tools/_analyzer_engine/context.py +103 -0
- package/mcp_server/tools/_analyzer_engine/flucoma.py +23 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +122 -0
- package/mcp_server/tools/_motif_engine.py +19 -4
- package/mcp_server/tools/analyzer.py +204 -180
- package/mcp_server/tools/clips.py +304 -1
- package/mcp_server/tools/devices.py +517 -5
- package/mcp_server/tools/diagnostics.py +42 -0
- package/mcp_server/tools/follow_actions.py +202 -0
- package/mcp_server/tools/grooves.py +142 -0
- package/mcp_server/tools/miditool.py +280 -0
- package/mcp_server/tools/scales.py +126 -0
- package/mcp_server/tools/take_lanes.py +135 -0
- package/mcp_server/tools/tracks.py +46 -3
- package/mcp_server/tools/transport.py +120 -4
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +15 -4
- package/remote_script/LivePilot/clips.py +62 -0
- package/remote_script/LivePilot/devices.py +444 -0
- package/remote_script/LivePilot/diagnostics.py +52 -1
- package/remote_script/LivePilot/follow_actions.py +235 -0
- package/remote_script/LivePilot/grooves.py +185 -0
- package/remote_script/LivePilot/scales.py +138 -0
- package/remote_script/LivePilot/take_lanes.py +175 -0
- package/remote_script/LivePilot/tracks.py +59 -1
- package/remote_script/LivePilot/transport.py +90 -1
- package/remote_script/LivePilot/version_detect.py +9 -0
- package/server.json +3 -3
|
@@ -201,24 +201,39 @@ def detect_motifs(
|
|
|
201
201
|
salience = _score_salience(pattern, len(occurrences), total_note_count)
|
|
202
202
|
fatigue = _score_fatigue(len(occurrences), total_bars)
|
|
203
203
|
|
|
204
|
-
# Get representative pitches from first occurrence
|
|
204
|
+
# Get representative pitches + inter-onset intervals from first occurrence.
|
|
205
|
+
# Rhythm is the list of start_time deltas between successive notes in
|
|
206
|
+
# the pattern window; until v1.10.9 this field was left empty with a
|
|
207
|
+
# "TODO: Phase 3" marker, which is what forced Hook Hunter's rhythm
|
|
208
|
+
# side to fall back to drum-track-name regex. Populating it here lets
|
|
209
|
+
# downstream code actually reason about rhythmic distinctiveness.
|
|
205
210
|
first_occ = occurrences[0] if occurrences else {}
|
|
206
211
|
first_track = first_occ.get("track", 0)
|
|
207
212
|
first_pos = first_occ.get("start_position", 0)
|
|
208
|
-
rep_pitches = []
|
|
213
|
+
rep_pitches: list[int] = []
|
|
214
|
+
rhythm_intervals: list[float] = []
|
|
209
215
|
if first_track in notes_by_track:
|
|
210
216
|
sorted_notes = sorted(notes_by_track[first_track],
|
|
211
217
|
key=lambda n: n.get("start_time", 0))
|
|
218
|
+
span = min(len(pattern) + 1, len(sorted_notes) - first_pos)
|
|
212
219
|
rep_pitches = [
|
|
213
220
|
sorted_notes[first_pos + j].get("pitch", 60)
|
|
214
|
-
for j in range(
|
|
221
|
+
for j in range(span)
|
|
222
|
+
]
|
|
223
|
+
rhythm_intervals = [
|
|
224
|
+
round(
|
|
225
|
+
float(sorted_notes[first_pos + j + 1].get("start_time", 0.0))
|
|
226
|
+
- float(sorted_notes[first_pos + j].get("start_time", 0.0)),
|
|
227
|
+
4,
|
|
228
|
+
)
|
|
229
|
+
for j in range(span - 1)
|
|
215
230
|
]
|
|
216
231
|
|
|
217
232
|
motif = MotifUnit(
|
|
218
233
|
motif_id=f"motif_{len(motifs):03d}",
|
|
219
234
|
kind="melodic" if any(abs(i) > 0 for i in pattern) else "rhythmic",
|
|
220
235
|
intervals=list(pattern),
|
|
221
|
-
rhythm=
|
|
236
|
+
rhythm=rhythm_intervals,
|
|
222
237
|
representative_pitches=rep_pitches,
|
|
223
238
|
occurrences=occurrences,
|
|
224
239
|
salience=salience,
|
|
@@ -2,88 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
30 tools requiring the LivePilot Analyzer M4L device on the master track.
|
|
4
4
|
These tools are optional — all core tools work without the device.
|
|
5
|
+
|
|
6
|
+
Helpers live in ``_analyzer_engine/`` (context accessors, Simpler
|
|
7
|
+
post-load hygiene, FluCoMa hint formatting). This file contains the
|
|
8
|
+
``@mcp.tool()`` surface only — keeping decorator order stable was
|
|
9
|
+
important for BUG-C1's refactor.
|
|
5
10
|
"""
|
|
6
11
|
|
|
7
12
|
from __future__ import annotations
|
|
8
13
|
|
|
9
14
|
import logging
|
|
10
15
|
import os
|
|
11
|
-
import re # used below in filename parsing helpers
|
|
12
16
|
from typing import Optional
|
|
13
17
|
|
|
14
18
|
from fastmcp import Context
|
|
15
19
|
|
|
16
20
|
from ..server import mcp, _identify_port_holder
|
|
21
|
+
from ._analyzer_engine import (
|
|
22
|
+
PITCH_NAMES,
|
|
23
|
+
_filename_stem,
|
|
24
|
+
_flucoma_hint,
|
|
25
|
+
_get_m4l,
|
|
26
|
+
_get_spectral,
|
|
27
|
+
_is_warped_loop,
|
|
28
|
+
_require_analyzer,
|
|
29
|
+
_simpler_post_load_hygiene,
|
|
30
|
+
)
|
|
17
31
|
|
|
18
|
-
# Logger must be defined before any helper that uses it — _require_analyzer
|
|
19
|
-
# below calls logger.debug on an exception path, so defining the logger later
|
|
20
|
-
# in the file risked NameError under unusual import orderings.
|
|
21
32
|
logger = logging.getLogger(__name__)
|
|
22
33
|
|
|
23
34
|
CAPTURE_DIR = os.path.expanduser("~/Documents/LivePilot/captures")
|
|
24
35
|
|
|
25
36
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
# Keep the active request context attached so analyzer error paths can
|
|
32
|
-
# distinguish "device missing" from "bridge disconnected".
|
|
33
|
-
setattr(cache, "_livepilot_ctx", ctx)
|
|
34
|
-
return cache
|
|
35
|
-
|
|
37
|
+
# Live 12 Simpler Slice mode maps slice N to MIDI pitch 36+N (C1 base).
|
|
38
|
+
# This is NOT exposed by the Remote Script API and is a common source of
|
|
39
|
+
# silent audio bugs (BUG-F2). See feedback_analyze_slices_before_programming
|
|
40
|
+
# memory for context.
|
|
41
|
+
SIMPLER_SLICE_BASE_PITCH = 36
|
|
36
42
|
|
|
37
|
-
def _get_m4l(ctx: Context):
|
|
38
|
-
"""Get M4LBridge from lifespan context."""
|
|
39
|
-
bridge = ctx.lifespan_context.get("m4l")
|
|
40
|
-
if not bridge:
|
|
41
|
-
raise ValueError("M4L bridge not initialized — restart the MCP server")
|
|
42
|
-
return bridge
|
|
43
43
|
|
|
44
|
+
def _enrich_slice_response(response: Optional[dict]) -> Optional[dict]:
|
|
45
|
+
"""Add base_midi_pitch field + per-slice midi_pitch to bridge response (BUG-F2).
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
for
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
)
|
|
64
|
-
if normalized == "livepilot analyzer":
|
|
65
|
-
analyzer_loaded = True
|
|
66
|
-
break
|
|
67
|
-
|
|
68
|
-
if analyzer_loaded:
|
|
69
|
-
holder = _identify_port_holder(9880)
|
|
70
|
-
detail = (
|
|
71
|
-
"LivePilot Analyzer is loaded on the master track, but its UDP bridge is not connected. "
|
|
72
|
-
)
|
|
73
|
-
if holder:
|
|
74
|
-
detail += (
|
|
75
|
-
"UDP port 9880 is currently held by another LivePilot instance "
|
|
76
|
-
f"({holder}). Close the other client/server, then retry."
|
|
77
|
-
)
|
|
78
|
-
else:
|
|
79
|
-
detail += "Reload the analyzer device or restart the MCP server."
|
|
80
|
-
raise ValueError(detail)
|
|
81
|
-
|
|
82
|
-
raise ValueError(
|
|
83
|
-
"LivePilot Analyzer not detected. "
|
|
84
|
-
"Drag 'LivePilot Analyzer' onto the master track from "
|
|
85
|
-
"Audio Effects > Max Audio Effect."
|
|
86
|
-
)
|
|
47
|
+
The Remote Script returns slice indices only. Users then have to know
|
|
48
|
+
that slice N plays at MIDI pitch 36+N — a fact that's undocumented in
|
|
49
|
+
both Ableton's and LivePilot's public API. This enrichment makes the
|
|
50
|
+
mapping explicit so MIDI pattern generation doesn't silently produce
|
|
51
|
+
out-of-range notes.
|
|
52
|
+
"""
|
|
53
|
+
if response is None:
|
|
54
|
+
return None
|
|
55
|
+
enriched = dict(response)
|
|
56
|
+
enriched["base_midi_pitch"] = SIMPLER_SLICE_BASE_PITCH
|
|
57
|
+
slices = enriched.get("slices") or []
|
|
58
|
+
# BUG-audit-M2: fall back to positional index when the bridge response
|
|
59
|
+
# omits the `index` field (protects against bridge version skew).
|
|
60
|
+
enriched["slices"] = [
|
|
61
|
+
{**s, "midi_pitch": SIMPLER_SLICE_BASE_PITCH + s.get("index", i)}
|
|
62
|
+
for i, s in enumerate(slices)
|
|
63
|
+
]
|
|
64
|
+
return enriched
|
|
87
65
|
|
|
88
66
|
|
|
89
67
|
@mcp.tool()
|
|
@@ -149,11 +127,36 @@ def get_master_spectrum(ctx: Context) -> dict:
|
|
|
149
127
|
return result
|
|
150
128
|
|
|
151
129
|
|
|
130
|
+
def _sanitize_pitch(pitch: Optional[dict]) -> Optional[dict]:
|
|
131
|
+
"""Validate a pitch reading from the M4L analyzer (BUG-F1).
|
|
132
|
+
|
|
133
|
+
The polyphonic pitch detector can emit out-of-range MIDI notes
|
|
134
|
+
(e.g., 319, -50, 128+) when it can't latch onto a single
|
|
135
|
+
fundamental — typical for dense mixes. The amplitude field is the
|
|
136
|
+
reliable confidence signal: if the detector was sure of its
|
|
137
|
+
reading, amplitude is non-zero.
|
|
138
|
+
|
|
139
|
+
Returns the original dict if the reading is usable, None otherwise.
|
|
140
|
+
"""
|
|
141
|
+
if not pitch:
|
|
142
|
+
return None
|
|
143
|
+
amplitude = pitch.get("amplitude")
|
|
144
|
+
midi_note = pitch.get("midi_note")
|
|
145
|
+
if amplitude is None or amplitude <= 0:
|
|
146
|
+
return None
|
|
147
|
+
if midi_note is None or midi_note < 0 or midi_note > 127:
|
|
148
|
+
return None
|
|
149
|
+
return pitch
|
|
150
|
+
|
|
151
|
+
|
|
152
152
|
@mcp.tool()
|
|
153
153
|
def get_master_rms(ctx: Context) -> dict:
|
|
154
154
|
"""Get real-time RMS and peak levels from the master bus.
|
|
155
155
|
|
|
156
156
|
More accurate than LOM meters — includes true RMS (not just peak hold).
|
|
157
|
+
Pitch readings are validated: the field is only present when the
|
|
158
|
+
polyphonic pitch detector produced a reading with non-zero
|
|
159
|
+
amplitude and a MIDI note in [0, 127] (BUG-F1).
|
|
157
160
|
Requires LivePilot Analyzer on master track.
|
|
158
161
|
"""
|
|
159
162
|
cache = _get_spectral(ctx)
|
|
@@ -169,9 +172,11 @@ def get_master_rms(ctx: Context) -> dict:
|
|
|
169
172
|
if peak:
|
|
170
173
|
result["peak"] = peak["value"]
|
|
171
174
|
|
|
172
|
-
|
|
173
|
-
if
|
|
174
|
-
|
|
175
|
+
pitch_entry = cache.get("pitch")
|
|
176
|
+
if pitch_entry:
|
|
177
|
+
clean = _sanitize_pitch(pitch_entry.get("value"))
|
|
178
|
+
if clean is not None:
|
|
179
|
+
result["pitch"] = clean
|
|
175
180
|
|
|
176
181
|
return result
|
|
177
182
|
|
|
@@ -305,102 +310,10 @@ async def get_clip_file_path(
|
|
|
305
310
|
# S Start=0, S Length=1, S Loop On=1 so the full loop plays in its
|
|
306
311
|
# musical phrasing. For ONE-SHOTS, leave defaults alone.
|
|
307
312
|
|
|
308
|
-
_BPM_IN_FILENAME_RE
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
"""Return True if the filename contains a BPM marker (likely a tempo-locked loop)."""
|
|
313
|
-
stem = os.path.splitext(os.path.basename(file_path))[0]
|
|
314
|
-
return bool(_BPM_IN_FILENAME_RE.search(stem))
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
def _filename_stem(file_path: str) -> str:
|
|
318
|
-
return os.path.splitext(os.path.basename(file_path))[0]
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
async def _simpler_post_load_hygiene(
|
|
322
|
-
bridge,
|
|
323
|
-
ableton,
|
|
324
|
-
track_index: int,
|
|
325
|
-
device_index: int,
|
|
326
|
-
file_path: str,
|
|
327
|
-
) -> dict:
|
|
328
|
-
"""Apply post-load hygiene to a newly loaded Simpler and verify success.
|
|
329
|
-
|
|
330
|
-
Steps:
|
|
331
|
-
1. Read track info to verify the device's actual name matches the
|
|
332
|
-
expected sample stem. If it doesn't, return an error.
|
|
333
|
-
2. Set Snap=0 (Off) — required so sample playback works.
|
|
334
|
-
3. If filename indicates a warped loop, set S Start=0, S Length=1,
|
|
335
|
-
S Loop On=1 so the loop plays fully instead of being cropped.
|
|
336
|
-
4. Return a verified response dict.
|
|
337
|
-
"""
|
|
338
|
-
expected_stem = _filename_stem(file_path)
|
|
339
|
-
|
|
340
|
-
# Step 1: verify device name matches expected file
|
|
341
|
-
try:
|
|
342
|
-
track_info = ableton.send_command(
|
|
343
|
-
"get_track_info", {"track_index": track_index}
|
|
344
|
-
)
|
|
345
|
-
except Exception as exc:
|
|
346
|
-
return {"error": f"Verification read failed: {exc}"}
|
|
347
|
-
|
|
348
|
-
devices = track_info.get("devices", []) or []
|
|
349
|
-
if device_index < 0 or device_index >= len(devices):
|
|
350
|
-
return {
|
|
351
|
-
"error": (
|
|
352
|
-
f"Device index {device_index} out of range after load "
|
|
353
|
-
f"(track has {len(devices)} devices)"
|
|
354
|
-
),
|
|
355
|
-
"verified": False,
|
|
356
|
-
}
|
|
357
|
-
device = devices[device_index]
|
|
358
|
-
actual_name = str(device.get("name") or "")
|
|
359
|
-
verified = expected_stem in actual_name or actual_name in expected_stem
|
|
360
|
-
if not verified:
|
|
361
|
-
return {
|
|
362
|
-
"error": (
|
|
363
|
-
f"Sample verification FAILED — Simpler name '{actual_name}' "
|
|
364
|
-
f"does not match requested file '{expected_stem}'. The bridge "
|
|
365
|
-
f"reported success but the actual sample is different. "
|
|
366
|
-
f"Try `load_browser_item` with a user_library URI instead."
|
|
367
|
-
),
|
|
368
|
-
"verified": False,
|
|
369
|
-
"actual_device_name": actual_name,
|
|
370
|
-
"expected_stem": expected_stem,
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
# Step 2: turn Snap OFF — required for reliable playback after replace
|
|
374
|
-
hygiene_params: list[dict] = [
|
|
375
|
-
{"name_or_index": "Snap", "value": 0},
|
|
376
|
-
]
|
|
377
|
-
|
|
378
|
-
# Step 3: smart defaults for warped loops
|
|
379
|
-
if _is_warped_loop(file_path):
|
|
380
|
-
hygiene_params.extend([
|
|
381
|
-
{"name_or_index": "S Start", "value": 0.0},
|
|
382
|
-
{"name_or_index": "S Length", "value": 1.0},
|
|
383
|
-
{"name_or_index": "S Loop On", "value": 1},
|
|
384
|
-
])
|
|
385
|
-
|
|
386
|
-
try:
|
|
387
|
-
ableton.send_command("batch_set_parameters", {
|
|
388
|
-
"track_index": track_index,
|
|
389
|
-
"device_index": device_index,
|
|
390
|
-
"parameters": hygiene_params,
|
|
391
|
-
})
|
|
392
|
-
except Exception as exc:
|
|
393
|
-
logger.debug("_simpler_post_load_hygiene failed: %s", exc)
|
|
394
|
-
# non-fatal — verification already succeeded
|
|
395
|
-
pass
|
|
396
|
-
|
|
397
|
-
return {
|
|
398
|
-
"verified": True,
|
|
399
|
-
"device_name": actual_name,
|
|
400
|
-
"track_index": track_index,
|
|
401
|
-
"device_index": device_index,
|
|
402
|
-
"warped_loop_defaults_applied": _is_warped_loop(file_path),
|
|
403
|
-
}
|
|
313
|
+
# _BPM_IN_FILENAME_RE, _is_warped_loop, _filename_stem, and the
|
|
314
|
+
# _simpler_post_load_hygiene coroutine now live in
|
|
315
|
+
# ``_analyzer_engine/sample.py`` — re-exported via this module's imports
|
|
316
|
+
# at the top of the file so tests importing them by name still resolve.
|
|
404
317
|
|
|
405
318
|
|
|
406
319
|
@mcp.tool()
|
|
@@ -558,15 +471,138 @@ async def get_simpler_slices(
|
|
|
558
471
|
) -> dict:
|
|
559
472
|
"""Get slice point positions from a Simpler device.
|
|
560
473
|
|
|
561
|
-
Returns each slice's position in frames and seconds,
|
|
562
|
-
(
|
|
563
|
-
|
|
474
|
+
Returns each slice's position in frames and seconds, the MIDI pitch
|
|
475
|
+
that triggers it (slice 0 = C1 / MIDI 36, slice 1 = C#1 / MIDI 37, etc.
|
|
476
|
+
per BUG-F2), plus sample metadata (sample rate, length, playback mode).
|
|
477
|
+
|
|
478
|
+
**Always use the returned `midi_pitch` when programming MIDI notes to
|
|
479
|
+
trigger slices.** The Live 12 Simpler Slice-mode base note is C1,
|
|
480
|
+
NOT C3 — writing notes at pitch 60+ on a sample with <24 slices
|
|
481
|
+
triggers nothing and produces silent output.
|
|
482
|
+
|
|
483
|
+
Use this to understand the rhythmic structure of a sliced sample
|
|
484
|
+
and program MIDI patterns targeting slices. Requires LivePilot
|
|
485
|
+
Analyzer on master track.
|
|
486
|
+
"""
|
|
487
|
+
cache = _get_spectral(ctx)
|
|
488
|
+
_require_analyzer(cache)
|
|
489
|
+
bridge = _get_m4l(ctx)
|
|
490
|
+
raw = await bridge.send_command("get_simpler_slices", track_index, device_index)
|
|
491
|
+
return _enrich_slice_response(raw)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
@mcp.tool()
|
|
495
|
+
async def classify_simpler_slices(
|
|
496
|
+
ctx: Context,
|
|
497
|
+
track_index: int,
|
|
498
|
+
device_index: int = 0,
|
|
499
|
+
file_path: Optional[str] = None,
|
|
500
|
+
) -> dict:
|
|
501
|
+
"""Classify each Simpler slice as KICK / SNARE / HAT / ghost via FFT analysis.
|
|
502
|
+
|
|
503
|
+
Reads slice positions via ``get_simpler_slices``, loads the backing
|
|
504
|
+
WAV file, and runs 4-band spectral classification on each segment.
|
|
505
|
+
Returns the enriched slice list with a ``label`` field per entry
|
|
506
|
+
plus feature breakdown (peak, rms, sub_pct, low_pct, mid_pct,
|
|
507
|
+
high_pct).
|
|
508
|
+
|
|
509
|
+
**Always run this before programming drum patterns on a sliced
|
|
510
|
+
break.** Slice content depends on transient detection order in the
|
|
511
|
+
source audio — slice 0 is NOT guaranteed to be a kick. Assuming
|
|
512
|
+
drum-rack convention produces wrong grooves that take iterations to
|
|
513
|
+
diagnose (see 2026-04-18 creative session for the canonical case).
|
|
514
|
+
|
|
515
|
+
Classification rules (validated on "Break Ghosts 90 bpm"):
|
|
516
|
+
- KICK: sub+low >= 45%, high < 40%
|
|
517
|
+
- HAT: high >= 70% AND mid < 25% (thin metal disc = no drum body)
|
|
518
|
+
- SNARE: mid >= 25% AND high >= 40% AND peak >= 0.6 (broadband loud)
|
|
519
|
+
- ghost: peak < 0.35
|
|
520
|
+
|
|
521
|
+
Parameters:
|
|
522
|
+
track_index, device_index: the Simpler to analyze
|
|
523
|
+
file_path: (optional) explicit WAV path. If omitted, attempts
|
|
524
|
+
lookup via the bridge. Bridge-native resolution is limited in
|
|
525
|
+
v1.11 — when the sample lives in the Core Library, pass the
|
|
526
|
+
absolute path explicitly.
|
|
527
|
+
|
|
528
|
+
Returns: dict with ``slices`` list. Each slice entry has:
|
|
529
|
+
index, frame, seconds, midi_pitch (36+index), label, peak, rms,
|
|
530
|
+
sub_pct, low_pct, mid_pct, high_pct.
|
|
531
|
+
|
|
564
532
|
Requires LivePilot Analyzer on master track.
|
|
565
533
|
"""
|
|
534
|
+
import soundfile as sf
|
|
535
|
+
|
|
536
|
+
from ..sample_engine.slice_classifier import classify_slices
|
|
537
|
+
|
|
566
538
|
cache = _get_spectral(ctx)
|
|
567
539
|
_require_analyzer(cache)
|
|
568
540
|
bridge = _get_m4l(ctx)
|
|
569
|
-
|
|
541
|
+
|
|
542
|
+
# 1. Get slice positions
|
|
543
|
+
raw_slices = await bridge.send_command(
|
|
544
|
+
"get_simpler_slices", track_index, device_index
|
|
545
|
+
)
|
|
546
|
+
enriched = _enrich_slice_response(raw_slices)
|
|
547
|
+
if enriched is None:
|
|
548
|
+
return {"error": "Bridge returned no slice data"}
|
|
549
|
+
|
|
550
|
+
# 2. Resolve file path
|
|
551
|
+
wav_path = file_path
|
|
552
|
+
if not wav_path:
|
|
553
|
+
try:
|
|
554
|
+
file_info = await bridge.send_command(
|
|
555
|
+
"get_simpler_file_path", track_index, device_index
|
|
556
|
+
)
|
|
557
|
+
if isinstance(file_info, dict):
|
|
558
|
+
wav_path = file_info.get("file_path")
|
|
559
|
+
except Exception: # noqa: BLE001 — bridge command may not exist yet
|
|
560
|
+
wav_path = None
|
|
561
|
+
|
|
562
|
+
if not wav_path:
|
|
563
|
+
return {
|
|
564
|
+
**enriched,
|
|
565
|
+
"error": (
|
|
566
|
+
"No file_path available — pass file_path= explicitly. "
|
|
567
|
+
"Bridge-based lookup for Simpler sample paths is a v1.12 "
|
|
568
|
+
"follow-up."
|
|
569
|
+
),
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
# 3. Load WAV and build frame boundaries
|
|
573
|
+
try:
|
|
574
|
+
audio, sr = sf.read(wav_path)
|
|
575
|
+
except (sf.LibsndfileError, sf.SoundFileError, RuntimeError, OSError) as exc:
|
|
576
|
+
# BUG-audit-C3: corrupt / missing / non-audio files must return a
|
|
577
|
+
# structured error dict instead of raising through the MCP framework
|
|
578
|
+
# (inconsistent with every other tool in this module).
|
|
579
|
+
return {
|
|
580
|
+
**enriched,
|
|
581
|
+
"error": f"Could not load WAV at {wav_path!r}: {exc}",
|
|
582
|
+
}
|
|
583
|
+
slices = enriched["slices"]
|
|
584
|
+
frame_boundaries = [s["frame"] for s in slices] + [len(audio)]
|
|
585
|
+
|
|
586
|
+
# 4. Classify
|
|
587
|
+
classifications = classify_slices(audio, sr, frame_boundaries)
|
|
588
|
+
|
|
589
|
+
# 5. Merge classification into each slice entry
|
|
590
|
+
merged_slices = []
|
|
591
|
+
for slice_entry, features in zip(slices, classifications):
|
|
592
|
+
merged_slices.append({
|
|
593
|
+
**slice_entry,
|
|
594
|
+
"label": features["label"],
|
|
595
|
+
"peak": features["peak"],
|
|
596
|
+
"rms": features["rms"],
|
|
597
|
+
"sub_pct": features["sub_pct"],
|
|
598
|
+
"low_pct": features["low_pct"],
|
|
599
|
+
"mid_pct": features["mid_pct"],
|
|
600
|
+
"high_pct": features["high_pct"],
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
enriched["slices"] = merged_slices
|
|
604
|
+
enriched["classifier_version"] = "v1.0"
|
|
605
|
+
return enriched
|
|
570
606
|
|
|
571
607
|
|
|
572
608
|
@mcp.tool()
|
|
@@ -847,21 +883,9 @@ async def capture_stop(ctx: Context) -> dict:
|
|
|
847
883
|
return await bridge.send_command("capture_stop")
|
|
848
884
|
|
|
849
885
|
# ── Phase 4: FluCoMa Real-Time ───────────────────────────────────────────
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
def _flucoma_hint(cache) -> str:
|
|
855
|
-
"""Return an error hint if no FluCoMa data has arrived.
|
|
856
|
-
|
|
857
|
-
If ANY stream has data, FluCoMa is working and the specific stream just
|
|
858
|
-
hasn't updated yet — return a 'play audio' hint. If NO streams have data,
|
|
859
|
-
FluCoMa may not be installed — return an install hint.
|
|
860
|
-
"""
|
|
861
|
-
for key in ("spectral_shape", "mel_bands", "chroma", "loudness"):
|
|
862
|
-
if cache.get(key):
|
|
863
|
-
return "play some audio"
|
|
864
|
-
return "FluCoMa may not be installed. Install via: npx livepilot --setup-flucoma"
|
|
886
|
+
#
|
|
887
|
+
# PITCH_NAMES + _flucoma_hint now live in ``_analyzer_engine/flucoma.py``
|
|
888
|
+
# and are re-exported via the top-of-file imports for tests/subclassers.
|
|
865
889
|
|
|
866
890
|
|
|
867
891
|
@mcp.tool()
|