Spaces:
Running
Running
| /** | |
| * 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) | |
| */ | |
| ; | |
| // ========================================================================= | |
| // 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); | |