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.
@@ -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
- 20 tools requiring the LivePilot Analyzer M4L device on the master track.
4
- These tools are optional — all 115 core tools work without the device.
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}