Engineering · April 30, 2026 · 6 min read

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:

Two things didn't transfer:

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.

0.55
Soft scale
1.0
Loud scale
3
Pills (dyn)
3
States (vel)

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.

© 2026 StudioMode · Same primitive, two surfaces.