diff --git a/src/bitwig_mcp/tools/clip.py b/src/bitwig_mcp/tools/clip.py index 4ecab75..3205e5c 100644 --- a/src/bitwig_mcp/tools/clip.py +++ b/src/bitwig_mcp/tools/clip.py @@ -120,9 +120,10 @@ def register(mcp) -> None: DrivenByMoss OSC has no direct note-editing API, so this tool works by: 1. Creating an empty clip of the requested length - 2. Enabling launcher overdub so the clip records while playing - 3. Launching the clip and sending precisely timed MIDI events via vkb_midi - 4. Waiting for one full loop, then disabling overdub + 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) @@ -161,34 +162,45 @@ def register(mcp) -> None: if n.get("start_beat", 0) >= length_beats: raise ValueError(f"start_beat {n['start_beat']} >= clip length {length_beats}") - # 1. Create empty clip on the target slot - c.cmd(f"/track/{track_index}/clip/{clip_index}/create", length_beats) - time.sleep(0.15) - - # 2. Select the track so it becomes the active cursor track + # 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) - # 3. Set launch quantization to None for immediate start + # 2. Set immediate launch quantization c.cmd("/launcher/defaultQuantization", "NONE") time.sleep(0.05) - # 4. Enable launcher overdub — clips record while playing - c.cmd("/overdub/launcher", 1) - 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) - # 5. Launch the clip and wait for it to actually start playing - c.cmd(f"/track/{track_index}/clip/{clip_index}/launch") + # 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: - playing = c.get_state(f"/track/{track_index}/clip/{clip_index}/isPlaying") - if playing and playing[0]: + 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: @@ -196,15 +208,13 @@ def register(mcp) -> None: 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 = start_s + dur_s - # Keep note-off inside the clip (avoid firing after loop wraps) - end_s = min(end_s, clip_seconds - 0.01) + end_s = min(start_s + dur_s, clip_seconds - 0.02) events.append((start_s, note, vel)) - events.append((end_s, note, 0)) # velocity 0 = note off + events.append((end_s, note, 0)) events.sort(key=lambda e: e[0]) - # 7. Send events at the right times + # 7. Send events at precise beat-aligned times for t_event, note, vel in events: elapsed = time.monotonic() - t_start wait = t_event - elapsed @@ -212,19 +222,26 @@ def register(mcp) -> None: time.sleep(wait) c.cmd(f"/vkb_midi/{channel}/note/{note}", vel) - # 8. Wait until the clip has completed one full loop, then stop overdub + # 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.08) + time.sleep(remaining + 0.05) - c.cmd("/overdub/launcher", 0) + # 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) - # Restore default quantization + # 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,