Velocity, twice: shipping the same idea to two different tools
Per-step velocity shipped on the drum sandbox in Round 143. Three rounds later it shipped on the piano roll's chord progression as a Dynamics row. Same primitive, same multiplier, same hash trick, two different surfaces. The cross-tool transfer cost less than the original implementation because the second tool already understood the shape.
The drums version
The drum sandbox needed a third state per cell: instead of
on / off, each step became off / soft / loud. The click handler
cycled through the three states; the renderer added a
.soft CSS class for the dimmer per-track tint;
the synth multiplied the kit's nominal gain by 0.55× when the
cell was soft and 1.0× when it was loud.
The hash gained an optional v= field in parallel
with the existing p= on/off bits. Old shared links
without v= defaulted to all-loud and played
identically to before.
// Round 143: scheduler reads cell value, passes velocity in
for (const t of TRACKS) {
const v = pattern[t.key][i];
if (v && !muted[t.key]) playDrum(t.key, nextStepTime, v);
}
// playDrum scales gain by the velocity arg
function playDrum(track, when, velocity) {
const vel = (velocity === 1) ? 0.55 : 1.0;
const masterGain = volume * vel;
// ...rest of synthesis path unchanged
}
Round 144 added a Humanize button that walked the grid in place and re-rolled velocities — kick downbeat + snare backbeat stay LOUD, hi-hat off-beats 50% chance demoted to SOFT, everything else 35% SOFT, 65% LOUD. Same data layer, different writer.
The piano roll version
The piano roll's auto-play chord progression was already using
a Web Audio look-ahead scheduler that called
scheduleChord(chord, when, durSec). Three rounds
after the drum velocity work, that schedule call learned a
fourth argument: a per-call gain multiplier.
// Round 146: scheduler picks per-chord gain at queue time
while (progNextChordTime < audioCtx.currentTime + LOOKAHEAD_S) {
const i = progCursorIdx;
const chord = activeProg.chords[i];
const gain = chordGainScale(i, activeProg.chords.length);
scheduleChord(chord, progNextChordTime, chordDur * 0.95, undefined, gain);
// ...
}
function chordGainScale(idx, total) {
if (dynamicsMode === 'cres') {
if (total <= 1) return 1.0;
return 0.55 + (1.0 - 0.55) * (idx / (total - 1));
}
if (dynamicsMode === 'wave') {
return (idx % 2 === 0) ? 1.0 : 0.55;
}
return 1.0; // 'flat' default
}
The UI was a three-pill row — Flat / Crescendo / Wave — sitting next to the BPM input. Flat is the default and emits no hash change. Crescendo ramps gain linearly from 0.55× on chord 0 to 1.0× on the final chord — a one-tap "verse builds into chorus." Wave alternates loud/soft per chord — the same alternating pattern that surfaced in the drum sandbox's wave-style velocity maps.
What transferred and what didn't
The cross-tool transfer saved roughly two-thirds of the work. Three things came along for free:
- The 0.55 / 1.0 split. The drum sandbox had already proved that 0.55× was loud enough to register as a present hit but quiet enough to read as a ghost note. The piano roll inherited the constant unchanged.
- The hash-extension pattern. Add an optional new field, only emit it when non-default, parse it on load, default to legacy behavior when absent. Both tools followed the same recipe; both kept their existing shared URLs byte-identical.
- The mental model. "Same scheduler, new per-call multiplier" had been worked out once. The piano roll version was a straight port of that idea, not a new design.
Two things didn't transfer:
- The cell-cycle UI. The drum sandbox's click-to-cycle works because each cell is one of four independent on-beats per beat. A chord progression is short (4-8 chords) and the user wants a global shape, not per-chord clicks. So the piano roll's UI is three pills (Flat / Crescendo / Wave) instead of clickable per-chord cells.
- The hash-key name. Drums uses
v=for per-cell digits. Piano roll usesdyn=for the global mode. Different shape of data, different key.
The hard part was deciding what gets a multiplier. The easy part was deciding what value the multiplier was. The piano roll inherited the easy part.
The compounding cost
The drum sandbox velocity work was about 60 lines — synthesis-path multiplier, click-handler cycle, CSS class, hash extension, surpriseMe re-tune. The piano roll's chord dynamics row was about 30 lines: a state variable, a gain-shape function, a UI pill row, a scheduler call-site change, a hash extension. Half the cost because half the decisions were already made.
This is the second time we've watched a drums-tool feature compound into a piano-roll feature for free. The first was the chord-progression overlay: shipping it on the piano roll taught us how to drive a Web Audio look-ahead scheduler with a chord queue, and that scheduler became the same one the drums tool used for its own progression overlay (Round 107). The flow goes the other way too — the drums tool is the sketchpad and the piano roll is the harmonizer, but the shared scheduler is the bridge.
What this isn't
This isn't a per-chord velocity layer (you can't tap one chord to make it soft and the next loud). It's a global shape — Flat / Crescendo / Wave. The same way the BPM ÷2/×2 toggle doesn't let you set a different BPM per beat; it picks a global re-read. Three options is the natural ceiling for a pill-row UI before it should become a dropdown.
The next layer probably is per-chord velocity — once the user
wants to tag verse chord A as soft and verse chord B as loud,
the global shape isn't enough. That'd map to a per-chord
digit string in the hash, much like the drum sandbox's
v=. Same recipe; just promoted from global to
per-position.
Try the dynamics row
Pick a progression, hit play, switch between Flat / Crescendo / Wave.
Open the piano roll →What's next
Three roads. Per-chord velocity (above). A "Humanize chords" button that mirrors the drums one — random small velocity jitter applied across the progression. Or a Wave-pattern preset that includes a tempo lift on the loud chords (gain + tempo as a pair, captured under a single dyn-mode value). Each one is now ~30 lines because the layer it sits on already understands its shape.