diff --git a/playground/js/shapeseq/clock.js b/playground/js/shapeseq/clock.js index b61d63a..aa87892 100644 --- a/playground/js/shapeseq/clock.js +++ b/playground/js/shapeseq/clock.js @@ -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 ────────────────────────────────────────────────── diff --git a/playground/js/shapeseq/primitives.js b/playground/js/shapeseq/primitives.js index 9e21cf8..6c20d1a 100644 --- a/playground/js/shapeseq/primitives.js +++ b/playground/js/shapeseq/primitives.js @@ -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.0–0.33: every 2nd step (8th note feel) + // 0.34–0.66: every 4th step (16th note feel) + // 0.67–1.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; } diff --git a/playground/js/shapeseq/projection.js b/playground/js/shapeseq/projection.js index 06fa0da..d4d5889 100644 --- a/playground/js/shapeseq/projection.js +++ b/playground/js/shapeseq/projection.js @@ -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' }, ], }; diff --git a/playground/js/shapeseq/sequencer.js b/playground/js/shapeseq/sequencer.js index 31d1d84..2eccde6 100644 --- a/playground/js/shapeseq/sequencer.js +++ b/playground/js/shapeseq/sequencer.js @@ -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} */ 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);