From dc88bffa5cacc30ba40725e57da6b9247cbc90d2 Mon Sep 17 00:00:00 2001 From: Misha Nasledov Date: Wed, 7 Oct 2026 12:10:01 -0700 Subject: [PATCH] worklet: a stop or a new run lets go of stamped keys A quantised or ahead key's release is a TW_NOTE stamped with a transport time. When a Stop is stamped between a key's press and its release, the stop halts the transport at its own time and the release sits in scheduled_ for a later time the stopped transport never reaches, until the next load drops it. The synth's voices are silenced by the halt, but the off is never applied: the key stays in keyRoutes_ and its off is never recorded, so the keyboard shows it lit. A Play over a running run is worse: the load and the begin drop the release and beginDue cleared keyRoutes_ without letting go, so a key on a plain channel keeps sounding into the new run. Both stops (stamped and frame-stamped) and every begin now release each key still in keyRoutes_ the way its on went. KEY_HEARD keys and direct keys are their player's to let go and are untouched, so a key held across a Stop on the local keyboard behaves as before. protocoltest holds free.gen's seat and a plain channel to having nothing left sounding after a Stop or a Play that falls between a stamped press and its stamped release. --- docs/JAM.md | 4 +- wasm/web/protocoltest.mjs | 85 +++++++++++++++++++++++++++++++++++++++ wasm/web/thinkweb.cpp | 72 +++++++++++++++++++++------------ 3 files changed, 134 insertions(+), 27 deletions(-) diff --git a/docs/JAM.md b/docs/JAM.md index b7c2a21c..816f8939 100644 --- a/docs/JAM.md +++ b/docs/JAM.md @@ -360,7 +360,9 @@ M4 is done. Where it stands: applies inside the step (`TW_NOTE`) -- so a key into a piece's `input midi` composes the same thing everywhere, and is logged for a late joiner. A key a bar ahead onto a plain channel is heard by its - player at once and by everyone else a bar later. + player at once and by everyone else a bar later. A Stop, or a Play over + the run, lets go of every stamped key still down, since a release + stamped past it is never reached. - Keys stamped for one time are applied in an order made from the sender's id and counter, not in the order they arrived: two quantised seats meet on grid lines all the time, and a quantizer passes on what diff --git a/wasm/web/protocoltest.mjs b/wasm/web/protocoltest.mjs index 51c7308c..6311cb30 100644 --- a/wasm/web/protocoltest.mjs +++ b/wasm/web/protocoltest.mjs @@ -830,6 +830,70 @@ async function staleIndex (createThinkWeb, piece, dsps) `${muted.join(' and ')}, wanted 0 and 1`; } +/* A stamped key pressed before the run ends and released after it, into + the piece on `seat' and onto `plain', which the piece does not take: the + way the run ends, a Stop or a Play over it, has to let go of both. The + keys left sounding, as `channel:note', from the record the page lights + its keyboard from. */ +async function keysAtTheEnd (createThinkWeb, piece, dsps, seat, plain, play) +{ + const { M, ok } = await loadPiece(createThinkWeb, { + gen: piece.text, instruments: dsps, + }); + + if (!ok) + return ['did not load']; + + M.ccall('tw_load', 'number', ['number', 'string'], + [plain, dsps['rhodes.dsp']]); + + const held = new Map(); + let frames = 0; + const render = () => + { + M._tw_render(128); + frames += 128; + + const base = M._tw_keys() >>> 0; + + for (let k = 0; k < M._tw_key_count(); k++) + { + const [channel, note, , on] = + M.HEAP32.subarray((base + k * 24 + 8) >> 2); + const key = `${channel}:${note}`; + + held.set(key, (held.get(key) ?? 0) + (on ? 1 : -1)); + } + + M._tw_keys_clear(); + }; + + engineApply(M, { type: 'begin', frame: 0 }); + + for (const channel of [seat, plain]) + for (const [at, on] of [[0.5, true], [1.5, false]]) + engineApply(M, { type: 'noteat', at, channel, note: 60, + velocity: 90, on, tie: 0 }); + + if (!play) + engineApply(M, { type: 'at', op: 'stop', at: 1 }); + + while (M._tw_now() < 1) + render(); + + if (play) + { + M.ccall('tw_piece_load', 'number', ['string', 'number'], + [piece.text, -1]); + engineApply(M, { type: 'begin', frame: frames + 1024 }); + } + + while (frames < 3 * 48000) + render(); + + return [...held].filter(([, n]) => n > 0).map(([key]) => key); +} + /* The first shown knob, as the page reads it (see session). */ function firstKnob (M) { @@ -1679,6 +1743,27 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) 'is dropped\n'); } + { + const piece = pieces(build).find((p) => p.name === 'free.gen'); + + for (const [play, how] of [[false, 'a Stop'], [true, 'a Play']]) + { + const lit = await keysAtTheEnd(createThinkWeb, piece, dsps, 0, 10, + play); + + if (lit.length > 0) + { + failures++; + process.stdout.write(`FAIL ${piece.name.padEnd(14)} keys ` + + `released past ${how}: ` + + `${lit.join(', ')} left sounding\n`); + } + else + process.stdout.write(`ok ${piece.name.padEnd(14)} ${how} ` + + 'lets go of keys released past it\n'); + } + } + /* Keys into a piece. */ { const piece = pieces(build).find((p) => p.name === 'hands.gen'); diff --git a/wasm/web/thinkweb.cpp b/wasm/web/thinkweb.cpp index b316ae68..4624d139 100644 --- a/wasm/web/thinkweb.cpp +++ b/wasm/web/thinkweb.cpp @@ -504,8 +504,8 @@ int epoch_; /* Where each stamped key's on went, by channel and note, so that its off goes the same way (TW_NOTE): an edit between the two can change whether the piece takes input on the channel, and an off sent the other way - leaves the note held. Emptied at each begin, where a late joiner's - catching up starts from too. */ + leaves the note held. Let go of at each stop and begin (releaseKeys), + where a late joiner's catching up starts from too. */ enum KeyRoute { KEY_PIECE, KEY_CHANNEL, KEY_HEARD }; std::map, KeyRoute> keyRoutes_; @@ -539,6 +539,46 @@ void playedKey (int channel, int note, int velocity, bool on) on ? 1 : 0 }; } +/* A stamped key, the way its route goes: nowhere for KEY_HEARD, whose + player sounded it live and lets go of it live. */ +void keyByRoute (KeyRoute route, int channel, int note, double velocity, + bool on) +{ + if (route == KEY_PIECE) + { + /* CMD_MIDI_ON's event, at the time it was stamped for rather than + the top of whichever window it arrived in -- which is what makes + a key into the piece compose the same thing on every peer. */ + thcEvent ev = {}; + + ev.type = on ? THC_EV_NOTE : THC_EV_NOTEOFF; + ev.at = sched_->now(); + ev.channel = channel; + ev.u.note.note = note; + ev.u.note.velocity = (int)velocity; + ev.u.note.duration = 0; + ev.u.note.level = 1; + + if (sched_->injectMidiEvent(ev) || !on) + playedKey(channel, note, (int)velocity, on); + } + else if (route == KEY_CHANNEL) + keyOnChannel(channel, (float)note, (float)velocity, on, + sched_->now()); +} + +/* Every stamped key still down, let go: at a stop or a begin the run its + keys were pressed in is over, and a release stamped later than that is + never reached -- dropped with the run, or waiting for a time a stopped + transport does not get to -- if it was sent at all. */ +void releaseKeys (void) +{ + for (const auto &k : keyRoutes_) + keyByRoute(k.second, k.first.first, k.first.second, 0, false); + + keyRoutes_.clear(); +} + void newEpoch (void) { epoch_++; @@ -637,6 +677,7 @@ void applyDue (double start, int len) ring out -- see thcScheduler::halt. */ case TW_STOP: sched_->halt(); + releaseKeys(); break; case TW_REWIND: @@ -690,7 +731,7 @@ void beginDue (double start, int len) that at the load (tw_piece_load). */ sched_->reset(); newEpoch(); - keyRoutes_.clear(); + releaseKeys(); /* From the top, or from where a seek said: played up to there without a sound, and the frame below pinned to wherever that is. */ @@ -1005,6 +1046,7 @@ void applyScheduled (const Scheduled &given) { case TW_STOP: sched_->halt(); + releaseKeys(); break; @@ -1171,29 +1213,7 @@ void applyScheduled (const Scheduled &given) keyRoutes_.erase(was); } - if (route == KEY_PIECE) - { - /* CMD_MIDI_ON's event, at the time it was stamped for - rather than the top of whichever window it arrived - in -- which is what makes a key into the piece compose - the same thing on every peer. */ - thcEvent ev = {}; - - ev.type = c.on ? THC_EV_NOTE : THC_EV_NOTEOFF; - ev.at = sched_->now(); - ev.channel = c.channel; - ev.u.note.note = c.note; - ev.u.note.velocity = (int)c.value; - ev.u.note.duration = 0; - ev.u.note.level = 1; - - if (sched_->injectMidiEvent(ev) || !c.on) - playedKey(c.channel, c.note, (int)c.value, c.on); - } - else if (route == KEY_CHANNEL) - keyOnChannel(c.channel, (float)c.note, (float)c.value, c.on, - sched_->now()); - + keyByRoute(route, c.channel, c.note, c.value, c.on); break; }