livepilot 1.7.5 → 1.8.0
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 +31 -0
- package/README.md +5 -5
- package/bin/livepilot.js +136 -1
- package/m4l_device/livepilot_bridge.js +117 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/m4l_bridge.py +81 -0
- package/mcp_server/server.py +1 -0
- package/mcp_server/tools/_perception_engine.py +459 -0
- package/mcp_server/tools/analyzer.py +186 -2
- package/mcp_server/tools/perception.py +214 -0
- package/package.json +2 -2
- package/plugin/plugin.json +2 -2
- package/plugin/skills/livepilot-core/SKILL.md +6 -6
- package/plugin/skills/livepilot-core/references/overview.md +3 -3
- package/remote_script/LivePilot/__init__.py +3 -3
- package/requirements.txt +6 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Offline audio perception engine for LivePilot v1.8.
|
|
2
|
+
|
|
3
|
+
Pure functions for loudness, spectral, comparison, and metadata analysis.
|
|
4
|
+
All heavy imports are lazy (inside functions) except numpy and os.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Constants
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
STREAMING_TARGETS: dict[str, float] = {
|
|
21
|
+
"spotify": -14.0,
|
|
22
|
+
"apple": -16.0,
|
|
23
|
+
"youtube": -14.0,
|
|
24
|
+
"tidal": -14.0,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
SILENCE_FLOOR = -70.0
|
|
28
|
+
|
|
29
|
+
BAND_EDGES: dict[str, tuple[float, float]] = {
|
|
30
|
+
"sub_60hz": (20.0, 60.0),
|
|
31
|
+
"low_250hz": (60.0, 250.0),
|
|
32
|
+
"mid_2khz": (250.0, 2000.0),
|
|
33
|
+
"high_8khz": (2000.0, 8000.0),
|
|
34
|
+
"air_16khz": (8000.0, 20000.0),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Internal helpers
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def _load_audio(file_path: str) -> tuple[np.ndarray, int]:
|
|
43
|
+
"""Load an audio file as (ndarray, sample_rate). Ensures stereo output."""
|
|
44
|
+
if not os.path.exists(file_path):
|
|
45
|
+
raise FileNotFoundError(f"Audio file not found: {file_path}")
|
|
46
|
+
import soundfile as sf
|
|
47
|
+
data, sr = sf.read(file_path, dtype="float32", always_2d=True)
|
|
48
|
+
# Ensure stereo: if mono, duplicate channel
|
|
49
|
+
if data.shape[1] == 1:
|
|
50
|
+
data = np.column_stack([data, data])
|
|
51
|
+
return data, sr
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _normalize_to_lufs(
|
|
55
|
+
file_path: str, current_lufs: float, target_lufs: float = -14.0
|
|
56
|
+
) -> str:
|
|
57
|
+
"""LUFS-normalize audio to target, write to temp file, return path."""
|
|
58
|
+
import soundfile as sf
|
|
59
|
+
|
|
60
|
+
if current_lufs <= SILENCE_FLOOR:
|
|
61
|
+
return file_path
|
|
62
|
+
gain_db = target_lufs - current_lufs
|
|
63
|
+
gain_linear = 10 ** (gain_db / 20.0)
|
|
64
|
+
data, sr = _load_audio(file_path)
|
|
65
|
+
normalized = np.clip(data * gain_linear, -1.0, 1.0)
|
|
66
|
+
tmp_path = tempfile.mktemp(suffix=".wav")
|
|
67
|
+
try:
|
|
68
|
+
sf.write(tmp_path, normalized, sr)
|
|
69
|
+
except Exception:
|
|
70
|
+
# Clean up on failure to avoid orphan files
|
|
71
|
+
try:
|
|
72
|
+
os.unlink(tmp_path)
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
raise
|
|
76
|
+
return tmp_path
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# compute_loudness
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
|
|
84
|
+
"""Analyze integrated loudness (LUFS), true peak, RMS, LRA, and streaming compliance.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
file_path: Absolute path to audio file (.wav, .flac, .ogg, .aiff).
|
|
88
|
+
detail: "summary" (default) or "full" (includes short_term_lufs array).
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
dict with integrated_lufs, sample_peak_dbfs, rms_dbfs, crest_factor_db,
|
|
92
|
+
lra_lu, meets_streaming, and optionally short_term_lufs.
|
|
93
|
+
"""
|
|
94
|
+
import pyloudnorm as pyln
|
|
95
|
+
|
|
96
|
+
if not os.path.exists(file_path):
|
|
97
|
+
raise FileNotFoundError(f"Audio file not found: {file_path}")
|
|
98
|
+
|
|
99
|
+
data, sr = _load_audio(file_path)
|
|
100
|
+
|
|
101
|
+
meter = pyln.Meter(sr)
|
|
102
|
+
|
|
103
|
+
# Integrated LUFS
|
|
104
|
+
# pyloudnorm expects (samples, channels)
|
|
105
|
+
raw_lufs = meter.integrated_loudness(data)
|
|
106
|
+
integrated_lufs = max(float(raw_lufs), SILENCE_FLOOR) if np.isfinite(raw_lufs) else SILENCE_FLOOR
|
|
107
|
+
|
|
108
|
+
# Sample peak (max absolute value, no oversampling — not EBU R128 true peak)
|
|
109
|
+
peak_linear = float(np.max(np.abs(data)))
|
|
110
|
+
sample_peak_dbfs = float(20.0 * np.log10(max(peak_linear, 1e-10)))
|
|
111
|
+
|
|
112
|
+
# RMS dBFS
|
|
113
|
+
rms_linear = float(np.sqrt(np.mean(data ** 2)))
|
|
114
|
+
rms_dbfs = float(20.0 * np.log10(max(rms_linear, 1e-10)))
|
|
115
|
+
|
|
116
|
+
# Crest factor
|
|
117
|
+
crest_factor_db = sample_peak_dbfs - rms_dbfs
|
|
118
|
+
|
|
119
|
+
# Short-term LUFS (3s window, 1s hop) — also used for LRA
|
|
120
|
+
window_samples = int(sr * 3.0)
|
|
121
|
+
hop_samples = int(sr * 1.0)
|
|
122
|
+
n_samples = data.shape[0]
|
|
123
|
+
short_term_raw: list[float] = []
|
|
124
|
+
|
|
125
|
+
pos = 0
|
|
126
|
+
while pos + window_samples <= n_samples:
|
|
127
|
+
window = data[pos : pos + window_samples]
|
|
128
|
+
try:
|
|
129
|
+
st = meter.integrated_loudness(window)
|
|
130
|
+
short_term_raw.append(float(st) if np.isfinite(st) else SILENCE_FLOOR)
|
|
131
|
+
except Exception:
|
|
132
|
+
short_term_raw.append(SILENCE_FLOOR)
|
|
133
|
+
pos += hop_samples
|
|
134
|
+
|
|
135
|
+
# Guard: if no windows, fall back to integrated value
|
|
136
|
+
if not short_term_raw:
|
|
137
|
+
short_term_raw = [integrated_lufs]
|
|
138
|
+
|
|
139
|
+
# Replace -inf values with floor
|
|
140
|
+
short_term_clean = [max(v, SILENCE_FLOOR) for v in short_term_raw]
|
|
141
|
+
|
|
142
|
+
# LRA: 95th - 10th percentile of short-term values
|
|
143
|
+
st_array = np.array(short_term_clean)
|
|
144
|
+
lra_lu = float(np.percentile(st_array, 95) - np.percentile(st_array, 10))
|
|
145
|
+
|
|
146
|
+
# Cap short-term to 100 points via mean-pooling
|
|
147
|
+
if len(short_term_clean) > 100:
|
|
148
|
+
arr = np.array(short_term_clean, dtype=float)
|
|
149
|
+
chunk_size = len(arr) / 100.0
|
|
150
|
+
pooled = [
|
|
151
|
+
float(np.mean(arr[int(i * chunk_size): int((i + 1) * chunk_size)]))
|
|
152
|
+
for i in range(100)
|
|
153
|
+
]
|
|
154
|
+
short_term_clean = pooled
|
|
155
|
+
|
|
156
|
+
# Streaming compliance
|
|
157
|
+
meets_streaming = {
|
|
158
|
+
name: integrated_lufs >= (target - 1.0) # ±1 LU tolerance
|
|
159
|
+
for name, target in STREAMING_TARGETS.items()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
result: dict[str, Any] = {
|
|
163
|
+
"integrated_lufs": round(integrated_lufs, 2),
|
|
164
|
+
"sample_peak_dbfs": round(sample_peak_dbfs, 2),
|
|
165
|
+
"rms_dbfs": round(rms_dbfs, 2),
|
|
166
|
+
"crest_factor_db": round(crest_factor_db, 2),
|
|
167
|
+
"lra_lu": round(lra_lu, 2),
|
|
168
|
+
"meets_streaming": meets_streaming,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if detail == "full":
|
|
172
|
+
result["short_term_lufs"] = [round(v, 2) for v in short_term_clean]
|
|
173
|
+
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# compute_spectral
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def compute_spectral(
|
|
182
|
+
file_path: str,
|
|
183
|
+
n_fft: int = 2048,
|
|
184
|
+
hop_length: int = 512,
|
|
185
|
+
) -> dict[str, Any]:
|
|
186
|
+
"""Compute spectral features using scipy.signal.stft.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
file_path: Absolute path to audio file.
|
|
190
|
+
n_fft: FFT size.
|
|
191
|
+
hop_length: Hop size in samples.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
dict with centroid_hz, rolloff_hz, spectral_flatness, bandwidth_hz,
|
|
195
|
+
band_balance.
|
|
196
|
+
"""
|
|
197
|
+
from scipy.signal import stft as scipy_stft
|
|
198
|
+
|
|
199
|
+
if not os.path.exists(file_path):
|
|
200
|
+
raise FileNotFoundError(f"Audio file not found: {file_path}")
|
|
201
|
+
|
|
202
|
+
data, sr = _load_audio(file_path)
|
|
203
|
+
|
|
204
|
+
# Mix to mono for spectral analysis
|
|
205
|
+
mono = data.mean(axis=1).astype(np.float64)
|
|
206
|
+
|
|
207
|
+
# STFT
|
|
208
|
+
freqs, _times, Zxx = scipy_stft(
|
|
209
|
+
mono,
|
|
210
|
+
fs=sr,
|
|
211
|
+
nperseg=n_fft,
|
|
212
|
+
noverlap=n_fft - hop_length,
|
|
213
|
+
window="hann",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Power spectrum (mean over time)
|
|
217
|
+
power = np.mean(np.abs(Zxx) ** 2, axis=1) # shape: (n_fft//2+1,)
|
|
218
|
+
|
|
219
|
+
# Guard against all-zero power
|
|
220
|
+
total_power = float(np.sum(power))
|
|
221
|
+
if total_power < 1e-30:
|
|
222
|
+
# Return zeroed-out result for silence
|
|
223
|
+
band_balance = {k: 0.0 for k in BAND_EDGES}
|
|
224
|
+
return {
|
|
225
|
+
"centroid_hz": 0.0,
|
|
226
|
+
"rolloff_hz": 0.0,
|
|
227
|
+
"spectral_flatness": 0.0,
|
|
228
|
+
"bandwidth_hz": 0.0,
|
|
229
|
+
"band_balance": band_balance,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# Spectral centroid (weighted mean frequency)
|
|
233
|
+
centroid_hz = float(np.sum(freqs * power) / total_power)
|
|
234
|
+
|
|
235
|
+
# Spectral rolloff (95% energy threshold)
|
|
236
|
+
cumulative = np.cumsum(power)
|
|
237
|
+
threshold = 0.95 * cumulative[-1]
|
|
238
|
+
rolloff_idx = int(np.searchsorted(cumulative, threshold))
|
|
239
|
+
rolloff_hz = float(freqs[min(rolloff_idx, len(freqs) - 1)])
|
|
240
|
+
|
|
241
|
+
# Spectral flatness (geometric mean / arithmetic mean)
|
|
242
|
+
eps = 1e-10
|
|
243
|
+
log_mean = float(np.mean(np.log(power + eps)))
|
|
244
|
+
arith_mean = float(np.mean(power))
|
|
245
|
+
geo_mean = np.exp(log_mean)
|
|
246
|
+
spectral_flatness = float(geo_mean / (arith_mean + eps))
|
|
247
|
+
|
|
248
|
+
# Spectral bandwidth (weighted std around centroid)
|
|
249
|
+
bandwidth_hz = float(
|
|
250
|
+
np.sqrt(np.sum(power * (freqs - centroid_hz) ** 2) / (total_power + eps))
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# 5-band energy balance
|
|
254
|
+
band_balance: dict[str, float] = {}
|
|
255
|
+
for band_name, (f_low, f_high) in BAND_EDGES.items():
|
|
256
|
+
mask = (freqs >= f_low) & (freqs < f_high)
|
|
257
|
+
band_power = float(np.sum(power[mask]))
|
|
258
|
+
band_balance[band_name] = round(band_power / (total_power + eps), 6)
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
"centroid_hz": round(centroid_hz, 2),
|
|
262
|
+
"rolloff_hz": round(rolloff_hz, 2),
|
|
263
|
+
"spectral_flatness": round(spectral_flatness, 6),
|
|
264
|
+
"bandwidth_hz": round(bandwidth_hz, 2),
|
|
265
|
+
"band_balance": band_balance,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# compare_to_reference
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def compare_to_reference(
|
|
274
|
+
mix_path: str,
|
|
275
|
+
reference_path: str,
|
|
276
|
+
normalize: bool = True,
|
|
277
|
+
) -> dict[str, Any]:
|
|
278
|
+
"""Compare a mix to a reference track.
|
|
279
|
+
|
|
280
|
+
Computes loudness delta, spectral centroid delta, stereo width delta,
|
|
281
|
+
per-band energy deltas, and actionable suggestions.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
mix_path: Path to the mix file.
|
|
285
|
+
reference_path: Path to the reference file.
|
|
286
|
+
normalize: If True, LUFS-normalize both to -14 before spectral comparison.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
dict with loudness_delta_lufs, centroid_delta_hz, stereo_width_mix,
|
|
290
|
+
stereo_width_ref, band_deltas, suggestions.
|
|
291
|
+
"""
|
|
292
|
+
if not os.path.exists(mix_path):
|
|
293
|
+
raise FileNotFoundError(f"Mix file not found: {mix_path}")
|
|
294
|
+
if not os.path.exists(reference_path):
|
|
295
|
+
raise FileNotFoundError(f"Reference file not found: {reference_path}")
|
|
296
|
+
|
|
297
|
+
mix_loudness = compute_loudness(mix_path)
|
|
298
|
+
ref_loudness = compute_loudness(reference_path)
|
|
299
|
+
|
|
300
|
+
mix_lufs = mix_loudness["integrated_lufs"]
|
|
301
|
+
ref_lufs = ref_loudness["integrated_lufs"]
|
|
302
|
+
loudness_delta = round(mix_lufs - ref_lufs, 2)
|
|
303
|
+
|
|
304
|
+
# Optionally LUFS-normalize before spectral comparison
|
|
305
|
+
mix_for_spectral = mix_path
|
|
306
|
+
ref_for_spectral = reference_path
|
|
307
|
+
tmp_files: list[str] = []
|
|
308
|
+
|
|
309
|
+
if normalize:
|
|
310
|
+
mix_for_spectral = _normalize_to_lufs(mix_path, mix_lufs, -14.0)
|
|
311
|
+
ref_for_spectral = _normalize_to_lufs(reference_path, ref_lufs, -14.0)
|
|
312
|
+
if mix_for_spectral != mix_path:
|
|
313
|
+
tmp_files.append(mix_for_spectral)
|
|
314
|
+
if ref_for_spectral != reference_path:
|
|
315
|
+
tmp_files.append(ref_for_spectral)
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
mix_spectral = compute_spectral(mix_for_spectral)
|
|
319
|
+
ref_spectral = compute_spectral(ref_for_spectral)
|
|
320
|
+
finally:
|
|
321
|
+
for f in tmp_files:
|
|
322
|
+
try:
|
|
323
|
+
os.unlink(f)
|
|
324
|
+
except OSError:
|
|
325
|
+
pass
|
|
326
|
+
|
|
327
|
+
centroid_delta = round(mix_spectral["centroid_hz"] - ref_spectral["centroid_hz"], 2)
|
|
328
|
+
|
|
329
|
+
# Stereo width: side_energy / (mid_energy + side_energy)
|
|
330
|
+
def _stereo_width(file_path: str) -> float:
|
|
331
|
+
data, _sr = _load_audio(file_path)
|
|
332
|
+
mid = (data[:, 0] + data[:, 1]) / 2.0
|
|
333
|
+
side = (data[:, 0] - data[:, 1]) / 2.0
|
|
334
|
+
mid_energy = float(np.mean(mid ** 2))
|
|
335
|
+
side_energy = float(np.mean(side ** 2))
|
|
336
|
+
total = mid_energy + side_energy
|
|
337
|
+
if total < 1e-30:
|
|
338
|
+
return 0.0
|
|
339
|
+
return round(side_energy / total, 4)
|
|
340
|
+
|
|
341
|
+
stereo_width_mix = _stereo_width(mix_path)
|
|
342
|
+
stereo_width_ref = _stereo_width(reference_path)
|
|
343
|
+
|
|
344
|
+
# Band deltas
|
|
345
|
+
mix_bands = mix_spectral["band_balance"]
|
|
346
|
+
ref_bands = ref_spectral["band_balance"]
|
|
347
|
+
band_deltas = {
|
|
348
|
+
band: round(mix_bands.get(band, 0.0) - ref_bands.get(band, 0.0), 6)
|
|
349
|
+
for band in BAND_EDGES
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
# Build suggestions
|
|
353
|
+
suggestions: list[str] = []
|
|
354
|
+
if loudness_delta < -3:
|
|
355
|
+
suggestions.append(
|
|
356
|
+
f"Mix is {abs(loudness_delta):.1f} LUFS quieter than reference — consider raising gain."
|
|
357
|
+
)
|
|
358
|
+
elif loudness_delta > 3:
|
|
359
|
+
suggestions.append(
|
|
360
|
+
f"Mix is {loudness_delta:.1f} LUFS louder than reference — consider reducing gain."
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
if abs(centroid_delta) > 200:
|
|
364
|
+
direction = "brighter" if centroid_delta > 0 else "darker"
|
|
365
|
+
suggestions.append(
|
|
366
|
+
f"Mix spectral centroid is {abs(centroid_delta):.0f} Hz {direction} than reference."
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
width_delta = stereo_width_mix - stereo_width_ref
|
|
370
|
+
if width_delta < -0.1:
|
|
371
|
+
suggestions.append("Mix is narrower than reference — consider widening the stereo image.")
|
|
372
|
+
elif width_delta > 0.1:
|
|
373
|
+
suggestions.append("Mix is wider than reference — check for phase issues.")
|
|
374
|
+
|
|
375
|
+
for band, delta in band_deltas.items():
|
|
376
|
+
if abs(delta) > 0.05:
|
|
377
|
+
direction = "more" if delta > 0 else "less"
|
|
378
|
+
suggestions.append(f"{band}: mix has {direction} energy than reference ({delta:+.3f}).")
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
"loudness_delta_lufs": loudness_delta,
|
|
382
|
+
"mix_lufs": mix_lufs,
|
|
383
|
+
"reference_lufs": ref_lufs,
|
|
384
|
+
"centroid_delta_hz": centroid_delta,
|
|
385
|
+
"stereo_width_mix": stereo_width_mix,
|
|
386
|
+
"stereo_width_ref": stereo_width_ref,
|
|
387
|
+
"band_deltas": band_deltas,
|
|
388
|
+
"suggestions": suggestions,
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ---------------------------------------------------------------------------
|
|
393
|
+
# read_audio_metadata
|
|
394
|
+
# ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
def read_audio_metadata(file_path: str) -> dict[str, Any]:
|
|
397
|
+
"""Read audio file metadata using mutagen (tags) and soundfile (format info).
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
file_path: Absolute path to audio file.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
dict with format, duration, sample_rate, channels, bitrate, tags,
|
|
404
|
+
has_artwork, file_size.
|
|
405
|
+
"""
|
|
406
|
+
if not os.path.exists(file_path):
|
|
407
|
+
raise FileNotFoundError(f"Audio file not found: {file_path}")
|
|
408
|
+
|
|
409
|
+
import soundfile as sf
|
|
410
|
+
|
|
411
|
+
file_size = os.path.getsize(file_path)
|
|
412
|
+
|
|
413
|
+
# soundfile for basic info
|
|
414
|
+
sf_info = sf.info(file_path)
|
|
415
|
+
duration = float(sf_info.duration)
|
|
416
|
+
sample_rate = int(sf_info.samplerate)
|
|
417
|
+
channels = int(sf_info.channels)
|
|
418
|
+
fmt = str(sf_info.subtype) if sf_info.subtype else str(sf_info.format)
|
|
419
|
+
|
|
420
|
+
# bitrate estimate (bits/s)
|
|
421
|
+
bitrate: int | None = None
|
|
422
|
+
if duration > 0:
|
|
423
|
+
bitrate = int((file_size * 8) / duration)
|
|
424
|
+
|
|
425
|
+
# mutagen for tags
|
|
426
|
+
tags: dict[str, Any] = {}
|
|
427
|
+
has_artwork = False
|
|
428
|
+
try:
|
|
429
|
+
import mutagen
|
|
430
|
+
audio = mutagen.File(file_path)
|
|
431
|
+
if audio is not None:
|
|
432
|
+
for key, value in audio.tags.items() if audio.tags else []:
|
|
433
|
+
try:
|
|
434
|
+
# Skip binary/artwork tags
|
|
435
|
+
str_val = str(value)
|
|
436
|
+
if len(str_val) < 2048:
|
|
437
|
+
tags[str(key)] = str_val
|
|
438
|
+
except Exception:
|
|
439
|
+
pass
|
|
440
|
+
# Detect artwork (common tag names)
|
|
441
|
+
artwork_keys = {"APIC", "covr", "METADATA_BLOCK_PICTURE", "artwork"}
|
|
442
|
+
if audio.tags:
|
|
443
|
+
for key in audio.tags.keys():
|
|
444
|
+
if any(k in str(key) for k in artwork_keys):
|
|
445
|
+
has_artwork = True
|
|
446
|
+
break
|
|
447
|
+
except Exception:
|
|
448
|
+
pass # mutagen can't parse — use soundfile info only
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
"format": fmt,
|
|
452
|
+
"duration": round(duration, 4),
|
|
453
|
+
"sample_rate": sample_rate,
|
|
454
|
+
"channels": channels,
|
|
455
|
+
"bitrate": bitrate,
|
|
456
|
+
"tags": tags,
|
|
457
|
+
"has_artwork": has_artwork,
|
|
458
|
+
"file_size": file_size,
|
|
459
|
+
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
"""Analyzer MCP tools — real-time spectral analysis and deep LOM access.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
These tools are optional — all
|
|
3
|
+
29 tools requiring the LivePilot Analyzer M4L device on the master track.
|
|
4
|
+
These tools are optional — all core tools work without the device.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import os
|
|
10
|
+
|
|
9
11
|
from fastmcp import Context
|
|
10
12
|
|
|
11
13
|
from ..server import mcp
|
|
12
14
|
|
|
15
|
+
CAPTURE_DIR = os.path.expanduser("~/Documents/LivePilot/captures")
|
|
16
|
+
|
|
13
17
|
|
|
14
18
|
def _get_spectral(ctx: Context):
|
|
15
19
|
"""Get SpectralCache from lifespan context."""
|
|
@@ -506,3 +510,183 @@ async def get_display_values(
|
|
|
506
510
|
_require_analyzer(cache)
|
|
507
511
|
bridge = _get_m4l(ctx)
|
|
508
512
|
return await bridge.send_command("get_display_values", track_index, device_index)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# ── Phase 3: Audio Capture ─────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@mcp.tool()
|
|
519
|
+
async def capture_audio(
|
|
520
|
+
ctx: Context,
|
|
521
|
+
duration_seconds: int = 10,
|
|
522
|
+
source: str = "master",
|
|
523
|
+
filename: str = "",
|
|
524
|
+
) -> dict:
|
|
525
|
+
"""Capture audio from Ableton Live to a WAV file on disk.
|
|
526
|
+
|
|
527
|
+
Records from the specified source (currently 'master') for the given
|
|
528
|
+
duration. Files are written to ~/Documents/LivePilot/captures/.
|
|
529
|
+
If filename is empty, a timestamped name is generated automatically.
|
|
530
|
+
|
|
531
|
+
Returns the path to the written file and capture metadata.
|
|
532
|
+
Requires LivePilot Analyzer on master track.
|
|
533
|
+
"""
|
|
534
|
+
cache = _get_spectral(ctx)
|
|
535
|
+
_require_analyzer(cache)
|
|
536
|
+
|
|
537
|
+
if duration_seconds < 1 or duration_seconds > 300:
|
|
538
|
+
raise ValueError("duration_seconds must be between 1 and 300")
|
|
539
|
+
if source not in ("master",):
|
|
540
|
+
raise ValueError(f"Unsupported source '{source}'. Valid: 'master'")
|
|
541
|
+
|
|
542
|
+
bridge = _get_m4l(ctx)
|
|
543
|
+
duration_ms = duration_seconds * 1000
|
|
544
|
+
result = await bridge.send_capture(
|
|
545
|
+
"capture_audio",
|
|
546
|
+
duration_ms,
|
|
547
|
+
filename,
|
|
548
|
+
timeout=float(duration_seconds + 10),
|
|
549
|
+
)
|
|
550
|
+
return result
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@mcp.tool()
|
|
554
|
+
async def capture_stop(ctx: Context) -> dict:
|
|
555
|
+
"""Stop an in-progress audio capture early.
|
|
556
|
+
|
|
557
|
+
Cancels the running buffer~ recording and returns whatever audio has
|
|
558
|
+
been captured so far. The partial file is still written to disk.
|
|
559
|
+
Requires LivePilot Analyzer on master track.
|
|
560
|
+
"""
|
|
561
|
+
cache = _get_spectral(ctx)
|
|
562
|
+
_require_analyzer(cache)
|
|
563
|
+
bridge = _get_m4l(ctx)
|
|
564
|
+
# Cancel the capture future so send_capture doesn't hang forever
|
|
565
|
+
bridge.cancel_capture_future()
|
|
566
|
+
return await bridge.send_command("capture_stop")
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# ── Phase 4: FluCoMa Real-Time ───────────────────────────────────────────
|
|
570
|
+
|
|
571
|
+
PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _flucoma_hint(cache) -> str:
|
|
575
|
+
"""Return an error hint if no FluCoMa data has arrived.
|
|
576
|
+
|
|
577
|
+
If ANY stream has data, FluCoMa is working and the specific stream just
|
|
578
|
+
hasn't updated yet — return a 'play audio' hint. If NO streams have data,
|
|
579
|
+
FluCoMa may not be installed — return an install hint.
|
|
580
|
+
"""
|
|
581
|
+
for key in ("spectral_shape", "mel_bands", "chroma", "loudness"):
|
|
582
|
+
if cache.get(key):
|
|
583
|
+
return "play some audio"
|
|
584
|
+
return "FluCoMa may not be installed. Install via: npx livepilot --setup-flucoma"
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@mcp.tool()
|
|
588
|
+
def get_spectral_shape(ctx: Context) -> dict:
|
|
589
|
+
"""Get 7 real-time spectral descriptors from FluCoMa.
|
|
590
|
+
|
|
591
|
+
Returns centroid, spread, skewness, kurtosis, rolloff, flatness, crest.
|
|
592
|
+
Requires FluCoMa package in Max.
|
|
593
|
+
"""
|
|
594
|
+
cache = _get_spectral(ctx)
|
|
595
|
+
_require_analyzer(cache)
|
|
596
|
+
data = cache.get("spectral_shape")
|
|
597
|
+
if not data:
|
|
598
|
+
hint = _flucoma_hint(cache)
|
|
599
|
+
return {"error": f"No spectral shape data — {hint}"}
|
|
600
|
+
return {**data["value"], "age_ms": data["age_ms"]}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@mcp.tool()
|
|
604
|
+
def get_mel_spectrum(ctx: Context) -> dict:
|
|
605
|
+
"""Get 40-band mel spectrum from FluCoMa (5x resolution of get_master_spectrum).
|
|
606
|
+
|
|
607
|
+
Requires FluCoMa package in Max.
|
|
608
|
+
"""
|
|
609
|
+
cache = _get_spectral(ctx)
|
|
610
|
+
_require_analyzer(cache)
|
|
611
|
+
data = cache.get("mel_bands")
|
|
612
|
+
if not data:
|
|
613
|
+
hint = _flucoma_hint(cache)
|
|
614
|
+
return {"error": f"No mel data — {hint}"}
|
|
615
|
+
return {"mel_bands": data["value"], "band_count": len(data["value"]), "age_ms": data["age_ms"]}
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
@mcp.tool()
|
|
619
|
+
def get_chroma(ctx: Context) -> dict:
|
|
620
|
+
"""Get 12 pitch class energies from FluCoMa for real-time chord detection.
|
|
621
|
+
|
|
622
|
+
Requires FluCoMa package in Max.
|
|
623
|
+
"""
|
|
624
|
+
cache = _get_spectral(ctx)
|
|
625
|
+
_require_analyzer(cache)
|
|
626
|
+
data = cache.get("chroma")
|
|
627
|
+
if not data:
|
|
628
|
+
hint = _flucoma_hint(cache)
|
|
629
|
+
return {"error": f"No chroma data — {hint}"}
|
|
630
|
+
values = data["value"]
|
|
631
|
+
chroma_dict = {PITCH_NAMES[i]: round(v, 3) for i, v in enumerate(values[:12])}
|
|
632
|
+
max_val = max(values[:12]) if values else 0
|
|
633
|
+
dominant = [PITCH_NAMES[i] for i, v in enumerate(values[:12])
|
|
634
|
+
if v >= max_val * 0.5 and max_val > 0.01]
|
|
635
|
+
return {"chroma": chroma_dict, "dominant_pitches": dominant, "age_ms": data["age_ms"]}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
@mcp.tool()
|
|
639
|
+
def get_onsets(ctx: Context) -> dict:
|
|
640
|
+
"""Get real-time onset/transient detection from FluCoMa.
|
|
641
|
+
|
|
642
|
+
Requires FluCoMa package in Max.
|
|
643
|
+
"""
|
|
644
|
+
cache = _get_spectral(ctx)
|
|
645
|
+
_require_analyzer(cache)
|
|
646
|
+
data = cache.get("onset")
|
|
647
|
+
if not data:
|
|
648
|
+
hint = _flucoma_hint(cache)
|
|
649
|
+
return {"error": f"No onset data — {hint}"}
|
|
650
|
+
return {**data["value"], "age_ms": data["age_ms"]}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@mcp.tool()
|
|
654
|
+
def get_novelty(ctx: Context) -> dict:
|
|
655
|
+
"""Get real-time spectral novelty for section boundary detection from FluCoMa.
|
|
656
|
+
|
|
657
|
+
Requires FluCoMa package in Max.
|
|
658
|
+
"""
|
|
659
|
+
cache = _get_spectral(ctx)
|
|
660
|
+
_require_analyzer(cache)
|
|
661
|
+
data = cache.get("novelty")
|
|
662
|
+
if not data:
|
|
663
|
+
hint = _flucoma_hint(cache)
|
|
664
|
+
return {"error": f"No novelty data — {hint}"}
|
|
665
|
+
return {**data["value"], "age_ms": data["age_ms"]}
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@mcp.tool()
|
|
669
|
+
def get_momentary_loudness(ctx: Context) -> dict:
|
|
670
|
+
"""Get EBU R128 momentary LUFS + true peak from FluCoMa.
|
|
671
|
+
|
|
672
|
+
Real-time LUFS metering — industry standard. Complements get_master_rms.
|
|
673
|
+
Requires FluCoMa package in Max.
|
|
674
|
+
"""
|
|
675
|
+
cache = _get_spectral(ctx)
|
|
676
|
+
_require_analyzer(cache)
|
|
677
|
+
data = cache.get("loudness")
|
|
678
|
+
if not data:
|
|
679
|
+
hint = _flucoma_hint(cache)
|
|
680
|
+
return {"error": f"No loudness data — {hint}"}
|
|
681
|
+
return {**data["value"], "age_ms": data["age_ms"]}
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@mcp.tool()
|
|
685
|
+
def check_flucoma(ctx: Context) -> dict:
|
|
686
|
+
"""Check if FluCoMa is installed and sending data."""
|
|
687
|
+
cache = _get_spectral(ctx)
|
|
688
|
+
streams = {}
|
|
689
|
+
for key in ("spectral_shape", "mel_bands", "chroma", "onset", "novelty", "loudness"):
|
|
690
|
+
streams[key] = cache.get(key) is not None
|
|
691
|
+
active = sum(1 for v in streams.values() if v)
|
|
692
|
+
return {"flucoma_available": active > 0, "active_streams": active, "streams": streams}
|