@mthines/reaper-mcp 0.4.0 → 0.6.0-beta.3.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
@@ -517,6 +517,424 @@ function registerAnalysisTools(server) {
517
517
  );
518
518
  }
519
519
 
520
+ // apps/reaper-mcp-server/src/tools/midi.ts
521
+ import { z as z10 } from "zod/v4";
522
+ function registerMidiTools(server) {
523
+ server.tool(
524
+ "create_midi_item",
525
+ "Create an empty MIDI item on a track at a given time range (seconds). Use this before inserting notes.",
526
+ {
527
+ trackIndex: z10.coerce.number().min(0).describe("0-based track index"),
528
+ startPosition: z10.coerce.number().min(0).describe("Start position in seconds from project start"),
529
+ endPosition: z10.coerce.number().min(0).describe("End position in seconds from project start")
530
+ },
531
+ async ({ trackIndex, startPosition, endPosition }) => {
532
+ const res = await sendCommand("create_midi_item", { trackIndex, startPosition, endPosition });
533
+ if (!res.success) {
534
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
535
+ }
536
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
537
+ }
538
+ );
539
+ server.tool(
540
+ "list_midi_items",
541
+ "List all MIDI items on a track with position, length, and note/CC counts",
542
+ {
543
+ trackIndex: z10.coerce.number().min(0).describe("0-based track index")
544
+ },
545
+ async ({ trackIndex }) => {
546
+ const res = await sendCommand("list_midi_items", { trackIndex });
547
+ if (!res.success) {
548
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
549
+ }
550
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
551
+ }
552
+ );
553
+ server.tool(
554
+ "get_midi_notes",
555
+ "Get all MIDI notes in a MIDI item. Returns pitch (0-127, 60=C4), velocity (0-127), position and duration in beats from item start.",
556
+ {
557
+ trackIndex: z10.coerce.number().min(0).describe("0-based track index"),
558
+ itemIndex: z10.coerce.number().min(0).describe("0-based item index on the track")
559
+ },
560
+ async ({ trackIndex, itemIndex }) => {
561
+ const res = await sendCommand("get_midi_notes", { trackIndex, itemIndex });
562
+ if (!res.success) {
563
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
564
+ }
565
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
566
+ }
567
+ );
568
+ server.tool(
569
+ "insert_midi_note",
570
+ "Insert a single MIDI note. Pitch: 0-127 (60=C4/Middle C, 48=C3, 72=C5). Velocity: 1-127 (64=medium, 100=strong, 127=max). Position/duration in beats from item start (1.0=quarter, 0.5=eighth, 0.25=sixteenth).",
571
+ {
572
+ trackIndex: z10.coerce.number().min(0).describe("0-based track index"),
573
+ itemIndex: z10.coerce.number().min(0).describe("0-based item index on the track"),
574
+ pitch: z10.coerce.number().min(0).max(127).describe("MIDI note number (60=C4/Middle C)"),
575
+ velocity: z10.coerce.number().min(1).max(127).describe("Note velocity (1-127)"),
576
+ startPosition: z10.coerce.number().min(0).describe("Start position in beats from item start"),
577
+ duration: z10.coerce.number().min(0).describe("Duration in beats (1.0=quarter note)"),
578
+ channel: z10.coerce.number().min(0).max(15).optional().describe("MIDI channel 0-15 (default 0)")
579
+ },
580
+ async ({ trackIndex, itemIndex, pitch, velocity, startPosition, duration, channel }) => {
581
+ const res = await sendCommand("insert_midi_note", {
582
+ trackIndex,
583
+ itemIndex,
584
+ pitch,
585
+ velocity,
586
+ startPosition,
587
+ duration,
588
+ channel: channel ?? 0
589
+ });
590
+ if (!res.success) {
591
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
592
+ }
593
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
594
+ }
595
+ );
596
+ server.tool(
597
+ "insert_midi_notes",
598
+ 'Batch insert multiple MIDI notes. Pass a JSON array of notes as a string. Each note: { "pitch": 60, "velocity": 100, "startPosition": 0.0, "duration": 1.0, "channel": 0 }. Positions/durations in beats from item start.',
599
+ {
600
+ trackIndex: z10.coerce.number().min(0).describe("0-based track index"),
601
+ itemIndex: z10.coerce.number().min(0).describe("0-based item index on the track"),
602
+ notes: z10.string().describe('JSON array string of notes: [{"pitch":60,"velocity":100,"startPosition":0,"duration":1,"channel":0}, ...]')
603
+ },
604
+ async ({ trackIndex, itemIndex, notes }) => {
605
+ const res = await sendCommand("insert_midi_notes", { trackIndex, itemIndex, notes });
606
+ if (!res.success) {
607
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
608
+ }
609
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
610
+ }
611
+ );
612
+ server.tool(
613
+ "edit_midi_note",
614
+ "Edit an existing MIDI note by index. Only provided fields are changed. Positions/durations in beats from item start.",
615
+ {
616
+ trackIndex: z10.coerce.number().min(0).describe("0-based track index"),
617
+ itemIndex: z10.coerce.number().min(0).describe("0-based item index on the track"),
618
+ noteIndex: z10.coerce.number().min(0).describe("0-based note index"),
619
+ pitch: z10.coerce.number().min(0).max(127).optional().describe("New pitch (0-127)"),
620
+ velocity: z10.coerce.number().min(1).max(127).optional().describe("New velocity (1-127)"),
621
+ startPosition: z10.coerce.number().min(0).optional().describe("New start position in beats from item start"),
622
+ duration: z10.coerce.number().min(0).optional().describe("New duration in beats"),
623
+ channel: z10.coerce.number().min(0).max(15).optional().describe("New MIDI channel (0-15)")
624
+ },
625
+ async ({ trackIndex, itemIndex, noteIndex, pitch, velocity, startPosition, duration, channel }) => {
626
+ const res = await sendCommand("edit_midi_note", {
627
+ trackIndex,
628
+ itemIndex,
629
+ noteIndex,
630
+ pitch,
631
+ velocity,
632
+ startPosition,
633
+ duration,
634
+ channel
635
+ });
636
+ if (!res.success) {
637
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
638
+ }
639
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
640
+ }
641
+ );
642
+ server.tool(
643
+ "delete_midi_note",
644
+ "Delete a MIDI note by index from a MIDI item",
645
+ {
646
+ trackIndex: z10.coerce.number().min(0).describe("0-based track index"),
647
+ itemIndex: z10.coerce.number().min(0).describe("0-based item index on the track"),
648
+ noteIndex: z10.coerce.number().min(0).describe("0-based note index to delete")
649
+ },
650
+ async ({ trackIndex, itemIndex, noteIndex }) => {
651
+ const res = await sendCommand("delete_midi_note", { trackIndex, itemIndex, noteIndex });
652
+ if (!res.success) {
653
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
654
+ }
655
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
656
+ }
657
+ );
658
+ server.tool(
659
+ "get_midi_cc",
660
+ "Get CC (continuous controller) events from a MIDI item. Optionally filter by CC number (e.g. 1=modulation, 7=volume, 10=pan, 11=expression, 64=sustain).",
661
+ {
662
+ trackIndex: z10.coerce.number().min(0).describe("0-based track index"),
663
+ itemIndex: z10.coerce.number().min(0).describe("0-based item index on the track"),
664
+ ccNumber: z10.coerce.number().min(0).max(127).optional().describe("Optional: filter by CC number (0-127)")
665
+ },
666
+ async ({ trackIndex, itemIndex, ccNumber }) => {
667
+ const res = await sendCommand("get_midi_cc", { trackIndex, itemIndex, ccNumber });
668
+ if (!res.success) {
669
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
670
+ }
671
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
672
+ }
673
+ );
674
+ server.tool(
675
+ "insert_midi_cc",
676
+ "Insert a CC (continuous controller) event. Common CC numbers: 1=modulation, 7=volume, 10=pan, 11=expression, 64=sustain, 74=filter cutoff.",
677
+ {
678
+ trackIndex: z10.coerce.number().min(0).describe("0-based track index"),
679
+ itemIndex: z10.coerce.number().min(0).describe("0-based item index on the track"),
680
+ ccNumber: z10.coerce.number().min(0).max(127).describe("CC number (0-127)"),
681
+ value: z10.coerce.number().min(0).max(127).describe("CC value (0-127)"),
682
+ position: z10.coerce.number().min(0).describe("Position in beats from item start"),
683
+ channel: z10.coerce.number().min(0).max(15).optional().describe("MIDI channel 0-15 (default 0)")
684
+ },
685
+ async ({ trackIndex, itemIndex, ccNumber, value, position, channel }) => {
686
+ const res = await sendCommand("insert_midi_cc", {
687
+ trackIndex,
688
+ itemIndex,
689
+ ccNumber,
690
+ value,
691
+ position,
692
+ channel: channel ?? 0
693
+ });
694
+ if (!res.success) {
695
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
696
+ }
697
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
698
+ }
699
+ );
700
+ server.tool(
701
+ "delete_midi_cc",
702
+ "Delete a CC event by index from a MIDI item",
703
+ {
704
+ trackIndex: z10.coerce.number().min(0).describe("0-based track index"),
705
+ itemIndex: z10.coerce.number().min(0).describe("0-based item index on the track"),
706
+ ccIndex: z10.coerce.number().min(0).describe("0-based CC event index to delete")
707
+ },
708
+ async ({ trackIndex, itemIndex, ccIndex }) => {
709
+ const res = await sendCommand("delete_midi_cc", { trackIndex, itemIndex, ccIndex });
710
+ if (!res.success) {
711
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
712
+ }
713
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
714
+ }
715
+ );
716
+ server.tool(
717
+ "get_midi_item_properties",
718
+ "Get detailed properties of a MIDI item including position, length, note count, CC count, mute state, and loop setting",
719
+ {
720
+ trackIndex: z10.coerce.number().min(0).describe("0-based track index"),
721
+ itemIndex: z10.coerce.number().min(0).describe("0-based item index on the track")
722
+ },
723
+ async ({ trackIndex, itemIndex }) => {
724
+ const res = await sendCommand("get_midi_item_properties", { trackIndex, itemIndex });
725
+ if (!res.success) {
726
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
727
+ }
728
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
729
+ }
730
+ );
731
+ server.tool(
732
+ "set_midi_item_properties",
733
+ "Set MIDI item properties: position (seconds), length (seconds), mute, loop source",
734
+ {
735
+ trackIndex: z10.coerce.number().min(0).describe("0-based track index"),
736
+ itemIndex: z10.coerce.number().min(0).describe("0-based item index on the track"),
737
+ position: z10.coerce.number().min(0).optional().describe("New position in seconds from project start"),
738
+ length: z10.coerce.number().min(0).optional().describe("New length in seconds"),
739
+ mute: z10.coerce.number().min(0).max(1).optional().describe("Mute state (0=unmuted, 1=muted)"),
740
+ loopSource: z10.coerce.number().min(0).max(1).optional().describe("Loop source (0=no loop, 1=loop)")
741
+ },
742
+ async ({ trackIndex, itemIndex, position, length, mute, loopSource }) => {
743
+ const res = await sendCommand("set_midi_item_properties", {
744
+ trackIndex,
745
+ itemIndex,
746
+ position,
747
+ length,
748
+ mute,
749
+ loopSource
750
+ });
751
+ if (!res.success) {
752
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
753
+ }
754
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
755
+ }
756
+ );
757
+ }
758
+
759
+ // apps/reaper-mcp-server/src/tools/media.ts
760
+ import { z as z11 } from "zod/v4";
761
+ function registerMediaTools(server) {
762
+ server.tool(
763
+ "list_media_items",
764
+ "List all media items on a track with position, length, name, volume, mute state, and whether it is MIDI or audio",
765
+ {
766
+ trackIndex: z11.coerce.number().min(0).describe("0-based track index")
767
+ },
768
+ async ({ trackIndex }) => {
769
+ const res = await sendCommand("list_media_items", { trackIndex });
770
+ if (!res.success) {
771
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
772
+ }
773
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
774
+ }
775
+ );
776
+ server.tool(
777
+ "get_media_item_properties",
778
+ "Get detailed properties of a media item: position, length, volume, fades, play rate, take info, source file",
779
+ {
780
+ trackIndex: z11.coerce.number().min(0).describe("0-based track index"),
781
+ itemIndex: z11.coerce.number().min(0).describe("0-based item index on the track")
782
+ },
783
+ async ({ trackIndex, itemIndex }) => {
784
+ const res = await sendCommand("get_media_item_properties", { trackIndex, itemIndex });
785
+ if (!res.success) {
786
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
787
+ }
788
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
789
+ }
790
+ );
791
+ server.tool(
792
+ "set_media_item_properties",
793
+ "Set media item properties: position (seconds), length (seconds), volume (dB), mute, fade in/out lengths, play rate",
794
+ {
795
+ trackIndex: z11.coerce.number().min(0).describe("0-based track index"),
796
+ itemIndex: z11.coerce.number().min(0).describe("0-based item index on the track"),
797
+ position: z11.coerce.number().min(0).optional().describe("New position in seconds"),
798
+ length: z11.coerce.number().min(0).optional().describe("New length in seconds"),
799
+ volume: z11.coerce.number().optional().describe("New volume in dB (0 = unity gain)"),
800
+ mute: z11.coerce.number().min(0).max(1).optional().describe("Mute state (0=unmuted, 1=muted)"),
801
+ fadeInLength: z11.coerce.number().min(0).optional().describe("Fade-in length in seconds"),
802
+ fadeOutLength: z11.coerce.number().min(0).optional().describe("Fade-out length in seconds"),
803
+ playRate: z11.coerce.number().min(0.1).max(10).optional().describe("Playback rate (1.0=normal, 0.5=half, 2.0=double)")
804
+ },
805
+ async ({ trackIndex, itemIndex, position, length, volume, mute, fadeInLength, fadeOutLength, playRate }) => {
806
+ const res = await sendCommand("set_media_item_properties", {
807
+ trackIndex,
808
+ itemIndex,
809
+ position,
810
+ length,
811
+ volume,
812
+ mute,
813
+ fadeInLength,
814
+ fadeOutLength,
815
+ playRate
816
+ });
817
+ if (!res.success) {
818
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
819
+ }
820
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
821
+ }
822
+ );
823
+ server.tool(
824
+ "split_media_item",
825
+ "Split a media item at a given position (absolute project time in seconds). Returns info about both resulting items.",
826
+ {
827
+ trackIndex: z11.coerce.number().min(0).describe("0-based track index"),
828
+ itemIndex: z11.coerce.number().min(0).describe("0-based item index on the track"),
829
+ position: z11.coerce.number().min(0).describe("Split position in seconds (absolute project time)")
830
+ },
831
+ async ({ trackIndex, itemIndex, position }) => {
832
+ const res = await sendCommand("split_media_item", { trackIndex, itemIndex, position });
833
+ if (!res.success) {
834
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
835
+ }
836
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
837
+ }
838
+ );
839
+ server.tool(
840
+ "delete_media_item",
841
+ "Delete a media item from a track",
842
+ {
843
+ trackIndex: z11.coerce.number().min(0).describe("0-based track index"),
844
+ itemIndex: z11.coerce.number().min(0).describe("0-based item index on the track")
845
+ },
846
+ async ({ trackIndex, itemIndex }) => {
847
+ const res = await sendCommand("delete_media_item", { trackIndex, itemIndex });
848
+ if (!res.success) {
849
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
850
+ }
851
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
852
+ }
853
+ );
854
+ server.tool(
855
+ "move_media_item",
856
+ "Move a media item to a new position and/or a different track",
857
+ {
858
+ trackIndex: z11.coerce.number().min(0).describe("0-based track index (current track)"),
859
+ itemIndex: z11.coerce.number().min(0).describe("0-based item index on the track"),
860
+ newPosition: z11.coerce.number().min(0).optional().describe("New position in seconds"),
861
+ newTrackIndex: z11.coerce.number().min(0).optional().describe("Move to this track (0-based index)")
862
+ },
863
+ async ({ trackIndex, itemIndex, newPosition, newTrackIndex }) => {
864
+ const res = await sendCommand("move_media_item", { trackIndex, itemIndex, newPosition, newTrackIndex });
865
+ if (!res.success) {
866
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
867
+ }
868
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
869
+ }
870
+ );
871
+ server.tool(
872
+ "trim_media_item",
873
+ "Trim a media item edges. Positive trimStart trims from the beginning (makes item shorter/later). Positive trimEnd trims from the end (makes item shorter). Negative values extend.",
874
+ {
875
+ trackIndex: z11.coerce.number().min(0).describe("0-based track index"),
876
+ itemIndex: z11.coerce.number().min(0).describe("0-based item index on the track"),
877
+ trimStart: z11.coerce.number().optional().describe("Seconds to trim from start (positive=trim in, negative=extend)"),
878
+ trimEnd: z11.coerce.number().optional().describe("Seconds to trim from end (positive=trim in, negative=extend)")
879
+ },
880
+ async ({ trackIndex, itemIndex, trimStart, trimEnd }) => {
881
+ const res = await sendCommand("trim_media_item", { trackIndex, itemIndex, trimStart, trimEnd });
882
+ if (!res.success) {
883
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
884
+ }
885
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
886
+ }
887
+ );
888
+ server.tool(
889
+ "add_stretch_marker",
890
+ "Add a stretch marker to a media item for time-stretching audio. Position is within the item (seconds from item start). Source position is the corresponding position in the original audio.",
891
+ {
892
+ trackIndex: z11.coerce.number().min(0).describe("0-based track index"),
893
+ itemIndex: z11.coerce.number().min(0).describe("0-based item index on the track"),
894
+ position: z11.coerce.number().min(0).describe("Position within the item in seconds (from item start)"),
895
+ sourcePosition: z11.coerce.number().min(0).optional().describe("Position in source audio in seconds (defaults to same as position)")
896
+ },
897
+ async ({ trackIndex, itemIndex, position, sourcePosition }) => {
898
+ const res = await sendCommand("add_stretch_marker", { trackIndex, itemIndex, position, sourcePosition });
899
+ if (!res.success) {
900
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
901
+ }
902
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
903
+ }
904
+ );
905
+ server.tool(
906
+ "get_stretch_markers",
907
+ "List all stretch markers in a media item with their positions and source positions",
908
+ {
909
+ trackIndex: z11.coerce.number().min(0).describe("0-based track index"),
910
+ itemIndex: z11.coerce.number().min(0).describe("0-based item index on the track")
911
+ },
912
+ async ({ trackIndex, itemIndex }) => {
913
+ const res = await sendCommand("get_stretch_markers", { trackIndex, itemIndex });
914
+ if (!res.success) {
915
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
916
+ }
917
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
918
+ }
919
+ );
920
+ server.tool(
921
+ "delete_stretch_marker",
922
+ "Delete a stretch marker by index from a media item",
923
+ {
924
+ trackIndex: z11.coerce.number().min(0).describe("0-based track index"),
925
+ itemIndex: z11.coerce.number().min(0).describe("0-based item index on the track"),
926
+ markerIndex: z11.coerce.number().min(0).describe("0-based stretch marker index")
927
+ },
928
+ async ({ trackIndex, itemIndex, markerIndex }) => {
929
+ const res = await sendCommand("delete_stretch_marker", { trackIndex, itemIndex, markerIndex });
930
+ if (!res.success) {
931
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
932
+ }
933
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
934
+ }
935
+ );
936
+ }
937
+
520
938
  // apps/reaper-mcp-server/src/server.ts
521
939
  function createServer() {
522
940
  const server = new McpServer({
@@ -533,6 +951,8 @@ function createServer() {
533
951
  registerSnapshotTools(server);
534
952
  registerRoutingTools(server);
535
953
  registerAnalysisTools(server);
954
+ registerMidiTools(server);
955
+ registerMediaTools(server);
536
956
  return server;
537
957
  }
538
958
 
@@ -631,7 +1051,7 @@ async function setup() {
631
1051
  console.log(" 2. Actions > Show action list > Load ReaScript");
632
1052
  console.log(` 3. Select: ${luaDest}`);
633
1053
  console.log(" 4. Run the script (it will keep running in background via defer loop)");
634
- console.log(" 5. Add reaper-mcp to your Claude Code config (see: reaper-mcp doctor)");
1054
+ console.log(" 5. Add reaper-mcp to your Claude Code config (see: npx @mthines/reaper-mcp doctor)");
635
1055
  }
636
1056
  async function installSkills() {
637
1057
  console.log("REAPER MCP \u2014 Install AI Mix Engineer Skills\n");
@@ -691,22 +1111,22 @@ async function doctor() {
691
1111
  const bridgeRunning = await isBridgeRunning();
692
1112
  console.log(`Lua bridge: ${bridgeRunning ? "\u2713 Connected" : "\u2717 Not detected"}`);
693
1113
  if (!bridgeRunning) {
694
- console.log(' \u2192 Run "reaper-mcp setup" then load mcp_bridge.lua in REAPER');
1114
+ console.log(' \u2192 Run "npx @mthines/reaper-mcp setup" then load mcp_bridge.lua in REAPER');
695
1115
  }
696
1116
  const agentsExist = existsSync2(join3(process.cwd(), ".claude", "agents"));
697
1117
  console.log(`Mix agents: ${agentsExist ? "\u2713 Found (.claude/agents/)" : "\u2717 Not installed"}`);
698
1118
  if (!agentsExist) {
699
- console.log(' \u2192 Run "reaper-mcp install-skills" in your project directory');
1119
+ console.log(' \u2192 Run "npx @mthines/reaper-mcp install-skills" in your project directory');
700
1120
  }
701
1121
  const knowledgeExists = existsSync2(join3(process.cwd(), "knowledge"));
702
1122
  console.log(`Knowledge base: ${knowledgeExists ? "\u2713 Found in project" : "\u2717 Not installed"}`);
703
1123
  if (!knowledgeExists) {
704
- console.log(' \u2192 Run "reaper-mcp install-skills" in your project directory');
1124
+ console.log(' \u2192 Run "npx @mthines/reaper-mcp install-skills" in your project directory');
705
1125
  }
706
1126
  const mcpJsonExists = existsSync2(join3(process.cwd(), ".mcp.json"));
707
1127
  console.log(`MCP config: ${mcpJsonExists ? "\u2713 .mcp.json found" : "\u2717 .mcp.json missing"}`);
708
1128
  if (!mcpJsonExists) {
709
- console.log(' \u2192 Run "reaper-mcp install-skills" to create .mcp.json');
1129
+ console.log(' \u2192 Run "npx @mthines/reaper-mcp install-skills" to create .mcp.json');
710
1130
  }
711
1131
  console.log('\nTo check SWS Extensions, start REAPER and use the "list_available_fx" tool.');
712
1132
  console.log("SWS provides enhanced plugin discovery and snapshot support.\n");
@@ -725,7 +1145,7 @@ async function serve() {
725
1145
  if (!bridgeRunning) {
726
1146
  log("WARNING: Lua bridge does not appear to be running in REAPER.");
727
1147
  log("Commands will timeout until the bridge script is started.");
728
- log('Run "reaper-mcp setup" for installation instructions.');
1148
+ log('Run "npx @mthines/reaper-mcp setup" for installation instructions.');
729
1149
  } else {
730
1150
  log("Lua bridge detected \u2014 connected to REAPER");
731
1151
  }
@@ -773,18 +1193,22 @@ switch (command) {
773
1193
  console.log(`reaper-mcp \u2014 AI-powered mixing for REAPER DAW
774
1194
 
775
1195
  Usage:
776
- reaper-mcp Start MCP server (stdio mode)
777
- reaper-mcp serve Start MCP server (stdio mode)
778
- reaper-mcp setup Install Lua bridge + JSFX analyzers into REAPER
779
- reaper-mcp install-skills Install AI mix engineer knowledge into your project
780
- reaper-mcp doctor Check that everything is configured correctly
781
- reaper-mcp status Check if Lua bridge is running in REAPER
1196
+ npx @mthines/reaper-mcp Start MCP server (stdio mode)
1197
+ npx @mthines/reaper-mcp serve Start MCP server (stdio mode)
1198
+ npx @mthines/reaper-mcp setup Install Lua bridge + JSFX analyzers into REAPER
1199
+ npx @mthines/reaper-mcp install-skills Install AI mix engineer knowledge + agents into your project
1200
+ npx @mthines/reaper-mcp doctor Check that everything is configured correctly
1201
+ npx @mthines/reaper-mcp status Check if Lua bridge is running in REAPER
782
1202
 
783
1203
  Quick Start:
784
- 1. reaper-mcp setup # install REAPER components
1204
+ 1. npx @mthines/reaper-mcp setup # install REAPER components
785
1205
  2. Load mcp_bridge.lua in REAPER (Actions > Load ReaScript > Run)
786
- 3. reaper-mcp install-skills # install AI knowledge in your project
787
- 4. Open Claude Code \u2014 REAPER tools + mix engineer brain are ready
1206
+ 3. npx @mthines/reaper-mcp install-skills # install AI knowledge + agents
1207
+ 4. Open Claude Code \u2014 REAPER tools + mix engineer agents are ready
1208
+
1209
+ Tip: install globally for shorter commands:
1210
+ npm install -g @mthines/reaper-mcp
1211
+ reaper-mcp setup
788
1212
  `);
789
1213
  break;
790
1214
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mthines/reaper-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.6.0-beta.3.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",
@@ -1009,6 +1009,781 @@ function handlers.read_track_crest(params)
1009
1009
  }
1010
1010
  end
1011
1011
 
1012
+ -- =============================================================================
1013
+ -- MIDI editing handlers
1014
+ -- =============================================================================
1015
+
1016
+ -- Helper: get a MIDI take from track/item indices, with validation
1017
+ local function get_midi_take(params)
1018
+ local track_idx = params.trackIndex
1019
+ local item_idx = params.itemIndex
1020
+ if track_idx == nil then return nil, nil, nil, "trackIndex required" end
1021
+ if item_idx == nil then return nil, nil, nil, "itemIndex required" end
1022
+
1023
+ local track = reaper.GetTrack(0, track_idx)
1024
+ if not track then return nil, nil, nil, "Track " .. track_idx .. " not found" end
1025
+
1026
+ local item_count = reaper.CountTrackMediaItems(track)
1027
+ if item_idx >= item_count then
1028
+ return nil, nil, nil, "Item " .. item_idx .. " not found (track has " .. item_count .. " items)"
1029
+ end
1030
+
1031
+ local item = reaper.GetTrackMediaItem(track, item_idx)
1032
+ if not item then return nil, nil, nil, "Item " .. item_idx .. " not found" end
1033
+
1034
+ local take = reaper.GetActiveTake(item)
1035
+ if not take then return nil, nil, nil, "Item has no active take" end
1036
+
1037
+ if not reaper.TakeIsMIDI(take) then
1038
+ return nil, nil, nil, "Item is not a MIDI item"
1039
+ end
1040
+
1041
+ return track, item, take, nil
1042
+ end
1043
+
1044
+ -- Helper: get a media item from track/item indices, with validation
1045
+ local function get_media_item(params)
1046
+ local track_idx = params.trackIndex
1047
+ local item_idx = params.itemIndex
1048
+ if track_idx == nil then return nil, nil, "trackIndex required" end
1049
+ if item_idx == nil then return nil, nil, "itemIndex required" end
1050
+
1051
+ local track = reaper.GetTrack(0, track_idx)
1052
+ if not track then return nil, nil, "Track " .. track_idx .. " not found" end
1053
+
1054
+ local item_count = reaper.CountTrackMediaItems(track)
1055
+ if item_idx >= item_count then
1056
+ return nil, nil, "Item " .. item_idx .. " not found (track has " .. item_count .. " items)"
1057
+ end
1058
+
1059
+ local item = reaper.GetTrackMediaItem(track, item_idx)
1060
+ if not item then return nil, nil, "Item " .. item_idx .. " not found" end
1061
+
1062
+ return track, item, nil
1063
+ end
1064
+
1065
+ -- Helper: convert beats from item start to PPQ ticks
1066
+ local function beats_to_ppq(take, item, beats)
1067
+ local item_start = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
1068
+ -- Convert beats to project time (using project tempo), then to PPQ
1069
+ local proj_time = item_start + reaper.TimeMap2_QNToTime(0, reaper.TimeMap2_timeToQN(0, item_start) + beats) - item_start
1070
+ return reaper.MIDI_GetPPQPosFromProjTime(take, proj_time)
1071
+ end
1072
+
1073
+ -- Helper: convert PPQ ticks to beats from item start
1074
+ local function ppq_to_beats(take, item)
1075
+ local item_start = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
1076
+ local item_start_qn = reaper.TimeMap2_timeToQN(0, item_start)
1077
+ return function(ppq)
1078
+ local proj_time = reaper.MIDI_GetProjTimeFromPPQPos(take, ppq)
1079
+ local proj_qn = reaper.TimeMap2_timeToQN(0, proj_time)
1080
+ return proj_qn - item_start_qn
1081
+ end
1082
+ end
1083
+
1084
+ function handlers.create_midi_item(params)
1085
+ local track_idx = params.trackIndex
1086
+ local start_pos = params.startPosition
1087
+ local end_pos = params.endPosition
1088
+ if track_idx == nil then return nil, "trackIndex required" end
1089
+ if not start_pos or not end_pos then return nil, "startPosition and endPosition required" end
1090
+ if end_pos <= start_pos then return nil, "endPosition must be greater than startPosition" end
1091
+
1092
+ local track = reaper.GetTrack(0, track_idx)
1093
+ if not track then return nil, "Track " .. track_idx .. " not found" end
1094
+
1095
+ reaper.Undo_BeginBlock()
1096
+ local item = reaper.CreateNewMIDIItemInProj(track, start_pos, end_pos)
1097
+ reaper.Undo_EndBlock("MCP: Create MIDI item", -1)
1098
+
1099
+ if not item then return nil, "Failed to create MIDI item" end
1100
+
1101
+ -- Find the index of the new item on the track
1102
+ local item_count = reaper.CountTrackMediaItems(track)
1103
+ local new_idx = -1
1104
+ for i = 0, item_count - 1 do
1105
+ if reaper.GetTrackMediaItem(track, i) == item then
1106
+ new_idx = i
1107
+ break
1108
+ end
1109
+ end
1110
+
1111
+ reaper.UpdateArrange()
1112
+
1113
+ return {
1114
+ trackIndex = track_idx,
1115
+ itemIndex = new_idx,
1116
+ position = start_pos,
1117
+ length = end_pos - start_pos,
1118
+ }
1119
+ end
1120
+
1121
+ function handlers.list_midi_items(params)
1122
+ local track_idx = params.trackIndex
1123
+ if track_idx == nil then return nil, "trackIndex required" end
1124
+
1125
+ local track = reaper.GetTrack(0, track_idx)
1126
+ if not track then return nil, "Track " .. track_idx .. " not found" end
1127
+
1128
+ local items = {}
1129
+ local item_count = reaper.CountTrackMediaItems(track)
1130
+ for i = 0, item_count - 1 do
1131
+ local item = reaper.GetTrackMediaItem(track, i)
1132
+ local take = reaper.GetActiveTake(item)
1133
+ if take and reaper.TakeIsMIDI(take) then
1134
+ local _, note_cnt, cc_cnt, _ = reaper.MIDI_CountEvts(take)
1135
+ items[#items + 1] = {
1136
+ itemIndex = i,
1137
+ position = reaper.GetMediaItemInfo_Value(item, "D_POSITION"),
1138
+ length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH"),
1139
+ noteCount = note_cnt,
1140
+ ccCount = cc_cnt,
1141
+ muted = reaper.GetMediaItemInfo_Value(item, "B_MUTE") ~= 0,
1142
+ }
1143
+ end
1144
+ end
1145
+
1146
+ return { trackIndex = track_idx, items = items, total = #items }
1147
+ end
1148
+
1149
+ function handlers.get_midi_notes(params)
1150
+ local track, item, take, err = get_midi_take(params)
1151
+ if err then return nil, err end
1152
+
1153
+ local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
1154
+ local to_beats = ppq_to_beats(take, item)
1155
+ local notes = {}
1156
+
1157
+ for i = 0, note_cnt - 1 do
1158
+ local _, sel, muted, start_ppq, end_ppq, chan, pitch, vel = reaper.MIDI_GetNote(take, i)
1159
+ local start_beats = to_beats(start_ppq)
1160
+ local end_beats = to_beats(end_ppq)
1161
+ notes[#notes + 1] = {
1162
+ noteIndex = i,
1163
+ pitch = pitch,
1164
+ velocity = vel,
1165
+ startPosition = start_beats,
1166
+ duration = end_beats - start_beats,
1167
+ channel = chan,
1168
+ selected = sel,
1169
+ muted = muted,
1170
+ }
1171
+ end
1172
+
1173
+ return { trackIndex = params.trackIndex, itemIndex = params.itemIndex, notes = notes, total = #notes }
1174
+ end
1175
+
1176
+ function handlers.insert_midi_note(params)
1177
+ local track, item, take, err = get_midi_take(params)
1178
+ if err then return nil, err end
1179
+
1180
+ local pitch = params.pitch
1181
+ local vel = params.velocity
1182
+ local start_pos = params.startPosition
1183
+ local dur = params.duration
1184
+ local chan = params.channel or 0
1185
+
1186
+ if not pitch or not vel or not start_pos or not dur then
1187
+ return nil, "pitch, velocity, startPosition, and duration required"
1188
+ end
1189
+
1190
+ -- Clamp values
1191
+ pitch = math.max(0, math.min(127, math.floor(pitch)))
1192
+ vel = math.max(1, math.min(127, math.floor(vel)))
1193
+ chan = math.max(0, math.min(15, math.floor(chan)))
1194
+
1195
+ local start_ppq = beats_to_ppq(take, item, start_pos)
1196
+ local end_ppq = beats_to_ppq(take, item, start_pos + dur)
1197
+
1198
+ reaper.Undo_BeginBlock()
1199
+ reaper.MIDI_InsertNote(take, false, false, start_ppq, end_ppq, chan, pitch, vel, false)
1200
+ reaper.MIDI_Sort(take)
1201
+ reaper.Undo_EndBlock("MCP: Insert MIDI note", -1)
1202
+
1203
+ reaper.UpdateArrange()
1204
+
1205
+ local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
1206
+ return { success = true, noteCount = note_cnt }
1207
+ end
1208
+
1209
+ function handlers.insert_midi_notes(params)
1210
+ local track, item, take, err = get_midi_take(params)
1211
+ if err then return nil, err end
1212
+
1213
+ local notes_str = params.notes
1214
+ if not notes_str or notes_str == "" then return nil, "notes JSON string required" end
1215
+
1216
+ -- Parse notes JSON array
1217
+ local notes_data = json_decode(notes_str)
1218
+ if not notes_data then return nil, "Failed to parse notes JSON" end
1219
+
1220
+ -- Handle both array-style and object-style parsed data
1221
+ local notes_list = {}
1222
+ if notes_data[1] then
1223
+ notes_list = notes_data
1224
+ else
1225
+ -- Try to extract from numbered keys (fallback parser)
1226
+ return nil, "Notes must be a JSON array. Ensure REAPER 7+ with CF_Json_Parse for array support."
1227
+ end
1228
+
1229
+ reaper.Undo_BeginBlock()
1230
+ local inserted = 0
1231
+ for _, note in ipairs(notes_list) do
1232
+ local pitch = note.pitch
1233
+ local vel = note.velocity
1234
+ local start_pos = note.startPosition
1235
+ local dur = note.duration
1236
+ local chan = note.channel or 0
1237
+
1238
+ if pitch and vel and start_pos and dur then
1239
+ pitch = math.max(0, math.min(127, math.floor(pitch)))
1240
+ vel = math.max(1, math.min(127, math.floor(vel)))
1241
+ chan = math.max(0, math.min(15, math.floor(chan)))
1242
+
1243
+ local start_ppq = beats_to_ppq(take, item, start_pos)
1244
+ local end_ppq = beats_to_ppq(take, item, start_pos + dur)
1245
+
1246
+ reaper.MIDI_InsertNote(take, false, false, start_ppq, end_ppq, chan, pitch, vel, true)
1247
+ inserted = inserted + 1
1248
+ end
1249
+ end
1250
+ reaper.MIDI_Sort(take)
1251
+ reaper.Undo_EndBlock("MCP: Insert " .. inserted .. " MIDI notes", -1)
1252
+
1253
+ reaper.UpdateArrange()
1254
+
1255
+ local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
1256
+ return { success = true, inserted = inserted, noteCount = note_cnt }
1257
+ end
1258
+
1259
+ function handlers.edit_midi_note(params)
1260
+ local track, item, take, err = get_midi_take(params)
1261
+ if err then return nil, err end
1262
+
1263
+ local note_idx = params.noteIndex
1264
+ if note_idx == nil then return nil, "noteIndex required" end
1265
+
1266
+ local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
1267
+ if note_idx >= note_cnt then
1268
+ return nil, "Note index " .. note_idx .. " out of range (item has " .. note_cnt .. " notes)"
1269
+ end
1270
+
1271
+ -- Get current note values
1272
+ local _, sel, muted, cur_start_ppq, cur_end_ppq, cur_chan, cur_pitch, cur_vel = reaper.MIDI_GetNote(take, note_idx)
1273
+
1274
+ -- Apply changes (nil means keep current)
1275
+ local new_pitch = params.pitch and math.max(0, math.min(127, math.floor(params.pitch))) or nil
1276
+ local new_vel = params.velocity and math.max(1, math.min(127, math.floor(params.velocity))) or nil
1277
+ local new_chan = params.channel and math.max(0, math.min(15, math.floor(params.channel))) or nil
1278
+
1279
+ local new_start_ppq = nil
1280
+ local new_end_ppq = nil
1281
+ if params.startPosition ~= nil then
1282
+ new_start_ppq = beats_to_ppq(take, item, params.startPosition)
1283
+ if params.duration ~= nil then
1284
+ new_end_ppq = beats_to_ppq(take, item, params.startPosition + params.duration)
1285
+ else
1286
+ -- Keep same duration
1287
+ local dur_ppq = cur_end_ppq - cur_start_ppq
1288
+ new_end_ppq = new_start_ppq + dur_ppq
1289
+ end
1290
+ elseif params.duration ~= nil then
1291
+ -- Change duration only, keep start
1292
+ local to_beats = ppq_to_beats(take, item)
1293
+ local cur_start_beats = to_beats(cur_start_ppq)
1294
+ new_end_ppq = beats_to_ppq(take, item, cur_start_beats + params.duration)
1295
+ end
1296
+
1297
+ reaper.Undo_BeginBlock()
1298
+ reaper.MIDI_SetNote(take, note_idx, sel, muted, new_start_ppq, new_end_ppq, new_chan, new_pitch, new_vel, false)
1299
+ reaper.MIDI_Sort(take)
1300
+ reaper.Undo_EndBlock("MCP: Edit MIDI note " .. note_idx, -1)
1301
+
1302
+ reaper.UpdateArrange()
1303
+
1304
+ return { success = true, noteIndex = note_idx }
1305
+ end
1306
+
1307
+ function handlers.delete_midi_note(params)
1308
+ local track, item, take, err = get_midi_take(params)
1309
+ if err then return nil, err end
1310
+
1311
+ local note_idx = params.noteIndex
1312
+ if note_idx == nil then return nil, "noteIndex required" end
1313
+
1314
+ local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
1315
+ if note_idx >= note_cnt then
1316
+ return nil, "Note index " .. note_idx .. " out of range (item has " .. note_cnt .. " notes)"
1317
+ end
1318
+
1319
+ reaper.Undo_BeginBlock()
1320
+ reaper.MIDI_DeleteNote(take, note_idx)
1321
+ reaper.MIDI_Sort(take)
1322
+ reaper.Undo_EndBlock("MCP: Delete MIDI note " .. note_idx, -1)
1323
+
1324
+ reaper.UpdateArrange()
1325
+
1326
+ local _, new_cnt, _, _ = reaper.MIDI_CountEvts(take)
1327
+ return { success = true, noteCount = new_cnt }
1328
+ end
1329
+
1330
+ function handlers.get_midi_cc(params)
1331
+ local track, item, take, err = get_midi_take(params)
1332
+ if err then return nil, err end
1333
+
1334
+ local _, _, cc_cnt, _ = reaper.MIDI_CountEvts(take)
1335
+ local to_beats = ppq_to_beats(take, item)
1336
+ local cc_filter = params.ccNumber
1337
+ local events = {}
1338
+
1339
+ for i = 0, cc_cnt - 1 do
1340
+ local _, sel, muted, ppq, chanmsg, chan, msg2, msg3 = reaper.MIDI_GetCC(take, i)
1341
+ -- msg2 = CC number, msg3 = CC value (for standard CC messages, chanmsg=176)
1342
+ if chanmsg == 176 then -- standard CC
1343
+ if cc_filter == nil or msg2 == cc_filter then
1344
+ events[#events + 1] = {
1345
+ ccIndex = i,
1346
+ ccNumber = msg2,
1347
+ value = msg3,
1348
+ position = to_beats(ppq),
1349
+ channel = chan,
1350
+ }
1351
+ end
1352
+ end
1353
+ end
1354
+
1355
+ return { trackIndex = params.trackIndex, itemIndex = params.itemIndex, events = events, total = #events }
1356
+ end
1357
+
1358
+ function handlers.insert_midi_cc(params)
1359
+ local track, item, take, err = get_midi_take(params)
1360
+ if err then return nil, err end
1361
+
1362
+ local cc_num = params.ccNumber
1363
+ local value = params.value
1364
+ local pos = params.position
1365
+ local chan = params.channel or 0
1366
+
1367
+ if not cc_num or not value or not pos then
1368
+ return nil, "ccNumber, value, and position required"
1369
+ end
1370
+
1371
+ cc_num = math.max(0, math.min(127, math.floor(cc_num)))
1372
+ value = math.max(0, math.min(127, math.floor(value)))
1373
+ chan = math.max(0, math.min(15, math.floor(chan)))
1374
+
1375
+ local ppq = beats_to_ppq(take, item, pos)
1376
+
1377
+ reaper.Undo_BeginBlock()
1378
+ -- chanmsg 176 = 0xB0 = CC message
1379
+ reaper.MIDI_InsertCC(take, false, false, ppq, 176, chan, cc_num, value)
1380
+ reaper.MIDI_Sort(take)
1381
+ reaper.Undo_EndBlock("MCP: Insert MIDI CC " .. cc_num, -1)
1382
+
1383
+ reaper.UpdateArrange()
1384
+
1385
+ local _, _, cc_cnt, _ = reaper.MIDI_CountEvts(take)
1386
+ return { success = true, ccCount = cc_cnt }
1387
+ end
1388
+
1389
+ function handlers.delete_midi_cc(params)
1390
+ local track, item, take, err = get_midi_take(params)
1391
+ if err then return nil, err end
1392
+
1393
+ local cc_idx = params.ccIndex
1394
+ if cc_idx == nil then return nil, "ccIndex required" end
1395
+
1396
+ local _, _, cc_cnt, _ = reaper.MIDI_CountEvts(take)
1397
+ if cc_idx >= cc_cnt then
1398
+ return nil, "CC index " .. cc_idx .. " out of range (item has " .. cc_cnt .. " CC events)"
1399
+ end
1400
+
1401
+ reaper.Undo_BeginBlock()
1402
+ reaper.MIDI_DeleteCC(take, cc_idx)
1403
+ reaper.MIDI_Sort(take)
1404
+ reaper.Undo_EndBlock("MCP: Delete MIDI CC " .. cc_idx, -1)
1405
+
1406
+ reaper.UpdateArrange()
1407
+
1408
+ local _, _, new_cnt, _ = reaper.MIDI_CountEvts(take)
1409
+ return { success = true, ccCount = new_cnt }
1410
+ end
1411
+
1412
+ function handlers.get_midi_item_properties(params)
1413
+ local track, item, take, err = get_midi_take(params)
1414
+ if err then return nil, err end
1415
+
1416
+ local _, note_cnt, cc_cnt, _ = reaper.MIDI_CountEvts(take)
1417
+
1418
+ return {
1419
+ trackIndex = params.trackIndex,
1420
+ itemIndex = params.itemIndex,
1421
+ position = reaper.GetMediaItemInfo_Value(item, "D_POSITION"),
1422
+ length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH"),
1423
+ noteCount = note_cnt,
1424
+ ccCount = cc_cnt,
1425
+ muted = reaper.GetMediaItemInfo_Value(item, "B_MUTE") ~= 0,
1426
+ loopSource = reaper.GetMediaItemInfo_Value(item, "B_LOOPSRC") ~= 0,
1427
+ }
1428
+ end
1429
+
1430
+ function handlers.set_midi_item_properties(params)
1431
+ local track, item, take, err = get_midi_take(params)
1432
+ if err then return nil, err end
1433
+
1434
+ reaper.Undo_BeginBlock()
1435
+
1436
+ if params.position ~= nil then
1437
+ reaper.SetMediaItemInfo_Value(item, "D_POSITION", params.position)
1438
+ end
1439
+ if params.length ~= nil then
1440
+ reaper.SetMediaItemInfo_Value(item, "D_LENGTH", params.length)
1441
+ end
1442
+ if params.mute ~= nil then
1443
+ reaper.SetMediaItemInfo_Value(item, "B_MUTE", params.mute)
1444
+ end
1445
+ if params.loopSource ~= nil then
1446
+ reaper.SetMediaItemInfo_Value(item, "B_LOOPSRC", params.loopSource)
1447
+ end
1448
+
1449
+ reaper.Undo_EndBlock("MCP: Set MIDI item properties", -1)
1450
+ reaper.UpdateArrange()
1451
+
1452
+ return { success = true, trackIndex = params.trackIndex, itemIndex = params.itemIndex }
1453
+ end
1454
+
1455
+ -- =============================================================================
1456
+ -- Media item editing handlers
1457
+ -- =============================================================================
1458
+
1459
+ function handlers.list_media_items(params)
1460
+ local track_idx = params.trackIndex
1461
+ if track_idx == nil then return nil, "trackIndex required" end
1462
+
1463
+ local track = reaper.GetTrack(0, track_idx)
1464
+ if not track then return nil, "Track " .. track_idx .. " not found" end
1465
+
1466
+ local items = {}
1467
+ local item_count = reaper.CountTrackMediaItems(track)
1468
+ for i = 0, item_count - 1 do
1469
+ local item = reaper.GetTrackMediaItem(track, i)
1470
+ local take = reaper.GetActiveTake(item)
1471
+ local take_name = ""
1472
+ local is_midi = false
1473
+ if take then
1474
+ take_name = reaper.GetTakeName(take) or ""
1475
+ is_midi = reaper.TakeIsMIDI(take)
1476
+ end
1477
+
1478
+ local vol_raw = reaper.GetMediaItemInfo_Value(item, "D_VOL")
1479
+ items[#items + 1] = {
1480
+ itemIndex = i,
1481
+ position = reaper.GetMediaItemInfo_Value(item, "D_POSITION"),
1482
+ length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH"),
1483
+ name = take_name,
1484
+ volume = to_db(vol_raw),
1485
+ muted = reaper.GetMediaItemInfo_Value(item, "B_MUTE") ~= 0,
1486
+ fadeInLength = reaper.GetMediaItemInfo_Value(item, "D_FADEINLEN"),
1487
+ fadeOutLength = reaper.GetMediaItemInfo_Value(item, "D_FADEOUTLEN"),
1488
+ playRate = take and reaper.GetMediaItemTakeInfo_Value(take, "D_PLAYRATE") or 1.0,
1489
+ isMidi = is_midi,
1490
+ takeName = take_name,
1491
+ }
1492
+ end
1493
+
1494
+ return { trackIndex = track_idx, items = items, total = #items }
1495
+ end
1496
+
1497
+ function handlers.get_media_item_properties(params)
1498
+ local track, item, err = get_media_item(params)
1499
+ if err then return nil, err end
1500
+
1501
+ local take = reaper.GetActiveTake(item)
1502
+ local take_name = ""
1503
+ local is_midi = false
1504
+ local play_rate = 1.0
1505
+ local pitch = 0.0
1506
+ local start_offset = 0.0
1507
+ local source_file = ""
1508
+
1509
+ if take then
1510
+ take_name = reaper.GetTakeName(take) or ""
1511
+ is_midi = reaper.TakeIsMIDI(take)
1512
+ play_rate = reaper.GetMediaItemTakeInfo_Value(take, "D_PLAYRATE")
1513
+ pitch = reaper.GetMediaItemTakeInfo_Value(take, "D_PITCH")
1514
+ start_offset = reaper.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS")
1515
+ local source = reaper.GetMediaItemTake_Source(take)
1516
+ if source then
1517
+ source_file = reaper.GetMediaSourceFileName(source) or ""
1518
+ end
1519
+ end
1520
+
1521
+ local vol_raw = reaper.GetMediaItemInfo_Value(item, "D_VOL")
1522
+
1523
+ return {
1524
+ trackIndex = params.trackIndex,
1525
+ itemIndex = params.itemIndex,
1526
+ position = reaper.GetMediaItemInfo_Value(item, "D_POSITION"),
1527
+ length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH"),
1528
+ name = take_name,
1529
+ volume = to_db(vol_raw),
1530
+ volumeRaw = vol_raw,
1531
+ muted = reaper.GetMediaItemInfo_Value(item, "B_MUTE") ~= 0,
1532
+ fadeInLength = reaper.GetMediaItemInfo_Value(item, "D_FADEINLEN"),
1533
+ fadeOutLength = reaper.GetMediaItemInfo_Value(item, "D_FADEOUTLEN"),
1534
+ fadeInShape = reaper.GetMediaItemInfo_Value(item, "C_FADEINSHAPE"),
1535
+ fadeOutShape = reaper.GetMediaItemInfo_Value(item, "C_FADEOUTSHAPE"),
1536
+ playRate = play_rate,
1537
+ pitch = pitch,
1538
+ startOffset = start_offset,
1539
+ loopSource = reaper.GetMediaItemInfo_Value(item, "B_LOOPSRC") ~= 0,
1540
+ isMidi = is_midi,
1541
+ takeName = take_name,
1542
+ sourceFile = source_file,
1543
+ locked = reaper.GetMediaItemInfo_Value(item, "C_LOCK") ~= 0,
1544
+ }
1545
+ end
1546
+
1547
+ function handlers.set_media_item_properties(params)
1548
+ local track, item, err = get_media_item(params)
1549
+ if err then return nil, err end
1550
+
1551
+ reaper.Undo_BeginBlock()
1552
+
1553
+ if params.position ~= nil then
1554
+ reaper.SetMediaItemInfo_Value(item, "D_POSITION", params.position)
1555
+ end
1556
+ if params.length ~= nil then
1557
+ reaper.SetMediaItemInfo_Value(item, "D_LENGTH", params.length)
1558
+ end
1559
+ if params.volume ~= nil then
1560
+ reaper.SetMediaItemInfo_Value(item, "D_VOL", from_db(params.volume))
1561
+ end
1562
+ if params.mute ~= nil then
1563
+ reaper.SetMediaItemInfo_Value(item, "B_MUTE", params.mute)
1564
+ end
1565
+ if params.fadeInLength ~= nil then
1566
+ reaper.SetMediaItemInfo_Value(item, "D_FADEINLEN", params.fadeInLength)
1567
+ end
1568
+ if params.fadeOutLength ~= nil then
1569
+ reaper.SetMediaItemInfo_Value(item, "D_FADEOUTLEN", params.fadeOutLength)
1570
+ end
1571
+ if params.playRate ~= nil then
1572
+ local take = reaper.GetActiveTake(item)
1573
+ if take then
1574
+ reaper.SetMediaItemTakeInfo_Value(take, "D_PLAYRATE", params.playRate)
1575
+ end
1576
+ end
1577
+
1578
+ reaper.Undo_EndBlock("MCP: Set media item properties", -1)
1579
+ reaper.UpdateArrange()
1580
+
1581
+ return { success = true, trackIndex = params.trackIndex, itemIndex = params.itemIndex }
1582
+ end
1583
+
1584
+ function handlers.split_media_item(params)
1585
+ local track, item, err = get_media_item(params)
1586
+ if err then return nil, err end
1587
+
1588
+ local position = params.position
1589
+ if not position then return nil, "position required" end
1590
+
1591
+ local item_start = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
1592
+ local item_end = item_start + reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
1593
+ if position <= item_start or position >= item_end then
1594
+ return nil, "Split position must be within item bounds (" .. item_start .. "s - " .. item_end .. "s)"
1595
+ end
1596
+
1597
+ reaper.Undo_BeginBlock()
1598
+ local right_item = reaper.SplitMediaItem(item, position)
1599
+ reaper.Undo_EndBlock("MCP: Split media item", -1)
1600
+
1601
+ if not right_item then return nil, "Failed to split item at position " .. position end
1602
+
1603
+ reaper.UpdateArrange()
1604
+
1605
+ return {
1606
+ success = true,
1607
+ leftItem = {
1608
+ position = reaper.GetMediaItemInfo_Value(item, "D_POSITION"),
1609
+ length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH"),
1610
+ },
1611
+ rightItem = {
1612
+ position = reaper.GetMediaItemInfo_Value(right_item, "D_POSITION"),
1613
+ length = reaper.GetMediaItemInfo_Value(right_item, "D_LENGTH"),
1614
+ },
1615
+ }
1616
+ end
1617
+
1618
+ function handlers.delete_media_item(params)
1619
+ local track, item, err = get_media_item(params)
1620
+ if err then return nil, err end
1621
+
1622
+ reaper.Undo_BeginBlock()
1623
+ local ok = reaper.DeleteTrackMediaItem(track, item)
1624
+ reaper.Undo_EndBlock("MCP: Delete media item", -1)
1625
+
1626
+ if not ok then return nil, "Failed to delete item" end
1627
+
1628
+ reaper.UpdateArrange()
1629
+
1630
+ return { success = true, trackIndex = params.trackIndex, itemIndex = params.itemIndex }
1631
+ end
1632
+
1633
+ function handlers.move_media_item(params)
1634
+ local track, item, err = get_media_item(params)
1635
+ if err then return nil, err end
1636
+
1637
+ reaper.Undo_BeginBlock()
1638
+
1639
+ if params.newPosition ~= nil then
1640
+ reaper.SetMediaItemInfo_Value(item, "D_POSITION", params.newPosition)
1641
+ end
1642
+
1643
+ if params.newTrackIndex ~= nil then
1644
+ local dest_track = reaper.GetTrack(0, params.newTrackIndex)
1645
+ if not dest_track then
1646
+ reaper.Undo_EndBlock("MCP: Move media item (failed)", -1)
1647
+ return nil, "Destination track " .. params.newTrackIndex .. " not found"
1648
+ end
1649
+ reaper.MoveMediaItemToTrack(item, dest_track)
1650
+ end
1651
+
1652
+ reaper.Undo_EndBlock("MCP: Move media item", -1)
1653
+ reaper.UpdateArrange()
1654
+
1655
+ return {
1656
+ success = true,
1657
+ position = reaper.GetMediaItemInfo_Value(item, "D_POSITION"),
1658
+ trackIndex = params.newTrackIndex or params.trackIndex,
1659
+ }
1660
+ end
1661
+
1662
+ function handlers.trim_media_item(params)
1663
+ local track, item, err = get_media_item(params)
1664
+ if err then return nil, err end
1665
+
1666
+ reaper.Undo_BeginBlock()
1667
+
1668
+ local pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
1669
+ local len = reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
1670
+ local take = reaper.GetActiveTake(item)
1671
+
1672
+ if params.trimStart ~= nil and params.trimStart ~= 0 then
1673
+ local new_pos = pos + params.trimStart
1674
+ local new_len = len - params.trimStart
1675
+ if new_len <= 0 then
1676
+ reaper.Undo_EndBlock("MCP: Trim media item (failed)", -1)
1677
+ return nil, "trimStart would result in zero or negative length"
1678
+ end
1679
+ reaper.SetMediaItemInfo_Value(item, "D_POSITION", new_pos)
1680
+ reaper.SetMediaItemInfo_Value(item, "D_LENGTH", new_len)
1681
+ -- Adjust take start offset
1682
+ if take then
1683
+ local offset = reaper.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS")
1684
+ reaper.SetMediaItemTakeInfo_Value(take, "D_STARTOFFS", offset + params.trimStart)
1685
+ end
1686
+ pos = new_pos
1687
+ len = new_len
1688
+ end
1689
+
1690
+ if params.trimEnd ~= nil and params.trimEnd ~= 0 then
1691
+ local new_len = len - params.trimEnd
1692
+ if new_len <= 0 then
1693
+ reaper.Undo_EndBlock("MCP: Trim media item (failed)", -1)
1694
+ return nil, "trimEnd would result in zero or negative length"
1695
+ end
1696
+ reaper.SetMediaItemInfo_Value(item, "D_LENGTH", new_len)
1697
+ len = new_len
1698
+ end
1699
+
1700
+ reaper.Undo_EndBlock("MCP: Trim media item", -1)
1701
+ reaper.UpdateArrange()
1702
+
1703
+ return {
1704
+ success = true,
1705
+ position = pos,
1706
+ length = len,
1707
+ }
1708
+ end
1709
+
1710
+ function handlers.add_stretch_marker(params)
1711
+ local track, item, err = get_media_item(params)
1712
+ if err then return nil, err end
1713
+
1714
+ local position = params.position
1715
+ if not position then return nil, "position required" end
1716
+
1717
+ local take = reaper.GetActiveTake(item)
1718
+ if not take then return nil, "Item has no active take" end
1719
+
1720
+ local src_pos = params.sourcePosition or position
1721
+
1722
+ reaper.Undo_BeginBlock()
1723
+ local idx = reaper.SetTakeStretchMarker(take, -1, position, src_pos)
1724
+ reaper.Undo_EndBlock("MCP: Add stretch marker", -1)
1725
+
1726
+ if idx < 0 then return nil, "Failed to add stretch marker" end
1727
+
1728
+ reaper.UpdateItemInProject(item)
1729
+ reaper.UpdateArrange()
1730
+
1731
+ return {
1732
+ success = true,
1733
+ markerIndex = idx,
1734
+ position = position,
1735
+ sourcePosition = src_pos,
1736
+ totalMarkers = reaper.GetTakeNumStretchMarkers(take),
1737
+ }
1738
+ end
1739
+
1740
+ function handlers.get_stretch_markers(params)
1741
+ local track, item, err = get_media_item(params)
1742
+ if err then return nil, err end
1743
+
1744
+ local take = reaper.GetActiveTake(item)
1745
+ if not take then return nil, "Item has no active take" end
1746
+
1747
+ local count = reaper.GetTakeNumStretchMarkers(take)
1748
+ local markers = {}
1749
+
1750
+ for i = 0, count - 1 do
1751
+ local _, pos, src_pos = reaper.GetTakeStretchMarker(take, i)
1752
+ markers[#markers + 1] = {
1753
+ index = i,
1754
+ position = pos,
1755
+ sourcePosition = src_pos,
1756
+ }
1757
+ end
1758
+
1759
+ return { trackIndex = params.trackIndex, itemIndex = params.itemIndex, markers = markers, total = count }
1760
+ end
1761
+
1762
+ function handlers.delete_stretch_marker(params)
1763
+ local track, item, err = get_media_item(params)
1764
+ if err then return nil, err end
1765
+
1766
+ local marker_idx = params.markerIndex
1767
+ if marker_idx == nil then return nil, "markerIndex required" end
1768
+
1769
+ local take = reaper.GetActiveTake(item)
1770
+ if not take then return nil, "Item has no active take" end
1771
+
1772
+ local count = reaper.GetTakeNumStretchMarkers(take)
1773
+ if marker_idx >= count then
1774
+ return nil, "Marker index " .. marker_idx .. " out of range (item has " .. count .. " markers)"
1775
+ end
1776
+
1777
+ reaper.Undo_BeginBlock()
1778
+ reaper.DeleteTakeStretchMarkers(take, marker_idx, 1)
1779
+ reaper.Undo_EndBlock("MCP: Delete stretch marker", -1)
1780
+
1781
+ reaper.UpdateItemInProject(item)
1782
+ reaper.UpdateArrange()
1783
+
1784
+ return { success = true, totalMarkers = reaper.GetTakeNumStretchMarkers(take) }
1785
+ end
1786
+
1012
1787
  -- =============================================================================
1013
1788
  -- Command dispatcher
1014
1789
  -- =============================================================================