@mthines/reaper-mcp 0.5.0 → 0.6.0-beta.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mthines/reaper-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.6.0-beta.3.2",
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",
@@ -58,6 +58,30 @@ local function json_decode(str)
58
58
  return obj
59
59
  end
60
60
 
61
+ -- Fallback JSON array-of-objects parser for when CF_Json_Parse is unavailable.
62
+ -- Handles: [{"key":val,...}, {"key":val,...}, ...]
63
+ local function json_decode_array(str)
64
+ if reaper.CF_Json_Parse then
65
+ local ok, val = reaper.CF_Json_Parse(str)
66
+ if ok then return val end
67
+ end
68
+
69
+ -- Fallback: extract each {...} from the array and parse individually
70
+ local arr = {}
71
+ for obj_str in str:gmatch("%b{}") do
72
+ local obj = {}
73
+ for k, v in obj_str:gmatch('"([^"]+)"%s*:%s*"([^"]*)"') do
74
+ obj[k] = v
75
+ end
76
+ for k, v in obj_str:gmatch('"([^"]+)"%s*:%s*(-?%d+%.?%d*)') do
77
+ if not obj[k] then obj[k] = tonumber(v) end
78
+ end
79
+ arr[#arr + 1] = obj
80
+ end
81
+ if #arr > 0 then return arr end
82
+ return nil
83
+ end
84
+
61
85
  local function json_encode(obj)
62
86
  -- Simple JSON encoder for our response objects
63
87
  local parts = {}
@@ -1009,6 +1033,772 @@ function handlers.read_track_crest(params)
1009
1033
  }
1010
1034
  end
1011
1035
 
1036
+ -- =============================================================================
1037
+ -- MIDI editing handlers
1038
+ -- =============================================================================
1039
+
1040
+ -- Helper: get a MIDI take from track/item indices, with validation
1041
+ local function get_midi_take(params)
1042
+ local track_idx = params.trackIndex
1043
+ local item_idx = params.itemIndex
1044
+ if track_idx == nil then return nil, nil, nil, "trackIndex required" end
1045
+ if item_idx == nil then return nil, nil, nil, "itemIndex required" end
1046
+
1047
+ local track = reaper.GetTrack(0, track_idx)
1048
+ if not track then return nil, nil, nil, "Track " .. track_idx .. " not found" end
1049
+
1050
+ local item_count = reaper.CountTrackMediaItems(track)
1051
+ if item_idx >= item_count then
1052
+ return nil, nil, nil, "Item " .. item_idx .. " not found (track has " .. item_count .. " items)"
1053
+ end
1054
+
1055
+ local item = reaper.GetTrackMediaItem(track, item_idx)
1056
+ if not item then return nil, nil, nil, "Item " .. item_idx .. " not found" end
1057
+
1058
+ local take = reaper.GetActiveTake(item)
1059
+ if not take then return nil, nil, nil, "Item has no active take" end
1060
+
1061
+ if not reaper.TakeIsMIDI(take) then
1062
+ return nil, nil, nil, "Item is not a MIDI item"
1063
+ end
1064
+
1065
+ return track, item, take, nil
1066
+ end
1067
+
1068
+ -- Helper: get a media item from track/item indices, with validation
1069
+ local function get_media_item(params)
1070
+ local track_idx = params.trackIndex
1071
+ local item_idx = params.itemIndex
1072
+ if track_idx == nil then return nil, nil, "trackIndex required" end
1073
+ if item_idx == nil then return nil, nil, "itemIndex required" end
1074
+
1075
+ local track = reaper.GetTrack(0, track_idx)
1076
+ if not track then return nil, nil, "Track " .. track_idx .. " not found" end
1077
+
1078
+ local item_count = reaper.CountTrackMediaItems(track)
1079
+ if item_idx >= item_count then
1080
+ return nil, nil, "Item " .. item_idx .. " not found (track has " .. item_count .. " items)"
1081
+ end
1082
+
1083
+ local item = reaper.GetTrackMediaItem(track, item_idx)
1084
+ if not item then return nil, nil, "Item " .. item_idx .. " not found" end
1085
+
1086
+ return track, item, nil
1087
+ end
1088
+
1089
+ -- Helper: convert beats from item start to PPQ ticks
1090
+ local function beats_to_ppq(take, item, beats)
1091
+ local item_start = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
1092
+ -- Convert beats to project time (using project tempo), then to PPQ
1093
+ local proj_time = item_start + reaper.TimeMap2_QNToTime(0, reaper.TimeMap2_timeToQN(0, item_start) + beats) - item_start
1094
+ return reaper.MIDI_GetPPQPosFromProjTime(take, proj_time)
1095
+ end
1096
+
1097
+ -- Helper: convert PPQ ticks to beats from item start
1098
+ local function ppq_to_beats(take, item)
1099
+ local item_start = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
1100
+ local item_start_qn = reaper.TimeMap2_timeToQN(0, item_start)
1101
+ return function(ppq)
1102
+ local proj_time = reaper.MIDI_GetProjTimeFromPPQPos(take, ppq)
1103
+ local proj_qn = reaper.TimeMap2_timeToQN(0, proj_time)
1104
+ return proj_qn - item_start_qn
1105
+ end
1106
+ end
1107
+
1108
+ function handlers.create_midi_item(params)
1109
+ local track_idx = params.trackIndex
1110
+ local start_pos = params.startPosition
1111
+ local end_pos = params.endPosition
1112
+ if track_idx == nil then return nil, "trackIndex required" end
1113
+ if not start_pos or not end_pos then return nil, "startPosition and endPosition required" end
1114
+ if end_pos <= start_pos then return nil, "endPosition must be greater than startPosition" end
1115
+
1116
+ local track = reaper.GetTrack(0, track_idx)
1117
+ if not track then return nil, "Track " .. track_idx .. " not found" end
1118
+
1119
+ local item = reaper.CreateNewMIDIItemInProj(track, start_pos, end_pos)
1120
+ if not item then return nil, "Failed to create MIDI item" end
1121
+
1122
+ reaper.Undo_BeginBlock()
1123
+ reaper.Undo_EndBlock("MCP: Create MIDI item", -1)
1124
+
1125
+ -- Find the index of the new item on the track
1126
+ local item_count = reaper.CountTrackMediaItems(track)
1127
+ local new_idx = -1
1128
+ for i = 0, item_count - 1 do
1129
+ if reaper.GetTrackMediaItem(track, i) == item then
1130
+ new_idx = i
1131
+ break
1132
+ end
1133
+ end
1134
+
1135
+ reaper.UpdateArrange()
1136
+
1137
+ return {
1138
+ trackIndex = track_idx,
1139
+ itemIndex = new_idx,
1140
+ position = start_pos,
1141
+ length = end_pos - start_pos,
1142
+ }
1143
+ end
1144
+
1145
+ function handlers.list_midi_items(params)
1146
+ local track_idx = params.trackIndex
1147
+ if track_idx == nil then return nil, "trackIndex required" end
1148
+
1149
+ local track = reaper.GetTrack(0, track_idx)
1150
+ if not track then return nil, "Track " .. track_idx .. " not found" end
1151
+
1152
+ local items = {}
1153
+ local item_count = reaper.CountTrackMediaItems(track)
1154
+ for i = 0, item_count - 1 do
1155
+ local item = reaper.GetTrackMediaItem(track, i)
1156
+ local take = reaper.GetActiveTake(item)
1157
+ if take and reaper.TakeIsMIDI(take) then
1158
+ local _, note_cnt, cc_cnt, _ = reaper.MIDI_CountEvts(take)
1159
+ items[#items + 1] = {
1160
+ itemIndex = i,
1161
+ position = reaper.GetMediaItemInfo_Value(item, "D_POSITION"),
1162
+ length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH"),
1163
+ noteCount = note_cnt,
1164
+ ccCount = cc_cnt,
1165
+ muted = reaper.GetMediaItemInfo_Value(item, "B_MUTE") ~= 0,
1166
+ }
1167
+ end
1168
+ end
1169
+
1170
+ return { trackIndex = track_idx, items = items, total = #items }
1171
+ end
1172
+
1173
+ function handlers.get_midi_notes(params)
1174
+ local track, item, take, err = get_midi_take(params)
1175
+ if err then return nil, err end
1176
+
1177
+ local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
1178
+ local to_beats = ppq_to_beats(take, item)
1179
+ local notes = {}
1180
+
1181
+ for i = 0, note_cnt - 1 do
1182
+ local _, sel, muted, start_ppq, end_ppq, chan, pitch, vel = reaper.MIDI_GetNote(take, i)
1183
+ local start_beats = to_beats(start_ppq)
1184
+ local end_beats = to_beats(end_ppq)
1185
+ notes[#notes + 1] = {
1186
+ noteIndex = i,
1187
+ pitch = pitch,
1188
+ velocity = vel,
1189
+ startPosition = start_beats,
1190
+ duration = end_beats - start_beats,
1191
+ channel = chan,
1192
+ selected = sel,
1193
+ muted = muted,
1194
+ }
1195
+ end
1196
+
1197
+ return { trackIndex = params.trackIndex, itemIndex = params.itemIndex, notes = notes, total = #notes }
1198
+ end
1199
+
1200
+ function handlers.insert_midi_note(params)
1201
+ local track, item, take, err = get_midi_take(params)
1202
+ if err then return nil, err end
1203
+
1204
+ local pitch = params.pitch
1205
+ local vel = params.velocity
1206
+ local start_pos = params.startPosition
1207
+ local dur = params.duration
1208
+ local chan = params.channel or 0
1209
+
1210
+ if not pitch or not vel or not start_pos or not dur then
1211
+ return nil, "pitch, velocity, startPosition, and duration required"
1212
+ end
1213
+
1214
+ -- Clamp values
1215
+ pitch = math.max(0, math.min(127, math.floor(pitch)))
1216
+ vel = math.max(1, math.min(127, math.floor(vel)))
1217
+ chan = math.max(0, math.min(15, math.floor(chan)))
1218
+
1219
+ local start_ppq = beats_to_ppq(take, item, start_pos)
1220
+ local end_ppq = beats_to_ppq(take, item, start_pos + dur)
1221
+
1222
+ reaper.Undo_BeginBlock()
1223
+ reaper.MIDI_InsertNote(take, false, false, start_ppq, end_ppq, chan, pitch, vel, false)
1224
+ reaper.MIDI_Sort(take)
1225
+ reaper.Undo_EndBlock("MCP: Insert MIDI note", -1)
1226
+
1227
+ reaper.UpdateArrange()
1228
+
1229
+ local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
1230
+ return { success = true, noteCount = note_cnt }
1231
+ end
1232
+
1233
+ function handlers.insert_midi_notes(params)
1234
+ local track, item, take, err = get_midi_take(params)
1235
+ if err then return nil, err end
1236
+
1237
+ local notes_str = params.notes
1238
+ if not notes_str or notes_str == "" then return nil, "notes JSON string required" end
1239
+
1240
+ -- Parse notes JSON array (uses dedicated array parser with fallback)
1241
+ local notes_list = json_decode_array(notes_str)
1242
+ if not notes_list or #notes_list == 0 then
1243
+ return nil, "Failed to parse notes JSON array. Expected: [{\"pitch\":60,\"velocity\":100,\"startPosition\":0,\"duration\":1}, ...]"
1244
+ end
1245
+
1246
+ reaper.Undo_BeginBlock()
1247
+ local inserted = 0
1248
+ for _, note in ipairs(notes_list) do
1249
+ local pitch = note.pitch
1250
+ local vel = note.velocity
1251
+ local start_pos = note.startPosition
1252
+ local dur = note.duration
1253
+ local chan = note.channel or 0
1254
+
1255
+ if pitch and vel and start_pos and dur then
1256
+ pitch = math.max(0, math.min(127, math.floor(pitch)))
1257
+ vel = math.max(1, math.min(127, math.floor(vel)))
1258
+ chan = math.max(0, math.min(15, math.floor(chan)))
1259
+
1260
+ local start_ppq = beats_to_ppq(take, item, start_pos)
1261
+ local end_ppq = beats_to_ppq(take, item, start_pos + dur)
1262
+
1263
+ reaper.MIDI_InsertNote(take, false, false, start_ppq, end_ppq, chan, pitch, vel, true)
1264
+ inserted = inserted + 1
1265
+ end
1266
+ end
1267
+ reaper.MIDI_Sort(take)
1268
+ reaper.Undo_EndBlock("MCP: Insert " .. inserted .. " MIDI notes", -1)
1269
+
1270
+ reaper.UpdateArrange()
1271
+
1272
+ local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
1273
+ return { success = true, inserted = inserted, noteCount = note_cnt }
1274
+ end
1275
+
1276
+ function handlers.edit_midi_note(params)
1277
+ local track, item, take, err = get_midi_take(params)
1278
+ if err then return nil, err end
1279
+
1280
+ local note_idx = params.noteIndex
1281
+ if note_idx == nil then return nil, "noteIndex required" end
1282
+
1283
+ local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
1284
+ if note_idx >= note_cnt then
1285
+ return nil, "Note index " .. note_idx .. " out of range (item has " .. note_cnt .. " notes)"
1286
+ end
1287
+
1288
+ -- Get current note values
1289
+ local _, sel, muted, cur_start_ppq, cur_end_ppq, cur_chan, cur_pitch, cur_vel = reaper.MIDI_GetNote(take, note_idx)
1290
+
1291
+ -- Apply changes (nil means keep current)
1292
+ local new_pitch = params.pitch and math.max(0, math.min(127, math.floor(params.pitch))) or nil
1293
+ local new_vel = params.velocity and math.max(1, math.min(127, math.floor(params.velocity))) or nil
1294
+ local new_chan = params.channel and math.max(0, math.min(15, math.floor(params.channel))) or nil
1295
+
1296
+ local new_start_ppq = nil
1297
+ local new_end_ppq = nil
1298
+ if params.startPosition ~= nil then
1299
+ new_start_ppq = beats_to_ppq(take, item, params.startPosition)
1300
+ if params.duration ~= nil then
1301
+ new_end_ppq = beats_to_ppq(take, item, params.startPosition + params.duration)
1302
+ else
1303
+ -- Keep same duration
1304
+ local dur_ppq = cur_end_ppq - cur_start_ppq
1305
+ new_end_ppq = new_start_ppq + dur_ppq
1306
+ end
1307
+ elseif params.duration ~= nil then
1308
+ -- Change duration only, keep start
1309
+ local to_beats = ppq_to_beats(take, item)
1310
+ local cur_start_beats = to_beats(cur_start_ppq)
1311
+ new_end_ppq = beats_to_ppq(take, item, cur_start_beats + params.duration)
1312
+ end
1313
+
1314
+ reaper.Undo_BeginBlock()
1315
+ reaper.MIDI_SetNote(take, note_idx, sel, muted, new_start_ppq, new_end_ppq, new_chan, new_pitch, new_vel, false)
1316
+ reaper.MIDI_Sort(take)
1317
+ reaper.Undo_EndBlock("MCP: Edit MIDI note " .. note_idx, -1)
1318
+
1319
+ reaper.UpdateArrange()
1320
+
1321
+ return { success = true, noteIndex = note_idx }
1322
+ end
1323
+
1324
+ function handlers.delete_midi_note(params)
1325
+ local track, item, take, err = get_midi_take(params)
1326
+ if err then return nil, err end
1327
+
1328
+ local note_idx = params.noteIndex
1329
+ if note_idx == nil then return nil, "noteIndex required" end
1330
+
1331
+ local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
1332
+ if note_idx >= note_cnt then
1333
+ return nil, "Note index " .. note_idx .. " out of range (item has " .. note_cnt .. " notes)"
1334
+ end
1335
+
1336
+ reaper.Undo_BeginBlock()
1337
+ reaper.MIDI_DeleteNote(take, note_idx)
1338
+ reaper.Undo_EndBlock("MCP: Delete MIDI note " .. note_idx, -1)
1339
+
1340
+ reaper.UpdateArrange()
1341
+
1342
+ local _, new_cnt, _, _ = reaper.MIDI_CountEvts(take)
1343
+ return { success = true, noteCount = new_cnt }
1344
+ end
1345
+
1346
+ function handlers.get_midi_cc(params)
1347
+ local track, item, take, err = get_midi_take(params)
1348
+ if err then return nil, err end
1349
+
1350
+ local _, _, cc_cnt, _ = reaper.MIDI_CountEvts(take)
1351
+ local to_beats = ppq_to_beats(take, item)
1352
+ local cc_filter = params.ccNumber
1353
+ local events = {}
1354
+
1355
+ for i = 0, cc_cnt - 1 do
1356
+ local _, sel, muted, ppq, chanmsg, chan, msg2, msg3 = reaper.MIDI_GetCC(take, i)
1357
+ -- msg2 = CC number, msg3 = CC value (for standard CC messages, chanmsg=176)
1358
+ if chanmsg == 176 then -- standard CC
1359
+ if cc_filter == nil or msg2 == cc_filter then
1360
+ events[#events + 1] = {
1361
+ ccIndex = i,
1362
+ ccNumber = msg2,
1363
+ value = msg3,
1364
+ position = to_beats(ppq),
1365
+ channel = chan,
1366
+ }
1367
+ end
1368
+ end
1369
+ end
1370
+
1371
+ return { trackIndex = params.trackIndex, itemIndex = params.itemIndex, events = events, total = #events }
1372
+ end
1373
+
1374
+ function handlers.insert_midi_cc(params)
1375
+ local track, item, take, err = get_midi_take(params)
1376
+ if err then return nil, err end
1377
+
1378
+ local cc_num = params.ccNumber
1379
+ local value = params.value
1380
+ local pos = params.position
1381
+ local chan = params.channel or 0
1382
+
1383
+ if not cc_num or not value or not pos then
1384
+ return nil, "ccNumber, value, and position required"
1385
+ end
1386
+
1387
+ cc_num = math.max(0, math.min(127, math.floor(cc_num)))
1388
+ value = math.max(0, math.min(127, math.floor(value)))
1389
+ chan = math.max(0, math.min(15, math.floor(chan)))
1390
+
1391
+ local ppq = beats_to_ppq(take, item, pos)
1392
+
1393
+ reaper.Undo_BeginBlock()
1394
+ -- chanmsg 176 = 0xB0 = CC message
1395
+ reaper.MIDI_InsertCC(take, false, false, ppq, 176, chan, cc_num, value)
1396
+ reaper.MIDI_Sort(take)
1397
+ reaper.Undo_EndBlock("MCP: Insert MIDI CC " .. cc_num, -1)
1398
+
1399
+ reaper.UpdateArrange()
1400
+
1401
+ local _, _, cc_cnt, _ = reaper.MIDI_CountEvts(take)
1402
+ return { success = true, ccCount = cc_cnt }
1403
+ end
1404
+
1405
+ function handlers.delete_midi_cc(params)
1406
+ local track, item, take, err = get_midi_take(params)
1407
+ if err then return nil, err end
1408
+
1409
+ local cc_idx = params.ccIndex
1410
+ if cc_idx == nil then return nil, "ccIndex required" end
1411
+
1412
+ local _, _, cc_cnt, _ = reaper.MIDI_CountEvts(take)
1413
+ if cc_idx >= cc_cnt then
1414
+ return nil, "CC index " .. cc_idx .. " out of range (item has " .. cc_cnt .. " CC events)"
1415
+ end
1416
+
1417
+ reaper.Undo_BeginBlock()
1418
+ reaper.MIDI_DeleteCC(take, cc_idx)
1419
+ reaper.Undo_EndBlock("MCP: Delete MIDI CC " .. cc_idx, -1)
1420
+
1421
+ reaper.UpdateArrange()
1422
+
1423
+ local _, _, new_cnt, _ = reaper.MIDI_CountEvts(take)
1424
+ return { success = true, ccCount = new_cnt }
1425
+ end
1426
+
1427
+ function handlers.get_midi_item_properties(params)
1428
+ local track, item, take, err = get_midi_take(params)
1429
+ if err then return nil, err end
1430
+
1431
+ local _, note_cnt, cc_cnt, _ = reaper.MIDI_CountEvts(take)
1432
+
1433
+ return {
1434
+ trackIndex = params.trackIndex,
1435
+ itemIndex = params.itemIndex,
1436
+ position = reaper.GetMediaItemInfo_Value(item, "D_POSITION"),
1437
+ length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH"),
1438
+ noteCount = note_cnt,
1439
+ ccCount = cc_cnt,
1440
+ muted = reaper.GetMediaItemInfo_Value(item, "B_MUTE") ~= 0,
1441
+ loopSource = reaper.GetMediaItemInfo_Value(item, "B_LOOPSRC") ~= 0,
1442
+ }
1443
+ end
1444
+
1445
+ function handlers.set_midi_item_properties(params)
1446
+ local track, item, take, err = get_midi_take(params)
1447
+ if err then return nil, err end
1448
+
1449
+ reaper.Undo_BeginBlock()
1450
+
1451
+ if params.position ~= nil then
1452
+ reaper.SetMediaItemInfo_Value(item, "D_POSITION", params.position)
1453
+ end
1454
+ if params.length ~= nil then
1455
+ reaper.SetMediaItemInfo_Value(item, "D_LENGTH", params.length)
1456
+ end
1457
+ if params.mute ~= nil then
1458
+ reaper.SetMediaItemInfo_Value(item, "B_MUTE", params.mute)
1459
+ end
1460
+ if params.loopSource ~= nil then
1461
+ reaper.SetMediaItemInfo_Value(item, "B_LOOPSRC", params.loopSource)
1462
+ end
1463
+
1464
+ reaper.Undo_EndBlock("MCP: Set MIDI item properties", -1)
1465
+ reaper.UpdateArrange()
1466
+
1467
+ return { success = true, trackIndex = params.trackIndex, itemIndex = params.itemIndex }
1468
+ end
1469
+
1470
+ -- =============================================================================
1471
+ -- Media item editing handlers
1472
+ -- =============================================================================
1473
+
1474
+ function handlers.list_media_items(params)
1475
+ local track_idx = params.trackIndex
1476
+ if track_idx == nil then return nil, "trackIndex required" end
1477
+
1478
+ local track = reaper.GetTrack(0, track_idx)
1479
+ if not track then return nil, "Track " .. track_idx .. " not found" end
1480
+
1481
+ local items = {}
1482
+ local item_count = reaper.CountTrackMediaItems(track)
1483
+ for i = 0, item_count - 1 do
1484
+ local item = reaper.GetTrackMediaItem(track, i)
1485
+ local take = reaper.GetActiveTake(item)
1486
+ local take_name = ""
1487
+ local is_midi = false
1488
+ if take then
1489
+ take_name = reaper.GetTakeName(take) or ""
1490
+ is_midi = reaper.TakeIsMIDI(take)
1491
+ end
1492
+
1493
+ local vol_raw = reaper.GetMediaItemInfo_Value(item, "D_VOL")
1494
+ items[#items + 1] = {
1495
+ itemIndex = i,
1496
+ position = reaper.GetMediaItemInfo_Value(item, "D_POSITION"),
1497
+ length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH"),
1498
+ name = take_name,
1499
+ volume = to_db(vol_raw),
1500
+ muted = reaper.GetMediaItemInfo_Value(item, "B_MUTE") ~= 0,
1501
+ fadeInLength = reaper.GetMediaItemInfo_Value(item, "D_FADEINLEN"),
1502
+ fadeOutLength = reaper.GetMediaItemInfo_Value(item, "D_FADEOUTLEN"),
1503
+ playRate = take and reaper.GetMediaItemTakeInfo_Value(take, "D_PLAYRATE") or 1.0,
1504
+ isMidi = is_midi,
1505
+ takeName = take_name,
1506
+ }
1507
+ end
1508
+
1509
+ return { trackIndex = track_idx, items = items, total = #items }
1510
+ end
1511
+
1512
+ function handlers.get_media_item_properties(params)
1513
+ local track, item, err = get_media_item(params)
1514
+ if err then return nil, err end
1515
+
1516
+ local take = reaper.GetActiveTake(item)
1517
+ local take_name = ""
1518
+ local is_midi = false
1519
+ local play_rate = 1.0
1520
+ local pitch = 0.0
1521
+ local start_offset = 0.0
1522
+ local source_file = ""
1523
+
1524
+ if take then
1525
+ take_name = reaper.GetTakeName(take) or ""
1526
+ is_midi = reaper.TakeIsMIDI(take)
1527
+ play_rate = reaper.GetMediaItemTakeInfo_Value(take, "D_PLAYRATE")
1528
+ pitch = reaper.GetMediaItemTakeInfo_Value(take, "D_PITCH")
1529
+ start_offset = reaper.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS")
1530
+ local source = reaper.GetMediaItemTake_Source(take)
1531
+ if source then
1532
+ local _, src_fn = reaper.GetMediaSourceFileName(source, "")
1533
+ source_file = src_fn or ""
1534
+ end
1535
+ end
1536
+
1537
+ local vol_raw = reaper.GetMediaItemInfo_Value(item, "D_VOL")
1538
+
1539
+ return {
1540
+ trackIndex = params.trackIndex,
1541
+ itemIndex = params.itemIndex,
1542
+ position = reaper.GetMediaItemInfo_Value(item, "D_POSITION"),
1543
+ length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH"),
1544
+ name = take_name,
1545
+ volume = to_db(vol_raw),
1546
+ volumeRaw = vol_raw,
1547
+ muted = reaper.GetMediaItemInfo_Value(item, "B_MUTE") ~= 0,
1548
+ fadeInLength = reaper.GetMediaItemInfo_Value(item, "D_FADEINLEN"),
1549
+ fadeOutLength = reaper.GetMediaItemInfo_Value(item, "D_FADEOUTLEN"),
1550
+ fadeInShape = reaper.GetMediaItemInfo_Value(item, "C_FADEINSHAPE"),
1551
+ fadeOutShape = reaper.GetMediaItemInfo_Value(item, "C_FADEOUTSHAPE"),
1552
+ playRate = play_rate,
1553
+ pitch = pitch,
1554
+ startOffset = start_offset,
1555
+ loopSource = reaper.GetMediaItemInfo_Value(item, "B_LOOPSRC") ~= 0,
1556
+ isMidi = is_midi,
1557
+ takeName = take_name,
1558
+ sourceFile = source_file,
1559
+ locked = reaper.GetMediaItemInfo_Value(item, "C_LOCK") ~= 0,
1560
+ }
1561
+ end
1562
+
1563
+ function handlers.set_media_item_properties(params)
1564
+ local track, item, err = get_media_item(params)
1565
+ if err then return nil, err end
1566
+
1567
+ reaper.Undo_BeginBlock()
1568
+
1569
+ if params.position ~= nil then
1570
+ reaper.SetMediaItemInfo_Value(item, "D_POSITION", params.position)
1571
+ end
1572
+ if params.length ~= nil then
1573
+ reaper.SetMediaItemInfo_Value(item, "D_LENGTH", params.length)
1574
+ end
1575
+ if params.volume ~= nil then
1576
+ reaper.SetMediaItemInfo_Value(item, "D_VOL", from_db(params.volume))
1577
+ end
1578
+ if params.mute ~= nil then
1579
+ reaper.SetMediaItemInfo_Value(item, "B_MUTE", params.mute)
1580
+ end
1581
+ if params.fadeInLength ~= nil then
1582
+ reaper.SetMediaItemInfo_Value(item, "D_FADEINLEN", params.fadeInLength)
1583
+ end
1584
+ if params.fadeOutLength ~= nil then
1585
+ reaper.SetMediaItemInfo_Value(item, "D_FADEOUTLEN", params.fadeOutLength)
1586
+ end
1587
+ if params.playRate ~= nil then
1588
+ local take = reaper.GetActiveTake(item)
1589
+ if take then
1590
+ reaper.SetMediaItemTakeInfo_Value(take, "D_PLAYRATE", params.playRate)
1591
+ end
1592
+ end
1593
+
1594
+ reaper.Undo_EndBlock("MCP: Set media item properties", -1)
1595
+ reaper.UpdateArrange()
1596
+
1597
+ return { success = true, trackIndex = params.trackIndex, itemIndex = params.itemIndex }
1598
+ end
1599
+
1600
+ function handlers.split_media_item(params)
1601
+ local track, item, err = get_media_item(params)
1602
+ if err then return nil, err end
1603
+
1604
+ local position = params.position
1605
+ if not position then return nil, "position required" end
1606
+
1607
+ local item_start = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
1608
+ local item_end = item_start + reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
1609
+ if position <= item_start or position >= item_end then
1610
+ return nil, "Split position must be within item bounds (" .. item_start .. "s - " .. item_end .. "s)"
1611
+ end
1612
+
1613
+ reaper.Undo_BeginBlock()
1614
+ local right_item = reaper.SplitMediaItem(item, position)
1615
+ reaper.Undo_EndBlock("MCP: Split media item", -1)
1616
+
1617
+ if not right_item then return nil, "Failed to split item at position " .. position end
1618
+
1619
+ reaper.UpdateArrange()
1620
+
1621
+ return {
1622
+ success = true,
1623
+ leftItem = {
1624
+ position = reaper.GetMediaItemInfo_Value(item, "D_POSITION"),
1625
+ length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH"),
1626
+ },
1627
+ rightItem = {
1628
+ position = reaper.GetMediaItemInfo_Value(right_item, "D_POSITION"),
1629
+ length = reaper.GetMediaItemInfo_Value(right_item, "D_LENGTH"),
1630
+ },
1631
+ }
1632
+ end
1633
+
1634
+ function handlers.delete_media_item(params)
1635
+ local track, item, err = get_media_item(params)
1636
+ if err then return nil, err end
1637
+
1638
+ reaper.Undo_BeginBlock()
1639
+ local ok = reaper.DeleteTrackMediaItem(track, item)
1640
+ reaper.Undo_EndBlock("MCP: Delete media item", -1)
1641
+
1642
+ if not ok then return nil, "Failed to delete item" end
1643
+
1644
+ reaper.UpdateArrange()
1645
+
1646
+ return { success = true, trackIndex = params.trackIndex, itemIndex = params.itemIndex }
1647
+ end
1648
+
1649
+ function handlers.move_media_item(params)
1650
+ local track, item, err = get_media_item(params)
1651
+ if err then return nil, err end
1652
+
1653
+ -- Validate destination track before starting undo block
1654
+ if params.newTrackIndex ~= nil then
1655
+ local dest_track = reaper.GetTrack(0, params.newTrackIndex)
1656
+ if not dest_track then
1657
+ return nil, "Destination track " .. params.newTrackIndex .. " not found"
1658
+ end
1659
+ end
1660
+
1661
+ reaper.Undo_BeginBlock()
1662
+
1663
+ -- Move track first, then set position (MoveMediaItemToTrack preserves position)
1664
+ if params.newTrackIndex ~= nil then
1665
+ local dest_track = reaper.GetTrack(0, params.newTrackIndex)
1666
+ reaper.MoveMediaItemToTrack(item, dest_track)
1667
+ end
1668
+
1669
+ if params.newPosition ~= nil then
1670
+ reaper.SetMediaItemInfo_Value(item, "D_POSITION", params.newPosition)
1671
+ end
1672
+
1673
+ reaper.Undo_EndBlock("MCP: Move media item", -1)
1674
+ reaper.UpdateArrange()
1675
+
1676
+ return {
1677
+ success = true,
1678
+ position = reaper.GetMediaItemInfo_Value(item, "D_POSITION"),
1679
+ trackIndex = params.newTrackIndex or params.trackIndex,
1680
+ }
1681
+ end
1682
+
1683
+ function handlers.trim_media_item(params)
1684
+ local track, item, err = get_media_item(params)
1685
+ if err then return nil, err end
1686
+
1687
+ local pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
1688
+ local len = reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
1689
+
1690
+ -- Validate both trims upfront before applying either
1691
+ local trim_start = (params.trimStart ~= nil and params.trimStart ~= 0) and params.trimStart or 0
1692
+ local trim_end = (params.trimEnd ~= nil and params.trimEnd ~= 0) and params.trimEnd or 0
1693
+ local new_len = len - trim_start - trim_end
1694
+ if new_len <= 0 then
1695
+ return nil, "Trim would result in zero or negative length (current: " .. len .. "s, trimStart: " .. trim_start .. "s, trimEnd: " .. trim_end .. "s)"
1696
+ end
1697
+
1698
+ reaper.Undo_BeginBlock()
1699
+
1700
+ local take = reaper.GetActiveTake(item)
1701
+
1702
+ if trim_start ~= 0 then
1703
+ pos = pos + trim_start
1704
+ reaper.SetMediaItemInfo_Value(item, "D_POSITION", pos)
1705
+ -- Adjust take start offset
1706
+ if take then
1707
+ local offset = reaper.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS")
1708
+ reaper.SetMediaItemTakeInfo_Value(take, "D_STARTOFFS", offset + trim_start)
1709
+ end
1710
+ end
1711
+
1712
+ reaper.SetMediaItemInfo_Value(item, "D_LENGTH", new_len)
1713
+ len = new_len
1714
+
1715
+ reaper.Undo_EndBlock("MCP: Trim media item", -1)
1716
+ reaper.UpdateArrange()
1717
+
1718
+ return {
1719
+ success = true,
1720
+ position = pos,
1721
+ length = len,
1722
+ }
1723
+ end
1724
+
1725
+ function handlers.add_stretch_marker(params)
1726
+ local track, item, err = get_media_item(params)
1727
+ if err then return nil, err end
1728
+
1729
+ local position = params.position
1730
+ if not position then return nil, "position required" end
1731
+
1732
+ local take = reaper.GetActiveTake(item)
1733
+ if not take then return nil, "Item has no active take" end
1734
+
1735
+ local src_pos = params.sourcePosition or position
1736
+
1737
+ reaper.Undo_BeginBlock()
1738
+ local idx = reaper.SetTakeStretchMarker(take, -1, position, src_pos)
1739
+ reaper.Undo_EndBlock("MCP: Add stretch marker", -1)
1740
+
1741
+ if idx < 0 then return nil, "Failed to add stretch marker" end
1742
+
1743
+ reaper.UpdateItemInProject(item)
1744
+ reaper.UpdateArrange()
1745
+
1746
+ return {
1747
+ success = true,
1748
+ markerIndex = idx,
1749
+ position = position,
1750
+ sourcePosition = src_pos,
1751
+ totalMarkers = reaper.GetTakeNumStretchMarkers(take),
1752
+ }
1753
+ end
1754
+
1755
+ function handlers.get_stretch_markers(params)
1756
+ local track, item, err = get_media_item(params)
1757
+ if err then return nil, err end
1758
+
1759
+ local take = reaper.GetActiveTake(item)
1760
+ if not take then return nil, "Item has no active take" end
1761
+
1762
+ local count = reaper.GetTakeNumStretchMarkers(take)
1763
+ local markers = {}
1764
+
1765
+ for i = 0, count - 1 do
1766
+ local _, pos, src_pos = reaper.GetTakeStretchMarker(take, i)
1767
+ markers[#markers + 1] = {
1768
+ index = i,
1769
+ position = pos,
1770
+ sourcePosition = src_pos,
1771
+ }
1772
+ end
1773
+
1774
+ return { trackIndex = params.trackIndex, itemIndex = params.itemIndex, markers = markers, total = count }
1775
+ end
1776
+
1777
+ function handlers.delete_stretch_marker(params)
1778
+ local track, item, err = get_media_item(params)
1779
+ if err then return nil, err end
1780
+
1781
+ local marker_idx = params.markerIndex
1782
+ if marker_idx == nil then return nil, "markerIndex required" end
1783
+
1784
+ local take = reaper.GetActiveTake(item)
1785
+ if not take then return nil, "Item has no active take" end
1786
+
1787
+ local count = reaper.GetTakeNumStretchMarkers(take)
1788
+ if marker_idx >= count then
1789
+ return nil, "Marker index " .. marker_idx .. " out of range (item has " .. count .. " markers)"
1790
+ end
1791
+
1792
+ reaper.Undo_BeginBlock()
1793
+ reaper.DeleteTakeStretchMarkers(take, marker_idx, 1)
1794
+ reaper.Undo_EndBlock("MCP: Delete stretch marker", -1)
1795
+
1796
+ reaper.UpdateItemInProject(item)
1797
+ reaper.UpdateArrange()
1798
+
1799
+ return { success = true, totalMarkers = reaper.GetTakeNumStretchMarkers(take) }
1800
+ end
1801
+
1012
1802
  -- =============================================================================
1013
1803
  -- Command dispatcher
1014
1804
  -- =============================================================================