@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.
- package/README.md +19 -570
- package/bridge-ui/mcp_bridge.html +129 -0
- package/bridge-ui/mcp_bridge.webmanifest +16 -0
- package/bridge-ui/mcp_bridge_icon_192.png +0 -0
- package/bridge-ui/mcp_bridge_icon_512.png +0 -0
- package/bridge-ui/mcp_bridge_sw.js +35 -0
- package/claude/agents/plugin-researcher.md +188 -0
- package/claude/knowledge/reference/aesthetics-scoring.md +48 -0
- package/{knowledge → claude/knowledge}/reference/common-mistakes.md +1 -1
- package/claude/knowledge/reference/lorekit-setup.md +36 -0
- package/claude/knowledge/reference/memory-protocol.md +377 -0
- package/{knowledge → claude/knowledge}/reference/metering.md +5 -5
- package/claude/knowledge/reference/perceived-loudness.md +182 -0
- package/{knowledge → claude/knowledge}/workflows/editing.md +4 -0
- package/{knowledge → claude/knowledge}/workflows/gain-staging.md +21 -23
- package/{knowledge → claude/knowledge}/workflows/learn-plugin.md +15 -48
- package/{knowledge → claude/knowledge}/workflows/master-bus.md +13 -1
- package/claude/knowledge/workflows/section-levels.md +138 -0
- package/{knowledge → claude/knowledge}/workflows/session-prep.md +4 -0
- package/claude/knowledge/workflows/transient-lighting.md +52 -0
- package/claude/skills/critique/SKILL.md +131 -0
- package/claude/skills/editing/SKILL.md +281 -0
- package/claude/skills/learn-plugin/SKILL.md +210 -0
- package/claude/skills/mastering/SKILL.md +151 -0
- package/claude/skills/mix-memory/SKILL.md +140 -0
- package/claude/skills/mixer/SKILL.md +253 -0
- package/claude/skills/new-extension/SKILL.md +324 -0
- package/main.js +7843 -1940
- package/package.json +17 -15
- package/reaper/CLAUDE.md +22 -4
- package/reaper/mcp_actions_lib.lua +375 -0
- package/reaper/mcp_analyzer.jsfx +27 -12
- package/reaper/mcp_arrange_lib.lua +949 -0
- package/reaper/mcp_bridge.lua +2321 -758
- package/reaper/mcp_edit_views_lib.lua +1378 -0
- package/reaper/mcp_envelopes_lib.lua +748 -0
- package/reaper/mcp_extensions_lib.lua +55 -0
- package/reaper/mcp_fxstate_lib.lua +144 -0
- package/reaper/mcp_gain_trace.jsfx +126 -0
- package/reaper/mcp_items_lib.lua +877 -0
- package/reaper/mcp_lufs_meter.jsfx +16 -13
- package/reaper/mcp_midi_lib.lua +555 -0
- package/reaper/mcp_mixing_lib.lua +1021 -0
- package/reaper/mcp_plan_lib.lua +801 -0
- package/reaper/mcp_prefs_lib.lua +127 -0
- package/reaper/mcp_project_lib.lua +1088 -0
- package/reaper/mcp_recording_lib.lua +595 -0
- package/reaper/mcp_render_lib.lua +492 -0
- package/reaper/mcp_snapshot_lib.lua +547 -120
- package/reaper/mcp_snapshot_next.lua +10 -4
- package/reaper/mcp_snapshot_prev.lua +11 -4
- package/reaper/mcp_snapshot_quick_save.lua +7 -32
- package/reaper/mcp_snapshots_ui_lib.lua +258 -0
- package/reaper/mcp_tracks_lib.lua +409 -0
- package/sidecar/analysis.py +661 -0
- package/sidecar/chords.py +96 -0
- package/sidecar/mixcheck.py +295 -0
- package/sidecar/requirements-chords.txt +6 -0
- package/sidecar/requirements-separation.txt +3 -0
- package/sidecar/requirements.txt +17 -0
- package/sidecar/separation.py +142 -0
- package/sidecar/server.py +622 -0
- package/sidecar/tests/test_analysis.py +249 -0
- package/sidecar/tests/test_chords.py +117 -0
- package/sidecar/tests/test_mixcheck.py +163 -0
- package/sidecar/tests/test_separation.py +73 -0
- package/sidecar/tests/test_server.py +464 -0
- package/sidecar/tests/test_verify.py +243 -0
- package/sidecar/tests/test_voice_render.py +268 -0
- package/sidecar/tests/test_voice_sketch.py +140 -0
- package/sidecar/verify.py +224 -0
- package/sidecar/voice_render.py +295 -0
- package/sidecar/voice_sketch.py +280 -0
- package/claude-agents/critique.md +0 -147
- package/claude-agents/editor.md +0 -184
- package/claude-agents/levels.md +0 -107
- package/claude-agents/mastering.md +0 -129
- package/claude-agents/mixer.md +0 -248
- package/claude-agents/preflight.md +0 -149
- package/claude-agents/producer.md +0 -159
- package/claude-agents/setup.md +0 -150
- package/claude-agents/stems.md +0 -135
- package/claude-rules/architecture.md +0 -39
- package/claude-rules/development.md +0 -54
- package/claude-rules/lua-bridge.md +0 -50
- package/claude-rules/testing.md +0 -42
- package/claude-skills/learn-plugin.md +0 -123
- package/knowledge/CLAUDE.md +0 -71
- package/knowledge/plugins/_template.md +0 -82
- package/knowledge/plugins/fabfilter/pro-c-2.md +0 -117
- package/knowledge/plugins/fabfilter/pro-l-2.md +0 -95
- package/knowledge/plugins/fabfilter/pro-q-3.md +0 -112
- package/knowledge/plugins/neural-dsp/helix-native.md +0 -104
- package/knowledge/plugins/stock-reaper/js-1175-compressor.md +0 -94
- package/knowledge/plugins/stock-reaper/rea-comp.md +0 -100
- package/knowledge/plugins/stock-reaper/rea-delay.md +0 -95
- package/knowledge/plugins/stock-reaper/rea-eq.md +0 -103
- package/knowledge/plugins/stock-reaper/rea-gate.md +0 -99
- package/knowledge/plugins/stock-reaper/rea-limit.md +0 -75
- package/knowledge/plugins/stock-reaper/rea-verb.md +0 -76
- package/knowledge/reference/perceived-loudness.md +0 -122
- package/reaper/mcp_snapshot_manager.lua +0 -1001
- /package/{knowledge → claude/knowledge}/genres/_template.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/electronic.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/hip-hop.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/metal.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/orchestral.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/pop.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/rock.md +0 -0
- /package/{knowledge → claude/knowledge}/reference/compression.md +0 -0
- /package/{knowledge → claude/knowledge}/reference/frequencies.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/delivery.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/drum-bus.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/low-end.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/stem-prep.md +0 -0
- /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
|
+
}
|