@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mthines/reaper-mcp",
3
- "version": "0.18.0",
3
+ "version": "0.19.0-beta.20.1",
4
4
  "type": "module",
5
5
  "description": "MCP server for controlling REAPER DAW — real-time mixing, FX control, and frequency analysis for AI agents",
6
6
  "license": "MIT",
@@ -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.