virtual_keyboard / tests /test_game_loop.js
github-actions[bot]
Deploy to HF Spaces
2e0c2a7
/**
* Game Loop Streaming Tests
*
* Tests the core timing and scheduling logic that lets the AI response
* play back while the model is still generating. All functions are
* extracted verbatim from keyboard.js so the tests run under Node
* without a browser.
*
* Run: node tests/test_game_loop.js
* (also invoked by pytest via tests/test_game_loop.py)
*/
"use strict";
// =========================================================================
// Extracted pure functions (copied from keyboard.js)
// =========================================================================
const GAME_BPM = 75;
const GAME_BEATS_PER_BAR = 4;
const GAME_COUNTIN_BEATS = 3;
function beatSec() { return 60 / GAME_BPM; }
function barSec() { return beatSec() * GAME_BEATS_PER_BAR; }
function barsToSeconds(bars) { return Math.max(1, Number(bars) || 1) * barSec(); }
function quantizeToStep(value, step) {
if (!Number.isFinite(value) || !Number.isFinite(step) || step <= 0) return value;
return Math.round(value / step) * step;
}
function clampValue(value, minValue, maxValue) {
return Math.max(minValue, Math.min(maxValue, value));
}
// Simulated keyboard range (2 octaves from C4)
const baseMidi = 60;
const numOctaves = 2;
function clampMidiNote(note) {
const minNote = baseMidi;
const maxNote = baseMidi + (numOctaves * 12) - 1;
return Math.max(minNote, Math.min(maxNote, note));
}
function sortEventsChronologically(eventsToSort) {
return [...eventsToSort].sort((a, b) => {
const ta = Number(a.time) || 0;
const tb = Number(b.time) || 0;
if (ta !== tb) return ta - tb;
if (a.type === b.type) return 0;
if (a.type === 'note_off') return -1;
if (b.type === 'note_off') return 1;
return 0;
});
}
function sanitizeEvents(rawEvents) {
if (!Array.isArray(rawEvents) || rawEvents.length === 0) return [];
const cleaned = rawEvents
.filter(e => e && (e.type === 'note_on' || e.type === 'note_off'))
.map(e => ({
type: e.type,
note: Number(e.note) || 0,
velocity: Number(e.velocity) || 0,
time: Number(e.time) || 0,
channel: Number(e.channel) || 0
}));
if (cleaned.length === 0) return [];
return sortEventsChronologically(cleaned);
}
function normalizeEventsToZero(rawEvents) {
const cleaned = sanitizeEvents(rawEvents);
if (cleaned.length === 0) return [];
const minTime = Math.min(...cleaned.map(e => e.time));
return sortEventsChronologically(
cleaned.map(e => ({ ...e, time: Math.max(0, e.time - minTime) }))
);
}
function eventsToNotePairs(rawEvents) {
const pairs = [];
const activeByNote = new Map();
const sorted = sortEventsChronologically(rawEvents);
sorted.forEach(event => {
const note = Number(event.note) || 0;
const time = Number(event.time) || 0;
const velocity = Number(event.velocity) || 100;
if (event.type === 'note_on' && velocity > 0) {
if (!activeByNote.has(note)) activeByNote.set(note, []);
activeByNote.get(note).push({ start: time, velocity });
return;
}
if (event.type === 'note_off' || (event.type === 'note_on' && velocity <= 0)) {
const stack = activeByNote.get(note);
if (!stack || stack.length === 0) return;
const active = stack.shift();
const end = Math.max(active.start + 0.05, time);
pairs.push({
note: clampMidiNote(note),
start: active.start,
end,
velocity: Math.max(1, Math.min(127, active.velocity))
});
}
});
return pairs.sort((a, b) => a.start - b.start);
}
function notePairsToEvents(pairs) {
const eventsOut = [];
pairs.forEach(pair => {
const note = clampMidiNote(Math.round(pair.note));
const start = Math.max(0, Number(pair.start) || 0);
const end = Math.max(start + 0.05, Number(pair.end) || start + 0.2);
const velocity = Math.max(1, Math.min(127, Math.round(Number(pair.velocity) || 100)));
eventsOut.push({ type: 'note_on', note, velocity, time: start, channel: 0 });
eventsOut.push({ type: 'note_off', note, velocity: 0, time: end, channel: 0 });
});
return sortEventsChronologically(eventsOut);
}
// =========================================================================
// scheduleNewNotes delay calculator — isolated from DOM/Tone side-effects.
// Returns an array of { note, velocity, onDelayMs, offDelayMs } objects.
// =========================================================================
function computeNoteDelays(newEvents, {
aiStartSec,
nowGameSec,
quantStepSec = null,
}) {
const minDurationSec = quantStepSec
? Math.max(0.08, quantStepSec * 0.5)
: 0.08;
const newPairs = eventsToNotePairs(newEvents);
if (newPairs.length === 0) return [];
const now = nowGameSec;
const processed = newPairs.map(pair => {
const rawStart = quantStepSec ? quantizeToStep(pair.start, quantStepSec) : pair.start;
const start = Math.max(0, rawStart);
const rawEnd = quantStepSec ? quantizeToStep(pair.end, quantStepSec) : pair.end;
const end = Math.max(start + minDurationSec, rawEnd);
return { pair, start, end };
});
const earliestGameSec = Math.min(...processed.map(p => aiStartSec + p.start));
const lateBy = now - earliestGameSec;
const shiftSec = lateBy > 0.05 ? lateBy : 0;
return processed.map(({ pair, start, end }) => {
const note = clampMidiNote(Math.round(pair.note));
const velocity = clampValue(Math.round(pair.velocity || 100), 1, 127);
const noteOnGameSec = aiStartSec + start + shiftSec;
const noteOffGameSec = aiStartSec + end + shiftSec;
return {
note,
velocity,
onDelayMs: Math.max(0, (noteOnGameSec - now) * 1000),
offDelayMs: Math.max(0, (noteOffGameSec - now) * 1000),
shiftSec,
};
});
}
// =========================================================================
// Simulated streaming callback — mirrors the onUpdate logic in
// finishUserTurnStreaming but captures scheduling decisions.
// =========================================================================
function simulateStreamingSession(chunks, { nowGameSecFn, nextBarAlignedStartFn }) {
const MIN_PAIRS_TO_START = 3;
let playbackStarted = false;
let aiStartSec = null;
let lastEventCount = 0;
let streamedAiEvents = [];
const scheduled = []; // all { note, onDelayMs, offDelayMs } records
let playbackStartedAt = null;
for (const chunk of chunks) {
const events = chunk.events || [];
if (events.length === 0) continue;
const normalized = normalizeEventsToZero(events);
const newEvents = normalized.slice(lastEventCount);
lastEventCount = normalized.length;
streamedAiEvents = normalized;
const bufferedPairs = eventsToNotePairs(streamedAiEvents);
let justStarted = false;
if (!playbackStarted && bufferedPairs.length >= MIN_PAIRS_TO_START) {
playbackStarted = true;
justStarted = true;
aiStartSec = nextBarAlignedStartFn();
playbackStartedAt = chunk.arrivalGameSec;
// Schedule all buffered notes.
const delays = computeNoteDelays(streamedAiEvents, {
aiStartSec,
nowGameSec: chunk.arrivalGameSec,
});
scheduled.push(...delays);
}
// Schedule newly arrived notes — skip if we just bulk-scheduled above.
if (!justStarted && playbackStarted && newEvents.length > 0) {
const delays = computeNoteDelays(newEvents, {
aiStartSec,
nowGameSec: chunk.arrivalGameSec,
});
scheduled.push(...delays);
}
}
// Fallback: if streaming finished before buffer threshold.
if (!playbackStarted && streamedAiEvents.length > 0) {
const lastChunk = chunks[chunks.length - 1];
playbackStarted = true;
aiStartSec = nextBarAlignedStartFn();
playbackStartedAt = lastChunk.arrivalGameSec;
const delays = computeNoteDelays(streamedAiEvents, {
aiStartSec,
nowGameSec: lastChunk.arrivalGameSec,
});
scheduled.push(...delays);
}
return { scheduled, playbackStarted, aiStartSec, playbackStartedAt, streamedAiEvents };
}
// =========================================================================
// Test harness
// =========================================================================
let passed = 0;
let failed = 0;
const failures = [];
function assert(condition, msg) {
if (!condition) throw new Error(`Assertion failed: ${msg}`);
}
function approx(a, b, tol = 0.001) {
return Math.abs(a - b) < tol;
}
function test(name, fn) {
try {
fn();
passed++;
process.stdout.write(` PASS ${name}\n`);
} catch (e) {
failed++;
failures.push({ name, error: e.message });
process.stdout.write(` FAIL ${name}\n ${e.message}\n`);
}
}
// =========================================================================
// Helper: make a simple melody as raw events
// =========================================================================
function makeMelody(notes, startTime = 0, duration = 0.3, gap = 0.1) {
const events = [];
let t = startTime;
for (const n of notes) {
events.push({ type: 'note_on', note: n, velocity: 80, time: t, channel: 0 });
events.push({ type: 'note_off', note: n, velocity: 0, time: t + duration, channel: 0 });
t += duration + gap;
}
return events;
}
// =========================================================================
// TESTS: Pure timing functions
// =========================================================================
console.log('\n--- Timing constants ---');
test('beatSec at 75 BPM is 0.8s', () => {
assert(approx(beatSec(), 0.8), `got ${beatSec()}`);
});
test('barSec at 75 BPM 4/4 is 3.2s', () => {
assert(approx(barSec(), 3.2), `got ${barSec()}`);
});
test('barsToSeconds(2) is 6.4s', () => {
assert(approx(barsToSeconds(2), 6.4), `got ${barsToSeconds(2)}`);
});
test('barsToSeconds(0) clamps to 1 bar', () => {
assert(approx(barsToSeconds(0), barSec()), `got ${barsToSeconds(0)}`);
});
// =========================================================================
// TESTS: quantizeToStep
// =========================================================================
console.log('\n--- Quantization ---');
test('quantize 0.41 to 8th note grid at 75 BPM', () => {
const step = beatSec() * 0.5; // 8th note = 0.4s
const q = quantizeToStep(0.41, step);
assert(approx(q, 0.4), `got ${q}`);
});
test('quantize exact grid value is unchanged', () => {
const step = 0.4;
assert(approx(quantizeToStep(0.8, step), 0.8), 'should stay 0.8');
});
test('quantize NaN returns NaN', () => {
assert(Number.isNaN(quantizeToStep(NaN, 0.4)), 'should be NaN');
});
test('quantize with step=0 returns original', () => {
assert(quantizeToStep(0.5, 0) === 0.5, 'should be 0.5');
});
// =========================================================================
// TESTS: Event normalization
// =========================================================================
console.log('\n--- Event normalization ---');
test('normalizeEventsToZero shifts first event to 0', () => {
const events = makeMelody([60, 62, 64], 1.5);
const norm = normalizeEventsToZero(events);
assert(norm[0].time === 0, `first time is ${norm[0].time}`);
});
test('normalizeEventsToZero preserves relative timing', () => {
const events = makeMelody([60, 62], 2.0, 0.3, 0.1);
const norm = normalizeEventsToZero(events);
const times = norm.map(e => e.time);
// note_on(60) at 0, note_off(60) at 0.3, note_on(62) at 0.4, note_off(62) at 0.7
assert(approx(times[0], 0), `t0=${times[0]}`);
assert(approx(times[1], 0.3), `t1=${times[1]}`);
assert(approx(times[2], 0.4), `t2=${times[2]}`);
assert(approx(times[3], 0.7), `t3=${times[3]}`);
});
test('normalizeEventsToZero with empty input', () => {
assert(normalizeEventsToZero([]).length === 0);
});
test('normalization is stable across growing chunks', () => {
// Simulates accumulated events from the model.
const full = makeMelody([60, 62, 64, 65], 1.0);
const chunk1 = full.slice(0, 4); // first 2 notes
const chunk2 = full.slice(0, 8); // all 4 notes
const norm1 = normalizeEventsToZero(chunk1);
const norm2 = normalizeEventsToZero(chunk2);
// The first 4 events should have identical times in both normalizations
// because the minimum time is the same (first event is always present).
for (let i = 0; i < norm1.length; i++) {
assert(
approx(norm1[i].time, norm2[i].time),
`event ${i}: chunk1=${norm1[i].time} vs chunk2=${norm2[i].time}`
);
}
});
// =========================================================================
// TESTS: eventsToNotePairs
// =========================================================================
console.log('\n--- Note pair extraction ---');
test('pairs melody into correct count', () => {
const events = makeMelody([60, 62, 64]);
const pairs = eventsToNotePairs(events);
assert(pairs.length === 3, `got ${pairs.length}`);
});
test('pairs have correct notes', () => {
const events = makeMelody([60, 62, 64]);
const pairs = eventsToNotePairs(events);
assert(pairs[0].note === 60);
assert(pairs[1].note === 62);
assert(pairs[2].note === 64);
});
test('pairs sorted by start time', () => {
const events = makeMelody([60, 62, 64]);
const pairs = eventsToNotePairs(events);
for (let i = 1; i < pairs.length; i++) {
assert(pairs[i].start >= pairs[i - 1].start, 'not sorted');
}
});
test('unpaired note_on produces no pair', () => {
const events = [
{ type: 'note_on', note: 60, velocity: 80, time: 0, channel: 0 },
// missing note_off
];
const pairs = eventsToNotePairs(events);
assert(pairs.length === 0, `expected 0, got ${pairs.length}`);
});
test('minimum duration is enforced', () => {
const events = [
{ type: 'note_on', note: 60, velocity: 80, time: 0.0, channel: 0 },
{ type: 'note_off', note: 60, velocity: 0, time: 0.01, channel: 0 },
];
const pairs = eventsToNotePairs(events);
assert(pairs[0].end >= pairs[0].start + 0.05, 'duration too short');
});
test('round-trip: notePairsToEvents -> eventsToNotePairs', () => {
const original = [
{ note: 60, start: 0, end: 0.3, velocity: 80 },
{ note: 64, start: 0.4, end: 0.7, velocity: 90 },
];
const events = notePairsToEvents(original);
const roundTrip = eventsToNotePairs(events);
assert(roundTrip.length === 2, `got ${roundTrip.length}`);
assert(roundTrip[0].note === 60);
assert(roundTrip[1].note === 64);
assert(approx(roundTrip[0].start, 0));
assert(approx(roundTrip[1].start, 0.4));
});
// =========================================================================
// TESTS: computeNoteDelays — the core scheduling math
// =========================================================================
console.log('\n--- Note scheduling delays ---');
test('notes in the future get positive delays', () => {
const events = makeMelody([60, 62, 64]);
const norm = normalizeEventsToZero(events);
const aiStartSec = 10.0;
const nowGameSec = 8.0; // 2 seconds before playback starts
const delays = computeNoteDelays(norm, { aiStartSec, nowGameSec });
assert(delays.length === 3, `expected 3 delays, got ${delays.length}`);
for (const d of delays) {
assert(d.onDelayMs > 0, `onDelay should be positive, got ${d.onDelayMs}`);
assert(d.offDelayMs > d.onDelayMs, 'offDelay should be after onDelay');
assert(d.shiftSec === 0, 'no shift needed for future notes');
}
});
test('notes exactly on time get zero shift', () => {
const events = makeMelody([60]);
const norm = normalizeEventsToZero(events);
const aiStartSec = 10.0;
const nowGameSec = 10.0; // right at aiStartSec
const delays = computeNoteDelays(norm, { aiStartSec, nowGameSec });
// lateBy = 10.0 - 10.0 = 0, which is < 0.05 threshold → no shift
assert(delays[0].shiftSec === 0, 'should not shift');
assert(delays[0].onDelayMs === 0, 'on-time note should have 0 delay');
});
test('late notes are shifted forward preserving relative timing', () => {
// Three notes at t=0, t=0.4, t=0.8
const events = makeMelody([60, 62, 64], 0, 0.3, 0.1);
const norm = normalizeEventsToZero(events);
const aiStartSec = 10.0;
const nowGameSec = 12.0; // 2 seconds late
const delays = computeNoteDelays(norm, { aiStartSec, nowGameSec });
assert(delays.length === 3, `got ${delays.length}`);
// All notes should be shifted by ~2 seconds.
for (const d of delays) {
assert(approx(d.shiftSec, 2.0, 0.01), `shift=${d.shiftSec}`);
}
// First note should play immediately (delay ≈ 0).
assert(approx(delays[0].onDelayMs, 0, 10), `first note delay=${delays[0].onDelayMs}`);
// Second note should be ~400ms after first.
assert(
approx(delays[1].onDelayMs - delays[0].onDelayMs, 400, 10),
`gap 0→1 = ${delays[1].onDelayMs - delays[0].onDelayMs}`
);
// Third note should be ~400ms after second.
assert(
approx(delays[2].onDelayMs - delays[1].onDelayMs, 400, 10),
`gap 1→2 = ${delays[2].onDelayMs - delays[1].onDelayMs}`
);
});
test('shift only triggers when >50ms late', () => {
const events = makeMelody([60]);
const norm = normalizeEventsToZero(events);
const aiStartSec = 10.0;
const nowGameSec = 10.04; // 40ms late — under threshold
const delays = computeNoteDelays(norm, { aiStartSec, nowGameSec });
assert(delays[0].shiftSec === 0, 'should not shift for small jitter');
});
test('late note offDelayMs preserves note duration', () => {
const events = [
{ type: 'note_on', note: 60, velocity: 80, time: 0.0, channel: 0 },
{ type: 'note_off', note: 60, velocity: 0, time: 0.5, channel: 0 },
];
const aiStartSec = 10.0;
const nowGameSec = 13.0; // 3 seconds late
const delays = computeNoteDelays(events, { aiStartSec, nowGameSec });
const durationMs = delays[0].offDelayMs - delays[0].onDelayMs;
assert(approx(durationMs, 500, 10), `duration=${durationMs}`);
});
test('with quantization, notes snap to grid', () => {
const events = [
{ type: 'note_on', note: 60, velocity: 80, time: 0.0, channel: 0 },
{ type: 'note_off', note: 60, velocity: 0, time: 0.35, channel: 0 },
{ type: 'note_on', note: 62, velocity: 80, time: 0.41, channel: 0 },
{ type: 'note_off', note: 62, velocity: 0, time: 0.75, channel: 0 },
];
const quantStepSec = beatSec() * 0.5; // 8th note = 0.4s
const aiStartSec = 10.0;
const nowGameSec = 8.0;
const delays = computeNoteDelays(events, { aiStartSec, nowGameSec, quantStepSec });
assert(delays.length === 2);
// First note snaps to 0.0, second snaps to 0.4
const gap = delays[1].onDelayMs - delays[0].onDelayMs;
assert(approx(gap, 400, 10), `quantized gap=${gap}`);
});
// =========================================================================
// TESTS: Full streaming session simulation
// =========================================================================
console.log('\n--- Streaming session simulation ---');
test('playback starts after MIN_PAIRS_TO_START (3) pairs are buffered', () => {
// Chunk 1: 2 notes (not enough). Chunk 2: 4 notes (triggers).
const fullMelody = makeMelody([60, 62, 64, 65], 1.0);
const chunk1Events = fullMelody.slice(0, 4); // 2 note pairs
const chunk2Events = fullMelody.slice(0, 8); // 4 note pairs
const result = simulateStreamingSession(
[
{ events: chunk1Events, arrivalGameSec: 5.0 },
{ events: chunk2Events, arrivalGameSec: 5.5 },
],
{
nowGameSecFn: () => 5.5,
nextBarAlignedStartFn: () => 8.0,
}
);
assert(result.playbackStarted === true, 'playback should have started');
assert(result.aiStartSec === 8.0, 'aiStartSec should be bar-aligned');
// Should schedule notes for all 4 pairs (buffered + new).
// Chunk 2 triggers start → schedules all 4 buffered.
// newEvents for chunk 2 = normalized.slice(4) which are 2 events for 2 notes,
// but those are already included in the initial bulk schedule.
assert(result.scheduled.length >= 4, `should schedule >=4, got ${result.scheduled.length}`);
});
test('playback does NOT start with only 2 pairs', () => {
const melody = makeMelody([60, 62], 1.0);
const result = simulateStreamingSession(
[{ events: melody, arrivalGameSec: 5.0 }],
{
nowGameSecFn: () => 5.0,
nextBarAlignedStartFn: () => 8.0,
}
);
// Only 2 pairs, below threshold → fallback kicks in.
// The fallback starts playback if there are any events after all chunks.
assert(result.playbackStarted === true, 'fallback should start playback');
assert(result.scheduled.length === 2, `got ${result.scheduled.length}`);
});
test('fallback starts playback even with 1 note', () => {
const events = makeMelody([60], 1.0);
const result = simulateStreamingSession(
[{ events, arrivalGameSec: 5.0 }],
{
nowGameSecFn: () => 5.0,
nextBarAlignedStartFn: () => 8.0,
}
);
assert(result.playbackStarted === true, 'should start via fallback');
assert(result.scheduled.length === 1);
});
test('no playback with zero events', () => {
const result = simulateStreamingSession(
[{ events: [], arrivalGameSec: 5.0 }],
{
nowGameSecFn: () => 5.0,
nextBarAlignedStartFn: () => 8.0,
}
);
assert(result.playbackStarted === false, 'should not start');
assert(result.scheduled.length === 0);
});
test('late-arriving chunks still schedule with correct spacing', () => {
const melody = makeMelody([60, 62, 64, 65, 67], 1.0, 0.3, 0.1);
// Chunk 1 arrives on time, chunk 2 arrives 3 seconds after aiStartSec.
const chunk1 = melody.slice(0, 6); // 3 note pairs — triggers start
const chunk2 = melody.slice(0, 10); // 5 note pairs — 2 new, arriving late
const result = simulateStreamingSession(
[
{ events: chunk1, arrivalGameSec: 5.0 },
{ events: chunk2, arrivalGameSec: 11.0 }, // well past aiStart=8.0
],
{
nowGameSecFn: () => 5.0,
nextBarAlignedStartFn: () => 8.0,
}
);
assert(result.playbackStarted);
// Find the late-arriving notes (shiftSec > 0).
const lateNotes = result.scheduled.filter(d => d.shiftSec > 0);
assert(lateNotes.length >= 2, `expected late notes, got ${lateNotes.length}`);
// Late notes should preserve relative timing (400ms gap between them).
if (lateNotes.length >= 2) {
const gap = lateNotes[1].onDelayMs - lateNotes[0].onDelayMs;
assert(approx(gap, 400, 20), `late note gap=${gap}`);
}
});
test('early-arriving notes have no shift applied', () => {
const melody = makeMelody([60, 62, 64], 0, 0.3, 0.1);
const result = simulateStreamingSession(
[{ events: melody, arrivalGameSec: 5.0 }],
{
nowGameSecFn: () => 5.0,
nextBarAlignedStartFn: () => 10.0, // 5 seconds in the future
}
);
assert(result.playbackStarted);
// All notes should be in the future relative to arrival → no shift.
for (const d of result.scheduled) {
assert(d.shiftSec === 0, `unexpected shift ${d.shiftSec}`);
assert(d.onDelayMs > 0, `should be future, got ${d.onDelayMs}`);
}
});
test('accumulated chunks do not double-schedule early notes', () => {
// Simulates the slice(lastEventCount) logic.
// Chunk 1: 3 pairs (6 events) → triggers start, schedules all 3.
// Chunk 2: 5 pairs (10 events) → newEvents = events[6..10], 2 new notes.
const melody = makeMelody([60, 62, 64, 65, 67], 0, 0.3, 0.1);
const chunk1 = melody.slice(0, 6);
const chunk2 = melody;
const result = simulateStreamingSession(
[
{ events: chunk1, arrivalGameSec: 5.0 },
{ events: chunk2, arrivalGameSec: 5.5 },
],
{
nowGameSecFn: () => 5.0,
nextBarAlignedStartFn: () => 10.0,
}
);
// Should schedule exactly 5 notes total (3 buffered + 2 new), not 8.
assert(
result.scheduled.length === 5,
`expected 5, got ${result.scheduled.length}`
);
});
// =========================================================================
// TESTS: Edge cases
// =========================================================================
console.log('\n--- Edge cases ---');
test('out-of-range notes are clamped to keyboard', () => {
const events = [
{ type: 'note_on', note: 30, velocity: 80, time: 0, channel: 0 },
{ type: 'note_off', note: 30, velocity: 0, time: 0.5, channel: 0 },
];
const pairs = eventsToNotePairs(events);
assert(pairs[0].note === 60, `clamped to ${pairs[0].note}`);
});
test('high notes are clamped to keyboard max', () => {
const events = [
{ type: 'note_on', note: 100, velocity: 80, time: 0, channel: 0 },
{ type: 'note_off', note: 100, velocity: 0, time: 0.5, channel: 0 },
];
const pairs = eventsToNotePairs(events);
assert(pairs[0].note === 83, `clamped to ${pairs[0].note}`);
});
test('velocity 0 note_on is defaulted to 100 by eventsToNotePairs', () => {
// eventsToNotePairs uses `|| 100` fallback, so velocity-0 note_on
// becomes velocity 100 and is treated as a note_on, not a note_off.
// Use explicit note_off events for proper pairing.
const events = [
{ type: 'note_on', note: 60, velocity: 80, time: 0, channel: 0 },
{ type: 'note_on', note: 60, velocity: 0, time: 0.5, channel: 0 },
];
const pairs = eventsToNotePairs(events);
// Both are treated as note_on (second gets velocity=100), no note_off → no pairs.
assert(pairs.length === 0, `expected 0 pairs, got ${pairs.length}`);
});
test('explicit note_off pairs correctly', () => {
const events = [
{ type: 'note_on', note: 60, velocity: 80, time: 0, channel: 0 },
{ type: 'note_off', note: 60, velocity: 0, time: 0.5, channel: 0 },
];
const pairs = eventsToNotePairs(events);
assert(pairs.length === 1, `expected 1 pair, got ${pairs.length}`);
assert(approx(pairs[0].end, 0.5));
});
test('sanitizeEvents filters garbage', () => {
const events = [
{ type: 'note_on', note: 60, velocity: 80, time: 0, channel: 0 },
{ type: 'invalid', note: 60, velocity: 80, time: 0.1 },
null,
undefined,
{ type: 'note_off', note: 60, velocity: 0, time: 0.5, channel: 0 },
];
const clean = sanitizeEvents(events);
assert(clean.length === 2, `expected 2, got ${clean.length}`);
});
test('sortEventsChronologically puts note_off before note_on at same time', () => {
const events = [
{ type: 'note_on', note: 62, velocity: 80, time: 0.5 },
{ type: 'note_off', note: 60, velocity: 0, time: 0.5 },
];
const sorted = sortEventsChronologically(events);
assert(sorted[0].type === 'note_off', 'note_off should come first at same time');
assert(sorted[1].type === 'note_on');
});
test('playback starts with exactly MIN_PAIRS_TO_START (3) pairs boundary', () => {
const melody = makeMelody([60, 62, 64], 0, 0.3, 0.1); // exactly 3 pairs
const result = simulateStreamingSession(
[{ events: melody, arrivalGameSec: 5.0 }],
{
nowGameSecFn: () => 5.0,
nextBarAlignedStartFn: () => 8.0,
}
);
assert(result.playbackStarted === true, 'should start at exactly 3 pairs');
assert(result.scheduled.length === 3, `expected 3, got ${result.scheduled.length}`);
assert(result.playbackStartedAt === 5.0, `expected 5.0, got ${result.playbackStartedAt}`);
});
test('fallback uses last chunk arrival time as playbackStartedAt', () => {
const events = makeMelody([60, 62], 0, 0.3, 0.1); // 2 pairs, below threshold
const result = simulateStreamingSession(
[
{ events: events.slice(0, 4), arrivalGameSec: 5.0 },
{ events: events, arrivalGameSec: 6.0 }, // last chunk
],
{
nowGameSecFn: () => 6.0,
nextBarAlignedStartFn: () => 9.6,
}
);
assert(result.playbackStarted === true, 'should start via fallback');
assert(result.playbackStartedAt === 6.0, `expected 6.0, got ${result.playbackStartedAt}`);
assert(result.aiStartSec === 9.6, `expected 9.6, got ${result.aiStartSec}`);
});
test('three accumulating chunks track lastEventCount correctly', () => {
const full = makeMelody([60, 62, 64, 65, 67, 69], 0, 0.3, 0.1);
const chunk1 = full.slice(0, 6); // 3 pairs — triggers start
const chunk2 = full.slice(0, 10); // 5 pairs — 2 new
const chunk3 = full; // 6 pairs — 1 new
const result = simulateStreamingSession(
[
{ events: chunk1, arrivalGameSec: 5.0 },
{ events: chunk2, arrivalGameSec: 5.3 },
{ events: chunk3, arrivalGameSec: 5.6 },
],
{
nowGameSecFn: () => 5.0,
nextBarAlignedStartFn: () => 10.0,
}
);
// 3 (bulk) + 2 (chunk2 new) + 1 (chunk3 new) = 6 total
assert(
result.scheduled.length === 6,
`expected 6, got ${result.scheduled.length}`
);
});
test('null quantStepSec leaves note timings unchanged', () => {
const events = makeMelody([60, 62], 0, 0.3, 0.1);
const norm = normalizeEventsToZero(events);
const aiStartSec = 10.0;
const nowGameSec = 8.0;
const delays = computeNoteDelays(norm, { aiStartSec, nowGameSec, quantStepSec: null });
assert(delays.length === 2, `expected 2, got ${delays.length}`);
// First note starts at aiStartSec + 0.0 → delay = (10 - 8) * 1000 = 2000ms
assert(approx(delays[0].onDelayMs, 2000, 10), `expected ~2000, got ${delays[0].onDelayMs}`);
// Gap between notes should be ~400ms (0.3 duration + 0.1 gap, second note_on at 0.4s)
const gap = delays[1].onDelayMs - delays[0].onDelayMs;
assert(approx(gap, 400, 10), `expected ~400ms gap, got ${gap}`);
});
// =========================================================================
// Summary
// =========================================================================
console.log(`\n${'='.repeat(50)}`);
console.log(`Results: ${passed} passed, ${failed} failed`);
if (failures.length > 0) {
console.log('\nFailures:');
for (const f of failures) {
console.log(` - ${f.name}: ${f.error}`);
}
}
console.log('');
process.exit(failed > 0 ? 1 : 0);