diff --git a/src/bitwig_mcp/tools/clip.py b/src/bitwig_mcp/tools/clip.py index 3205e5c..cb52f79 100644 --- a/src/bitwig_mcp/tools/clip.py +++ b/src/bitwig_mcp/tools/clip.py @@ -1,6 +1,5 @@ """MCP tools for Bitwig Studio cursor clip controls via DrivenByMoss OSC.""" -import time from typing import Any from bitwig_mcp.osc_client import get_client @@ -106,162 +105,3 @@ def register(mcp) -> None: "color": _v(c.get_state("/clip/color")), "pinned": _v(c.get_state("/clip/pinned")), } - - @mcp.tool() - def clip_record_notes( - track_index: int, - clip_index: int, - notes: list, - length_beats: float = 4.0, - channel: int = 1, - bpm: float | None = None, - ) -> dict: - """Create a clip and write MIDI notes into it using timed real-time recording. - - DrivenByMoss OSC has no direct note-editing API, so this tool works by: - 1. Creating an empty clip of the requested length - 2. Arming the track and sending /record to put the clip in recording mode - 3. Waiting for isRecording=1 confirmation before starting the note timer - 4. Sending precisely timed vkb_midi events for each note on/off - 5. Letting the clip finish one loop (recording stops automatically) - - notes: list of dicts, each with: - - note: int — MIDI note number (0-127, 60=C4, 62=D4, 64=E4, 65=F4, 67=G4, 69=A4, 71=B4) - - velocity: int — 1-127 (default 100) - - start_beat: float — beat position from start of clip (0.0 = first beat) - - duration_beats: float — note length in beats (default 0.25 = 16th note) - - track_index: 1-8 (which track to place the clip on) - clip_index: 1-8 (which slot in the track's clip launcher) - length_beats: total clip length in beats (4.0 = 1 bar in 4/4, 8.0 = 2 bars) - channel: MIDI channel 1-16 (default 1) - bpm: tempo override; if omitted, reads current Bitwig tempo from state - - Example — a simple C major arpeggio over 4 beats: - notes=[ - {"note": 60, "velocity": 100, "start_beat": 0.0, "duration_beats": 0.5}, - {"note": 64, "velocity": 90, "start_beat": 1.0, "duration_beats": 0.5}, - {"note": 67, "velocity": 95, "start_beat": 2.0, "duration_beats": 0.5}, - {"note": 72, "velocity": 85, "start_beat": 3.0, "duration_beats": 1.0}, - ] - """ - c = get_client() - - # Resolve BPM - if bpm is None: - tempo_state = c.get_state("/tempo/raw") - bpm = float(tempo_state[0]) if tempo_state else 120.0 - - beat_seconds = 60.0 / bpm - clip_seconds = length_beats * beat_seconds - - # Validate notes - for n in notes: - if not (0 <= n["note"] <= 127): - raise ValueError(f"note {n['note']} out of range 0-127") - if n.get("start_beat", 0) >= length_beats: - raise ValueError(f"start_beat {n['start_beat']} >= clip length {length_beats}") - - # 1. Select the track and arm it - c.cmd(f"/track/{track_index}/select") - time.sleep(0.05) - c.cmd(f"/track/{track_index}/recarm", 1) - time.sleep(0.05) - - # 2. Set immediate launch quantization - c.cmd("/launcher/defaultQuantization", "NONE") - time.sleep(0.05) - - # 3. Create empty clip (overwrites any existing content) - c.cmd(f"/track/{track_index}/clip/{clip_index}/create", length_beats) - time.sleep(0.2) - - # 4. Send record command — puts the clip into active recording mode - c.cmd(f"/track/{track_index}/clip/{clip_index}/record") - - # 5. Wait for isRecording=1 (up to 3s) - t_start = time.monotonic() - recording_confirmed = False - deadline = t_start + 3.0 - while time.monotonic() < deadline: - rec = c.get_state(f"/track/{track_index}/clip/{clip_index}/isRecording") - if rec and rec[0]: - recording_confirmed = True - t_start = time.monotonic() # reset timer to actual recording start - break - # Also accept isPlaying (some Bitwig versions go straight to playing+overdub) - play = c.get_state(f"/track/{track_index}/clip/{clip_index}/isPlaying") - if play and play[0]: - recording_confirmed = True - t_start = time.monotonic() - break - time.sleep(0.005) - - if not recording_confirmed: - # Fallback: proceed anyway with a small buffer for latency - time.sleep(0.05) - - # 6. Build sorted event list: (time_seconds, note, velocity) - events = [] - for n in notes: - note = int(n["note"]) - vel = int(n.get("velocity", 100)) - start_s = float(n.get("start_beat", 0.0)) * beat_seconds - dur_s = float(n.get("duration_beats", 0.25)) * beat_seconds - end_s = min(start_s + dur_s, clip_seconds - 0.02) - events.append((start_s, note, vel)) - events.append((end_s, note, 0)) - - events.sort(key=lambda e: e[0]) - - # 7. Send events at precise beat-aligned times - for t_event, note, vel in events: - elapsed = time.monotonic() - t_start - wait = t_event - elapsed - if wait > 0: - time.sleep(wait) - c.cmd(f"/vkb_midi/{channel}/note/{note}", vel) - - # 8. Wait for the clip to finish its first loop, then stop recording - elapsed = time.monotonic() - t_start - remaining = clip_seconds - elapsed - if remaining > 0: - time.sleep(remaining + 0.05) - - # Send all-notes-off before stopping to prevent stuck notes - for n in notes: - c.cmd(f"/vkb_midi/{channel}/note/{int(n['note'])}", 0) - - # Launch (toggles recording → playing) to finalise the clip - c.cmd(f"/track/{track_index}/clip/{clip_index}/launch") - time.sleep(0.1) - - # Restore default launch quantization - c.cmd("/launcher/defaultQuantization", "1") - - return { - "ok": True, - "recording_confirmed": recording_confirmed, - "track_index": track_index, - "clip_index": clip_index, - "bpm": bpm, - "length_beats": length_beats, - "clip_duration_seconds": round(clip_seconds, 3), - "notes_written": len(notes), - "channel": channel, - } - - @mcp.tool() - def clip_note_info() -> str: - """Return a reference of MIDI note numbers for common notes. - - Use these values in the 'note' field of clip_record_notes. - """ - return ( - "Octave 3: C=48 D=50 E=52 F=53 G=55 A=57 B=59\n" - "Octave 4: C=60 D=62 E=64 F=65 G=67 A=69 B=71 (middle C = 60)\n" - "Octave 5: C=72 D=74 E=76 F=77 G=79 A=81 B=83\n" - "Sharps/flats add or subtract 1 (e.g. C#4=61, Bb4=70)\n" - "Drum GM: Kick=36 Snare=38 HiHat(closed)=42 HiHat(open)=46 " - "Crash=49 Ride=51 Tom(hi)=48 Tom(mid)=45 Tom(lo)=41" - )