@mthines/reaper-mcp 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/main.js +188 -9
- package/package.json +1 -1
- package/reaper/mcp_bridge.lua +183 -16
package/main.js
CHANGED
|
@@ -131,6 +131,91 @@ function getTimeoutCounter() {
|
|
|
131
131
|
}
|
|
132
132
|
return _timeoutCounter;
|
|
133
133
|
}
|
|
134
|
+
var _handlerDurationHistogram = null;
|
|
135
|
+
var _pickupDurationHistogram = null;
|
|
136
|
+
function getHandlerDurationHistogram() {
|
|
137
|
+
if (!_handlerDurationHistogram) {
|
|
138
|
+
_handlerDurationHistogram = getMeter().createHistogram(
|
|
139
|
+
"mcp.bridge.handler.duration",
|
|
140
|
+
{
|
|
141
|
+
description: "Time Lua spent executing the command handler inside REAPER",
|
|
142
|
+
unit: "ms",
|
|
143
|
+
advice: {
|
|
144
|
+
explicitBucketBoundaries: [
|
|
145
|
+
0.1,
|
|
146
|
+
0.25,
|
|
147
|
+
0.5,
|
|
148
|
+
1,
|
|
149
|
+
2,
|
|
150
|
+
5,
|
|
151
|
+
10,
|
|
152
|
+
25,
|
|
153
|
+
50,
|
|
154
|
+
100,
|
|
155
|
+
250,
|
|
156
|
+
500
|
|
157
|
+
]
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return _handlerDurationHistogram;
|
|
163
|
+
}
|
|
164
|
+
function getPickupDurationHistogram() {
|
|
165
|
+
if (!_pickupDurationHistogram) {
|
|
166
|
+
_pickupDurationHistogram = getMeter().createHistogram(
|
|
167
|
+
"mcp.bridge.pickup.duration",
|
|
168
|
+
{
|
|
169
|
+
description: "Time from Node writing the command file to Lua picking it up (defer cycle latency)",
|
|
170
|
+
unit: "ms",
|
|
171
|
+
advice: {
|
|
172
|
+
explicitBucketBoundaries: [
|
|
173
|
+
1,
|
|
174
|
+
2,
|
|
175
|
+
5,
|
|
176
|
+
10,
|
|
177
|
+
16,
|
|
178
|
+
33,
|
|
179
|
+
50,
|
|
180
|
+
100,
|
|
181
|
+
200,
|
|
182
|
+
500,
|
|
183
|
+
1e3
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return _pickupDurationHistogram;
|
|
190
|
+
}
|
|
191
|
+
var _deferP50Ms = 0;
|
|
192
|
+
var _deferP95Ms = 0;
|
|
193
|
+
var _scanAvgMs = 0;
|
|
194
|
+
var _scanMaxMs = 0;
|
|
195
|
+
function updateDeferStats(p50, p95) {
|
|
196
|
+
_deferP50Ms = p50;
|
|
197
|
+
_deferP95Ms = p95;
|
|
198
|
+
}
|
|
199
|
+
function updateScanStats(avg, max) {
|
|
200
|
+
_scanAvgMs = avg;
|
|
201
|
+
_scanMaxMs = max;
|
|
202
|
+
}
|
|
203
|
+
function registerBridgeGauges() {
|
|
204
|
+
getMeter().createObservableGauge("mcp.bridge.defer.interval", {
|
|
205
|
+
description: "REAPER defer loop interval (p50/p95 over last 100 cycles)",
|
|
206
|
+
unit: "ms"
|
|
207
|
+
}).addCallback((obs) => {
|
|
208
|
+
obs.observe(_deferP50Ms, { percentile: "p50" });
|
|
209
|
+
obs.observe(_deferP95Ms, { percentile: "p95" });
|
|
210
|
+
});
|
|
211
|
+
getMeter().createObservableGauge("mcp.bridge.scan.duration", {
|
|
212
|
+
description: "Time Lua spends per main_loop scan cycle (avg/max over last 100 scans)",
|
|
213
|
+
unit: "ms"
|
|
214
|
+
}).addCallback((obs) => {
|
|
215
|
+
obs.observe(_scanAvgMs, { percentile: "avg" });
|
|
216
|
+
obs.observe(_scanMaxMs, { percentile: "max" });
|
|
217
|
+
});
|
|
218
|
+
}
|
|
134
219
|
|
|
135
220
|
// apps/reaper-mcp-server/src/bridge.ts
|
|
136
221
|
import { randomUUID } from "node:crypto";
|
|
@@ -224,9 +309,29 @@ async function sendCommand(type, params = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
224
309
|
const tCleanup = now();
|
|
225
310
|
await Promise.allSettled([unlink(commandPath), unlink(responsePath)]);
|
|
226
311
|
if (profiling) t["cleanup"] = now() - tCleanup;
|
|
312
|
+
const rawResponse = response;
|
|
313
|
+
const handlerMs = rawResponse["_handlerMs"];
|
|
314
|
+
const pickupMs = rawResponse["_pickupMs"];
|
|
315
|
+
const deferCycle = rawResponse["_deferCycle"];
|
|
227
316
|
const durationMs2 = Date.now() - startMs;
|
|
228
317
|
const succeeded = response.success;
|
|
229
318
|
span.setAttribute("mcp.response.success", succeeded);
|
|
319
|
+
if (handlerMs != null) {
|
|
320
|
+
span.setAttribute("mcp.bridge.handler_ms", handlerMs);
|
|
321
|
+
getHandlerDurationHistogram().record(handlerMs, {
|
|
322
|
+
command_type: type,
|
|
323
|
+
success: String(succeeded)
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
if (pickupMs != null) {
|
|
327
|
+
span.setAttribute("mcp.bridge.pickup_ms", pickupMs);
|
|
328
|
+
getPickupDurationHistogram().record(pickupMs, {
|
|
329
|
+
command_type: type
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
if (deferCycle != null) {
|
|
333
|
+
span.setAttribute("mcp.bridge.defer_cycle", deferCycle);
|
|
334
|
+
}
|
|
230
335
|
if (!succeeded) {
|
|
231
336
|
span.setStatus({ code: SpanStatusCode.ERROR, message: response.error ?? "Bridge error" });
|
|
232
337
|
span.setAttribute("mcp.response.error", response.error ?? "unknown");
|
|
@@ -333,6 +438,69 @@ function getReaperEffectsPath() {
|
|
|
333
438
|
function sleep(ms) {
|
|
334
439
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
335
440
|
}
|
|
441
|
+
async function readBridgeEvents() {
|
|
442
|
+
const eventsPath = join2(getBridgeDir(), "bridge_events.jsonl");
|
|
443
|
+
let content;
|
|
444
|
+
try {
|
|
445
|
+
content = await readFile(eventsPath, "utf-8");
|
|
446
|
+
} catch {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
await writeFile(eventsPath, "", "utf-8").catch(() => {
|
|
450
|
+
});
|
|
451
|
+
const lines = content.split("\n").filter((l) => l.trim() !== "");
|
|
452
|
+
for (const line of lines) {
|
|
453
|
+
try {
|
|
454
|
+
const evt = JSON.parse(line);
|
|
455
|
+
console.error(JSON.stringify({ msg: "bridge_event", ...evt }));
|
|
456
|
+
} catch {
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
var _diagnosticsTimer = null;
|
|
461
|
+
var _eventsTimer = null;
|
|
462
|
+
function startDiagnosticsPoller(intervalMs = 6e4) {
|
|
463
|
+
if (_diagnosticsTimer) return;
|
|
464
|
+
_diagnosticsTimer = setInterval(async () => {
|
|
465
|
+
try {
|
|
466
|
+
const res = await sendCommand(
|
|
467
|
+
"_bridge_diagnostics",
|
|
468
|
+
{},
|
|
469
|
+
5e3
|
|
470
|
+
);
|
|
471
|
+
if (res.success && res.data) {
|
|
472
|
+
const d = res.data;
|
|
473
|
+
if (d.deferIntervals) {
|
|
474
|
+
updateDeferStats(d.deferIntervals.p50Ms, d.deferIntervals.p95Ms);
|
|
475
|
+
}
|
|
476
|
+
if (d.scanDurations) {
|
|
477
|
+
updateScanStats(d.scanDurations.avgMs, d.scanDurations.maxMs);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
} catch {
|
|
481
|
+
}
|
|
482
|
+
}, intervalMs);
|
|
483
|
+
}
|
|
484
|
+
function startEventsPoller(intervalMs = 3e4) {
|
|
485
|
+
if (_eventsTimer) return;
|
|
486
|
+
readBridgeEvents().catch(() => {
|
|
487
|
+
});
|
|
488
|
+
_eventsTimer = setInterval(
|
|
489
|
+
() => readBridgeEvents().catch(() => {
|
|
490
|
+
}),
|
|
491
|
+
intervalMs
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
function stopPollers() {
|
|
495
|
+
if (_diagnosticsTimer) {
|
|
496
|
+
clearInterval(_diagnosticsTimer);
|
|
497
|
+
_diagnosticsTimer = null;
|
|
498
|
+
}
|
|
499
|
+
if (_eventsTimer) {
|
|
500
|
+
clearInterval(_eventsTimer);
|
|
501
|
+
_eventsTimer = null;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
336
504
|
|
|
337
505
|
// apps/reaper-mcp-server/src/tools/project.ts
|
|
338
506
|
function registerProjectTools(server) {
|
|
@@ -670,13 +838,15 @@ import { z as z7 } from "zod/v4";
|
|
|
670
838
|
function registerSnapshotTools(server) {
|
|
671
839
|
server.tool(
|
|
672
840
|
"snapshot_save",
|
|
673
|
-
|
|
841
|
+
'Save the current mixer state as a named snapshot. Stored in .reaper-mcp/snapshots/ alongside the project file (falls back to global bridge dir for unsaved projects). Captures track volumes, pans, mutes, solos, FX bypass/offline states, full FX parameter values, preset names, track names/colors, and send levels. Response includes storageLocation ("project" or "global").',
|
|
674
842
|
{
|
|
675
843
|
name: z7.string().min(1).describe('Unique snapshot name (e.g. "before-compression", "v1-mix")'),
|
|
676
|
-
description: z7.string().optional().describe("Optional human-readable description")
|
|
844
|
+
description: z7.string().optional().describe("Optional human-readable description"),
|
|
845
|
+
includeFxParams: z7.boolean().optional().default(true).describe("Include FX parameter values in snapshot (default true)"),
|
|
846
|
+
maxParamsPerFx: z7.coerce.number().int().min(1).max(2e3).optional().default(500).describe("Max parameters to capture per FX plugin (default 500)")
|
|
677
847
|
},
|
|
678
|
-
async ({ name, description }) => {
|
|
679
|
-
const res = await sendCommand("snapshot_save", { name, description });
|
|
848
|
+
async ({ name, description, includeFxParams, maxParamsPerFx }) => {
|
|
849
|
+
const res = await sendCommand("snapshot_save", { name, description, includeFxParams, maxParamsPerFx });
|
|
680
850
|
if (!res.success) {
|
|
681
851
|
return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
|
|
682
852
|
}
|
|
@@ -685,12 +855,14 @@ function registerSnapshotTools(server) {
|
|
|
685
855
|
);
|
|
686
856
|
server.tool(
|
|
687
857
|
"snapshot_restore",
|
|
688
|
-
"Restore a previously saved mixer snapshot by name",
|
|
858
|
+
"Restore a previously saved mixer snapshot by name. Reads from .reaper-mcp/snapshots/ alongside the project file (falls back to global bridge dir for unsaved projects). Restores volumes, pans, mutes, solos, FX states, and optionally FX parameters (if snapshot is v2). FX parameters are only restored when the plugin name matches the snapshot to prevent applying wrong values.",
|
|
689
859
|
{
|
|
690
|
-
name: z7.string().min(1).describe("Name of the snapshot to restore")
|
|
860
|
+
name: z7.string().min(1).describe("Name of the snapshot to restore"),
|
|
861
|
+
restoreTrackMeta: z7.boolean().optional().default(false).describe("Also restore track names and colors (default false)"),
|
|
862
|
+
restoreSendLevels: z7.boolean().optional().default(true).describe("Restore send volume/pan/mute for existing sends (default true)")
|
|
691
863
|
},
|
|
692
|
-
async ({ name }) => {
|
|
693
|
-
const res = await sendCommand("snapshot_restore", { name });
|
|
864
|
+
async ({ name, restoreTrackMeta, restoreSendLevels }) => {
|
|
865
|
+
const res = await sendCommand("snapshot_restore", { name, restoreTrackMeta, restoreSendLevels });
|
|
694
866
|
if (!res.success) {
|
|
695
867
|
return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
|
|
696
868
|
}
|
|
@@ -699,7 +871,7 @@ function registerSnapshotTools(server) {
|
|
|
699
871
|
);
|
|
700
872
|
server.tool(
|
|
701
873
|
"snapshot_list",
|
|
702
|
-
|
|
874
|
+
'List all saved mixer snapshots for the current project. Reads from .reaper-mcp/snapshots/ alongside the project file (falls back to global bridge dir for unsaved projects). Response includes storageLocation ("project" or "global").',
|
|
703
875
|
{},
|
|
704
876
|
async () => {
|
|
705
877
|
const res = await sendCommand("snapshot_list", {});
|
|
@@ -2086,6 +2258,9 @@ async function doctor() {
|
|
|
2086
2258
|
async function serve() {
|
|
2087
2259
|
const log = (...args) => console.error("[reaper-mcp]", ...args);
|
|
2088
2260
|
await initTelemetry();
|
|
2261
|
+
registerBridgeGauges();
|
|
2262
|
+
startDiagnosticsPoller();
|
|
2263
|
+
startEventsPoller();
|
|
2089
2264
|
log("Starting REAPER MCP Server...");
|
|
2090
2265
|
log(`Entry: ${fileURLToPath2(import.meta.url)}`);
|
|
2091
2266
|
const tracer = getTracer();
|
|
@@ -2195,18 +2370,22 @@ Tip: install globally for shorter commands:
|
|
|
2195
2370
|
}
|
|
2196
2371
|
process.on("SIGINT", () => {
|
|
2197
2372
|
console.error("[reaper-mcp] Interrupted");
|
|
2373
|
+
stopPollers();
|
|
2198
2374
|
shutdownTelemetry().finally(() => process.exit(0));
|
|
2199
2375
|
});
|
|
2200
2376
|
process.on("SIGTERM", () => {
|
|
2201
2377
|
console.error("[reaper-mcp] Terminated");
|
|
2378
|
+
stopPollers();
|
|
2202
2379
|
shutdownTelemetry().finally(() => process.exit(0));
|
|
2203
2380
|
});
|
|
2204
2381
|
process.on("uncaughtException", (err) => {
|
|
2205
2382
|
console.error("[reaper-mcp] Uncaught exception:", err);
|
|
2383
|
+
stopPollers();
|
|
2206
2384
|
shutdownTelemetry().finally(() => process.exit(1));
|
|
2207
2385
|
});
|
|
2208
2386
|
process.on("unhandledRejection", (reason) => {
|
|
2209
2387
|
console.error("[reaper-mcp] Unhandled rejection:", reason);
|
|
2388
|
+
stopPollers();
|
|
2210
2389
|
shutdownTelemetry().finally(() => process.exit(1));
|
|
2211
2390
|
});
|
|
2212
2391
|
//# sourceMappingURL=main.js.map
|
package/package.json
CHANGED
package/reaper/mcp_bridge.lua
CHANGED
|
@@ -231,6 +231,22 @@ local function write_file(path, content)
|
|
|
231
231
|
return true
|
|
232
232
|
end
|
|
233
233
|
|
|
234
|
+
-- =============================================================================
|
|
235
|
+
-- Telemetry event logging
|
|
236
|
+
-- =============================================================================
|
|
237
|
+
|
|
238
|
+
local EVENTS_LOG = bridge_dir .. "bridge_events.jsonl"
|
|
239
|
+
|
|
240
|
+
local function log_event(event_type, payload)
|
|
241
|
+
local f = io.open(EVENTS_LOG, "a")
|
|
242
|
+
if not f then return end
|
|
243
|
+
payload = payload or {}
|
|
244
|
+
payload.ts = os.time() * 1000
|
|
245
|
+
payload.type = event_type
|
|
246
|
+
f:write(json_encode(payload) .. "\n")
|
|
247
|
+
f:close()
|
|
248
|
+
end
|
|
249
|
+
|
|
234
250
|
local function file_exists(path)
|
|
235
251
|
local f = io.open(path, "r")
|
|
236
252
|
if f then f:close() return true end
|
|
@@ -815,9 +831,22 @@ end
|
|
|
815
831
|
-- =============================================================================
|
|
816
832
|
|
|
817
833
|
local function get_snapshot_dir()
|
|
834
|
+
local proj_path = reaper.GetProjectPath()
|
|
835
|
+
if proj_path and proj_path ~= "" then
|
|
836
|
+
return proj_path .. "/.reaper-mcp/snapshots/"
|
|
837
|
+
end
|
|
838
|
+
-- Fallback for unsaved projects
|
|
818
839
|
return bridge_dir .. "snapshots/"
|
|
819
840
|
end
|
|
820
841
|
|
|
842
|
+
local function get_snapshot_storage_location()
|
|
843
|
+
local proj_path = reaper.GetProjectPath()
|
|
844
|
+
if proj_path and proj_path ~= "" then
|
|
845
|
+
return "project"
|
|
846
|
+
end
|
|
847
|
+
return "global"
|
|
848
|
+
end
|
|
849
|
+
|
|
821
850
|
local function ensure_snapshot_dir()
|
|
822
851
|
reaper.RecursiveCreateDirectory(get_snapshot_dir(), 0)
|
|
823
852
|
end
|
|
@@ -828,9 +857,13 @@ local function snapshot_path(name)
|
|
|
828
857
|
return get_snapshot_dir() .. safe .. ".json"
|
|
829
858
|
end
|
|
830
859
|
|
|
831
|
-
local function capture_mixer_state()
|
|
832
|
-
|
|
860
|
+
local function capture_mixer_state(params)
|
|
861
|
+
params = params or {}
|
|
862
|
+
local include_fx_params = params.includeFxParams ~= false -- default true
|
|
863
|
+
local max_params = params.maxParamsPerFx or 500
|
|
864
|
+
local state = { version = 2, tracks = {} }
|
|
833
865
|
local count = reaper.CountTracks(0)
|
|
866
|
+
|
|
834
867
|
for i = 0, count - 1 do
|
|
835
868
|
local track = reaper.GetTrack(0, i)
|
|
836
869
|
local _, name = reaper.GetTrackName(track)
|
|
@@ -838,22 +871,75 @@ local function capture_mixer_state()
|
|
|
838
871
|
local pan = reaper.GetMediaTrackInfo_Value(track, "D_PAN")
|
|
839
872
|
local mute = reaper.GetMediaTrackInfo_Value(track, "B_MUTE")
|
|
840
873
|
local solo = reaper.GetMediaTrackInfo_Value(track, "I_SOLO")
|
|
874
|
+
local color = reaper.GetMediaTrackInfo_Value(track, "I_CUSTOMCOLOR")
|
|
841
875
|
|
|
842
|
-
--
|
|
876
|
+
-- FX state with full parameter capture
|
|
843
877
|
local fx_count = reaper.TrackFX_GetCount(track)
|
|
844
878
|
local fx_states = {}
|
|
845
879
|
for j = 0, fx_count - 1 do
|
|
846
|
-
|
|
880
|
+
local enabled = reaper.TrackFX_GetEnabled(track, j)
|
|
881
|
+
local offline = reaper.TrackFX_GetOffline(track, j)
|
|
882
|
+
local _, preset = reaper.TrackFX_GetPreset(track, j)
|
|
883
|
+
local _, fx_name = reaper.TrackFX_GetFXName(track, j)
|
|
884
|
+
|
|
885
|
+
local params_data = {}
|
|
886
|
+
if include_fx_params then
|
|
887
|
+
local param_count = reaper.TrackFX_GetNumParams(track, j)
|
|
888
|
+
local limit = math.min(param_count, max_params)
|
|
889
|
+
for p = 0, limit - 1 do
|
|
890
|
+
local val = reaper.TrackFX_GetParam(track, j, p)
|
|
891
|
+
params_data[p + 1] = val
|
|
892
|
+
end
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
fx_states[j + 1] = {
|
|
896
|
+
name = fx_name,
|
|
897
|
+
enabled = enabled,
|
|
898
|
+
offline = offline,
|
|
899
|
+
preset = preset or "",
|
|
900
|
+
params = params_data,
|
|
901
|
+
}
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
-- Send levels (volume, pan, mute for existing sends)
|
|
905
|
+
local send_count = reaper.GetTrackNumSends(track, 0)
|
|
906
|
+
local sends = {}
|
|
907
|
+
for s = 0, send_count - 1 do
|
|
908
|
+
local dest_track = reaper.GetTrackSendInfo_Value(track, 0, s, "P_DESTTRACK")
|
|
909
|
+
local dest_idx = -1
|
|
910
|
+
local dest_name = ""
|
|
911
|
+
if dest_track then
|
|
912
|
+
dest_idx = reaper.GetMediaTrackInfo_Value(dest_track, "IP_TRACKNUMBER") - 1
|
|
913
|
+
local _, dname = reaper.GetTrackName(dest_track)
|
|
914
|
+
dest_name = dname or ""
|
|
915
|
+
end
|
|
916
|
+
sends[s + 1] = {
|
|
917
|
+
destTrackIndex = dest_idx,
|
|
918
|
+
destTrackName = dest_name,
|
|
919
|
+
volume = reaper.GetTrackSendInfo_Value(track, 0, s, "D_VOL"),
|
|
920
|
+
pan = reaper.GetTrackSendInfo_Value(track, 0, s, "D_PAN"),
|
|
921
|
+
muted = reaper.GetTrackSendInfo_Value(track, 0, s, "B_MUTE") ~= 0,
|
|
922
|
+
}
|
|
847
923
|
end
|
|
848
924
|
|
|
849
|
-
state.tracks[
|
|
925
|
+
state.tracks[i + 1] = {
|
|
850
926
|
index = i,
|
|
851
927
|
name = name,
|
|
928
|
+
color = color,
|
|
852
929
|
volume = vol,
|
|
853
930
|
pan = pan,
|
|
854
931
|
mute = mute ~= 0,
|
|
855
932
|
solo = solo ~= 0,
|
|
856
|
-
|
|
933
|
+
fx = fx_states,
|
|
934
|
+
sends = sends,
|
|
935
|
+
-- Legacy compat: flat fxEnabled array (derived from fx_states)
|
|
936
|
+
fxEnabled = (function()
|
|
937
|
+
local arr = {}
|
|
938
|
+
for j = 1, #fx_states do
|
|
939
|
+
arr[j] = fx_states[j].enabled
|
|
940
|
+
end
|
|
941
|
+
return arr
|
|
942
|
+
end)(),
|
|
857
943
|
}
|
|
858
944
|
end
|
|
859
945
|
return state
|
|
@@ -872,7 +958,10 @@ function handlers.snapshot_save(params)
|
|
|
872
958
|
name = name,
|
|
873
959
|
description = params.description or "",
|
|
874
960
|
timestamp = timestamp,
|
|
875
|
-
mixerState = capture_mixer_state(
|
|
961
|
+
mixerState = capture_mixer_state({
|
|
962
|
+
includeFxParams = params.includeFxParams ~= false,
|
|
963
|
+
maxParamsPerFx = params.maxParamsPerFx or 500,
|
|
964
|
+
}),
|
|
876
965
|
}
|
|
877
966
|
|
|
878
967
|
local path = snapshot_path(name)
|
|
@@ -881,7 +970,7 @@ function handlers.snapshot_save(params)
|
|
|
881
970
|
return nil, "Failed to write snapshot file: " .. path
|
|
882
971
|
end
|
|
883
972
|
|
|
884
|
-
return { success = true, name = name, timestamp = timestamp, path = path }
|
|
973
|
+
return { success = true, name = name, timestamp = timestamp, path = path, storageLocation = get_snapshot_storage_location() }
|
|
885
974
|
end
|
|
886
975
|
|
|
887
976
|
function handlers.snapshot_restore(params)
|
|
@@ -901,30 +990,89 @@ function handlers.snapshot_restore(params)
|
|
|
901
990
|
return nil, "Invalid snapshot file for: " .. name
|
|
902
991
|
end
|
|
903
992
|
|
|
904
|
-
-- Restore each track state
|
|
905
|
-
local restored = 0
|
|
906
993
|
local state = snapshot.mixerState
|
|
994
|
+
local version = state.version or 1
|
|
995
|
+
local restore_meta = params.restoreTrackMeta == true -- default false
|
|
996
|
+
local restore_sends = params.restoreSendLevels ~= false -- default true
|
|
997
|
+
local warnings = {}
|
|
907
998
|
|
|
908
999
|
reaper.Undo_BeginBlock()
|
|
909
1000
|
|
|
1001
|
+
local restored = 0
|
|
1002
|
+
|
|
910
1003
|
if state.tracks then
|
|
911
1004
|
for _, track_state in ipairs(state.tracks) do
|
|
912
1005
|
local track = reaper.GetTrack(0, track_state.index)
|
|
913
1006
|
if track then
|
|
1007
|
+
-- Basic mixer state (all versions)
|
|
914
1008
|
reaper.SetMediaTrackInfo_Value(track, "D_VOL", track_state.volume)
|
|
915
1009
|
reaper.SetMediaTrackInfo_Value(track, "D_PAN", track_state.pan)
|
|
916
1010
|
reaper.SetMediaTrackInfo_Value(track, "B_MUTE", track_state.mute and 1 or 0)
|
|
917
1011
|
reaper.SetMediaTrackInfo_Value(track, "I_SOLO", track_state.solo and 1 or 0)
|
|
918
1012
|
|
|
919
|
-
--
|
|
920
|
-
if
|
|
1013
|
+
-- Track metadata (v2, opt-in)
|
|
1014
|
+
if restore_meta and version >= 2 then
|
|
1015
|
+
if track_state.name then
|
|
1016
|
+
reaper.GetSetMediaTrackInfo_String(track, "P_NAME", track_state.name, true)
|
|
1017
|
+
end
|
|
1018
|
+
if track_state.color and track_state.color ~= 0 then
|
|
1019
|
+
reaper.SetMediaTrackInfo_Value(track, "I_CUSTOMCOLOR", track_state.color)
|
|
1020
|
+
end
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
-- FX state (v2: full params; v1: bypass only)
|
|
1024
|
+
if version >= 2 and track_state.fx then
|
|
1025
|
+
for j, fx_state in ipairs(track_state.fx) do
|
|
1026
|
+
local fx_idx = j - 1
|
|
1027
|
+
if fx_idx < reaper.TrackFX_GetCount(track) then
|
|
1028
|
+
-- Restore enabled/offline state
|
|
1029
|
+
reaper.TrackFX_SetEnabled(track, fx_idx, fx_state.enabled)
|
|
1030
|
+
if fx_state.offline ~= nil then
|
|
1031
|
+
reaper.TrackFX_SetOffline(track, fx_idx, fx_state.offline)
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
-- Restore FX parameters only if plugin name matches
|
|
1035
|
+
if fx_state.params and #fx_state.params > 0 then
|
|
1036
|
+
local _, current_name = reaper.TrackFX_GetFXName(track, fx_idx)
|
|
1037
|
+
if current_name == fx_state.name then
|
|
1038
|
+
for p, val in ipairs(fx_state.params) do
|
|
1039
|
+
reaper.TrackFX_SetParam(track, fx_idx, p - 1, val)
|
|
1040
|
+
end
|
|
1041
|
+
else
|
|
1042
|
+
warnings[#warnings + 1] = "Track " .. track_state.index .. " FX " .. fx_idx .. ": name mismatch ('" .. (current_name or "?") .. "' vs '" .. (fx_state.name or "?") .. "'), skipped params"
|
|
1043
|
+
end
|
|
1044
|
+
end
|
|
1045
|
+
end
|
|
1046
|
+
end
|
|
1047
|
+
elseif track_state.fxEnabled then
|
|
1048
|
+
-- Legacy v1: bypass state only
|
|
921
1049
|
for j, enabled in ipairs(track_state.fxEnabled) do
|
|
922
|
-
local fx_idx = j - 1
|
|
1050
|
+
local fx_idx = j - 1
|
|
923
1051
|
if fx_idx < reaper.TrackFX_GetCount(track) then
|
|
924
1052
|
reaper.TrackFX_SetEnabled(track, fx_idx, enabled)
|
|
925
1053
|
end
|
|
926
1054
|
end
|
|
927
1055
|
end
|
|
1056
|
+
|
|
1057
|
+
-- Send levels (v2, default on)
|
|
1058
|
+
if version >= 2 and restore_sends and track_state.sends then
|
|
1059
|
+
local send_count = reaper.GetTrackNumSends(track, 0)
|
|
1060
|
+
for _, saved_send in ipairs(track_state.sends) do
|
|
1061
|
+
for s = 0, send_count - 1 do
|
|
1062
|
+
local dest = reaper.GetTrackSendInfo_Value(track, 0, s, "P_DESTTRACK")
|
|
1063
|
+
if dest then
|
|
1064
|
+
local dest_idx = reaper.GetMediaTrackInfo_Value(dest, "IP_TRACKNUMBER") - 1
|
|
1065
|
+
if dest_idx == saved_send.destTrackIndex then
|
|
1066
|
+
reaper.SetTrackSendInfo_Value(track, 0, s, "D_VOL", saved_send.volume)
|
|
1067
|
+
reaper.SetTrackSendInfo_Value(track, 0, s, "D_PAN", saved_send.pan)
|
|
1068
|
+
reaper.SetTrackSendInfo_Value(track, 0, s, "B_MUTE", saved_send.muted and 1 or 0)
|
|
1069
|
+
break
|
|
1070
|
+
end
|
|
1071
|
+
end
|
|
1072
|
+
end
|
|
1073
|
+
end
|
|
1074
|
+
end
|
|
1075
|
+
|
|
928
1076
|
restored = restored + 1
|
|
929
1077
|
end
|
|
930
1078
|
end
|
|
@@ -935,12 +1083,15 @@ function handlers.snapshot_restore(params)
|
|
|
935
1083
|
reaper.TrackList_AdjustWindows(false)
|
|
936
1084
|
reaper.UpdateArrange()
|
|
937
1085
|
|
|
938
|
-
|
|
1086
|
+
local result = {
|
|
939
1087
|
success = true,
|
|
940
1088
|
name = name,
|
|
941
1089
|
timestamp = snapshot.timestamp,
|
|
942
1090
|
tracksRestored = restored,
|
|
1091
|
+
version = version,
|
|
943
1092
|
}
|
|
1093
|
+
if #warnings > 0 then result.warnings = warnings end
|
|
1094
|
+
return result
|
|
944
1095
|
end
|
|
945
1096
|
|
|
946
1097
|
function handlers.snapshot_list(params)
|
|
@@ -971,7 +1122,7 @@ function handlers.snapshot_list(params)
|
|
|
971
1122
|
-- Sort by timestamp descending (newest first)
|
|
972
1123
|
table.sort(snapshots, function(a, b) return a.timestamp > b.timestamp end)
|
|
973
1124
|
|
|
974
|
-
return { snapshots = snapshots, total = #snapshots }
|
|
1125
|
+
return { snapshots = snapshots, total = #snapshots, storageLocation = get_snapshot_storage_location() }
|
|
975
1126
|
end
|
|
976
1127
|
|
|
977
1128
|
-- =============================================================================
|
|
@@ -2788,12 +2939,18 @@ local function process_command(filename)
|
|
|
2788
2939
|
-- Dispatch to handler
|
|
2789
2940
|
local handler = handlers[cmd.type]
|
|
2790
2941
|
local response = {}
|
|
2942
|
+
local handler_ms = 0
|
|
2791
2943
|
|
|
2792
2944
|
if handler then
|
|
2945
|
+
local handler_start = reaper.time_precise()
|
|
2793
2946
|
local ok, data_or_err, err_msg = pcall(handler, cmd.params or {})
|
|
2947
|
+
handler_ms = (reaper.time_precise() - handler_start) * 1000
|
|
2948
|
+
|
|
2794
2949
|
if not ok then
|
|
2795
2950
|
-- pcall caught a Lua runtime error — handler crashed
|
|
2796
|
-
|
|
2951
|
+
local err_str = tostring(data_or_err)
|
|
2952
|
+
log_event("handler_error", { commandType = cmd.type, commandId = cmd.id, error = err_str })
|
|
2953
|
+
response = { id = cmd.id, success = false, error = "Handler error: " .. err_str, timestamp = os.time() * 1000 }
|
|
2797
2954
|
elseif err_msg then
|
|
2798
2955
|
-- Handler returned (nil, errorString)
|
|
2799
2956
|
response = { id = cmd.id, success = false, error = err_msg, timestamp = os.time() * 1000 }
|
|
@@ -2802,6 +2959,7 @@ local function process_command(filename)
|
|
|
2802
2959
|
response = { id = cmd.id, success = true, data = data_or_err, timestamp = os.time() * 1000 }
|
|
2803
2960
|
end
|
|
2804
2961
|
else
|
|
2962
|
+
log_event("handler_unknown", { commandType = cmd.type, commandId = cmd.id })
|
|
2805
2963
|
response = {
|
|
2806
2964
|
id = cmd.id,
|
|
2807
2965
|
success = false,
|
|
@@ -2810,6 +2968,9 @@ local function process_command(filename)
|
|
|
2810
2968
|
}
|
|
2811
2969
|
end
|
|
2812
2970
|
|
|
2971
|
+
-- Add handler timing to response (rounded to microsecond precision)
|
|
2972
|
+
response._handlerMs = math.floor(handler_ms * 1000 + 0.5) / 1000
|
|
2973
|
+
|
|
2813
2974
|
-- Add pickup timing to response for profiling
|
|
2814
2975
|
if cmd.timestamp then
|
|
2815
2976
|
response._pickupMs = math.floor((pickup_time - (cmd.timestamp / 1000)) * 1000 + 0.5)
|
|
@@ -2887,5 +3048,11 @@ reaper.ShowConsoleMsg("MCP Bridge: Polling every " .. (POLL_INTERVAL * 1000) ..
|
|
|
2887
3048
|
-- Write initial heartbeat
|
|
2888
3049
|
write_heartbeat()
|
|
2889
3050
|
|
|
3051
|
+
-- Log bridge start event for OTel consumption
|
|
3052
|
+
log_event("bridge_start", { reaperVersion = reaper.GetAppVersion(), bridgeDir = bridge_dir })
|
|
3053
|
+
|
|
3054
|
+
-- Register shutdown handler
|
|
3055
|
+
reaper.atexit(function() log_event("bridge_stop", {}) end)
|
|
3056
|
+
|
|
2890
3057
|
-- Start the loop
|
|
2891
3058
|
main_loop()
|