@mthines/reaper-mcp 0.19.0 → 0.70.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.
Files changed (116) hide show
  1. package/README.md +19 -570
  2. package/bridge-ui/mcp_bridge.html +129 -0
  3. package/bridge-ui/mcp_bridge.webmanifest +16 -0
  4. package/bridge-ui/mcp_bridge_icon_192.png +0 -0
  5. package/bridge-ui/mcp_bridge_icon_512.png +0 -0
  6. package/bridge-ui/mcp_bridge_sw.js +35 -0
  7. package/claude/agents/plugin-researcher.md +188 -0
  8. package/claude/knowledge/reference/aesthetics-scoring.md +48 -0
  9. package/{knowledge → claude/knowledge}/reference/common-mistakes.md +1 -1
  10. package/claude/knowledge/reference/lorekit-setup.md +36 -0
  11. package/claude/knowledge/reference/memory-protocol.md +377 -0
  12. package/{knowledge → claude/knowledge}/reference/metering.md +5 -5
  13. package/claude/knowledge/reference/perceived-loudness.md +182 -0
  14. package/{knowledge → claude/knowledge}/workflows/editing.md +4 -0
  15. package/{knowledge → claude/knowledge}/workflows/gain-staging.md +21 -23
  16. package/{knowledge → claude/knowledge}/workflows/learn-plugin.md +15 -48
  17. package/{knowledge → claude/knowledge}/workflows/master-bus.md +13 -1
  18. package/claude/knowledge/workflows/section-levels.md +138 -0
  19. package/{knowledge → claude/knowledge}/workflows/session-prep.md +4 -0
  20. package/claude/knowledge/workflows/transient-lighting.md +52 -0
  21. package/claude/skills/critique/SKILL.md +131 -0
  22. package/claude/skills/editing/SKILL.md +281 -0
  23. package/claude/skills/learn-plugin/SKILL.md +210 -0
  24. package/claude/skills/mastering/SKILL.md +151 -0
  25. package/claude/skills/mix-memory/SKILL.md +140 -0
  26. package/claude/skills/mixer/SKILL.md +253 -0
  27. package/claude/skills/new-extension/SKILL.md +324 -0
  28. package/main.js +7843 -1940
  29. package/package.json +17 -15
  30. package/reaper/CLAUDE.md +22 -4
  31. package/reaper/mcp_actions_lib.lua +375 -0
  32. package/reaper/mcp_analyzer.jsfx +27 -12
  33. package/reaper/mcp_arrange_lib.lua +949 -0
  34. package/reaper/mcp_bridge.lua +2321 -758
  35. package/reaper/mcp_edit_views_lib.lua +1378 -0
  36. package/reaper/mcp_envelopes_lib.lua +748 -0
  37. package/reaper/mcp_extensions_lib.lua +55 -0
  38. package/reaper/mcp_fxstate_lib.lua +144 -0
  39. package/reaper/mcp_gain_trace.jsfx +126 -0
  40. package/reaper/mcp_items_lib.lua +877 -0
  41. package/reaper/mcp_lufs_meter.jsfx +16 -13
  42. package/reaper/mcp_midi_lib.lua +555 -0
  43. package/reaper/mcp_mixing_lib.lua +1021 -0
  44. package/reaper/mcp_plan_lib.lua +801 -0
  45. package/reaper/mcp_prefs_lib.lua +127 -0
  46. package/reaper/mcp_project_lib.lua +1088 -0
  47. package/reaper/mcp_recording_lib.lua +595 -0
  48. package/reaper/mcp_render_lib.lua +492 -0
  49. package/reaper/mcp_snapshot_lib.lua +547 -120
  50. package/reaper/mcp_snapshot_next.lua +10 -4
  51. package/reaper/mcp_snapshot_prev.lua +11 -4
  52. package/reaper/mcp_snapshot_quick_save.lua +7 -32
  53. package/reaper/mcp_snapshots_ui_lib.lua +258 -0
  54. package/reaper/mcp_tracks_lib.lua +409 -0
  55. package/sidecar/analysis.py +661 -0
  56. package/sidecar/chords.py +96 -0
  57. package/sidecar/mixcheck.py +295 -0
  58. package/sidecar/requirements-chords.txt +6 -0
  59. package/sidecar/requirements-separation.txt +3 -0
  60. package/sidecar/requirements.txt +17 -0
  61. package/sidecar/separation.py +142 -0
  62. package/sidecar/server.py +622 -0
  63. package/sidecar/tests/test_analysis.py +249 -0
  64. package/sidecar/tests/test_chords.py +117 -0
  65. package/sidecar/tests/test_mixcheck.py +163 -0
  66. package/sidecar/tests/test_separation.py +73 -0
  67. package/sidecar/tests/test_server.py +464 -0
  68. package/sidecar/tests/test_verify.py +243 -0
  69. package/sidecar/tests/test_voice_render.py +268 -0
  70. package/sidecar/tests/test_voice_sketch.py +140 -0
  71. package/sidecar/verify.py +224 -0
  72. package/sidecar/voice_render.py +295 -0
  73. package/sidecar/voice_sketch.py +280 -0
  74. package/claude-agents/critique.md +0 -147
  75. package/claude-agents/editor.md +0 -184
  76. package/claude-agents/levels.md +0 -107
  77. package/claude-agents/mastering.md +0 -129
  78. package/claude-agents/mixer.md +0 -248
  79. package/claude-agents/preflight.md +0 -149
  80. package/claude-agents/producer.md +0 -159
  81. package/claude-agents/setup.md +0 -150
  82. package/claude-agents/stems.md +0 -135
  83. package/claude-rules/architecture.md +0 -39
  84. package/claude-rules/development.md +0 -54
  85. package/claude-rules/lua-bridge.md +0 -50
  86. package/claude-rules/testing.md +0 -42
  87. package/claude-skills/learn-plugin.md +0 -123
  88. package/knowledge/CLAUDE.md +0 -71
  89. package/knowledge/plugins/_template.md +0 -82
  90. package/knowledge/plugins/fabfilter/pro-c-2.md +0 -117
  91. package/knowledge/plugins/fabfilter/pro-l-2.md +0 -95
  92. package/knowledge/plugins/fabfilter/pro-q-3.md +0 -112
  93. package/knowledge/plugins/neural-dsp/helix-native.md +0 -104
  94. package/knowledge/plugins/stock-reaper/js-1175-compressor.md +0 -94
  95. package/knowledge/plugins/stock-reaper/rea-comp.md +0 -100
  96. package/knowledge/plugins/stock-reaper/rea-delay.md +0 -95
  97. package/knowledge/plugins/stock-reaper/rea-eq.md +0 -103
  98. package/knowledge/plugins/stock-reaper/rea-gate.md +0 -99
  99. package/knowledge/plugins/stock-reaper/rea-limit.md +0 -75
  100. package/knowledge/plugins/stock-reaper/rea-verb.md +0 -76
  101. package/knowledge/reference/perceived-loudness.md +0 -122
  102. package/reaper/mcp_snapshot_manager.lua +0 -1001
  103. /package/{knowledge → claude/knowledge}/genres/_template.md +0 -0
  104. /package/{knowledge → claude/knowledge}/genres/electronic.md +0 -0
  105. /package/{knowledge → claude/knowledge}/genres/hip-hop.md +0 -0
  106. /package/{knowledge → claude/knowledge}/genres/metal.md +0 -0
  107. /package/{knowledge → claude/knowledge}/genres/orchestral.md +0 -0
  108. /package/{knowledge → claude/knowledge}/genres/pop.md +0 -0
  109. /package/{knowledge → claude/knowledge}/genres/rock.md +0 -0
  110. /package/{knowledge → claude/knowledge}/reference/compression.md +0 -0
  111. /package/{knowledge → claude/knowledge}/reference/frequencies.md +0 -0
  112. /package/{knowledge → claude/knowledge}/workflows/delivery.md +0 -0
  113. /package/{knowledge → claude/knowledge}/workflows/drum-bus.md +0 -0
  114. /package/{knowledge → claude/knowledge}/workflows/low-end.md +0 -0
  115. /package/{knowledge → claude/knowledge}/workflows/stem-prep.md +0 -0
  116. /package/{knowledge → claude/knowledge}/workflows/vocal-chain.md +0 -0
@@ -0,0 +1,661 @@
1
+ """
2
+ Offline audio analysis for the reaper-mcp sidecar.
3
+
4
+ Everything here works on float32 numpy arrays shaped (channels, frames) and
5
+ uses only numpy and torch / torchaudio.functional (already installed for the
6
+ Audiobox model). torchaudio.load/info are never used: torchaudio 2.9+ moved
7
+ them to torchcodec.
8
+
9
+ - Loudness follows ITU-R BS.1770-4 (K-weighting, 400 ms blocks, absolute and
10
+ relative gating) and EBU Tech 3342 for loudness range (LRA).
11
+ - True peak follows BS.1770 Annex 2: 4x oversampling, then the sample peak.
12
+ - Spectral figures come from a long-term average spectrum (Welch-style STFT).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from typing import Any
19
+
20
+ import numpy as np
21
+
22
+ ABSOLUTE_GATE_LUFS = -70.0
23
+ SILENCE_LUFS = -70.0
24
+
25
+ # Bands for the tonal balance summary (Hz). Names are what a mix engineer says.
26
+ BANDS: list[tuple[str, float, float]] = [
27
+ ("sub", 20, 60),
28
+ ("bass", 60, 250),
29
+ ("lowMid", 250, 500),
30
+ ("mid", 500, 2000),
31
+ ("upperMid", 2000, 4000),
32
+ ("presence", 4000, 6000),
33
+ ("brilliance", 6000, 20000),
34
+ ]
35
+
36
+ SIBILANCE_BAND = (5000.0, 10000.0)
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Small helpers
41
+ # ---------------------------------------------------------------------------
42
+
43
+ def _db(x: float, floor: float = -150.0) -> float:
44
+ return 20.0 * math.log10(x) if x > 0 else floor
45
+
46
+
47
+ def _round(x: float | None, digits: int = 2) -> float | None:
48
+ if x is None or not math.isfinite(x):
49
+ return None
50
+ return round(float(x), digits)
51
+
52
+
53
+ def _power_to_lufs(mean_square_sum: float) -> float:
54
+ return -0.691 + 10.0 * math.log10(mean_square_sum) if mean_square_sum > 0 else -math.inf
55
+
56
+
57
+ def load_audio(path: str, start: float = 0.0, end: float | None = None) -> tuple[np.ndarray, int]:
58
+ """Read [start, end) seconds of a file as (channels, frames) float32."""
59
+ import soundfile as sf # type: ignore
60
+
61
+ sample_rate = sf.info(path).samplerate
62
+ stop = None if end is None else int(end * sample_rate)
63
+ data, sample_rate = sf.read(path, start=int(start * sample_rate), stop=stop,
64
+ dtype="float32", always_2d=True)
65
+ return np.ascontiguousarray(data.T), int(sample_rate)
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # K-weighting (BS.1770-4) — coefficients derived for any sample rate
70
+ # ---------------------------------------------------------------------------
71
+
72
+ def _k_weighting_coeffs(sample_rate: int) -> list[tuple[list[float], list[float]]]:
73
+ """Return [(b, a), (b, a)] for the shelf and high-pass stages."""
74
+ # Stage 1: high shelf (pyloudnorm / libebur128 derivation)
75
+ f0, gain_db, q = 1681.974450955533, 3.999843853973347, 0.7071752369554196
76
+ k = math.tan(math.pi * f0 / sample_rate)
77
+ vh = 10 ** (gain_db / 20)
78
+ vb = vh ** 0.4996667741545416
79
+ a0 = 1 + k / q + k * k
80
+ shelf_b = [(vh + vb * k / q + k * k) / a0, 2 * (k * k - vh) / a0, (vh - vb * k / q + k * k) / a0]
81
+ shelf_a = [1.0, 2 * (k * k - 1) / a0, (1 - k / q + k * k) / a0]
82
+ # Stage 2: RLB high-pass
83
+ f0, q = 38.13547087602444, 0.5003270373238773
84
+ k = math.tan(math.pi * f0 / sample_rate)
85
+ a0 = 1 + k / q + k * k
86
+ hp_b = [1.0, -2.0, 1.0]
87
+ hp_a = [1.0, 2 * (k * k - 1) / a0, (1 - k / q + k * k) / a0]
88
+ return [(shelf_b, shelf_a), (hp_b, hp_a)]
89
+
90
+
91
+ # Long renders are processed in chunks so memory stays flat: a 900 s stereo
92
+ # render filtered or oversampled in one piece peaked at ~7 GB.
93
+ CHUNK_S = 30.0
94
+ # The K-weighting filters settle in milliseconds; 1 s of preroll before each
95
+ # chunk makes the chunked output match the one-piece output.
96
+ K_PREROLL_S = 1.0
97
+ # Resampler context on each side of a true-peak chunk.
98
+ TP_PAD_S = 0.05
99
+
100
+
101
+ def k_weight(audio: np.ndarray, sample_rate: int) -> np.ndarray:
102
+ import torch # type: ignore
103
+ import torchaudio.functional as F # type: ignore
104
+
105
+ coeffs = [(torch.tensor(a, dtype=torch.float64), torch.tensor(b, dtype=torch.float64))
106
+ for b, a in _k_weighting_coeffs(sample_rate)]
107
+ frames = audio.shape[1]
108
+ chunk = int(CHUNK_S * sample_rate)
109
+ preroll = int(K_PREROLL_S * sample_rate)
110
+ out = np.empty(audio.shape, dtype=np.float64)
111
+ for start in range(0, frames, chunk):
112
+ lo = max(0, start - preroll)
113
+ hi = min(frames, start + chunk)
114
+ x = torch.from_numpy(np.ascontiguousarray(audio[:, lo:hi], dtype=np.float64))
115
+ for a, b in coeffs:
116
+ x = F.lfilter(x, a, b, clamp=False)
117
+ out[:, start:hi] = x.numpy()[:, start - lo:]
118
+ return out
119
+
120
+
121
+ def _block_powers(weighted: np.ndarray, sample_rate: int, block_s: float, hop_s: float) -> np.ndarray:
122
+ """Channel-summed mean square per block (BS.1770 z_i summed with G=1)."""
123
+ block = int(round(block_s * sample_rate))
124
+ hop = int(round(hop_s * sample_rate))
125
+ frames = weighted.shape[1]
126
+ if frames < block:
127
+ return np.zeros(0)
128
+ # Channels are summed (G=1; the 1.41 surround weights do not apply to stereo
129
+ # renders), so sum the squares first; cumulative sums make every window O(1)
130
+ power = np.einsum("ij,ij->j", weighted, weighted)
131
+ csum = np.concatenate([[0.0], np.cumsum(power)])
132
+ starts = np.arange(0, frames - block + 1, hop)
133
+ return (csum[starts + block] - csum[starts]) / block
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Loudness
138
+ # ---------------------------------------------------------------------------
139
+
140
+ def integrated_loudness(audio: np.ndarray, sample_rate: int, weighted: np.ndarray | None = None) -> float:
141
+ """BS.1770-4 gated integrated loudness in LUFS (-inf for silence)."""
142
+ w = k_weight(audio, sample_rate) if weighted is None else weighted
143
+ powers = _block_powers(w, sample_rate, 0.4, 0.1)
144
+ if powers.size == 0:
145
+ return -math.inf
146
+ loud = np.array([_power_to_lufs(p) for p in powers])
147
+ gated = powers[loud > ABSOLUTE_GATE_LUFS]
148
+ if gated.size == 0:
149
+ return -math.inf
150
+ relative_gate = _power_to_lufs(gated.mean()) - 10.0
151
+ final = powers[(loud > ABSOLUTE_GATE_LUFS) & (loud > relative_gate)]
152
+ return _power_to_lufs(final.mean()) if final.size else -math.inf
153
+
154
+
155
+ def loudness_range(audio: np.ndarray, sample_rate: int, weighted: np.ndarray | None = None) -> float | None:
156
+ """EBU Tech 3342 LRA in LU (None when the clip is shorter than one 3 s block)."""
157
+ w = k_weight(audio, sample_rate) if weighted is None else weighted
158
+ powers = _block_powers(w, sample_rate, 3.0, 1.0)
159
+ if powers.size == 0:
160
+ return None
161
+ st = np.array([_power_to_lufs(p) for p in powers])
162
+ st = st[st > ABSOLUTE_GATE_LUFS]
163
+ if st.size == 0:
164
+ return 0.0
165
+ relative_gate = _power_to_lufs(np.mean(10 ** ((st + 0.691) / 10))) - 20.0
166
+ st = st[st > relative_gate]
167
+ if st.size < 2:
168
+ return 0.0
169
+ return float(np.percentile(st, 95) - np.percentile(st, 10))
170
+
171
+
172
+ def true_peak(audio: np.ndarray, sample_rate: int) -> float:
173
+ """Linear true peak via 4x oversampling (BS.1770-4 Annex 2)."""
174
+ import torch # type: ignore
175
+ import torchaudio.functional as F # type: ignore
176
+
177
+ frames = audio.shape[1]
178
+ chunk = int(CHUNK_S * sample_rate)
179
+ pad = int(TP_PAD_S * sample_rate)
180
+ peak = float(np.abs(audio).max(initial=0.0))
181
+ for start in range(0, frames, chunk):
182
+ lo = max(0, start - pad)
183
+ hi = min(frames, start + chunk + pad)
184
+ x = torch.from_numpy(np.ascontiguousarray(audio[:, lo:hi], dtype=np.float32))
185
+ up = F.resample(x, orig_freq=sample_rate, new_freq=sample_rate * 4)
186
+ # Keep only this chunk's own span; the padding is context for the filter
187
+ keep = up[:, (start - lo) * 4:(min(start + chunk, frames) - lo) * 4]
188
+ if keep.numel():
189
+ peak = max(peak, float(keep.abs().max().item()))
190
+ return peak
191
+
192
+
193
+ def section_loudness(weighted: np.ndarray, sample_rate: int, section_s: float) -> list[dict[str, Any]]:
194
+ """Loudness per consecutive section (ungated mean of 400 ms blocks)."""
195
+ hop = int(section_s * sample_rate)
196
+ out = []
197
+ for i, start in enumerate(range(0, weighted.shape[1], hop)):
198
+ chunk = weighted[:, start:start + hop]
199
+ if chunk.shape[1] < int(0.4 * sample_rate):
200
+ break
201
+ powers = _block_powers(chunk, sample_rate, 0.4, 0.1)
202
+ lufs = _power_to_lufs(powers.mean()) if powers.size else -math.inf
203
+ out.append({
204
+ "start": _round(start / sample_rate, 1),
205
+ "end": _round(min(start + hop, weighted.shape[1]) / sample_rate, 1),
206
+ "lufs": _round(max(lufs, SILENCE_LUFS), 1),
207
+ })
208
+ return out
209
+
210
+
211
+ def loudness_stats(audio: np.ndarray, sample_rate: int, section_s: float | None = None) -> dict[str, Any]:
212
+ weighted = k_weight(audio, sample_rate)
213
+ integrated = integrated_loudness(audio, sample_rate, weighted)
214
+ sample_peak = float(np.abs(audio).max(initial=0.0))
215
+ tp = true_peak(audio, sample_rate)
216
+ rms = float(np.sqrt(np.mean(audio.astype(np.float64) ** 2))) if audio.size else 0.0
217
+ st_powers = _block_powers(weighted, sample_rate, 3.0, 1.0)
218
+ st = [_power_to_lufs(p) for p in st_powers]
219
+ stats: dict[str, Any] = {
220
+ "integratedLufs": _round(integrated if math.isfinite(integrated) else SILENCE_LUFS, 2),
221
+ "loudnessRangeLu": _round(loudness_range(audio, sample_rate, weighted), 1),
222
+ "maxShortTermLufs": _round(max(st), 1) if st else None,
223
+ "truePeakDbtp": _round(_db(tp), 2),
224
+ "samplePeakDbfs": _round(_db(sample_peak), 2),
225
+ "rmsDbfs": _round(_db(rms), 2),
226
+ "crestFactorDb": _round(_db(sample_peak) - _db(rms), 2) if rms > 0 else None,
227
+ "durationSeconds": _round(audio.shape[1] / sample_rate, 2),
228
+ }
229
+ if section_s:
230
+ stats["sections"] = section_loudness(weighted, sample_rate, section_s)
231
+ return stats
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # Spectrum
236
+ # ---------------------------------------------------------------------------
237
+
238
+ def _stft_power(audio: np.ndarray, sample_rate: int, n_fft: int = 4096) -> tuple[np.ndarray, np.ndarray]:
239
+ """Mono STFT power, shape (bins, frames), plus bin frequencies."""
240
+ import torch # type: ignore
241
+
242
+ mono = torch.from_numpy(np.ascontiguousarray(audio.mean(axis=0), dtype=np.float32))
243
+ if mono.numel() < n_fft:
244
+ mono = torch.nn.functional.pad(mono, (0, n_fft - mono.numel()))
245
+ spec = torch.stft(mono, n_fft=n_fft, hop_length=n_fft // 2, window=torch.hann_window(n_fft),
246
+ return_complex=True, center=False)
247
+ power = (spec.abs() ** 2).numpy()
248
+ freqs = np.fft.rfftfreq(n_fft, 1.0 / sample_rate)
249
+ return power, freqs
250
+
251
+
252
+ # Below ~110 Hz an 8192-point bin (5.4 Hz at 44.1 kHz) is wider than a semitone and lands on the
253
+ # wrong pitch class; a bass note still counts through its harmonics above the floor.
254
+ CHROMA_LOW_HZ, CHROMA_HIGH_HZ = 110.0, 4200.0
255
+
256
+
257
+ def chroma(audio: np.ndarray, sample_rate: int, n_fft: int = 8192, segments: list | None = None) -> dict:
258
+ """
259
+ Pitch-class energy (C first), for key and chord detection.
260
+
261
+ Each STFT bin from 110 Hz to 4.2 kHz adds its magnitude to the pitch class nearest its
262
+ frequency; every frame is normalized to its own largest class so loud passages do not
263
+ outweigh quiet ones, frames more than 60 dB under the loudest are skipped as silence,
264
+ and the average is scaled so the largest class is 1.
265
+
266
+ With `segments` ([start, end] seconds into `audio`), also each segment's chroma: the same
267
+ average over the frames whose centre falls inside it (12 zeros for a silent segment).
268
+ """
269
+ power, freqs = _stft_power(audio, sample_rate, n_fft)
270
+ band = (freqs >= CHROMA_LOW_HZ) & (freqs <= CHROMA_HIGH_HZ)
271
+ classes = np.mod(np.round(12 * np.log2(freqs[band] / 440.0) + 69).astype(int), 12)
272
+ mags = np.sqrt(power[band])
273
+ frames = np.zeros((12, mags.shape[1]))
274
+ for pc in range(12):
275
+ frames[pc] = mags[classes == pc].sum(axis=0)
276
+ energy = power[band].sum(axis=0)
277
+ loud = energy > (energy.max() * 1e-6 if energy.size and energy.max() > 0 else np.inf)
278
+ normed = frames / np.maximum(frames.max(axis=0, keepdims=True), 1e-12)
279
+
280
+ def average(mask: np.ndarray) -> list[float]:
281
+ if not mask.any():
282
+ return [0.0] * 12
283
+ avg = normed[:, mask].mean(axis=1)
284
+ top = avg.max()
285
+ return [_round(float(v / top), 4) if top > 0 else 0.0 for v in avg]
286
+
287
+ out = {"chroma": average(loud), "frames": int(loud.sum()), "durationSeconds": _round(audio.shape[1] / sample_rate, 3)}
288
+ if segments is not None:
289
+ hop = n_fft // 2
290
+ centres = (np.arange(frames.shape[1]) * hop + n_fft / 2) / sample_rate
291
+ out["segments"] = [average(loud & (centres >= float(a)) & (centres < float(b))) for a, b in segments]
292
+ return out
293
+
294
+
295
+ def band_balance(avg_power: np.ndarray, freqs: np.ndarray) -> dict[str, float | None]:
296
+ """Each band's share of total energy in dB (0 dB = all energy in that band)."""
297
+ total = avg_power[(freqs >= 20) & (freqs < 20000)].sum()
298
+ out: dict[str, float | None] = {}
299
+ for name, lo, hi in BANDS:
300
+ e = avg_power[(freqs >= lo) & (freqs < hi)].sum()
301
+ out[name] = _round(10 * math.log10(e / total), 1) if total > 0 and e > 0 else None
302
+ return out
303
+
304
+
305
+ def _third_octave_db(avg_power: np.ndarray, freqs: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
306
+ centers = 1000.0 * 2.0 ** (np.arange(-17, 14) / 3.0) # ~20 Hz .. ~20 kHz
307
+ levels = []
308
+ for c in centers:
309
+ lo, hi = c / 2 ** (1 / 6), c * 2 ** (1 / 6)
310
+ e = avg_power[(freqs >= lo) & (freqs < hi)].sum()
311
+ levels.append(10 * math.log10(e) if e > 0 else -150.0)
312
+ return centers, np.array(levels)
313
+
314
+
315
+ def resonance_hints(avg_power: np.ndarray, freqs: np.ndarray, limit: int = 5) -> list[dict[str, float]]:
316
+ """Narrow peaks standing above the smoothed long-term spectrum (80 Hz-12 kHz)."""
317
+ db = 10 * np.log10(avg_power + 1e-20)
318
+ # Smooth on a log-frequency scale: ±1/3 octave moving median
319
+ hints = []
320
+ for i in range(1, len(freqs) - 1):
321
+ f = freqs[i]
322
+ if f < 80 or f > 12000:
323
+ continue
324
+ if not (db[i] > db[i - 1] and db[i] >= db[i + 1]):
325
+ continue
326
+ win = (freqs >= f / 2 ** (1 / 3)) & (freqs <= f * 2 ** (1 / 3))
327
+ excess = float(db[i]) - float(np.median(db[win]))
328
+ if excess >= 6.0:
329
+ hints.append({"hz": round(float(f)), "excessDb": round(excess, 1)})
330
+ hints.sort(key=lambda h: h["excessDb"], reverse=True)
331
+ # Keep only peaks at least 1/6 octave apart
332
+ kept: list[dict[str, float]] = []
333
+ for h in hints:
334
+ if all(abs(math.log2(h["hz"] / k["hz"])) > 1 / 6 for k in kept):
335
+ kept.append(h)
336
+ if len(kept) == limit:
337
+ break
338
+ return kept
339
+
340
+
341
+ def sibilance_hint(power: np.ndarray, freqs: np.ndarray, frame_s: float) -> dict[str, Any]:
342
+ """Share of energy in 5-10 kHz over time; spikes suggest de-essing."""
343
+ band = (freqs >= SIBILANCE_BAND[0]) & (freqs < SIBILANCE_BAND[1])
344
+ total = power[(freqs >= 20) & (freqs < 20000)].sum(axis=0)
345
+ loud = total > total.max(initial=0) * 1e-3 # ignore near-silent frames
346
+ if not loud.any():
347
+ return {"medianDb": None, "peakDb": None, "spikeCount": 0, "spikeSeconds": []}
348
+ ratio_db = 10 * np.log10(power[band].sum(axis=0)[loud] / total[loud] + 1e-20)
349
+ times = np.nonzero(loud)[0] * frame_s
350
+ median = float(np.median(ratio_db))
351
+ # One entry per event: frames closer than 0.5 s belong to the same spike
352
+ events: list[float] = []
353
+ for t in times[ratio_db > median + 9.0]:
354
+ if not events or t - events[-1] >= 0.5:
355
+ events.append(float(t))
356
+ return {
357
+ "medianDb": round(median, 1),
358
+ "peakDb": round(float(ratio_db.max()), 1),
359
+ "spikeCount": len(events),
360
+ "spikeSeconds": [round(t, 1) for t in events[:10]],
361
+ }
362
+
363
+
364
+ def spectral_stats(audio: np.ndarray, sample_rate: int) -> dict[str, Any]:
365
+ n_fft = 4096
366
+ power, freqs = _stft_power(audio, sample_rate, n_fft)
367
+ avg = power.mean(axis=1)
368
+ centroid = float((avg * freqs).sum() / avg.sum()) if avg.sum() > 0 else None
369
+ return {
370
+ "bandBalanceDb": band_balance(avg, freqs),
371
+ "spectralCentroidHz": round(centroid) if centroid else None,
372
+ "resonances": resonance_hints(avg, freqs),
373
+ "sibilance": sibilance_hint(power, freqs, (n_fft // 2) / sample_rate),
374
+ }
375
+
376
+
377
+ # ---------------------------------------------------------------------------
378
+ # Reference comparison
379
+ # ---------------------------------------------------------------------------
380
+
381
+ def compare_to_reference(audio: np.ndarray, sample_rate: int, ref: np.ndarray, ref_rate: int) -> dict[str, Any]:
382
+ """Tonal balance and loudness of `audio` relative to `ref` (positive = more than the reference)."""
383
+ a_lufs = integrated_loudness(audio, sample_rate)
384
+ r_lufs = integrated_loudness(ref, ref_rate)
385
+ a_pow, a_f = _stft_power(audio, sample_rate)
386
+ r_pow, r_f = _stft_power(ref, ref_rate)
387
+ a_avg, r_avg = a_pow.mean(axis=1), r_pow.mean(axis=1)
388
+ a_bal, r_bal = band_balance(a_avg, a_f), band_balance(r_avg, r_f)
389
+ band_delta = {
390
+ k: (_round(a_bal[k] - r_bal[k], 1) if a_bal[k] is not None and r_bal[k] is not None else None)
391
+ for k in a_bal
392
+ }
393
+ # Third-octave curve difference with the overall level offset (the median
394
+ # band difference) removed, so one loud band does not shift all the others
395
+ centers, a_curve = _third_octave_db(a_avg, a_f)
396
+ _, r_curve = _third_octave_db(r_avg, r_f)
397
+ valid = (a_curve > -140) & (r_curve > -140)
398
+ diff = a_curve - r_curve
399
+ if valid.any():
400
+ diff = diff - float(np.median(diff[valid]))
401
+ curve = [
402
+ {"hz": round(float(c)), "deltaDb": round(float(d), 1)}
403
+ for c, d, ok in zip(centers, diff, valid)
404
+ if ok
405
+ ]
406
+ biggest = sorted(curve, key=lambda p: abs(p["deltaDb"]), reverse=True)[:3]
407
+ return {
408
+ "loudnessDeltaLu": _round(a_lufs - r_lufs, 1) if math.isfinite(a_lufs) and math.isfinite(r_lufs) else None,
409
+ "referenceIntegratedLufs": _round(r_lufs, 2) if math.isfinite(r_lufs) else None,
410
+ "bandDeltaDb": band_delta,
411
+ "thirdOctaveDeltaDb": curve,
412
+ "largestDifferences": biggest,
413
+ }
414
+
415
+
416
+ def analyze(audio: np.ndarray, sample_rate: int, section_s: float | None = None,
417
+ ref: tuple[np.ndarray, int] | None = None) -> dict[str, Any]:
418
+ result: dict[str, Any] = {"loudness": loudness_stats(audio, sample_rate, section_s),
419
+ "spectrum": spectral_stats(audio, sample_rate)}
420
+ if ref is not None:
421
+ result["reference"] = compare_to_reference(audio, sample_rate, ref[0], ref[1])
422
+ return result
423
+
424
+
425
+ # ---------------------------------------------------------------------------
426
+ # Normalization (for level-matched scoring)
427
+ # ---------------------------------------------------------------------------
428
+
429
+ def normalize_to_lufs(audio: np.ndarray, sample_rate: int, target_lufs: float) -> tuple[np.ndarray, float | None, float]:
430
+ """Scale audio to target integrated loudness. Returns (audio, input LUFS, gain dB).
431
+
432
+ Silence (no block above the absolute gate) is returned unchanged with gain 0.
433
+ """
434
+ measured = integrated_loudness(audio, sample_rate)
435
+ if not math.isfinite(measured):
436
+ return audio, None, 0.0
437
+ gain_db = target_lufs - measured
438
+ return (audio * 10 ** (gain_db / 20)).astype(np.float32), measured, gain_db
439
+
440
+
441
+ # ---------------------------------------------------------------------------
442
+ # Alignment
443
+ # ---------------------------------------------------------------------------
444
+
445
+ def alignment(reference: np.ndarray, target: np.ndarray, sample_rate: int, max_lag_ms: float = 20.0) -> dict[str, Any]:
446
+ """Delay of `target` relative to `reference` via FFT cross-correlation of mono sums.
447
+
448
+ A positive delay means `target` arrives later than `reference` (move it earlier
449
+ by that amount, i.e. a negative playback offset, to align).
450
+ """
451
+ a = reference.mean(axis=0).astype(np.float64)
452
+ b = target.mean(axis=0).astype(np.float64)
453
+ n = min(a.size, b.size)
454
+ a, b = a[:n] - a[:n].mean(), b[:n] - b[:n].mean()
455
+ silent = [name for name, x in (("reference", a), ("track", b)) if n == 0 or not np.any(x)]
456
+ if silent:
457
+ return {"delaySamples": None, "delayMs": None, "correlation": None, "polarityInverted": None,
458
+ "error": f"The {' and '.join(silent)} {'is' if len(silent) == 1 else 'are'} silent in this window; "
459
+ "pick a range where both tracks play"}
460
+ norm = math.sqrt(float((a * a).sum() * (b * b).sum()))
461
+ size = 1 << (2 * n - 1).bit_length()
462
+ xc = np.fft.irfft(np.fft.rfft(b, size) * np.conj(np.fft.rfft(a, size)), size)
463
+ max_lag = max(1, int(sample_rate * max_lag_ms / 1000.0))
464
+ lags = np.concatenate([np.arange(0, max_lag + 1), np.arange(-max_lag, 0)])
465
+ values = np.concatenate([xc[:max_lag + 1], xc[-max_lag:]])
466
+ best = int(np.argmax(np.abs(values)))
467
+ lag = int(lags[best])
468
+ corr = float(values[best] / norm)
469
+ return {
470
+ "delaySamples": lag,
471
+ "delayMs": round(lag * 1000.0 / sample_rate, 3),
472
+ "correlation": round(abs(corr), 3),
473
+ "polarityInverted": corr < 0,
474
+ "sampleRate": sample_rate,
475
+ "maxLagMs": max_lag_ms,
476
+ }
477
+
478
+
479
+ # ---------------------------------------------------------------------------
480
+ # Transients (onsets)
481
+ # ---------------------------------------------------------------------------
482
+
483
+ # Bands transients are detected in (Hz). low ~ kick/bass, mid ~ snare/vocal/keys
484
+ # body, high ~ hats/cymbals/clicks. "full" is the whole spectrum.
485
+ TRANSIENT_BANDS: dict[str, tuple[float, float]] = {
486
+ "low": (20.0, 150.0),
487
+ "mid": (150.0, 4000.0),
488
+ "high": (4000.0, 16000.0),
489
+ "full": (20.0, 16000.0),
490
+ }
491
+ ONSET_FFT = 1024
492
+ ONSET_HOP = 256
493
+ # Frames back the rise is measured against (~12 ms): catches softer attacks
494
+ ONSET_LAG = 2
495
+ # Frames per STFT chunk: bounds memory on long renders (15 min at 44.1 kHz)
496
+ ONSET_CHUNK_FRAMES = 8192
497
+
498
+
499
+ def _band_flux(mono: np.ndarray, sample_rate: int) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray], float]:
500
+ """Onset strength per band, one value per hop.
501
+
502
+ Returns (envelopes, rises, frame seconds): envelopes drive detection, rises
503
+ (mean log-magnitude rise per bin, unscaled) say which band a hit is in.
504
+
505
+ Frames are centred (the signal is padded by half a window), so frame t sits at
506
+ t * hop / sample_rate. mid/high are log-magnitude spectral flux (the rise
507
+ against ONSET_LAG frames back, summed over the band's bins); low, which
508
+ spans only a few bins, is the rise of the band's log energy, which beating
509
+ bass notes disturb less. Each band is scaled to its own 99th percentile and
510
+ "full" is their mean, so a kick counts as much as a broadband hat.
511
+ """
512
+ pad = ONSET_FFT // 2
513
+ x = np.pad(mono.astype(np.float32), (pad, pad))
514
+ n_frames = max(1, 1 + (x.size - ONSET_FFT) // ONSET_HOP)
515
+ window = np.hanning(ONSET_FFT).astype(np.float32)
516
+ freqs = np.fft.rfftfreq(ONSET_FFT, 1.0 / sample_rate)
517
+ bands = [name for name in TRANSIENT_BANDS if name != "full"]
518
+ masks = {name: (freqs >= TRANSIENT_BANDS[name][0]) & (freqs < TRANSIENT_BANDS[name][1]) for name in bands}
519
+ flux = {name: np.zeros(n_frames, dtype=np.float32) for name in bands}
520
+ rises = {name: np.zeros(n_frames, dtype=np.float32) for name in bands}
521
+ tail: np.ndarray | None = None # last ONSET_LAG frames of the previous chunk
522
+ for start in range(0, n_frames, ONSET_CHUNK_FRAMES):
523
+ stop = min(n_frames, start + ONSET_CHUNK_FRAMES)
524
+ idx = (np.arange(start, stop)[:, None] * ONSET_HOP) + np.arange(ONSET_FFT)[None, :]
525
+ mag = np.abs(np.fft.rfft(x[idx] * window, axis=1)).astype(np.float32)
526
+ low_energy = np.log1p(100.0 * (mag[:, masks["low"]] ** 2).sum(axis=1, keepdims=True))
527
+ feats = np.hstack([np.log1p(100.0 * mag), low_energy])
528
+ head = np.repeat(feats[:1], ONSET_LAG, axis=0) if tail is None else tail
529
+ before = np.vstack([head, feats])[: feats.shape[0]]
530
+ rise = np.maximum(feats - before, 0.0)
531
+ flux["low"][start:stop] = rise[:, -1]
532
+ for name in bands:
533
+ if name != "low":
534
+ flux[name][start:stop] = rise[:, :-1][:, masks[name]].sum(axis=1)
535
+ rises[name][start:stop] = rise[:, :-1][:, masks[name]].mean(axis=1)
536
+ tail = feats[-ONSET_LAG:]
537
+ for name in bands:
538
+ scale = float(np.percentile(flux[name], 99))
539
+ if scale > 0:
540
+ flux[name] /= scale
541
+ flux["full"] = np.mean([flux[name] for name in bands], axis=0).astype(np.float32)
542
+ return flux, rises, ONSET_HOP / sample_rate
543
+
544
+
545
+ def _pick_peaks(env: np.ndarray, frame_s: float, sensitivity: float, min_gap_s: float) -> np.ndarray:
546
+ """Frames that are local maxima above an adaptive threshold (moving median + delta)."""
547
+ if env.size < 3 or not np.any(env > 0):
548
+ return np.zeros(0, dtype=int)
549
+ # Moving median over ~0.4 s around each frame tracks the local busyness
550
+ half = max(1, int(0.2 / frame_s))
551
+ padded = np.pad(env, (half, half), mode="edge")
552
+ windows = np.lib.stride_tricks.sliding_window_view(padded, 2 * half + 1)
553
+ local = np.median(windows, axis=1)
554
+ scale = float(np.percentile(env, 99)) or float(env.max())
555
+ # sensitivity 0..1: 0 keeps only the biggest hits, 1 nearly every bump
556
+ delta = scale * (0.6 - 0.55 * sensitivity)
557
+ floor = scale * (0.2 - 0.18 * sensitivity)
558
+ above = (env > local + delta) & (env > floor)
559
+ # Local maximum within +-~30 ms
560
+ w = max(1, int(0.03 / frame_s))
561
+ padded_max = np.pad(env, (w, w), mode="constant")
562
+ local_max = np.lib.stride_tricks.sliding_window_view(padded_max, 2 * w + 1).max(axis=1)
563
+ candidates = np.flatnonzero(above & (env >= local_max))
564
+ # Enforce the minimum gap, keeping the stronger of two close peaks
565
+ gap = max(1, int(round(min_gap_s / frame_s)))
566
+ kept: list[int] = []
567
+ for c in candidates:
568
+ if kept and c - kept[-1] < gap:
569
+ if env[c] > env[kept[-1]]:
570
+ kept[-1] = int(c)
571
+ continue
572
+ kept.append(int(c))
573
+ return np.asarray(kept, dtype=int)
574
+
575
+
576
+ def _refine_onset(mono: np.ndarray, sample_rate: int, centre: int) -> int:
577
+ """Sample where the amplitude rises fastest within a window around a flux peak."""
578
+ lo = max(0, centre - ONSET_FFT // 2)
579
+ hi = min(mono.size, centre + ONSET_FFT // 4)
580
+ seg = np.abs(mono[lo:hi])
581
+ if seg.size < 16:
582
+ return centre
583
+ # 1 ms smoothed envelope, then its steepest rise
584
+ k = max(1, sample_rate // 1000)
585
+ env = np.convolve(seg, np.ones(k, dtype=np.float32) / k, mode="same")
586
+ rise = np.diff(env)
587
+ return lo + int(np.argmax(rise)) if rise.size else centre
588
+
589
+
590
+ def estimate_tempo(env: np.ndarray, frame_s: float) -> dict[str, float | None]:
591
+ """BPM from the autocorrelation of the onset envelope (60-200 BPM)."""
592
+ x = env.astype(np.float64) - env.mean()
593
+ if x.size < int(4.0 / frame_s) or not np.any(x):
594
+ return {"bpm": None, "confidence": None}
595
+ size = 1 << (2 * x.size - 1).bit_length()
596
+ spec = np.fft.rfft(x, size)
597
+ ac = np.fft.irfft(spec * np.conj(spec), size)[: x.size]
598
+ ac /= ac[0]
599
+ lo, hi = int(60.0 / 200.0 / frame_s), int(60.0 / 60.0 / frame_s)
600
+ if hi >= ac.size:
601
+ return {"bpm": None, "confidence": None}
602
+ # Weight towards ~120 BPM so a half/double tempo does not win on noise
603
+ lags = np.arange(lo, hi + 1)
604
+ bpms = 60.0 / (lags * frame_s)
605
+ weight = np.exp(-0.5 * (np.log2(bpms / 120.0) / 1.0) ** 2)
606
+ best = int(np.argmax(ac[lo:hi + 1] * weight))
607
+ lag = lo + best
608
+ # Parabolic interpolation for a sub-frame lag
609
+ if lo < lag < hi:
610
+ a, b, c = ac[lag - 1], ac[lag], ac[lag + 1]
611
+ denom = a - 2 * b + c
612
+ lag_f = lag + (0.5 * (a - c) / denom if denom else 0.0)
613
+ else:
614
+ lag_f = float(lag)
615
+ return {"bpm": _round(60.0 / (lag_f * frame_s), 1), "confidence": _round(max(0.0, float(ac[lag])), 2)}
616
+
617
+
618
+ def transients(audio: np.ndarray, sample_rate: int, band: str = "full", sensitivity: float = 0.5,
619
+ min_gap_ms: float = 50.0) -> dict[str, Any]:
620
+ """Transient (onset) times with a 0..1 strength and the band that dominates each.
621
+
622
+ band: "full" (default), "low", "mid", "high" — the band the onsets are picked in.
623
+ sensitivity: 0..1, higher finds softer hits. min_gap_ms: closest two onsets may be.
624
+ Times are seconds from the start of `audio`.
625
+ """
626
+ if band not in TRANSIENT_BANDS:
627
+ raise ValueError(f"band must be one of {', '.join(TRANSIENT_BANDS)}")
628
+ sensitivity = min(1.0, max(0.0, float(sensitivity)))
629
+ mono = audio.mean(axis=0).astype(np.float32)
630
+ duration = mono.size / sample_rate
631
+ flux, rises, frame_s = _band_flux(mono, sample_rate)
632
+ env = flux[band]
633
+ peaks = _pick_peaks(env, frame_s, sensitivity, max(0.005, min_gap_ms / 1000.0))
634
+ # Strength: flux relative to the loudest hits of this band (99th pct of peaks)
635
+ ref = float(np.percentile(env[peaks], 95)) if peaks.size else 1.0
636
+ peak_abs = float(np.abs(mono).max()) or 1.0
637
+ items: list[dict[str, Any]] = []
638
+ for p in peaks:
639
+ onset = _refine_onset(mono, sample_rate, int(p * ONSET_HOP))
640
+ seg = mono[onset: onset + int(0.05 * sample_rate)]
641
+ level = float(np.abs(seg).max()) if seg.size else 0.0
642
+ # Dominant band: the largest rise per bin within +-1 frame of the peak
643
+ near = slice(max(0, p - 1), p + 2)
644
+ shares = {name: float(r[near].max()) for name, r in rises.items()}
645
+ items.append({
646
+ "time": round(onset / sample_rate, 4),
647
+ "strength": round(min(1.0, float(env[p]) / ref), 3) if ref > 0 else 0.0,
648
+ "band": max(shares, key=shares.get),
649
+ "peakDb": _round(_db(level / peak_abs), 1),
650
+ })
651
+ counts = {name: sum(1 for t in items if t["band"] == name) for name in rises}
652
+ return {
653
+ "durationSeconds": round(duration, 3),
654
+ "band": band,
655
+ "sensitivity": sensitivity,
656
+ "minGapMs": min_gap_ms,
657
+ "count": len(items),
658
+ "bandCounts": counts,
659
+ "tempo": estimate_tempo(flux["full"], frame_s),
660
+ "transients": items,
661
+ }