fix(shapeseq): fix 6 bugs found in fresh-eyes review

1. Pitch pipeline: IntervalLock stores midiNote/127, sequencer now
   converts back via pitch*127. Removed pitch RangeMap from projection
   presets (was double-mapping already-quantized values).

2. Clock step duration: changed from quarter-note grid (60/bpm) to
   16th-note grid (60/bpm/4). 8 steps at 120 BPM now = 1 second.

3. Method name: drawWeights → randomiseWeights (matching WasmIML API).

4. Dirty-check: setSequenceInputs now skips re-evaluation when inputs
   haven't changed (epsilon 1e-5). Avoids 60fps chain evaluation.

5. SwingGroove: swingGrid param now functional — selects 8th note
   (period 2), 16th note (period 4), or triplet (period 3) swing grid.

6. IntervalLock base octave: starts from C3 (MIDI 48) instead of C0,
   so default output is in a playable range.
This commit is contained in:
w1n5t0n 2026-03-24 11:42:25 +02:00
parent 505ed26f21
commit d40711eb77
4 changed files with 53 additions and 17 deletions

View file

@ -239,11 +239,12 @@ export class ClockEngine {
/**
* Duration of one step in seconds at the current BPM.
* One beat = one step (quarter-note grid).
* One step = one 16th note (4 steps per beat).
* At 120 BPM: one step = 0.125s, 8 steps = 1 second.
* @returns {number}
*/
_stepDuration() {
return 60 / this._bpm;
return 60 / this._bpm / 4;
}
// ── Event emission ──────────────────────────────────────────────────

View file

@ -268,15 +268,31 @@ export class SwingGroove extends Primitive {
process(params, patternDesc, state, rng) {
const swingAmount = params[0];
const swingGrid = params[1];
const pattern = clonePattern(patternDesc);
// Max swing = 0.33 (triplet feel)
const maxOffset = 0.33;
const offset = swingAmount * maxOffset;
// Apply swing to every other step (odd-indexed steps)
for (let i = 1; i < pattern.stepCount; i += 2) {
pattern.steps[i].timeOffset = offset;
// swingGrid selects which subdivision gets swung:
// 0.00.33: every 2nd step (8th note feel)
// 0.340.66: every 4th step (16th note feel)
// 0.671.0: every 3rd step (triplet feel)
let period;
if (swingGrid < 0.34) {
period = 2;
} else if (swingGrid < 0.67) {
period = 4;
} else {
period = 3;
}
// Apply swing to steps that fall on the swing grid
for (let i = 0; i < pattern.stepCount; i++) {
if (i % period === period - 1) {
pattern.steps[i].timeOffset = offset;
}
}
return { patternDesc: pattern, nextState: {} };
@ -399,10 +415,13 @@ export class IntervalLock extends Primitive {
const pattern = clonePattern(patternDesc);
// Build the full set of MIDI notes in this scale + root + range
// Base octave offset: root param selects the note class (0-11),
// we start from C3 (MIDI 48) so that default output is in a playable range
const BASE_OCTAVE = 48;
const notes = [];
for (let oct = 0; oct < octaveRange; oct++) {
for (let i = 0; i < scale.length; i++) {
const midiNote = root + scale[i] + oct * 12;
const midiNote = BASE_OCTAVE + root + scale[i] + oct * 12;
if (midiNote <= 127) {
notes.push(midiNote);
}
@ -418,7 +437,9 @@ export class IntervalLock extends Primitive {
// Quantize pitch [0,1] to nearest note in our scale
const targetIdx = Math.round(step.pitch * (notes.length - 1));
const clampedIdx = targetIdx < 0 ? 0 : targetIdx >= notes.length ? notes.length - 1 : targetIdx;
// Store as MIDI note / 127 to stay in [0,1]
// Store MIDI note directly — downstream (sequencer._handleNoteOn)
// reads this as a MIDI note number, not a [0,1] value.
// We store as note/127 to stay within the [0,1] pattern field range.
step.pitch = notes[clampedIdx] / 127;
}

View file

@ -294,10 +294,13 @@ export function applyProjection(chain, patternDesc) {
* ready to pass to createProjectionChain().
*/
export const PRESETS = {
/** Exponential velocity shaping + pitch range map to MIDI 48-84 */
/**
* Exponential velocity shaping.
* Pitch is handled by IntervalLock primitive (outputs MIDI note / 127).
* No pitch RangeMap needed the sequencer converts pitch * 127 to MIDI note.
*/
expressive: [
{ transform: VelocityCurve, params: { shape: 'exponential' }, field: 'velocity' },
{ transform: RangeMap, params: { min: 48, max: 84 }, field: 'pitch' },
],
/** Gate threshold at 0.5 + exponential velocity curve */
@ -306,9 +309,8 @@ export const PRESETS = {
{ transform: VelocityCurve, params: { shape: 'exponential' }, field: 'velocity' },
],
/** Wide pitch range map (24-96) + linear velocity (identity) */
/** S-curve velocity for more dynamic contrast */
fullRange: [
{ transform: RangeMap, params: { min: 24, max: 96 }, field: 'pitch' },
{ transform: VelocityCurve, params: { shape: 'linear' }, field: 'velocity' },
{ transform: VelocityCurve, params: { shape: 'sCurve' }, field: 'velocity' },
],
};

View file

@ -66,6 +66,9 @@ export class ShapeSeqEngine {
/** @private */ this._playing = false;
/** @private */ this._initialized = false;
// Dirty-check: skip re-evaluation when inputs haven't changed
/** @private */ this._lastInputs = [NaN, NaN];
// Track active notes for orphan prevention
/** @private @type {Set<number>} */
this._activeNotes = new Set();
@ -88,7 +91,7 @@ export class ShapeSeqEngine {
this._sequenceIML = await createSequenceIML();
// Randomize weights with default spread
this._sequenceIML.drawWeights(DEFAULT_SPREAD);
this._sequenceIML.randomiseWeights(DEFAULT_SPREAD);
// 2. Create the default primitive chain
this._chain = new Chain();
@ -224,6 +227,15 @@ export class ShapeSeqEngine {
setSequenceInputs(values) {
if (!this._initialized || !this._sequenceIML) return;
// Dirty-check: skip re-evaluation if inputs haven't changed
const EPS = 1e-5;
if (Math.abs(values[0] - this._lastInputs[0]) < EPS &&
Math.abs(values[1] - this._lastInputs[1]) < EPS) {
return;
}
this._lastInputs[0] = values[0];
this._lastInputs[1] = values[1];
// 1. Forward inputs to the sequence IML
this._sequenceIML.setInputs(values);
@ -264,9 +276,9 @@ export class ShapeSeqEngine {
* @param {Object} data - { pitch, velocity, stepIndex, time, accent, isSubdivision }
*/
_handleNoteOn(data) {
// pitch comes from the projection layer; after RangeMap it's already
// in MIDI note range (e.g., 48-84). Round to nearest integer.
const midiNote = Math.round(data.pitch) | 0;
// pitch is stored as midiNote/127 in the pattern (set by IntervalLock).
// Convert back to MIDI note number.
const midiNote = Math.round(data.pitch * 127) | 0;
const velocity = data.velocity;
// Clamp to valid MIDI range
@ -284,7 +296,7 @@ export class ShapeSeqEngine {
* @param {Object} data - { pitch, velocity, stepIndex, time }
*/
_handleNoteOff(data) {
const midiNote = Math.round(data.pitch) | 0;
const midiNote = Math.round(data.pitch * 127) | 0;
const note = midiNote < 0 ? 0 : midiNote > 127 ? 127 : midiNote;
this._c15.noteOff(note);