From a8acf2a04d134d5034446cccaeb616e6ed1d6023 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:58:16 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20=EA=B0=80?= =?UTF-8?q?=EC=83=81=20=ED=8C=A8=EB=84=90=EC=9D=B4=20MIDI=20=EB=A1=9C=20?= =?UTF-8?q?=EB=82=B4=EB=B3=B4=EB=82=B4=EB=8A=94=20=EA=B2=83=EA=B3=BC=20/ap?= =?UTF-8?q?i/midi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NEBrYPPE5rzyWZ4xHhbfUE From 6c50b85a08e40ff8b0e6ea7841c2cab6f3c8a1e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:03:40 +0000 Subject: [PATCH 2/3] tools: the virtual panel sends MIDI out and answers /api/midi Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NEBrYPPE5rzyWZ4xHhbfUE --- tools/virtual-panel/transports/midi-out.ts | 146 +++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 tools/virtual-panel/transports/midi-out.ts diff --git a/tools/virtual-panel/transports/midi-out.ts b/tools/virtual-panel/transports/midi-out.ts new file mode 100644 index 00000000..56c95ac1 --- /dev/null +++ b/tools/virtual-panel/transports/midi-out.ts @@ -0,0 +1,146 @@ +/** + * docs/midi-spec.md's device → host table (lines 57 to 60) on midi.ts's session, with GET and POST /api/midi (115 to + * 120) and the midi block in /api/status (68 to 93). Once a frame, as observeFrame() in features/midi/core_midi.h runs: + * a hand's clicks become steps by each knob's outDiv and outMul, the remainder carried, and leave as CC 24 to 27 at + * most once a knob every 50 ms, the last value always; a hand's button is a note, and a new pattern a Program Change. + * knobs counts a hand's clicks and nothing else's, so a CC or note a host sent never comes back (line 62), and neither + * does a lane. The spec where it speaks, core_midi.h and core_midi_http.h where it is silent: the order the arguments + * are read in and the error texts, outDiv and outMul resetting each other even to 1, tx counting what goes out with + * nobody joined, and the pattern under SELECT's cursor as the one that changed. runtime stays true and on= is not + * read, since midi.ts applies what comes in whatever a switch says. host is kept and reported, and nobody is invited, + * since midi.ts never invites. you, the asker's address, is left out: a route is not told who asked. + */ +import { isIPv4 } from "node:net"; + +import { SIM_IP } from "@/lib/guide/deviceSim"; +import { KNOB_COUNT } from "@/lib/shared/constants"; + +import type { Reply } from "../http"; +import type { Transport } from "../panel"; +import { midi } from "./midi"; + +// The feature's numbers, by the names core_midi.h gives them. +const [CHANNEL, REL, NOTE] = [1, 24, 60]; // PF_MIDI_CHANNEL, PF_MIDI_CC_REL_BASE, PF_MIDI_NOTE_BASE +const [DIV_MAX, MUL_MAX, PACE_MS] = [16, 8, 50]; // OUT_DIVISOR_MAX, OUT_MULTIPLIER_MAX, PF_MIDI_OUT_MIN_INTERVAL_MS +const STEPS_MAX = 63; // the most one relative CC carries either way +const FRAME_MS = 1000 / 60; // the frame panel.ts ticks DeviceSim at; observeFrame() runs once a frame +const CH = (CHANNEL || 1) - 1; // channel 0 sends on 1 (line 31), and the status nibble counts from 0 +const [CC, NOTE_ON, NOTE_OFF, PROGRAM] = [0xb0 | CH, 0x90 | CH, 0x80 | CH, 0xc0 | CH]; + +const toInt = (v: string) => parseInt(v, 10) || 0; // String::toInt(): leading digits, else 0 +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); +const fail = (error: string): Reply => ({ status: 400, body: { ok: false, error } }); +const each = (v: T) => Array(KNOB_COUNT).fill(v); + +export default { + feature: "midi", + caps: [], // midi.ts gives "midi" + attach({ sim, clock, routes, status, bound, on }) { + // midi.ts sorts after this file and attaches after it, so the session is looked up once a frame or a request comes. + const session = () => midi(sim)!; + // PF_MIDI_OUT_DIVISOR, PF_MIDI_OUT_MULTIPLIER and PF_MIDI_OUT_ABSOLUTE, and no host to invite. + const [outDiv, outMul] = [each(1), each(1)]; + let [absolute, host, tx] = [true, "", 0]; + // Per knob: the clicks outDiv has not made a step of, the position abs keeps and the one the host last heard, the + // steps rel has not sent, and when the knob last went out. The first message goes out at once. + const [accum, pos, sentPos, pending, lastSent] = [each(0), each(64), each(64), each(0), each(-Infinity)]; + const emit = (...message: number[]) => { + tx++; // emit() counts it, a participant joined or not + session().send(message); + }; + + // A hand's buttons as the frame reads them: a press is the edge down, held the state, sounding a note-on sent. + const [held, pressed, sounding] = [each(false), each(false), each(false)]; + on("button", ({ knob, down }) => { + if (down && !held[knob - 1]) pressed[knob - 1] = true; + held[knob - 1] = down; + }); + let [clicks, index] = [[...sim.knobState().knobs], sim.snapshot().patternIndex]; + + clock.every(FRAME_MS, () => { + const now = Math.floor(clock.now() + 1e-6); // millis(): one clock for all four knobs, in whole ms + const knobs = sim.knobState().knobs; + for (let i = 0; i < KNOB_COUNT; i++) { + if (knobs[i] !== clicks[i]) { + // Whole steps go out and the rest waits. Truncation keeps the sign, so turning back cancels it. + accum[i] += knobs[i] - clicks[i]; + const whole = Math.trunc(accum[i] / outDiv[i]); + accum[i] -= whole * outDiv[i]; + const steps = clamp(whole * outMul[i], -STEPS_MAX, STEPS_MAX); + if (absolute) pos[i] = clamp(pos[i] + steps, 0, 127); + else pending[i] += steps; + } + // The pacer runs on every frame, motion or not: a still frame is the one that sends the last value. + const due = now - lastSent[i] >= PACE_MS; + if (absolute && due && pos[i] !== sentPos[i]) { + [sentPos[i], lastSent[i]] = [pos[i], now]; + emit(CC, REL + i, pos[i]); + } else if (!absolute && pending[i] && (due || Math.abs(pending[i]) > STEPS_MAX)) { + // A sum past ±63 cannot wait, and what one message cannot carry stays for the next. + const send = clamp(pending[i], -STEPS_MAX, STEPS_MAX); + [pending[i], lastSent[i]] = [pending[i] - send, now]; + emit(CC, REL + i, 64 + send); + } + if (pressed[i]) { + emit(NOTE_ON, NOTE + i, 127); + sounding[i] = true; + } + // Every note-on gets its note-off, a button down and up inside one frame too. + if (sounding[i] && !held[i]) { + emit(NOTE_OFF, NOTE + i, 0); + sounding[i] = false; + } + } + clicks = [...knobs]; + pressed.fill(false); + // Whoever changed it, MIDI included (line 60). On SELECT it is the one under the cursor, as currentPatternIdx is. + const shown = sim.snapshot().patternIndex; + if (shown !== index) { + index = shown; + if (shown <= 127) emit(PROGRAM, shown); + } + }); + + // appendStatus()'s block. rxDetail's counters past what midi.ts keeps stay at 0: no worker task, no queue out. + const block = () => { + const r = session().read(); + const rxDetail = { + ...{ worker: true, abs: r.abs, rel: r.rel, queueDropped: r.queueDropped, txQueueDropped: 0, packetGaps: 0 }, + ...{ parseErrors: 0, bufferOverflows: 0, pollMaxGapUs: 0, pollMaxUs: 0, queueMaxAgeUs: 0, stackMin: 0 }, + }; + const outMode = absolute ? "abs" : "rel"; + const peer = r.peers[r.peers.length - 1] ?? ""; // the one that joined last + return { runtime: true, channel: CHANNEL, outDiv: [...outDiv], outMul: [...outMul], outMode, host, rtpPeers: r.peers.length, rtpPeer: peer, rx: r.rx, tx, rxDetail }; + }; + // sendState(): the same, with where to find the session and each knob's position. + const state = (): Reply => ({ body: { ok: true, ...block(), ip: SIM_IP, port: bound.midi, outPos: [...pos] } }); + + routes("GET", "/api/midi", state); + // knob first, then outDiv, outMul, outMode and host, each applied before the next is read. + routes("POST", "/api/midi", ({ args }) => { + const knob = args.has("knob") ? toInt(args.get("knob")!) - 1 : -1; + if (args.has("knob") && (knob < 0 || knob >= KNOB_COUNT)) return fail("knob must be 1..4"); + // A gain is one number, ×N, 1:1 or 1/N, so setting either side resets the other (line 117), and the carry goes. + const gain = (div: number, mul: number) => { + for (let i = 0; i < KNOB_COUNT; i++) if (knob < 0 || i === knob) [outDiv[i], outMul[i], accum[i]] = [div, mul, 0]; + }; + const [div, mul] = [toInt(args.get("outDiv") ?? ""), toInt(args.get("outMul") ?? "")]; + if (args.has("outDiv") && (div < 1 || div > DIV_MAX)) return fail("outDiv must be 1..16"); + if (args.has("outDiv")) gain(div, 1); + if (args.has("outMul") && (mul < 1 || mul > MUL_MAX)) return fail("outMul must be 1..8"); + if (args.has("outMul")) gain(1, mul); + const mode = args.get("outMode"); + if (mode !== null && mode !== "abs" && mode !== "rel") return fail("outMode must be abs or rel"); + if (mode !== null) { + absolute = mode === "abs"; + // Steps summed for one encoding mean nothing in the other. + [accum, pending].forEach((a) => a.fill(0)); + } + const to = args.get("host"); + if (to !== null && to !== "" && !isIPv4(to)) return fail("host must be an IPv4 address or empty"); + if (to !== null) host = to; + return state(); + }); + status.push(() => ({ midi: block() })); + }, +} satisfies Transport; From e614d5bc6fecaca61fdbd591114d1a2dc837769a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:07:11 +0000 Subject: [PATCH 3/3] tools: say the button event is why a host's note never comes back Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NEBrYPPE5rzyWZ4xHhbfUE --- tools/virtual-panel/transports/midi-out.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/virtual-panel/transports/midi-out.ts b/tools/virtual-panel/transports/midi-out.ts index 56c95ac1..f0ca985c 100644 --- a/tools/virtual-panel/transports/midi-out.ts +++ b/tools/virtual-panel/transports/midi-out.ts @@ -3,8 +3,8 @@ * 120) and the midi block in /api/status (68 to 93). Once a frame, as observeFrame() in features/midi/core_midi.h runs: * a hand's clicks become steps by each knob's outDiv and outMul, the remainder carried, and leave as CC 24 to 27 at * most once a knob every 50 ms, the last value always; a hand's button is a note, and a new pattern a Program Change. - * knobs counts a hand's clicks and nothing else's, so a CC or note a host sent never comes back (line 62), and neither - * does a lane. The spec where it speaks, core_midi.h and core_midi_http.h where it is silent: the order the arguments + * knobs counts a hand's clicks and nothing else's, and the button event is a hand's, so a CC or note a host sent never + * comes back (line 62), and neither does a lane. The spec where it speaks, core_midi.h and core_midi_http.h where it is silent: the order the arguments * are read in and the error texts, outDiv and outMul resetting each other even to 1, tx counting what goes out with * nobody joined, and the pattern under SELECT's cursor as the one that changed. runtime stays true and on= is not * read, since midi.ts applies what comes in whatever a switch says. host is kept and reported, and nobody is invited,