Fix clip_record_notes: use /record + isRecording polling instead of overdub

Switch from the launcher-overdub approach (which never actually recorded notes)
to arming the track and sending /track/N/clip/N/record, then waiting for
isRecording=1 before starting the MIDI event timer. Adds all-notes-off at loop
end to prevent stuck notes, and reports recording_confirmed in the return value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 19:01:53 +02:00
parent 4c147774aa
commit 7125a2ce4c
+42 -25
View File
@@ -120,9 +120,10 @@ def register(mcp) -> None:
DrivenByMoss OSC has no direct note-editing API, so this tool works by: DrivenByMoss OSC has no direct note-editing API, so this tool works by:
1. Creating an empty clip of the requested length 1. Creating an empty clip of the requested length
2. Enabling launcher overdub so the clip records while playing 2. Arming the track and sending /record to put the clip in recording mode
3. Launching the clip and sending precisely timed MIDI events via vkb_midi 3. Waiting for isRecording=1 confirmation before starting the note timer
4. Waiting for one full loop, then disabling overdub 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: 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) - 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: if n.get("start_beat", 0) >= length_beats:
raise ValueError(f"start_beat {n['start_beat']} >= clip length {length_beats}") raise ValueError(f"start_beat {n['start_beat']} >= clip length {length_beats}")
# 1. Create empty clip on the target slot # 1. Select the track and arm it
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
c.cmd(f"/track/{track_index}/select") c.cmd(f"/track/{track_index}/select")
time.sleep(0.05) 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") c.cmd("/launcher/defaultQuantization", "NONE")
time.sleep(0.05) time.sleep(0.05)
# 4. Enable launcher overdub — clips record while playing # 3. Create empty clip (overwrites any existing content)
c.cmd("/overdub/launcher", 1) c.cmd(f"/track/{track_index}/clip/{clip_index}/create", length_beats)
time.sleep(0.05) time.sleep(0.2)
# 5. Launch the clip and wait for it to actually start playing # 4. Send record command — puts the clip into active recording mode
c.cmd(f"/track/{track_index}/clip/{clip_index}/launch") c.cmd(f"/track/{track_index}/clip/{clip_index}/record")
# 5. Wait for isRecording=1 (up to 3s)
t_start = time.monotonic() t_start = time.monotonic()
recording_confirmed = False
deadline = t_start + 3.0 deadline = t_start + 3.0
while time.monotonic() < deadline: while time.monotonic() < deadline:
playing = c.get_state(f"/track/{track_index}/clip/{clip_index}/isPlaying") rec = c.get_state(f"/track/{track_index}/clip/{clip_index}/isRecording")
if playing and playing[0]: 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() t_start = time.monotonic()
break break
time.sleep(0.005) 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) # 6. Build sorted event list: (time_seconds, note, velocity)
events = [] events = []
for n in notes: for n in notes:
@@ -196,15 +208,13 @@ def register(mcp) -> None:
vel = int(n.get("velocity", 100)) vel = int(n.get("velocity", 100))
start_s = float(n.get("start_beat", 0.0)) * beat_seconds start_s = float(n.get("start_beat", 0.0)) * beat_seconds
dur_s = float(n.get("duration_beats", 0.25)) * beat_seconds dur_s = float(n.get("duration_beats", 0.25)) * beat_seconds
end_s = start_s + dur_s end_s = min(start_s + dur_s, clip_seconds - 0.02)
# Keep note-off inside the clip (avoid firing after loop wraps)
end_s = min(end_s, clip_seconds - 0.01)
events.append((start_s, note, vel)) 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]) 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: for t_event, note, vel in events:
elapsed = time.monotonic() - t_start elapsed = time.monotonic() - t_start
wait = t_event - elapsed wait = t_event - elapsed
@@ -212,19 +222,26 @@ def register(mcp) -> None:
time.sleep(wait) time.sleep(wait)
c.cmd(f"/vkb_midi/{channel}/note/{note}", vel) 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 elapsed = time.monotonic() - t_start
remaining = clip_seconds - elapsed remaining = clip_seconds - elapsed
if remaining > 0: 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") c.cmd("/launcher/defaultQuantization", "1")
return { return {
"ok": True, "ok": True,
"recording_confirmed": recording_confirmed,
"track_index": track_index, "track_index": track_index,
"clip_index": clip_index, "clip_index": clip_index,
"bpm": bpm, "bpm": bpm,