@mthines/reaper-mcp 0.18.0 → 0.19.0-beta.20.1
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 +57 -0
- package/package.json +1 -1
- package/reaper/mcp_bridge.lua +113 -0
- package/reaper/mcp_midi_emitter.jsfx +44 -0
package/main.js
CHANGED
|
@@ -1375,6 +1375,59 @@ function registerMidiTools(server) {
|
|
|
1375
1375
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
1376
1376
|
}
|
|
1377
1377
|
);
|
|
1378
|
+
server.tool(
|
|
1379
|
+
"send_midi_cc",
|
|
1380
|
+
"Send a MIDI CC (continuous controller) event in real-time to a track's MIDI output. Auto-inserts the MCP MIDI Emitter JSFX if not present. Common CC numbers: 1=modulation, 7=volume, 10=pan, 11=expression, 64=sustain, 74=filter cutoff.",
|
|
1381
|
+
{
|
|
1382
|
+
trackIndex: z10.coerce.number().min(0).describe("0-based track index"),
|
|
1383
|
+
cc: z10.coerce.number().min(0).max(127).describe("CC number (0-127)"),
|
|
1384
|
+
value: z10.coerce.number().min(0).max(127).describe("CC value (0-127)"),
|
|
1385
|
+
channel: z10.coerce.number().min(0).max(15).optional().describe("MIDI channel 0-15 (default 0)")
|
|
1386
|
+
},
|
|
1387
|
+
async ({ trackIndex, cc, value, channel }) => {
|
|
1388
|
+
const res = await sendCommand("send_midi_cc", { trackIndex, cc, value, channel: channel ?? 0 });
|
|
1389
|
+
if (!res.success) {
|
|
1390
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
|
|
1391
|
+
}
|
|
1392
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
1393
|
+
}
|
|
1394
|
+
);
|
|
1395
|
+
server.tool(
|
|
1396
|
+
"send_midi_pc",
|
|
1397
|
+
"Send a MIDI program change in real-time, optionally preceded by bank select (CC0 MSB + CC32 LSB). Auto-inserts the MCP MIDI Emitter JSFX if not present.",
|
|
1398
|
+
{
|
|
1399
|
+
trackIndex: z10.coerce.number().min(0).describe("0-based track index"),
|
|
1400
|
+
program: z10.coerce.number().min(0).max(127).describe("Program number (0-127)"),
|
|
1401
|
+
channel: z10.coerce.number().min(0).max(15).optional().describe("MIDI channel 0-15 (default 0)"),
|
|
1402
|
+
bankMsb: z10.coerce.number().min(0).max(127).optional().describe("Bank select MSB (CC0, 0-127) \u2014 omit if no bank select needed"),
|
|
1403
|
+
bankLsb: z10.coerce.number().min(0).max(127).optional().describe("Bank select LSB (CC32, 0-127) \u2014 omit if no bank select needed")
|
|
1404
|
+
},
|
|
1405
|
+
async ({ trackIndex, program, channel, bankMsb, bankLsb }) => {
|
|
1406
|
+
const res = await sendCommand("send_midi_pc", { trackIndex, program, channel: channel ?? 0, bankMsb, bankLsb });
|
|
1407
|
+
if (!res.success) {
|
|
1408
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
|
|
1409
|
+
}
|
|
1410
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
1411
|
+
}
|
|
1412
|
+
);
|
|
1413
|
+
server.tool(
|
|
1414
|
+
"send_midi_note",
|
|
1415
|
+
"Send a MIDI note-on in real-time to a track's MIDI output. If durationMs is provided, a note-off is automatically scheduled after that many milliseconds. Auto-inserts the MCP MIDI Emitter JSFX if not present.",
|
|
1416
|
+
{
|
|
1417
|
+
trackIndex: z10.coerce.number().min(0).describe("0-based track index"),
|
|
1418
|
+
pitch: z10.coerce.number().min(0).max(127).describe("MIDI note number (0-127, 60=C4/Middle C)"),
|
|
1419
|
+
velocity: z10.coerce.number().min(1).max(127).describe("Note velocity (1-127)"),
|
|
1420
|
+
channel: z10.coerce.number().min(0).max(15).optional().describe("MIDI channel 0-15 (default 0)"),
|
|
1421
|
+
durationMs: z10.coerce.number().min(1).int().optional().describe("Note duration in milliseconds (\u22651) \u2014 if provided, schedules an automatic note-off")
|
|
1422
|
+
},
|
|
1423
|
+
async ({ trackIndex, pitch, velocity, channel, durationMs }) => {
|
|
1424
|
+
const res = await sendCommand("send_midi_note", { trackIndex, pitch, velocity, channel: channel ?? 0, durationMs });
|
|
1425
|
+
if (!res.success) {
|
|
1426
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
|
|
1427
|
+
}
|
|
1428
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
1429
|
+
}
|
|
1430
|
+
);
|
|
1378
1431
|
}
|
|
1379
1432
|
|
|
1380
1433
|
// apps/reaper-mcp-server/src/tools/media.ts
|
|
@@ -2307,6 +2360,7 @@ var REAPER_ASSETS = [
|
|
|
2307
2360
|
"mcp_lufs_meter.jsfx",
|
|
2308
2361
|
"mcp_correlation_meter.jsfx",
|
|
2309
2362
|
"mcp_crest_factor.jsfx",
|
|
2363
|
+
"mcp_midi_emitter.jsfx",
|
|
2310
2364
|
"mcp_snapshot_manager.lua",
|
|
2311
2365
|
"mcp_snapshot_lib.lua",
|
|
2312
2366
|
"mcp_snapshot_next.lua",
|
|
@@ -2367,6 +2421,9 @@ var MCP_TOOL_NAMES = [
|
|
|
2367
2421
|
"delete_midi_cc",
|
|
2368
2422
|
"get_midi_item_properties",
|
|
2369
2423
|
"set_midi_item_properties",
|
|
2424
|
+
"send_midi_cc",
|
|
2425
|
+
"send_midi_pc",
|
|
2426
|
+
"send_midi_note",
|
|
2370
2427
|
// media
|
|
2371
2428
|
"list_media_items",
|
|
2372
2429
|
"get_media_item_properties",
|
package/package.json
CHANGED
package/reaper/mcp_bridge.lua
CHANGED
|
@@ -1532,6 +1532,7 @@ end
|
|
|
1532
1532
|
local MCP_LUFS_METER_FX_NAME = "reaper-mcp/mcp_lufs_meter"
|
|
1533
1533
|
local MCP_CORRELATION_METER_FX_NAME = "reaper-mcp/mcp_correlation_meter"
|
|
1534
1534
|
local MCP_CREST_FACTOR_FX_NAME = "reaper-mcp/mcp_crest_factor"
|
|
1535
|
+
local MCP_MIDI_EMITTER_FX_NAME = "reaper-mcp/mcp_midi_emitter"
|
|
1535
1536
|
local MCP_FX_PREFIX = "reaper%-mcp/" -- Lua pattern for matching MCP JSFX names
|
|
1536
1537
|
|
|
1537
1538
|
-- Per-track cache of MCP container FX index to avoid rescanning on every read.
|
|
@@ -3499,6 +3500,118 @@ function handlers.insert_envelope_points(params)
|
|
|
3499
3500
|
}
|
|
3500
3501
|
end
|
|
3501
3502
|
|
|
3503
|
+
-- =============================================================================
|
|
3504
|
+
-- Live MIDI output handlers
|
|
3505
|
+
-- =============================================================================
|
|
3506
|
+
|
|
3507
|
+
-- Write a single 3-byte MIDI event to the MCPMidiEmitter gmem ring buffer.
|
|
3508
|
+
-- status: fully-formed status byte (caller is responsible for ORing channel)
|
|
3509
|
+
-- d1, d2: data bytes
|
|
3510
|
+
-- Caller must have called reaper.gmem_attach("MCPMidiEmitter") before the
|
|
3511
|
+
-- first write in a handler invocation — gmem_attach is not repeated here.
|
|
3512
|
+
-- Lua is single-threaded relative to JSFX block boundaries; no lock needed.
|
|
3513
|
+
-- IMPORTANT: slot bytes (2+slot*3) must be written BEFORE advancing write_head
|
|
3514
|
+
-- (gmem[0]). The JSFX reads write_head first; advancing it early exposes an
|
|
3515
|
+
-- incompletely-written slot to the audio thread.
|
|
3516
|
+
local function write_midi_to_ring(status, d1, d2)
|
|
3517
|
+
local slot = math.floor(reaper.gmem_read(0)) % 16
|
|
3518
|
+
reaper.gmem_write(2 + slot*3 + 0, status)
|
|
3519
|
+
reaper.gmem_write(2 + slot*3 + 1, d1)
|
|
3520
|
+
reaper.gmem_write(2 + slot*3 + 2, d2)
|
|
3521
|
+
reaper.gmem_write(0, (slot + 1) % 16)
|
|
3522
|
+
end
|
|
3523
|
+
|
|
3524
|
+
function handlers.send_midi_cc(params)
|
|
3525
|
+
local idx = params.trackIndex
|
|
3526
|
+
if idx == nil then return nil, "trackIndex required" end
|
|
3527
|
+
if params.cc == nil then return nil, "cc required" end
|
|
3528
|
+
if params.value == nil then return nil, "value required" end
|
|
3529
|
+
|
|
3530
|
+
local track = reaper.GetTrack(0, idx)
|
|
3531
|
+
if not track then return nil, "Track " .. idx .. " not found" end
|
|
3532
|
+
|
|
3533
|
+
local channel = math.max(0, math.min(15, math.floor(params.channel or 0)))
|
|
3534
|
+
local ok, err = ensure_jsfx_on_track(track, MCP_MIDI_EMITTER_FX_NAME)
|
|
3535
|
+
if not ok then return nil, err end
|
|
3536
|
+
|
|
3537
|
+
reaper.gmem_attach("MCPMidiEmitter")
|
|
3538
|
+
-- status byte: 0xB0 = CC, OR with channel (0-15)
|
|
3539
|
+
write_midi_to_ring(0xB0 | channel, math.floor(params.cc), math.floor(params.value))
|
|
3540
|
+
|
|
3541
|
+
-- timestampMs uses REAPER's internal high-resolution clock (not wall-clock epoch).
|
|
3542
|
+
-- Suitable for round-trip latency measurement within a session.
|
|
3543
|
+
return { sent = true, timestampMs = math.floor(reaper.time_precise() * 1000) }
|
|
3544
|
+
end
|
|
3545
|
+
|
|
3546
|
+
function handlers.send_midi_pc(params)
|
|
3547
|
+
local idx = params.trackIndex
|
|
3548
|
+
if idx == nil then return nil, "trackIndex required" end
|
|
3549
|
+
if params.program == nil then return nil, "program required" end
|
|
3550
|
+
|
|
3551
|
+
local track = reaper.GetTrack(0, idx)
|
|
3552
|
+
if not track then return nil, "Track " .. idx .. " not found" end
|
|
3553
|
+
|
|
3554
|
+
local channel = math.max(0, math.min(15, math.floor(params.channel or 0)))
|
|
3555
|
+
local ok, err = ensure_jsfx_on_track(track, MCP_MIDI_EMITTER_FX_NAME)
|
|
3556
|
+
if not ok then return nil, err end
|
|
3557
|
+
|
|
3558
|
+
reaper.gmem_attach("MCPMidiEmitter")
|
|
3559
|
+
|
|
3560
|
+
-- Bank select CC0 (MSB) — use ~= nil so that bank byte value 0 is not dropped
|
|
3561
|
+
if params.bankMsb ~= nil then
|
|
3562
|
+
write_midi_to_ring(0xB0 | channel, 0, math.floor(params.bankMsb))
|
|
3563
|
+
end
|
|
3564
|
+
|
|
3565
|
+
-- Bank select CC32 (LSB) — same nil-check rationale
|
|
3566
|
+
if params.bankLsb ~= nil then
|
|
3567
|
+
write_midi_to_ring(0xB0 | channel, 32, math.floor(params.bankLsb))
|
|
3568
|
+
end
|
|
3569
|
+
|
|
3570
|
+
-- Program change: data2 is always 0 per MIDI 1.0 spec
|
|
3571
|
+
write_midi_to_ring(0xC0 | channel, math.floor(params.program), 0)
|
|
3572
|
+
|
|
3573
|
+
return { sent = true, timestampMs = math.floor(reaper.time_precise() * 1000) }
|
|
3574
|
+
end
|
|
3575
|
+
|
|
3576
|
+
function handlers.send_midi_note(params)
|
|
3577
|
+
local idx = params.trackIndex
|
|
3578
|
+
if idx == nil then return nil, "trackIndex required" end
|
|
3579
|
+
if params.pitch == nil then return nil, "pitch required" end
|
|
3580
|
+
if params.velocity == nil then return nil, "velocity required" end
|
|
3581
|
+
|
|
3582
|
+
local track = reaper.GetTrack(0, idx)
|
|
3583
|
+
if not track then return nil, "Track " .. idx .. " not found" end
|
|
3584
|
+
|
|
3585
|
+
local channel = math.max(0, math.min(15, math.floor(params.channel or 0)))
|
|
3586
|
+
local ok, err = ensure_jsfx_on_track(track, MCP_MIDI_EMITTER_FX_NAME)
|
|
3587
|
+
if not ok then return nil, err end
|
|
3588
|
+
|
|
3589
|
+
local pitch = math.floor(params.pitch)
|
|
3590
|
+
local velocity = math.floor(params.velocity)
|
|
3591
|
+
|
|
3592
|
+
reaper.gmem_attach("MCPMidiEmitter")
|
|
3593
|
+
-- Write note-on event: status 0x90 = note-on, OR with channel
|
|
3594
|
+
write_midi_to_ring(0x90 | channel, pitch, velocity)
|
|
3595
|
+
|
|
3596
|
+
-- Schedule deferred note-off if durationMs is provided
|
|
3597
|
+
if params.durationMs ~= nil then
|
|
3598
|
+
local deadline = reaper.time_precise() + (params.durationMs / 1000.0)
|
|
3599
|
+
local function note_off_callback()
|
|
3600
|
+
if reaper.time_precise() >= deadline then
|
|
3601
|
+
-- Re-attach gmem: deferred callbacks run outside the handler invocation.
|
|
3602
|
+
reaper.gmem_attach("MCPMidiEmitter")
|
|
3603
|
+
-- Write note-off event: status 0x80 = note-off, velocity 0
|
|
3604
|
+
write_midi_to_ring(0x80 | channel, pitch, 0)
|
|
3605
|
+
else
|
|
3606
|
+
reaper.defer(note_off_callback)
|
|
3607
|
+
end
|
|
3608
|
+
end
|
|
3609
|
+
reaper.defer(note_off_callback)
|
|
3610
|
+
end
|
|
3611
|
+
|
|
3612
|
+
return { sent = true, timestampMs = math.floor(reaper.time_precise() * 1000) }
|
|
3613
|
+
end
|
|
3614
|
+
|
|
3502
3615
|
-- =============================================================================
|
|
3503
3616
|
-- Bridge diagnostics handler
|
|
3504
3617
|
-- =============================================================================
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
desc:MCP MIDI Emitter
|
|
2
|
+
// Real-time MIDI event emitter for MCP bridge integration.
|
|
3
|
+
// Drains a 16-slot gmem ring buffer (written by the Lua bridge) and
|
|
4
|
+
// emits each event via midisend() in @block.
|
|
5
|
+
//
|
|
6
|
+
// gmem layout (namespace MCPMidiEmitter):
|
|
7
|
+
// gmem[0] = write_head (Lua advances, 0..15 wrapping)
|
|
8
|
+
// gmem[1] = read_head (JSFX advances, 0..15 wrapping)
|
|
9
|
+
// gmem[2 + slot*3 + 0] = status byte (e.g. 0xB0 | channel for CC)
|
|
10
|
+
// gmem[2 + slot*3 + 1] = data1 byte (CC number, note pitch, program number)
|
|
11
|
+
// gmem[2 + slot*3 + 2] = data2 byte (CC value, velocity, 0 for PC)
|
|
12
|
+
//
|
|
13
|
+
// 16 slots total. Total gmem footprint: 2 + 16*3 = 50 cells.
|
|
14
|
+
//
|
|
15
|
+
// Ring overflow guard: not needed. Lua is single-threaded relative to JSFX
|
|
16
|
+
// block boundaries. JSFX drains per block (~3ms at 256/48kHz). At realistic
|
|
17
|
+
// AI-agent call rates, the ring never fills.
|
|
18
|
+
//
|
|
19
|
+
// No sliders needed. Passes audio through unmodified.
|
|
20
|
+
|
|
21
|
+
options:gmem=MCPMidiEmitter
|
|
22
|
+
|
|
23
|
+
@init
|
|
24
|
+
read_head = 0;
|
|
25
|
+
// Initialise read_head in gmem[1] so the first @block drain is consistent.
|
|
26
|
+
// write_head (gmem[0]) starts at 0 by JSFX spec (gmem cells default to 0).
|
|
27
|
+
gmem[1] = 0;
|
|
28
|
+
|
|
29
|
+
@block
|
|
30
|
+
// Drain all pending MIDI events from the ring buffer.
|
|
31
|
+
// write_head is at gmem[0], read_head is at gmem[1].
|
|
32
|
+
write_head = gmem[0];
|
|
33
|
+
read_head = gmem[1];
|
|
34
|
+
// EEL2 loop() takes a COUNT, not a condition — use while() for a drain loop.
|
|
35
|
+
while (read_head != write_head) (
|
|
36
|
+
status = gmem[2 + read_head*3 + 0];
|
|
37
|
+
d1 = gmem[2 + read_head*3 + 1];
|
|
38
|
+
d2 = gmem[2 + read_head*3 + 2];
|
|
39
|
+
midisend(0, status, d1, d2);
|
|
40
|
+
read_head = (read_head + 1) % 16;
|
|
41
|
+
gmem[1] = read_head;
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// No @sample section — passes audio through unmodified.
|