game6 / client / test / looper.test.mjs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
// The loop station: bar-boundary arming, the 16th-step quantization that turns
// a played riff into wire events, the per-player quota, and the 160 ms
// lookahead scheduler's exact AudioContext times. Fixtures recorded off the
// real src/game/looper.ts (with only ./audio stubbed, so the recorded noteOn
// calls are the ones the scheduler actually made).
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import * as lib from "../test-dist/testlib.js";

const v = JSON.parse(readFileSync(new URL("./vectors/looper.json", import.meta.url), "utf8"));
const plain = (x) => JSON.parse(JSON.stringify(x));

test("the scheduler cadence is unchanged: 50 ms tick, 160 ms lookahead", () => {
  assert.equal(lib.tickMs, v.tickMs);
  assert.equal(lib.lookaheadMs, v.lookaheadMs);
});

test("arming lines the recording up on the next bar boundary, +120 ms of room", () => {
  for (const c of v.arm) {
    const bar = lib.barMsOf(c.bpm);
    assert.equal(lib.nextBarAt(c.now + 120, c.origin, bar), c.recStartHost, c.name);
  }
});

// quantize-events is fed the recEvents the TS captured; the fixture holds the
// input events (offsets from recStartHost) and the loop the TS produced.
for (const c of v.quantize) {
  test(`quantize ${c.name}`, () => {
    const START = 500_000;
    const recEvents = c.events.map(([at, n, vel, off]) => {
      const e = { at: START + at, n, v: vel };
      if (off !== null) e.off = START + at + off;
      return e;
    });
    // captureNoteOn drops anything more than half a step before bar zero
    const sm = lib.stepMsOf(c.bpm);
    const kept = recEvents.filter((e) => lib.captureNote(e.at, START, sm));
    const ev = plain(lib.quantizeEvents(kept, START, c.bars, c.bpm, c.inst === 1));
    if (c.loops.length === 0) {
      assert.equal(ev.length, 0, "silence is not broadcast");
    } else {
      assert.deepEqual(ev, c.loops[0][4]);
      assert.equal(c.loops[0][3], c.bars);
      assert.equal(c.loops[0][2], c.inst);
    }
  });
}

test("loop ids are owner-scoped and counter-suffixed", () => {
  const MY_ID = "12D3KooWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
  const withLoop = v.quantize.find((c) => c.loops.length > 0);
  assert.equal(lib.loopId(MY_ID, 1), withLoop.loops[0][0]);
  assert.equal(lib.loopId(MY_ID, 1), "AAAAAA.1");
  assert.equal(lib.loopId("shortid", 7), "hortid.7");
});

test("captureNoteOff stamps the most recent still-open note", () => {
  const ev = [{ at: 0, n: 60, v: 1, off: 5 }, { at: 10, n: 60, v: 1 }, { at: 20, n: 62, v: 1 }];
  lib.closeNote(ev, 60, 99);
  assert.equal(ev[1].off, 99);
  assert.equal(ev[0].off, 5, "an already-closed note is left alone");
  lib.closeNote(ev, 64, 99); // a note that was never opened is a no-op
  assert.equal(ev.length, 3);
});

test("the recording only takes notes from half a step before bar zero", () => {
  const sm = lib.stepMsOf(100); // 150 ms
  assert.equal(lib.captureNote(500_000, 500_000, sm), true);
  assert.equal(lib.captureNote(500_000 - 75, 500_000, sm), true);
  assert.equal(lib.captureNote(500_000 - 76, 500_000, sm), false);
  assert.equal(lib.captureNote(500_000 - 200, 500_000, sm), false);
});

test("three loops per player: a fourth evicts the owner's oldest", () => {
  assert.equal(v.maxLoopsPerPlayer, 3);
  for (const c of v.evict) {
    const owners = c.have.map((owner, k) => [`L${k}`, owner]);
    const victim = lib.evictId(owners, c.add);
    const after = c.have.map((_, k) => `L${k}`).filter((id) => id !== victim).concat("NEW");
    assert.deepEqual(after, c.after, c.name);
  }
});

test("the lookahead window schedules exactly the loop events TS scheduled", () => {
  for (const c of v.schedule) {
    const hn = c.now;
    const from = Math.max(0, hn); // scheduledUntil was reset before every case
    const to = hn + lib.lookaheadMs;
    const sm = lib.stepMsOf(c.bpm);
    const got = [];
    if (c.metronome) {
      for (const [t, accent] of plain(lib.metronomeBeats(from, to, c.origin, lib.beatMsOf(c.bpm)))) {
        got.push(["metronomeTick", accent, lib.audioTimeAt(t, c.audioNow, hn)]);
      }
    }
    for (const [id, , inst, len, ev] of c.loops) {
      if (c.muted.includes(id)) continue;
      const cycle = len * lib.barMsOf(c.bpm);
      for (const [t, , n, vel, dur] of plain(lib.loopWindowEvents(ev, from, to, c.origin, cycle, sm))) {
        got.push(["noteOn", id, inst, n, vel / 100, lib.audioTimeAt(t, c.audioNow, hn), (dur * sm) / 1000]);
      }
    }
    assert.deepEqual(got, c.calls, c.name);
  }
});

test("the pulse callbacks are delayed to each note's own instant", () => {
  for (const c of v.schedule) {
    const hn = c.now;
    const from = Math.max(0, hn);
    const to = hn + lib.lookaheadMs;
    const sm = lib.stepMsOf(c.bpm);
    const delays = [];
    for (const [id, , , len, ev] of c.loops) {
      if (c.muted.includes(id)) continue;
      const cycle = len * lib.barMsOf(c.bpm);
      for (const [t] of plain(lib.loopWindowEvents(ev, from, to, c.origin, cycle, sm))) {
        delays.push(Math.max(0, t - hn));
      }
    }
    assert.deepEqual(delays, c.pulseDelays, c.name);
  }
});

test("a second pass over the same window schedules nothing new", () => {
  for (const c of v.schedule) assert.equal(c.secondTickCalls, 0, c.name);
});

test("the scheduler refuses to run unsynced, silent or off-stage", () => {
  for (const c of v.metronome) {
    const shouldRun = c.synced && c.ready && c.phase === "hall";
    assert.equal(c.calls > 0, shouldRun, c.name);
  }
});

test("the recording state machine rides the same tick", () => {
  const by = Object.fromEntries(v.recTransitions.map((c) => [c.name, c]));
  assert.deepEqual(
    [by["armed-waiting"].recArmed, by["armed-waiting"].recording],
    [true, false],
    "still waiting for the boundary",
  );
  assert.deepEqual([by["armed-fires"].recArmed, by["armed-fires"].recording], [false, true]);
  assert.deepEqual([by["recording-continues"].recArmed, by["recording-continues"].recording], [false, true]);
  assert.deepEqual([by["recording-finishes"].recArmed, by["recording-finishes"].recording], [false, false]);
  assert.equal(by["recording-finishes"].emitted.length, 1, "finishing broadcasts the loop");
  assert.equal(by["recording-continues"].emitted.length, 0);
});

test("metronomeBeats accents only the downbeat", () => {
  const beats = plain(lib.metronomeBeats(500_000, 500_000 + 4 * 600, 500_000, 600));
  assert.deepEqual(beats.map(([, a]) => a), [true, false, false, false]);
  assert.deepEqual(beats.map(([t]) => t), [500_000, 500_600, 501_200, 501_800]);
});

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/game6.git