@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,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chord recognition for the reaper-mcp sidecar, with lv-chordia (MIT): a large-vocabulary
|
|
3
|
+
recognizer that returns chord segments with Harte labels ("C:maj", "A:min7", "C:maj/3",
|
|
4
|
+
"N" for no chord, "X" for unknown).
|
|
5
|
+
|
|
6
|
+
Opt-in: installed by `reaper-mcp setup-sidecar --with-chords`. The labels are passed on
|
|
7
|
+
as they are; the TypeScript tool maps them onto the bar/beat grid and its chord names.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import analysis
|
|
18
|
+
|
|
19
|
+
VOCABULARIES = ("submission", "ismir2017", "full")
|
|
20
|
+
NOT_INSTALLED = ("The chord model is not installed. Run: reaper-mcp setup-sidecar --with-chords "
|
|
21
|
+
"(installs lv-chordia, ~135 MB)")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_error() -> str | None:
|
|
25
|
+
"""None when the recognizer imports; else why not ("" when lv-chordia is not installed at all)."""
|
|
26
|
+
try:
|
|
27
|
+
import lv_chordia.chord_recognition # type: ignore # noqa: F401
|
|
28
|
+
except ModuleNotFoundError as exc:
|
|
29
|
+
return "" if exc.name == "lv_chordia" else str(exc)
|
|
30
|
+
except Exception as exc: # noqa: BLE001 — any failure while loading means the model is unusable
|
|
31
|
+
return str(exc) or type(exc).__name__
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def status() -> dict[str, Any]:
|
|
36
|
+
"""Whether the model loads, and what to do when it does not. A package that is there but fails
|
|
37
|
+
to import (a dependency missing) counts as not installed, so `auto` falls back to the templates."""
|
|
38
|
+
if is_installed():
|
|
39
|
+
return {"installed": True, "message": ""}
|
|
40
|
+
error = _load_error()
|
|
41
|
+
if not error:
|
|
42
|
+
return {"installed": False, "message": NOT_INSTALLED}
|
|
43
|
+
return {"installed": False, "message": f"The chord model is installed but does not load ({error}). "
|
|
44
|
+
"Reinstall it: reaper-mcp setup-sidecar --with-chords"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_installed() -> bool:
|
|
48
|
+
return _load_error() is None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _recognize(path: str, vocabulary: str) -> list[dict[str, Any]]:
|
|
52
|
+
from lv_chordia.chord_recognition import chord_recognition # type: ignore
|
|
53
|
+
|
|
54
|
+
return list(chord_recognition(audio_path=path, chord_dict_name=vocabulary) or [])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def recognize(path: str, start: float = 0.0, end: float | None = None,
|
|
58
|
+
vocabulary: str = "submission") -> dict[str, Any]:
|
|
59
|
+
"""
|
|
60
|
+
Chord segments of [start, end) of `path`, in seconds of the file:
|
|
61
|
+
{segments: [{start, end, label}], durationSeconds, vocabulary, seconds}.
|
|
62
|
+
A range is cut to a temporary WAV first (the model reads whole files).
|
|
63
|
+
"""
|
|
64
|
+
if vocabulary not in VOCABULARIES:
|
|
65
|
+
raise ValueError(f"Unknown vocabulary {vocabulary}. Use {', '.join(VOCABULARIES)}")
|
|
66
|
+
if not is_installed():
|
|
67
|
+
raise RuntimeError(status()["message"])
|
|
68
|
+
t0 = time.time()
|
|
69
|
+
source, offset, temp = path, 0.0, None
|
|
70
|
+
if start > 0 or end is not None:
|
|
71
|
+
import soundfile as sf # type: ignore
|
|
72
|
+
|
|
73
|
+
audio, sr = analysis.load_audio(path, start, end)
|
|
74
|
+
if audio.shape[1] == 0:
|
|
75
|
+
raise ValueError("The range holds no audio")
|
|
76
|
+
fd, temp = tempfile.mkstemp(suffix=".wav", prefix="reaper-mcp-chords-")
|
|
77
|
+
os.close(fd)
|
|
78
|
+
sf.write(temp, audio.T, sr, subtype="FLOAT")
|
|
79
|
+
source, offset = temp, start
|
|
80
|
+
try:
|
|
81
|
+
raw = _recognize(source, vocabulary)
|
|
82
|
+
finally:
|
|
83
|
+
if temp:
|
|
84
|
+
try:
|
|
85
|
+
os.remove(temp)
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
segments = []
|
|
89
|
+
for seg in raw:
|
|
90
|
+
a, b = float(seg["start_time"]), float(seg["end_time"])
|
|
91
|
+
if b <= a:
|
|
92
|
+
continue
|
|
93
|
+
segments.append({"start": round(a + offset, 3), "end": round(b + offset, 3), "label": str(seg["chord"])})
|
|
94
|
+
duration = (segments[-1]["end"] - offset) if segments else 0.0
|
|
95
|
+
return {"segments": segments, "durationSeconds": round(duration, 3), "vocabulary": vocabulary,
|
|
96
|
+
"seconds": round(time.time() - t0, 2)}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mix checks for the reaper-mcp sidecar: loudness over time, stereo correlation
|
|
3
|
+
per band, and frequency masking between tracks.
|
|
4
|
+
|
|
5
|
+
Same conventions as analysis.py: float32 numpy arrays shaped (channels, frames),
|
|
6
|
+
numpy and torch only, results bounded in size whatever the input length.
|
|
7
|
+
Levels used for balance decisions are perceived (K-weighted, LU / kDb), per
|
|
8
|
+
knowledge/reference/perceived-loudness.md.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
import analysis
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Loudness curve
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
# A curve never returns more points than this, whatever the length.
|
|
25
|
+
CURVE_POINTS = 240
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _lufs_series(powers: np.ndarray) -> np.ndarray:
|
|
29
|
+
with np.errstate(divide="ignore"):
|
|
30
|
+
return np.where(powers > 0, -0.691 + 10.0 * np.log10(np.maximum(powers, 1e-30)), -math.inf)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _bucket(values: np.ndarray, points: int) -> np.ndarray:
|
|
34
|
+
"""Downsample to at most `points` values, keeping each bucket's maximum (a peak is never lost)."""
|
|
35
|
+
if values.size <= points:
|
|
36
|
+
return values
|
|
37
|
+
edges = np.linspace(0, values.size, points + 1).astype(int)
|
|
38
|
+
return np.array([values[a:b].max() for a, b in zip(edges[:-1], edges[1:])])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _floor(values: np.ndarray) -> list[float]:
|
|
42
|
+
return [round(float(max(v, analysis.SILENCE_LUFS)), 1) for v in values]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def loudness_curve(audio: np.ndarray, sample_rate: int, target_lufs: float | None = None,
|
|
46
|
+
tolerance_lu: float = 1.0, points: int = CURVE_POINTS) -> dict[str, Any]:
|
|
47
|
+
"""
|
|
48
|
+
Short-term (3 s) and momentary (400 ms) loudness over time, bucketed to at most
|
|
49
|
+
`points` values each (bucket maximum), with the loudest and quietest short-term
|
|
50
|
+
windows and, with a target, the spans whose short-term loudness exceeds it.
|
|
51
|
+
"""
|
|
52
|
+
weighted = analysis.k_weight(audio, sample_rate)
|
|
53
|
+
integrated = analysis.integrated_loudness(audio, sample_rate, weighted)
|
|
54
|
+
duration = audio.shape[1] / sample_rate
|
|
55
|
+
st_hop = 0.5
|
|
56
|
+
st = _lufs_series(analysis._block_powers(weighted, sample_rate, 3.0, st_hop))
|
|
57
|
+
mom = _lufs_series(analysis._block_powers(weighted, sample_rate, 0.4, 0.1))
|
|
58
|
+
tp = analysis.true_peak(audio, sample_rate)
|
|
59
|
+
|
|
60
|
+
result: dict[str, Any] = {
|
|
61
|
+
"durationSeconds": round(duration, 2),
|
|
62
|
+
"integratedLufs": analysis._round(integrated if math.isfinite(integrated) else analysis.SILENCE_LUFS, 2),
|
|
63
|
+
"truePeakDbtp": analysis._round(analysis._db(tp), 2),
|
|
64
|
+
"loudnessRangeLu": analysis._round(analysis.loudness_range(audio, sample_rate, weighted), 1),
|
|
65
|
+
}
|
|
66
|
+
if math.isfinite(integrated):
|
|
67
|
+
result["plrDb"] = analysis._round(analysis._db(tp) - integrated, 1)
|
|
68
|
+
|
|
69
|
+
if mom.size:
|
|
70
|
+
m = _bucket(mom, points)
|
|
71
|
+
result["momentary"] = {"step": round(duration / m.size, 3), "lufs": _floor(m)}
|
|
72
|
+
result["maxMomentaryLufs"] = analysis._round(float(mom.max()) if np.isfinite(mom.max()) else None, 1)
|
|
73
|
+
if st.size:
|
|
74
|
+
s = _bucket(st, points)
|
|
75
|
+
result["shortTerm"] = {"step": round(duration / s.size, 3), "lufs": _floor(s)}
|
|
76
|
+
audible = np.where(np.isfinite(st) & (st > analysis.ABSOLUTE_GATE_LUFS))[0]
|
|
77
|
+
if audible.size:
|
|
78
|
+
loud, quiet = audible[np.argmax(st[audible])], audible[np.argmin(st[audible])]
|
|
79
|
+
result["loudest"] = {"start": round(loud * st_hop, 1), "end": round(loud * st_hop + 3, 1),
|
|
80
|
+
"lufs": round(float(st[loud]), 1)}
|
|
81
|
+
result["quietest"] = {"start": round(quiet * st_hop, 1), "end": round(quiet * st_hop + 3, 1),
|
|
82
|
+
"lufs": round(float(st[quiet]), 1)}
|
|
83
|
+
else:
|
|
84
|
+
result["warning"] = "Shorter than one 3 s short-term window: only momentary loudness is reported"
|
|
85
|
+
|
|
86
|
+
if target_lufs is not None and st.size:
|
|
87
|
+
result["targetLufs"] = target_lufs
|
|
88
|
+
over = st > target_lufs + tolerance_lu
|
|
89
|
+
spans: list[dict[str, Any]] = []
|
|
90
|
+
i = 0
|
|
91
|
+
while i < over.size and len(spans) < 20:
|
|
92
|
+
if over[i]:
|
|
93
|
+
j = i
|
|
94
|
+
while j + 1 < over.size and over[j + 1]:
|
|
95
|
+
j += 1
|
|
96
|
+
spans.append({"start": round(i * st_hop, 1), "end": round(j * st_hop + 3, 1),
|
|
97
|
+
"maxLufs": round(float(st[i:j + 1].max()), 1)})
|
|
98
|
+
i = j + 1
|
|
99
|
+
else:
|
|
100
|
+
i += 1
|
|
101
|
+
result["overTarget"] = spans
|
|
102
|
+
result["overTargetSeconds"] = round(float(over.sum()) * st_hop, 1)
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Stereo correlation per band
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
STEREO_BANDS: list[tuple[str, float, float]] = [
|
|
111
|
+
("low", 20, 120),
|
|
112
|
+
("lowMid", 120, 500),
|
|
113
|
+
("mid", 500, 2000),
|
|
114
|
+
("high", 2000, 8000),
|
|
115
|
+
("air", 8000, 20000),
|
|
116
|
+
]
|
|
117
|
+
BAND_SERIES_POINTS = 60
|
|
118
|
+
STEREO_FFT = 4096
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _stereo_spectra(audio: np.ndarray, n_fft: int = STEREO_FFT) -> tuple[np.ndarray, np.ndarray]:
|
|
122
|
+
import torch # type: ignore
|
|
123
|
+
|
|
124
|
+
x = torch.from_numpy(np.ascontiguousarray(audio[:2], dtype=np.float32))
|
|
125
|
+
if x.shape[1] < n_fft:
|
|
126
|
+
x = torch.nn.functional.pad(x, (0, n_fft - x.shape[1]))
|
|
127
|
+
spec = torch.stft(x, n_fft=n_fft, hop_length=n_fft // 2, window=torch.hann_window(n_fft),
|
|
128
|
+
return_complex=True, center=False).numpy()
|
|
129
|
+
return spec[0], spec[1]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def band_correlation(audio: np.ndarray, sample_rate: int) -> dict[str, Any]:
|
|
133
|
+
"""
|
|
134
|
+
Per band: energy-weighted correlation (+1 mono, 0 wide, <0 out of phase), the worst
|
|
135
|
+
~1 s window, the share of time below 0, side-to-mid level, and a bucketed series.
|
|
136
|
+
Low end below 0 is what collapses in mono; a flag says so.
|
|
137
|
+
"""
|
|
138
|
+
if audio.shape[0] < 2:
|
|
139
|
+
raise ValueError("Needs a stereo source: this one has 1 channel")
|
|
140
|
+
left, right = _stereo_spectra(audio)
|
|
141
|
+
freqs = np.fft.rfftfreq(STEREO_FFT, 1.0 / sample_rate)
|
|
142
|
+
frame_s = (STEREO_FFT // 2) / sample_rate
|
|
143
|
+
# ~1 s windows of frames for the worst-window and time-share figures
|
|
144
|
+
per_window = max(1, int(round(1.0 / frame_s)))
|
|
145
|
+
cross = (left * np.conj(right)).real
|
|
146
|
+
pl, pr = np.abs(left) ** 2, np.abs(right) ** 2
|
|
147
|
+
mid_p, side_p = np.abs(left + right) ** 2 / 4, np.abs(left - right) ** 2 / 4
|
|
148
|
+
|
|
149
|
+
bands: list[dict[str, Any]] = []
|
|
150
|
+
flags: list[str] = []
|
|
151
|
+
for name, lo, hi in STEREO_BANDS:
|
|
152
|
+
sel = (freqs >= lo) & (freqs < min(hi, sample_rate / 2))
|
|
153
|
+
if not sel.any():
|
|
154
|
+
continue
|
|
155
|
+
c, a, b = cross[sel].sum(axis=0), pl[sel].sum(axis=0), pr[sel].sum(axis=0)
|
|
156
|
+
energy = a + b
|
|
157
|
+
total = energy.sum()
|
|
158
|
+
if total <= 1e-12:
|
|
159
|
+
bands.append({"band": name, "fromHz": lo, "toHz": hi, "silent": True})
|
|
160
|
+
continue
|
|
161
|
+
overall = float(c.sum() / math.sqrt(a.sum() * b.sum())) if a.sum() > 0 and b.sum() > 0 else 0.0
|
|
162
|
+
n = c.size // per_window
|
|
163
|
+
windows: list[float] = []
|
|
164
|
+
weights: list[float] = []
|
|
165
|
+
for k in range(max(n, 1)):
|
|
166
|
+
sl = slice(k * per_window, (k + 1) * per_window if n else c.size)
|
|
167
|
+
wa, wb = a[sl].sum(), b[sl].sum()
|
|
168
|
+
if wa > 0 and wb > 0:
|
|
169
|
+
windows.append(float(c[sl].sum() / math.sqrt(wa * wb)))
|
|
170
|
+
weights.append(float(energy[sl].sum()))
|
|
171
|
+
w = np.array(windows)
|
|
172
|
+
loud = np.array(weights) > total / max(len(weights), 1) * 0.01 # ignore near-silent windows
|
|
173
|
+
audible = w[loud] if loud.any() else w
|
|
174
|
+
side, mid = side_p[sel].sum(), mid_p[sel].sum()
|
|
175
|
+
row: dict[str, Any] = {
|
|
176
|
+
"band": name, "fromHz": lo, "toHz": hi,
|
|
177
|
+
"correlation": round(overall, 3),
|
|
178
|
+
"worst": round(float(audible.min()), 3) if audible.size else None,
|
|
179
|
+
"negativeShare": round(float((audible < 0).mean()), 3) if audible.size else 0.0,
|
|
180
|
+
"sideToMidDb": analysis._round(10 * math.log10(side / mid), 1) if side > 0 and mid > 0 else None,
|
|
181
|
+
"series": [round(float(v), 2) for v in _bucket_mean(w, BAND_SERIES_POINTS)],
|
|
182
|
+
}
|
|
183
|
+
bands.append(row)
|
|
184
|
+
if name == "low" and (overall < 0.5 or row["negativeShare"] > 0.1):
|
|
185
|
+
flags.append("low end is wide or out of phase: it loses level in mono — narrow it below ~120 Hz")
|
|
186
|
+
elif overall < 0:
|
|
187
|
+
words = {"lowMid": "the low mids", "mid": "the mids", "high": "the highs", "air": "the air band"}.get(name, name)
|
|
188
|
+
flags.append(f"{words} ({lo:g}–{hi:g} Hz) out of phase overall (correlation {overall:.2f}): it cancels in mono")
|
|
189
|
+
return {"bands": bands, "windowSeconds": round(per_window * frame_s, 2), "flags": flags,
|
|
190
|
+
"durationSeconds": round(audio.shape[1] / sample_rate, 2)}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _bucket_mean(values: np.ndarray, points: int) -> np.ndarray:
|
|
194
|
+
if values.size <= points:
|
|
195
|
+
return values
|
|
196
|
+
edges = np.linspace(0, values.size, points + 1).astype(int)
|
|
197
|
+
return np.array([values[a:b].mean() for a, b in zip(edges[:-1], edges[1:])])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# Masking between tracks
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
# Third-octave centres from 40 Hz to 16 kHz, and the nominal labels engineers use (IEC 61260)
|
|
205
|
+
MASK_CENTERS = 1000.0 * 2.0 ** (np.arange(-14, 13) / 3.0)
|
|
206
|
+
MASK_LABELS = [40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600,
|
|
207
|
+
2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000]
|
|
208
|
+
MASK_FFT = 4096
|
|
209
|
+
# Two tracks mask each other in a band when both are active and within this many dB
|
|
210
|
+
MASK_CLOSE_DB = 6.0
|
|
211
|
+
# A band is active for a track in a frame when within this of the track's own loudest band-frame
|
|
212
|
+
MASK_ACTIVE_DB = 30.0
|
|
213
|
+
MAX_CONFLICTS = 10
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _k_gain_db(freqs: np.ndarray, sample_rate: int) -> np.ndarray:
|
|
217
|
+
"""K-weighting magnitude response at `freqs` (dB), from analysis's own coefficients."""
|
|
218
|
+
z = np.exp(-2j * np.pi * freqs / sample_rate)
|
|
219
|
+
h = np.ones_like(z)
|
|
220
|
+
for b, a in analysis._k_weighting_coeffs(sample_rate):
|
|
221
|
+
h *= (b[0] + b[1] * z + b[2] * z * z) / (a[0] + a[1] * z + a[2] * z * z)
|
|
222
|
+
return 20 * np.log10(np.maximum(np.abs(h), 1e-12))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _band_levels(audio: np.ndarray, sample_rate: int) -> np.ndarray:
|
|
226
|
+
"""Perceived (K-weighted) third-octave levels in dB, shape (bands, frames)."""
|
|
227
|
+
power, freqs = analysis._stft_power(audio, sample_rate, MASK_FFT)
|
|
228
|
+
k = 10 ** (_k_gain_db(freqs, sample_rate) / 10)
|
|
229
|
+
power = power * k[:, None]
|
|
230
|
+
out = np.empty((MASK_CENTERS.size, power.shape[1]))
|
|
231
|
+
for i, c in enumerate(MASK_CENTERS):
|
|
232
|
+
sel = (freqs >= c / 2 ** (1 / 6)) & (freqs < c * 2 ** (1 / 6))
|
|
233
|
+
e = power[sel].sum(axis=0) if sel.any() else np.zeros(power.shape[1])
|
|
234
|
+
out[i] = 10 * np.log10(np.maximum(e, 1e-15))
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def masking(tracks: list[tuple[str, np.ndarray]], sample_rate: int) -> dict[str, Any]:
|
|
239
|
+
"""
|
|
240
|
+
Where tracks compete for the same third-octave bands at the same time.
|
|
241
|
+
|
|
242
|
+
Per pair and band, `score` is the share of frames in which both tracks are active
|
|
243
|
+
and within MASK_CLOSE_DB of each other (0..1). Conflicts are the highest-scoring
|
|
244
|
+
pair/band cells, each with which track to cut there: the one for which the band
|
|
245
|
+
matters less (further below that track's own loudest band).
|
|
246
|
+
"""
|
|
247
|
+
if len(tracks) < 2:
|
|
248
|
+
raise ValueError("Needs at least 2 tracks")
|
|
249
|
+
levels = [_band_levels(a, sample_rate) for _, a in tracks]
|
|
250
|
+
frames = min(lv.shape[1] for lv in levels)
|
|
251
|
+
levels = [lv[:, :frames] for lv in levels]
|
|
252
|
+
active = []
|
|
253
|
+
profile = []
|
|
254
|
+
for lv in levels:
|
|
255
|
+
peak = lv.max()
|
|
256
|
+
active.append((lv > peak - MASK_ACTIVE_DB) & (lv > -100))
|
|
257
|
+
# Each band's typical level relative to the track's loudest band (its own spectrum shape)
|
|
258
|
+
mean_db = 10 * np.log10(np.maximum((10 ** (lv / 10)).mean(axis=1), 1e-15))
|
|
259
|
+
profile.append(mean_db - mean_db.max())
|
|
260
|
+
|
|
261
|
+
pairs: list[dict[str, Any]] = []
|
|
262
|
+
cells: list[tuple[float, float, int, int, int]] = []
|
|
263
|
+
for i in range(len(tracks)):
|
|
264
|
+
for j in range(i + 1, len(tracks)):
|
|
265
|
+
both = active[i] & active[j]
|
|
266
|
+
close = both & (np.abs(levels[i] - levels[j]) <= MASK_CLOSE_DB)
|
|
267
|
+
score = close.sum(axis=1) / frames if frames else np.zeros(MASK_CENTERS.size)
|
|
268
|
+
pairs.append({"a": tracks[i][0], "b": tracks[j][0],
|
|
269
|
+
"scores": [round(float(s), 2) for s in score],
|
|
270
|
+
"overall": round(float(score.mean()), 3)})
|
|
271
|
+
for k, s in enumerate(score):
|
|
272
|
+
if s >= 0.2:
|
|
273
|
+
# Ties (a tone also leaks into the next band) go to the louder band
|
|
274
|
+
level = float(np.mean((levels[i][k] + levels[j][k])[both[k]])) if both[k].any() else -300.0
|
|
275
|
+
cells.append((round(float(s), 2), level, i, j, k))
|
|
276
|
+
|
|
277
|
+
cells.sort(reverse=True)
|
|
278
|
+
conflicts = []
|
|
279
|
+
for s, _, i, j, k in cells[:MAX_CONFLICTS]:
|
|
280
|
+
both = active[i][k] & active[j][k]
|
|
281
|
+
diff = float(np.median(levels[i][k][both] - levels[j][k][both])) if both.any() else 0.0
|
|
282
|
+
# Cut the track for which this band is less central to its own sound
|
|
283
|
+
cut, keep = (i, j) if profile[i][k] < profile[j][k] else (j, i)
|
|
284
|
+
conflicts.append({
|
|
285
|
+
"a": tracks[i][0], "b": tracks[j][0], "hz": MASK_LABELS[k], "score": s,
|
|
286
|
+
"levelDiffDb": round(diff, 1),
|
|
287
|
+
"suggestion": f"cut {tracks[cut][0]} around {MASK_LABELS[k]} Hz (it matters more to {tracks[keep][0]})",
|
|
288
|
+
})
|
|
289
|
+
return {
|
|
290
|
+
"bandsHz": MASK_LABELS,
|
|
291
|
+
"tracks": [name for name, _ in tracks],
|
|
292
|
+
"pairs": pairs,
|
|
293
|
+
"conflicts": conflicts,
|
|
294
|
+
"frameSeconds": round((MASK_FFT // 2) / sample_rate, 3),
|
|
295
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Chord recognition (detect_chords engine "model") — opt-in: reaper-mcp setup-sidecar --with-chords
|
|
2
|
+
# lv-chordia (MIT): a large-vocabulary chord recognizer (CNN + CRF, Harte labels such as C:maj, A:min7, C:maj/3).
|
|
3
|
+
# Reuses the sidecar's torch; adds librosa and its weights (~135 MB download, ~400 MB installed).
|
|
4
|
+
lv-chordia==1.1.0
|
|
5
|
+
# lv-chordia imports pydub, which needs the audioop module Python 3.13 removed from the standard library
|
|
6
|
+
audioop-lts==0.2.2; python_version >= "3.13"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Audiobox Aesthetics perceptual quality model (Meta, CC-BY 4.0)
|
|
2
|
+
# https://huggingface.co/facebook/audiobox-aesthetics
|
|
3
|
+
audiobox-aesthetics>=0.0.4
|
|
4
|
+
|
|
5
|
+
# PyTorch (required by audiobox-aesthetics; CPU build is fine for inference)
|
|
6
|
+
torch>=2.2.0
|
|
7
|
+
torchaudio>=2.2.0
|
|
8
|
+
|
|
9
|
+
# HuggingFace Hub for model weight management
|
|
10
|
+
huggingface_hub>=0.23.0
|
|
11
|
+
|
|
12
|
+
# audiobox_aesthetics.utils imports requests without declaring it, and
|
|
13
|
+
# huggingface_hub 2.x no longer pulls it in
|
|
14
|
+
requests>=2.31.0
|
|
15
|
+
|
|
16
|
+
# soundfile for audio duration check (lightweight, no heavy deps)
|
|
17
|
+
soundfile>=0.12.1
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Source separation (stems from a mix) for the reaper-mcp sidecar, with Demucs
|
|
3
|
+
(Meta, MIT licence): htdemucs gives drums, bass, other and vocals; htdemucs_6s
|
|
4
|
+
adds guitar and piano.
|
|
5
|
+
|
|
6
|
+
Opt-in: installed by `reaper-mcp setup-sidecar --with-separation` (demucs and
|
|
7
|
+
the model weights, ~80 MB per model, cached by torch hub). Audio goes in and
|
|
8
|
+
out through soundfile; demucs's own torchaudio-based I/O is never used
|
|
9
|
+
(torchaudio 2.9+ moved load/save to torchcodec).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
import analysis
|
|
23
|
+
|
|
24
|
+
MODELS = {
|
|
25
|
+
"htdemucs": ["drums", "bass", "other", "vocals"],
|
|
26
|
+
"htdemucs_6s": ["drums", "bass", "other", "vocals", "guitar", "piano"],
|
|
27
|
+
}
|
|
28
|
+
NOT_INSTALLED = ("Stem separation is not installed. Run: reaper-mcp setup-sidecar --with-separation "
|
|
29
|
+
"(installs Demucs and its model, ~80 MB)")
|
|
30
|
+
|
|
31
|
+
_models: dict[str, Any] = {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_installed() -> bool:
|
|
35
|
+
try:
|
|
36
|
+
import demucs.apply # noqa: F401 type: ignore
|
|
37
|
+
import demucs.pretrained # noqa: F401 type: ignore
|
|
38
|
+
except Exception: # noqa: BLE001
|
|
39
|
+
return False
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _device() -> str:
|
|
44
|
+
import torch # type: ignore
|
|
45
|
+
|
|
46
|
+
if os.environ.get("REAPER_MCP_SEPARATION_DEVICE"):
|
|
47
|
+
return os.environ["REAPER_MCP_SEPARATION_DEVICE"]
|
|
48
|
+
if torch.cuda.is_available():
|
|
49
|
+
return "cuda"
|
|
50
|
+
if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
|
|
51
|
+
return "mps"
|
|
52
|
+
return "cpu"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _model(name: str) -> Any:
|
|
56
|
+
if name not in _models:
|
|
57
|
+
from demucs.pretrained import get_model # type: ignore
|
|
58
|
+
|
|
59
|
+
model = get_model(name)
|
|
60
|
+
model.eval()
|
|
61
|
+
_models[name] = model
|
|
62
|
+
return _models[name]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def separate(path: str, out_dir: str, model_name: str = "htdemucs", stems: list[str] | None = None,
|
|
66
|
+
start: float = 0.0, end: float | None = None, max_seconds: float | None = None) -> dict[str, Any]:
|
|
67
|
+
"""
|
|
68
|
+
Split [start, end) of `path` into stems written to `out_dir/<stem>.wav` (32-bit float, never clipped;
|
|
69
|
+
the model's sample rate, stereo). `stems` keeps only those (the others are not
|
|
70
|
+
written). Returns each written stem with its path, peak and loudness.
|
|
71
|
+
"""
|
|
72
|
+
if model_name not in MODELS:
|
|
73
|
+
raise ValueError(f"Unknown model {model_name}. Use {', '.join(MODELS)}")
|
|
74
|
+
wanted = stems or MODELS[model_name]
|
|
75
|
+
unknown = [s for s in wanted if s not in MODELS[model_name]]
|
|
76
|
+
if unknown:
|
|
77
|
+
raise ValueError(f"{model_name} has no stem {', '.join(unknown)}: it gives {', '.join(MODELS[model_name])}")
|
|
78
|
+
if not is_installed():
|
|
79
|
+
raise RuntimeError(NOT_INSTALLED)
|
|
80
|
+
|
|
81
|
+
import soundfile as sf # type: ignore
|
|
82
|
+
import torch # type: ignore
|
|
83
|
+
import torchaudio.functional as F # type: ignore
|
|
84
|
+
from demucs.apply import apply_model # type: ignore
|
|
85
|
+
|
|
86
|
+
audio, sr = analysis.load_audio(path, start, end)
|
|
87
|
+
if audio.shape[1] < sr:
|
|
88
|
+
raise ValueError("Audio too short: need at least 1 s")
|
|
89
|
+
if max_seconds and audio.shape[1] / sr > max_seconds + 0.5:
|
|
90
|
+
raise ValueError(f"Audio is {audio.shape[1] / sr:.0f} s: max {max_seconds:.0f} s per call (pass startTime/endTime)")
|
|
91
|
+
model = _model(model_name)
|
|
92
|
+
started = time.time() # after the model load: the speed is the separation's own
|
|
93
|
+
wav = torch.from_numpy(audio)
|
|
94
|
+
if wav.shape[0] == 1:
|
|
95
|
+
wav = wav.repeat(2, 1)
|
|
96
|
+
wav = wav[:2]
|
|
97
|
+
if sr != model.samplerate:
|
|
98
|
+
wav = F.resample(wav, sr, model.samplerate)
|
|
99
|
+
# Demucs's own normalisation (as its CLI): zero mean, unit deviation of the mono mix
|
|
100
|
+
ref = wav.mean(0)
|
|
101
|
+
mean, std = ref.mean(), ref.std()
|
|
102
|
+
if float(std) < 1e-8:
|
|
103
|
+
raise ValueError("The audio is silent: nothing to separate")
|
|
104
|
+
wav = (wav - mean) / std
|
|
105
|
+
|
|
106
|
+
device = _device()
|
|
107
|
+
fallback: dict[str, str] = {}
|
|
108
|
+
try:
|
|
109
|
+
with torch.no_grad():
|
|
110
|
+
sources = apply_model(model, wav[None], device=device, split=True, overlap=0.25, progress=False)[0]
|
|
111
|
+
except Exception as exc: # noqa: BLE001
|
|
112
|
+
if device == "cpu":
|
|
113
|
+
raise
|
|
114
|
+
# An op the accelerator lacks: CPU always works. Labelled apart from a plain CPU run,
|
|
115
|
+
# so the fallback shows in the speed metric and the answer.
|
|
116
|
+
fallback = {"fallbackFrom": device, "fallbackError": str(exc)[:300]}
|
|
117
|
+
print(f"separate: {device} failed, falling back to CPU: {exc}", file=sys.stderr, flush=True)
|
|
118
|
+
device = "cpu_fallback"
|
|
119
|
+
with torch.no_grad():
|
|
120
|
+
sources = apply_model(model, wav[None], device="cpu", split=True, overlap=0.25, progress=False)[0]
|
|
121
|
+
sources = sources * std + mean
|
|
122
|
+
|
|
123
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
124
|
+
written = []
|
|
125
|
+
for name, src in zip(model.sources, sources):
|
|
126
|
+
if name not in wanted:
|
|
127
|
+
continue
|
|
128
|
+
data = src.cpu().numpy().astype(np.float32)
|
|
129
|
+
out = os.path.join(out_dir, f"{name}.wav")
|
|
130
|
+
sf.write(out, data.T, model.samplerate, subtype="FLOAT") # a stem can peak above 0 dBFS
|
|
131
|
+
peak = float(np.abs(data).max(initial=0.0))
|
|
132
|
+
lufs = analysis.integrated_loudness(data, model.samplerate)
|
|
133
|
+
written.append({
|
|
134
|
+
"stem": name, "path": out,
|
|
135
|
+
"peakDbfs": analysis._round(analysis._db(peak), 1),
|
|
136
|
+
"lufs": analysis._round(lufs, 1) if math.isfinite(lufs) else None,
|
|
137
|
+
})
|
|
138
|
+
return {
|
|
139
|
+
"model": model_name, "sampleRate": int(model.samplerate), "device": device,
|
|
140
|
+
"seconds": round(audio.shape[1] / sr, 2), "processingSeconds": round(time.time() - started, 1),
|
|
141
|
+
"stems": written, "outDir": out_dir, **fallback,
|
|
142
|
+
}
|