feat(pattern): add midiNote field + trigger-aware merge

Replace the pitch*127 encoding hack with a dedicated midiNote field on
pattern steps. IntervalLock now writes integer MIDI notes to step.midiNote
while preserving the original [0,1] pitch for other consumers. The clock
and sequencer bridge pass midiNote through the event pipeline.

Also reworks mergePatterns to be trigger-aware: when only one generator
fires at a step, use its values directly instead of averaging with the
other generator's defaults.
This commit is contained in:
w1n5t0n 2026-04-06 23:28:01 +01:00
parent 3f4ffcd4f9
commit aec58537db
6 changed files with 549 additions and 18 deletions

View file

@ -96,6 +96,7 @@ export class ClockEngine {
stepIndex: this._lastNote.stepIndex,
time: this._ctx.currentTime,
pitch: this._lastNote.pitch,
midiNote: this._lastNote.midiNote,
velocity: 0,
});
this._lastNote = null;
@ -192,16 +193,18 @@ export class ClockEngine {
const subs = step.subdivisions;
const midiNote = step.midiNote != null ? step.midiNote : null;
if (subs <= 1) {
// Single hit
this._scheduleNote(stepIndex, offsetTime, step.pitch, step.velocity, step.accent, false);
this._scheduleNote(stepIndex, offsetTime, step.pitch, step.velocity, step.accent, false, midiNote);
} else {
// Ratchet: evenly divide this step's duration
const subDur = stepDuration / subs;
for (let s = 0; s < subs; s++) {
const t = offsetTime + s * subDur;
const vel = s === 0 ? step.velocity : step.velocity * SUBDIVISION_VEL_SCALE;
this._scheduleNote(stepIndex, t, step.pitch, vel, step.accent, s > 0);
this._scheduleNote(stepIndex, t, step.pitch, vel, step.accent, s > 0, midiNote);
}
}
}
@ -209,20 +212,21 @@ export class ClockEngine {
/**
* Schedule a single noteOn (with preceding noteOff for the previous note).
*/
_scheduleNote(stepIndex, time, pitch, velocity, accent, isSubdivision) {
_scheduleNote(stepIndex, time, pitch, velocity, accent, isSubdivision, midiNote) {
// NoteOff for previous note
if (this._lastNote) {
this._emit(SEQ.NOTE_OFF, {
stepIndex: this._lastNote.stepIndex,
time,
pitch: this._lastNote.pitch,
midiNote: this._lastNote.midiNote,
velocity: 0,
});
}
const noteData = { stepIndex, time, pitch, velocity, accent, isSubdivision };
const noteData = { stepIndex, time, pitch, midiNote: midiNote ?? null, velocity, accent, isSubdivision };
this._emit(SEQ.NOTE_ON, noteData);
this._lastNote = { stepIndex, time, pitch, velocity };
this._lastNote = { stepIndex, time, pitch, midiNote: midiNote ?? null, velocity };
}
/**

View file

@ -32,6 +32,7 @@ export function createStep() {
accent: DEFAULT_ACCENT,
timeOffset: DEFAULT_TIME_OFFSET,
subdivisions: DEFAULT_SUBDIVISIONS,
midiNote: null,
};
}
@ -80,6 +81,7 @@ export function clonePattern(pattern) {
accent: s.accent,
timeOffset: s.timeOffset,
subdivisions: s.subdivisions,
midiNote: s.midiNote,
};
}
@ -133,14 +135,61 @@ export function mergePatterns(patternA, patternB, mode) {
const b = stepsB[i];
const trigger = isAdditive ? (a.trigger || b.trigger) : (a.trigger && b.trigger);
const bothTriggered = a.trigger && b.trigger;
let pitch, velocity, accent, timeOffset, subdivisions, midiNote;
if (bothTriggered) {
// Both generators triggered: average continuous values, combine accent by mode
pitch = (a.pitch + b.pitch) * 0.5;
velocity = (a.velocity + b.velocity) * 0.5;
accent = isAdditive ? (a.accent || b.accent) : (a.accent && b.accent);
timeOffset = (a.timeOffset + b.timeOffset) * 0.5;
subdivisions = Math.max(a.subdivisions, b.subdivisions);
// midiNote: both set → average (rounded), one set → use it, neither → null
if (a.midiNote != null && b.midiNote != null) {
midiNote = Math.round((a.midiNote + b.midiNote) * 0.5);
} else if (a.midiNote != null) {
midiNote = a.midiNote;
} else if (b.midiNote != null) {
midiNote = b.midiNote;
} else {
midiNote = null;
}
} else if (a.trigger) {
// Only A triggered: use A's values directly
pitch = a.pitch;
velocity = a.velocity;
accent = a.accent;
timeOffset = a.timeOffset;
subdivisions = a.subdivisions;
midiNote = a.midiNote;
} else if (b.trigger) {
// Only B triggered: use B's values directly
pitch = b.pitch;
velocity = b.velocity;
accent = b.accent;
timeOffset = b.timeOffset;
subdivisions = b.subdivisions;
midiNote = b.midiNote;
} else {
// Neither triggered: defaults
pitch = DEFAULT_PITCH;
velocity = DEFAULT_VELOCITY;
accent = DEFAULT_ACCENT;
timeOffset = DEFAULT_TIME_OFFSET;
subdivisions = DEFAULT_SUBDIVISIONS;
midiNote = null;
}
steps[i] = {
trigger: trigger,
pitch: (a.pitch + b.pitch) * 0.5,
velocity: (a.velocity + b.velocity) * 0.5,
accent: isAdditive ? (a.accent || b.accent) : (a.accent && b.accent),
timeOffset: (a.timeOffset + b.timeOffset) * 0.5,
subdivisions: Math.max(a.subdivisions, b.subdivisions),
pitch: pitch,
velocity: velocity,
accent: accent,
timeOffset: timeOffset,
subdivisions: subdivisions,
midiNote: midiNote,
};
}
@ -174,6 +223,7 @@ export function setStep(pattern, index, stepData) {
if (stepData.accent !== undefined) step.accent = !!stepData.accent;
if (stepData.timeOffset !== undefined) step.timeOffset = +stepData.timeOffset;
if (stepData.subdivisions !== undefined) step.subdivisions = stepData.subdivisions | 0;
if (stepData.midiNote !== undefined) step.midiNote = stepData.midiNote === null ? null : stepData.midiNote | 0;
}
/**
@ -208,6 +258,12 @@ export function validatePattern(pattern) {
if (typeof s.timeOffset !== 'number' || s.timeOffset < -0.5 || s.timeOffset > 0.5) return false;
if (typeof s.subdivisions !== 'number' || (s.subdivisions | 0) < 1 || (s.subdivisions | 0) > 4) return false;
if (s.subdivisions !== (s.subdivisions | 0)) return false;
// midiNote: null (pre-quantization) or integer 0-127
if (s.midiNote !== null) {
if (typeof s.midiNote !== 'number') return false;
if (s.midiNote !== (s.midiNote | 0)) return false;
if (s.midiNote < 0 || s.midiNote > 127) return false;
}
}
return true;

View file

@ -437,10 +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 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;
// Store the integer MIDI note in midiNote; leave pitch as-is
// (pre-quantization [0,1] value) for other consumers.
step.midiNote = notes[clampedIdx];
}
return { patternDesc: pattern, nextState: {} };

View file

@ -276,9 +276,9 @@ export class ShapeSeqEngine {
* @param {Object} data - { pitch, velocity, stepIndex, time, accent, isSubdivision }
*/
_handleNoteOn(data) {
// 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;
// Use integer midiNote if set (post-IntervalLock), otherwise fall back
// to the old pitch*127 encoding for backward compatibility.
const midiNote = data.midiNote != null ? data.midiNote : (Math.round(data.pitch * 127) | 0);
const velocity = data.velocity;
// Clamp to valid MIDI range
@ -296,7 +296,7 @@ export class ShapeSeqEngine {
* @param {Object} data - { pitch, velocity, stepIndex, time }
*/
_handleNoteOff(data) {
const midiNote = Math.round(data.pitch * 127) | 0;
const midiNote = data.midiNote != null ? data.midiNote : (Math.round(data.pitch * 127) | 0);
const note = midiNote < 0 ? 0 : midiNote > 127 ? 127 : midiNote;
this._c15.noteOff(note);

View file

@ -0,0 +1,243 @@
/**
* Tests for the midiNote field on pattern step data structures.
*
* Covers: createStep, clonePattern, setStep, validatePattern,
* mergePatterns, and IntervalLock.process() behavior.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
createStep, createPattern, clonePattern,
mergePatterns, setStep, validatePattern,
} from '../pattern.js';
import { IntervalLock } from '../primitives.js';
import { createPRNG } from '../prng.js';
// ── createStep ──────────────────────────────────────────────────────
describe('createStep midiNote', () => {
it('has midiNote: null by default', () => {
const step = createStep();
assert.strictEqual(step.midiNote, null);
});
});
// ── clonePattern ────────────────────────────────────────────────────
describe('clonePattern midiNote', () => {
it('preserves midiNote across clone', () => {
const p = createPattern(2);
p.steps[0].midiNote = 60;
p.steps[1].midiNote = null;
const c = clonePattern(p);
assert.strictEqual(c.steps[0].midiNote, 60);
assert.strictEqual(c.steps[1].midiNote, null);
});
it('clone is independent (mutation does not propagate)', () => {
const p = createPattern(1);
p.steps[0].midiNote = 72;
const c = clonePattern(p);
c.steps[0].midiNote = 48;
assert.strictEqual(p.steps[0].midiNote, 72);
});
});
// ── setStep ─────────────────────────────────────────────────────────
describe('setStep midiNote', () => {
it('can set midiNote to an integer', () => {
const p = createPattern(1);
setStep(p, 0, { midiNote: 64 });
assert.strictEqual(p.steps[0].midiNote, 64);
});
it('can set midiNote to null', () => {
const p = createPattern(1);
p.steps[0].midiNote = 60;
setStep(p, 0, { midiNote: null });
assert.strictEqual(p.steps[0].midiNote, null);
});
it('coerces midiNote to integer', () => {
const p = createPattern(1);
setStep(p, 0, { midiNote: 60.9 });
assert.strictEqual(p.steps[0].midiNote, 60);
});
it('does not touch midiNote when not in stepData', () => {
const p = createPattern(1);
p.steps[0].midiNote = 72;
setStep(p, 0, { pitch: 0.3 });
assert.strictEqual(p.steps[0].midiNote, 72);
});
});
// ── validatePattern ─────────────────────────────────────────────────
describe('validatePattern midiNote', () => {
it('accepts midiNote: null', () => {
const p = createPattern(1);
assert.strictEqual(validatePattern(p), true);
});
it('accepts midiNote: 0', () => {
const p = createPattern(1);
p.steps[0].midiNote = 0;
assert.strictEqual(validatePattern(p), true);
});
it('accepts midiNote: 127', () => {
const p = createPattern(1);
p.steps[0].midiNote = 127;
assert.strictEqual(validatePattern(p), true);
});
it('accepts midiNote: 60 (middle C)', () => {
const p = createPattern(1);
p.steps[0].midiNote = 60;
assert.strictEqual(validatePattern(p), true);
});
it('rejects midiNote: float (60.5)', () => {
const p = createPattern(1);
p.steps[0].midiNote = 60.5;
assert.strictEqual(validatePattern(p), false);
});
it('rejects midiNote: -1', () => {
const p = createPattern(1);
p.steps[0].midiNote = -1;
assert.strictEqual(validatePattern(p), false);
});
it('rejects midiNote: 128', () => {
const p = createPattern(1);
p.steps[0].midiNote = 128;
assert.strictEqual(validatePattern(p), false);
});
it('rejects midiNote: string', () => {
const p = createPattern(1);
p.steps[0].midiNote = 'C4';
assert.strictEqual(validatePattern(p), false);
});
});
// ── mergePatterns ───────────────────────────────────────────────────
describe('mergePatterns midiNote', () => {
it('both triggered, both have midiNote → average rounded', () => {
const a = createPattern(1);
setStep(a, 0, { trigger: true, midiNote: 60 });
const b = createPattern(1);
setStep(b, 0, { trigger: true, midiNote: 65 });
const m = mergePatterns(a, b, 'additive');
// (60 + 65) / 2 = 62.5 → 63
assert.strictEqual(m.steps[0].midiNote, 63);
});
it('both triggered, only A has midiNote → use A', () => {
const a = createPattern(1);
setStep(a, 0, { trigger: true, midiNote: 72 });
const b = createPattern(1);
setStep(b, 0, { trigger: true });
const m = mergePatterns(a, b, 'additive');
assert.strictEqual(m.steps[0].midiNote, 72);
});
it('both triggered, only B has midiNote → use B', () => {
const a = createPattern(1);
setStep(a, 0, { trigger: true });
const b = createPattern(1);
setStep(b, 0, { trigger: true, midiNote: 48 });
const m = mergePatterns(a, b, 'additive');
assert.strictEqual(m.steps[0].midiNote, 48);
});
it('both triggered, neither has midiNote → null', () => {
const a = createPattern(1);
setStep(a, 0, { trigger: true });
const b = createPattern(1);
setStep(b, 0, { trigger: true });
const m = mergePatterns(a, b, 'additive');
assert.strictEqual(m.steps[0].midiNote, null);
});
it('only A triggered → uses A midiNote', () => {
const a = createPattern(1);
setStep(a, 0, { trigger: true, midiNote: 55 });
const b = createPattern(1);
// b not triggered (default)
const m = mergePatterns(a, b, 'additive');
assert.strictEqual(m.steps[0].midiNote, 55);
});
it('only B triggered → uses B midiNote', () => {
const a = createPattern(1);
// a not triggered
const b = createPattern(1);
setStep(b, 0, { trigger: true, midiNote: 80 });
const m = mergePatterns(a, b, 'additive');
assert.strictEqual(m.steps[0].midiNote, 80);
});
it('neither triggered → null', () => {
const a = createPattern(1);
const b = createPattern(1);
const m = mergePatterns(a, b, 'additive');
assert.strictEqual(m.steps[0].midiNote, null);
});
});
// ── IntervalLock.process() ──────────────────────────────────────────
describe('IntervalLock midiNote field', () => {
it('sets midiNote as integer and leaves pitch as [0,1]', () => {
const lock = new IntervalLock();
const rng = createPRNG(42);
// Create a pattern with a triggered step at pitch 0.5
const input = createPattern(1);
setStep(input, 0, { trigger: true, pitch: 0.5 });
// root=0 (C), mode=0.1 (major), octaveRange=0.25 (1 octave)
const result = lock.process([0.0, 0.1, 0.25], input, {}, rng);
const step = result.patternDesc.steps[0];
// midiNote should be an integer (a MIDI note from the C major scale)
assert.strictEqual(typeof step.midiNote, 'number');
assert.strictEqual(step.midiNote, step.midiNote | 0, 'midiNote should be integer');
assert.ok(step.midiNote >= 0 && step.midiNote <= 127, 'midiNote in MIDI range');
// pitch should remain a [0,1] float (the original pre-quantization value)
assert.strictEqual(step.pitch, 0.5, 'pitch should be unchanged from input');
});
it('produces different midiNote values for different pitch inputs', () => {
const lock = new IntervalLock();
const rng = createPRNG(42);
const input = createPattern(2);
setStep(input, 0, { trigger: true, pitch: 0.0 });
setStep(input, 1, { trigger: true, pitch: 1.0 });
const result = lock.process([0.0, 0.1, 0.5], input, {}, rng);
const note0 = result.patternDesc.steps[0].midiNote;
const note1 = result.patternDesc.steps[1].midiNote;
assert.notStrictEqual(note0, note1, 'different pitches should produce different MIDI notes');
assert.ok(note0 < note1, 'higher pitch should produce higher MIDI note');
});
});

View file

@ -0,0 +1,229 @@
import { describe, it } from 'node:test';
import { strict as assert } from 'node:assert';
import { createPattern, setStep, mergePatterns } from '../pattern.js';
/**
* Helper: build a 4-step pattern and configure specific steps.
* stepConfigs is an array of { index, ...stepData } objects.
*/
function buildPattern(stepConfigs) {
const p = createPattern(4);
for (const { index, ...data } of stepConfigs) {
setStep(p, index, data);
}
return p;
}
// ---------------------------------------------------------------------------
// Additive mode
// ---------------------------------------------------------------------------
describe('mergePatterns — additive mode', () => {
it('both trigger: averages pitch, velocity, timeOffset; ORs accent; max subdivisions', () => {
const a = buildPattern([
{ index: 0, trigger: true, pitch: 0.8, velocity: 0.6, accent: true, timeOffset: 0.1, subdivisions: 2 },
]);
const b = buildPattern([
{ index: 0, trigger: true, pitch: 0.4, velocity: 1.0, accent: false, timeOffset: -0.1, subdivisions: 3 },
]);
const merged = mergePatterns(a, b, 'additive');
const s = merged.steps[0];
assert.equal(s.trigger, true);
assert.ok(Math.abs(s.pitch - 0.6) < 1e-9, `pitch should be 0.6, got ${s.pitch}`);
assert.ok(Math.abs(s.velocity - 0.8) < 1e-9, `velocity should be 0.8, got ${s.velocity}`);
assert.equal(s.accent, true); // OR
assert.ok(Math.abs(s.timeOffset - 0.0) < 1e-9, `timeOffset should be 0.0, got ${s.timeOffset}`);
assert.equal(s.subdivisions, 3); // max
});
it('only A triggers: uses A values directly', () => {
const a = buildPattern([
{ index: 1, trigger: true, pitch: 0.9, velocity: 0.3, accent: true, timeOffset: 0.2, subdivisions: 2 },
]);
const b = buildPattern([]); // step 1 stays default (trigger: false)
const merged = mergePatterns(a, b, 'additive');
const s = merged.steps[1];
assert.equal(s.trigger, true);
assert.equal(s.pitch, 0.9);
assert.equal(s.velocity, 0.3);
assert.equal(s.accent, true);
assert.equal(s.timeOffset, 0.2);
assert.equal(s.subdivisions, 2);
});
it('only B triggers: uses B values directly', () => {
const a = buildPattern([]); // step 2 stays default (trigger: false)
const b = buildPattern([
{ index: 2, trigger: true, pitch: 0.1, velocity: 0.95, accent: false, timeOffset: -0.3, subdivisions: 4 },
]);
const merged = mergePatterns(a, b, 'additive');
const s = merged.steps[2];
assert.equal(s.trigger, true);
assert.equal(s.pitch, 0.1);
assert.equal(s.velocity, 0.95);
assert.equal(s.accent, false);
assert.equal(s.timeOffset, -0.3);
assert.equal(s.subdivisions, 4);
});
it('neither triggers: step stays untriggered with defaults', () => {
const a = buildPattern([]);
const b = buildPattern([]);
const merged = mergePatterns(a, b, 'additive');
const s = merged.steps[0];
assert.equal(s.trigger, false);
assert.equal(s.pitch, 0.5); // DEFAULT_PITCH
assert.equal(s.velocity, 0.7); // DEFAULT_VELOCITY
assert.equal(s.accent, false); // DEFAULT_ACCENT
assert.equal(s.timeOffset, 0.0); // DEFAULT_TIME_OFFSET
assert.equal(s.subdivisions, 1); // DEFAULT_SUBDIVISIONS
});
it('timeOffset averaged when both trigger', () => {
const a = buildPattern([{ index: 0, trigger: true, timeOffset: 0.4 }]);
const b = buildPattern([{ index: 0, trigger: true, timeOffset: -0.2 }]);
const merged = mergePatterns(a, b, 'additive');
assert.ok(Math.abs(merged.steps[0].timeOffset - 0.1) < 1e-9);
});
it('subdivisions: max when both trigger, sole value when one triggers', () => {
const a = buildPattern([
{ index: 0, trigger: true, subdivisions: 1 },
{ index: 1, trigger: true, subdivisions: 3 },
]);
const b = buildPattern([
{ index: 0, trigger: true, subdivisions: 4 },
// step 1: not triggered
]);
const merged = mergePatterns(a, b, 'additive');
assert.equal(merged.steps[0].subdivisions, 4); // max(1, 4)
assert.equal(merged.steps[1].subdivisions, 3); // only A triggered
});
});
// ---------------------------------------------------------------------------
// Multiplicative mode
// ---------------------------------------------------------------------------
describe('mergePatterns — multiplicative mode', () => {
it('both trigger: triggers, averages values, ANDs accent', () => {
const a = buildPattern([
{ index: 0, trigger: true, pitch: 0.8, velocity: 0.6, accent: true, timeOffset: 0.1, subdivisions: 2 },
]);
const b = buildPattern([
{ index: 0, trigger: true, pitch: 0.4, velocity: 1.0, accent: false, timeOffset: -0.1, subdivisions: 3 },
]);
const merged = mergePatterns(a, b, 'multiplicative');
const s = merged.steps[0];
assert.equal(s.trigger, true);
assert.ok(Math.abs(s.pitch - 0.6) < 1e-9);
assert.ok(Math.abs(s.velocity - 0.8) < 1e-9);
assert.equal(s.accent, false); // AND: true && false
assert.ok(Math.abs(s.timeOffset - 0.0) < 1e-9);
assert.equal(s.subdivisions, 3);
});
it('only A triggers: no trigger in multiplicative (AND)', () => {
const a = buildPattern([
{ index: 0, trigger: true, pitch: 0.9, velocity: 0.3, accent: true },
]);
const b = buildPattern([]);
const merged = mergePatterns(a, b, 'multiplicative');
const s = merged.steps[0];
// AND: true && false = false, so step not triggered
assert.equal(s.trigger, false);
// Since only A triggered (but result is untriggered due to AND),
// values come from the A-only branch
assert.equal(s.pitch, 0.9);
assert.equal(s.velocity, 0.3);
});
it('only B triggers: no trigger in multiplicative (AND)', () => {
const a = buildPattern([]);
const b = buildPattern([
{ index: 0, trigger: true, pitch: 0.1, velocity: 0.95, accent: false, timeOffset: -0.3, subdivisions: 4 },
]);
const merged = mergePatterns(a, b, 'multiplicative');
const s = merged.steps[0];
assert.equal(s.trigger, false);
// Values from the B-only branch
assert.equal(s.pitch, 0.1);
assert.equal(s.velocity, 0.95);
});
it('neither triggers: untriggered with defaults', () => {
const a = buildPattern([]);
const b = buildPattern([]);
const merged = mergePatterns(a, b, 'multiplicative');
const s = merged.steps[0];
assert.equal(s.trigger, false);
assert.equal(s.pitch, 0.5);
assert.equal(s.velocity, 0.7);
assert.equal(s.accent, false);
assert.equal(s.timeOffset, 0.0);
assert.equal(s.subdivisions, 1);
});
it('timeOffset from sole triggering generator in multiplicative', () => {
const a = buildPattern([{ index: 0, trigger: true, timeOffset: 0.4 }]);
const b = buildPattern([]);
const merged = mergePatterns(a, b, 'multiplicative');
assert.equal(merged.steps[0].timeOffset, 0.4);
});
it('subdivisions from sole triggering generator in multiplicative', () => {
const a = buildPattern([]);
const b = buildPattern([{ index: 0, trigger: true, subdivisions: 3 }]);
const merged = mergePatterns(a, b, 'multiplicative');
assert.equal(merged.steps[0].subdivisions, 3);
});
});
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
describe('mergePatterns — edge cases', () => {
it('tiles mismatched step counts to LCM instead of rejecting', () => {
const a = createPattern(4);
const b = createPattern(8);
const merged = mergePatterns(a, b, 'additive');
assert.equal(merged.stepCount, 8); // lcm(4, 8) = 8
});
it('rejects invalid mode', () => {
const a = createPattern(4);
const b = createPattern(4);
assert.throws(() => mergePatterns(a, b, 'xor'), TypeError);
});
it('does not mutate input patterns', () => {
const a = buildPattern([{ index: 0, trigger: true, pitch: 0.2 }]);
const b = buildPattern([{ index: 0, trigger: true, pitch: 0.8 }]);
mergePatterns(a, b, 'additive');
assert.equal(a.steps[0].pitch, 0.2);
assert.equal(b.steps[0].pitch, 0.8);
});
});