@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,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,3 @@
1
+ # Source separation (separate_stems) — opt-in: reaper-mcp setup-sidecar --with-separation
2
+ # Demucs (Meta, MIT). Its audio I/O is not used (the sidecar reads and writes with soundfile).
3
+ demucs==4.0.1
@@ -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
+ }