Commit c9e7558b by PLN (Algolia)

test(cale): the scheduler had one musical rule and no way to check it

`Deck.scheduleBar` mixed the decision (what plays, when) with the audio nodes, so the
only rule in the rack with real musical consequence could not be tested without a
browser. Extracted as `hitsInBar(voice, barIndex, bpm, swing, bufDur)`.

The rule is that a bar-loop starts only on a bar that is a multiple of its own length.
Break it and the rack still makes sound — it plays a 4-bar loop's bar 1 underneath the
phrase's bar 4, so the groove turns around in the wrong place. That is not a crash, it
is a musical wrongness you would find mid-set and probably blame on the sample.

11 cases, including the ones I would otherwise have got wrong: a negative bar index
when the transport is nudged backwards, a phrase that runs past bar 8, a cleared cell
at the second entry of a 4-bar loop, and the two asymmetries between the modes — a
bar-loop is stretched to the DECK tempo (that is what layering means) while a chop
never is (a stab is a stab at 90 or 174), and a chop is unconstrained by stride
because it is sub-bar by definition.

Confirmed the stride tests fail when the rule is disabled: 2 failed / 22 passed.
Suite 13 → 24.
parent 2c0718aa
/**
* The scheduler's one musical rule, tested away from the audio graph.
*
* `hitsInBar` decides what plays and when. Get it wrong and the rack still makes
* sound — it just makes the wrong sound, with a groove that turns around in the wrong
* place, which is exactly the class of bug you discover mid-set.
*/
import { describe, expect, it } from 'vitest'
import { hitsInBar, barSeconds, CHOP_STEPS, PHRASE_BARS, type Voice } from '../engine'
const loop = (o: Partial<Voice> = {}): Voice => ({
id: 'k/x', url: '/x.wav', bars: 4, durS: 7.805, gain: 1, muted: false,
cells: Array.from({ length: PHRASE_BARS }, () => true),
...o,
})
describe('hitsInBar — bar loops', () => {
it('starts a 4-bar loop only on bars 0 and 4 of an 8-bar phrase', () => {
const v = loop({ bars: 4 })
const fires = Array.from({ length: PHRASE_BARS }, (_, b) => hitsInBar(v, b, 123).length)
expect(fires).toEqual([1, 0, 0, 0, 1, 0, 0, 0])
})
it('starts a 2-bar loop on every even bar, and a 1-bar loop on every bar', () => {
expect(Array.from({ length: 8 }, (_, b) => hitsInBar(loop({ bars: 2 }), b, 123).length))
.toEqual([1, 0, 1, 0, 1, 0, 1, 0])
expect(Array.from({ length: 8 }, (_, b) => hitsInBar(loop({ bars: 1 }), b, 123).length))
.toEqual([1, 1, 1, 1, 1, 1, 1, 1])
})
it('holds the voice for exactly its own bar count', () => {
const [h] = hitsInBar(loop({ bars: 4 }), 0, 123)
expect(h.holdS).toBeCloseTo(4 * barSeconds(123), 6)
expect(h.offsetS).toBe(0)
})
it('respects a cleared cell at the entry it would otherwise use', () => {
const cells = Array.from({ length: PHRASE_BARS }, () => true)
cells[4] = false
expect(hitsInBar(loop({ bars: 4, cells }), 0, 123)).toHaveLength(1)
expect(hitsInBar(loop({ bars: 4, cells }), 4, 123)).toHaveLength(0)
})
it('keeps the phrase when the transport runs past it, and when it runs backwards', () => {
// bar 8 is bar 0 of the next phrase; a negative index must not fall off the array
expect(hitsInBar(loop({ bars: 4 }), 8, 123)).toHaveLength(1)
expect(hitsInBar(loop({ bars: 4 }), 12, 123)).toHaveLength(1)
expect(() => hitsInBar(loop({ bars: 4 }), -3, 123)).not.toThrow()
})
it('plays nothing when muted or at zero gain', () => {
expect(hitsInBar(loop({ muted: true }), 0, 123)).toHaveLength(0)
expect(hitsInBar(loop({ gain: 0 }), 0, 123)).toHaveLength(0)
})
it('stretches to the DECK tempo, not the file\'s — that is what layering means', () => {
const v = loop({ bars: 4, durS: 7.805 }) // 4 bars at 123 bpm
expect(hitsInBar(v, 0, 123)[0].rate).toBeCloseTo(1, 3)
expect(hitsInBar(v, 0, 150)[0].rate).toBeCloseTo(150 / 123, 3)
})
})
describe('hitsInBar — chops', () => {
const chop = (cells: boolean[]): Voice =>
({ ...loop(), bars: 0, durS: 0.4, cells })
it('fires on every armed 16th of every bar', () => {
const cells = Array.from({ length: CHOP_STEPS }, (_, i) => i % 4 === 0)
const hits = hitsInBar(chop(cells), 0, 120)
expect(hits).toHaveLength(4)
const step = barSeconds(120) / CHOP_STEPS
expect(hits.map((h) => h.offsetS / step)).toEqual([0, 4, 8, 12])
// unlike a bar-loop, a chop is not constrained to a stride — bar 3 plays too
expect(hitsInBar(chop(cells), 3, 120)).toHaveLength(4)
})
it('never stretches a chop: a stab is a stab at any tempo', () => {
const cells = Array.from({ length: CHOP_STEPS }, (_, i) => i === 0)
expect(hitsInBar(chop(cells), 0, 90)[0].rate).toBe(1)
expect(hitsInBar(chop(cells), 0, 174)[0].rate).toBe(1)
})
it('pushes odd 16ths late by the swing fraction, and leaves even ones alone', () => {
const cells = Array.from({ length: CHOP_STEPS }, () => true)
const step = barSeconds(120) / CHOP_STEPS
const hits = hitsInBar(chop(cells), 0, 120, 0.25)
expect(hits[0].offsetS).toBeCloseTo(0, 9)
expect(hits[1].offsetS).toBeCloseTo(step * 1.25, 9)
expect(hits[2].offsetS).toBeCloseTo(step * 2, 9)
})
it('never holds a chop past the end of its buffer', () => {
const cells = Array.from({ length: CHOP_STEPS }, (_, i) => i === 0)
expect(hitsInBar(chop(cells), 0, 120, 0, 0.12)[0].holdS).toBe(0.12)
})
})
......@@ -102,6 +102,43 @@ export function audition(buf: AudioBuffer, opts: { loop: boolean; gain?: number
}
}
export type Hit = { offsetS: number; rate: number; holdS: number }
/**
* What this voice plays during bar `barIndex`, as offsets from the top of that bar.
*
* Extracted from the scheduler because it is the only part with a musical rule in it,
* and a rule you cannot test is a rule you find out about on stage. The rule: a
* bar-loop only starts on a bar that is a multiple of ITS OWN length. A 4-bar loop in
* an 8-bar phrase therefore has two possible entries, not eight — firing it on bar 3
* would play its bar 1 underneath the phrase's bar 4, which is the groove turning
* around in the wrong place, and not something anyone should have to reason about while
* performing. A chop has no such constraint: it is sub-bar, so it fires on any of the
* 16 steps, with odd steps optionally pushed late by `swing`.
*/
export function hitsInBar(v: Voice, barIndex: number, bpm: number, swing = 0,
bufDurS = Infinity): Hit[] {
if (v.muted || v.gain <= 0) return []
const bar = barSeconds(bpm)
const slot = ((barIndex % PHRASE_BARS) + PHRASE_BARS) % PHRASE_BARS
if (v.bars > 0) {
if (slot % v.bars !== 0) return []
if (!v.cells[slot]) return []
return [{ offsetS: 0, rate: loopAtRate(v.durS, v.bars, bpm), holdS: v.bars * bar }]
}
const step = bar / CHOP_STEPS
const hits: Hit[] = []
for (let i = 0; i < CHOP_STEPS; i++) {
if (!v.cells[i]) continue
hits.push({
offsetS: i * step + (i % 2 === 1 ? swing * step : 0),
rate: 1,
holdS: Math.min(bufDurS, step * 4),
})
}
return hits
}
// ── the rack ─────────────────────────────────────────────────────────────────
type Scheduled = { src: AudioBufferSourceNode; at: number }
......@@ -176,30 +213,11 @@ export class Deck {
}
private scheduleBar(barIndex: number, at: number) {
const ctx = audioCtx()
const bar = barSeconds(this.bpm)
const slot = ((barIndex % PHRASE_BARS) + PHRASE_BARS) % PHRASE_BARS
for (const { v, buf } of this.voices.values()) {
if (v.muted || v.gain <= 0) continue
if (v.bars > 0) {
// A loop only starts on a bar that is a multiple of its own length, so a
// 4-bar loop in an 8-bar phrase has two possible entries, not eight. Firing
// it on bar 3 would play its bar 1 over the phrase's bar 4 — audible as the
// groove turning around in the wrong place, which is not a thing you want to
// have to reason about while performing.
if (slot % v.bars !== 0) continue
if (!v.cells[slot]) continue
this.fire(buf, at, v, loopAtRate(v.durS, v.bars, this.bpm), v.bars * bar)
} else {
const step = bar / CHOP_STEPS
for (let i = 0; i < CHOP_STEPS; i++) {
if (!v.cells[i]) continue
const sw = i % 2 === 1 ? this.swing * step : 0
this.fire(buf, at + i * step + sw, v, 1, Math.min(buf.duration, step * 4))
}
for (const h of hitsInBar(v, barIndex, this.bpm, this.swing, buf.duration)) {
this.fire(buf, at + h.offsetS, v, h.rate, h.holdS)
}
}
if (at < ctx.currentTime) return
}
private fire(buf: AudioBuffer, at: number, v: Voice, rate: number, holdS: number) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment