diff --git a/assets/audio/ot_d5.mp3 b/assets/audio/ot_d5.mp3 new file mode 100644 index 000000000..a5409c281 Binary files /dev/null and b/assets/audio/ot_d5.mp3 differ diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 7bb2a2512..49a2a4b14 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -221,7 +221,92 @@ }, "overview": { "listen": "Listen", - "compose": "Compose" + "compose": "Compose", + "practice": "Practice" + }, + "practice": { + "pick-tune": "Pick a tune from the sidebar to start practicing.", + "no-hits": "No strokes to practice for this instrument.", + "results": { + "title": "Session results" + }, + "score": { + "title": "Score", + "avgDelta": "Timing", + "drift": "Drift", + "hits": "Hits", + "misses": "Misses", + "extras": "Extras", + "behind": "behind", + "early": "early", + "sessionTotals": "Session totals", + "idle": "Press ▶ to begin" + }, + "mic": { + "listening": "listening", + "stopped": "stopped" + }, + "toolbar": { + "start": "Start", + "stop": "Stop", + "part": "Part", + "instrument": "Instrument", + "modeInstrument": "Instrument", + "modeBand": "Band" + }, + "speed": { + "label": "Speed", + "reset": "Reset" + }, + "difficulty": { + "label": "Difficulty", + "easy": "Easy", + "normal": "Normal", + "hard": "Hard" + }, + "latency": { + "label": "Latency offset", + "calibrate": "Calibrate…" + }, + "sensitivity": { + "label": "Sensitivity", + "reset": "Reset" + }, + "settings": { + "title": "Settings" + }, + "calibration": { + "title": "Calibrate latency", + "idle-instructions": "This plays a series of clicks through your speakers and listens with your microphone to measure audio latency. Turn your speakers up, keep the room quiet, then press Start.", + "listening": "Listening… measuring latency, please wait.", + "result-median": "Measured latency: {{median}} ms", + "result-spread": "Spread: {{spread}} ms", + "spread-warning": "High spread — consider trying again.", + "fail-too-few": "Couldn't hear enough clicks — turn your volume up, move the mic closer, or reduce background noise.", + "fail-too-noisy": "Readings were inconsistent — try again somewhere quieter.", + "fail-worklet": "Audio setup failed — please try again.", + "fail-mic-denied": "Microphone access is needed to measure latency. Enable it in your browser, or set the offset manually with the slider.", + "retry": "Try again", + "cancel": "Cancel", + "start": "Start", + "apply": "Apply {{median}} ms" + }, + "permission": { + "title": "Use your microphone?", + "body": "To score your timing, Practice listens to the microphone and compares what it hears with the partition.", + "privacy": "Audio is processed locally on your device and never leaves it.", + "cancel": "Cancel", + "enable": "Enable microphone" + }, + "headphones": { + "title": "Headphones recommended", + "body": "For accurate scoring, please use headphones — otherwise the microphone will hear the playback, not just you.", + "continue": "Continue" + }, + "rotate": { + "title": "Rotate to landscape", + "hint": "Practice needs the width to show your timing and the pattern together." + } }, "pattern-list-filter": { "filter-placeholder": "Filter: {{category}}" @@ -264,6 +349,7 @@ "stroke-description-.": "Silent stroke", "stroke-description-wh": "Whistle", "stroke-description-wh2": "Long whistle", + "stroke-description-whistle-in": "Whistle-in cue (single long blast)", "stroke-description-s": "Soft flare", "time-with-triplets": "{{time}} with triplets", "time-with-quintuplets": "{{time}} with quintuplets", diff --git a/package.json b/package.json index ef0ef718e..bb94b7d64 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test-watch": "vitest" }, "devDependencies": { + "@types/audioworklet": "^0.0.100", "@types/bootstrap": "^5.2.10", "@types/file-saver": "^2.0.7", "@types/lodash-es": "^4.17.12", @@ -45,6 +46,9 @@ "vue-tsc": "^3.2.2" }, "author": "Candid Dauth", + "resolutions": { + "beatbox.js": "5.0.1" + }, "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0", diff --git a/src/app.ts b/src/app.ts index 348b11915..b197a0476 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,7 +6,7 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import Overview from "./ui/overview.vue"; import { registerServiceWorker } from "./services/service-worker"; import { library } from '@fortawesome/fontawesome-svg-core' -import { faBars, faCaretDown, faCheck, faClock, faCode, faCog, faCopy, faDownload, faEraser, faExclamationCircle, faInfoCircle, faFileExport, faFileImport, faHandPointRight, faHeadphones, faMobileAlt, faMusic, faPause, faPen, faPencilAlt, faPlay, faPlayCircle, faPlus, faQuestionCircle, faShare, faSlidersH, faStar, faStop, faTrash, faVolumeMute, faWindowClose } from '@fortawesome/free-solid-svg-icons' +import { faBars, faCaretDown, faCheck, faClock, faCode, faCog, faCopy, faDownload, faEraser, faExclamationCircle, faInfoCircle, faFileExport, faFileImport, faHandPointRight, faHeadphones, faMicrophone, faMicrophoneSlash, faMobileAlt, faMusic, faPause, faPen, faPencilAlt, faPlay, faPlayCircle, faPlus, faQuestionCircle, faShare, faSlidersH, faStar, faStop, faTrash, faVolumeMute, faWindowClose } from '@fortawesome/free-solid-svg-icons' import Vue3TouchEvents, { Vue3TouchEventsOptions } from "vue3-touch-events"; import { ensurePersistentStorage, reactiveLocalStorage } from "./services/localStorage"; import { reactiveLocationHash } from "./services/router"; @@ -14,7 +14,7 @@ import { theme } from "./services/bootstrap"; registerServiceWorker(); -library.add(faBars, faCaretDown, faCheck, faClock, faCode, faCog, faCopy, faDownload, faEraser, faExclamationCircle, faInfoCircle, faFileExport, faFileImport, faHandPointRight, faHeadphones, faMobileAlt, faMusic, faPause, faPen, faPencilAlt, faPlay, faPlayCircle, faPlus, faQuestionCircle, faShare, faSlidersH, faStar, faStop, faTrash, faVolumeMute, faWindowClose); +library.add(faBars, faCaretDown, faCheck, faClock, faCode, faCog, faCopy, faDownload, faEraser, faExclamationCircle, faInfoCircle, faFileExport, faFileImport, faHandPointRight, faHeadphones, faMicrophone, faMicrophoneSlash, faMobileAlt, faMusic, faPause, faPen, faPencilAlt, faPlay, faPlayCircle, faPlus, faQuestionCircle, faShare, faSlidersH, faStar, faStop, faTrash, faVolumeMute, faWindowClose); const Root = defineComponent({ setup() { diff --git a/src/config.ts b/src/config.ts index bf4a97810..705428099 100644 --- a/src/config.ts +++ b/src/config.ts @@ -107,7 +107,7 @@ const config: Config = { }, ot: { name: () => getI18n().t("config.instruments-ot"), - strokes: [ "w", "y", "A", "B", "D", "E", "F", "G", "J", "K", "L", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "Y", "Z", "9", "8", "7", "6", "5", "b", "c", "d", "e", "g", "q", "j", "k", "m", "n", "u", "v", "x", "i", "l", "p", "$", "%", "&", "'", "(", ")", "*", ",", "-", "?", ":", ";", "<", "=", ">", "K", "[", "\\", "^", "_", "`", "{", "|", "}", "~", "À", "Á", "Â", "Ã", "Ä", "Å", "Æ", "Ç", "È", "É", "Ê", "Ë", "Ì", "Í", "Î", "Ï", "İ", "Ǐ", "Ī", "Ĩ", "Į", "IJ", "Ð", "Ñ", "Ò", "Ó", "Ô" ] + strokes: [ "w", "y", "A", "B", "D", "E", "F", "G", "J", "K", "L", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "Y", "Z", "9", "8", "7", "6", "5", "b", "c", "d", "e", "g", "q", "j", "k", "m", "n", "u", "v", "x", "i", "l", "p", "$", "%", "&", "'", "(", ")", "*", ",", "-", "?", ":", ";", "<", "=", ">", "K", "[", "\\", "^", "_", "`", "{", "|", "}", "~", "À", "Á", "Â", "Ã", "Ä", "Å", "Æ", "Ç", "È", "É", "Ê", "Ë", "Ì", "Í", "Î", "Ï", "İ", "Ǐ", "Ī", "Ĩ", "Į", "IJ", "Ð", "Ñ", "Ò", "Ó", "Ô", "Õ" ] } }, @@ -227,6 +227,7 @@ const config: Config = { "Ò": "que", "Ó": "re", "Ô": "mos", + "Õ": "Whi", // ] }, @@ -239,7 +240,8 @@ const config: Config = { ".": () => getI18n().t("config.stroke-description-."), "w" :() => getI18n().t("config.stroke-description-wh"), "y" :() => getI18n().t("config.stroke-description-wh2"), - "z": () => getI18n().t("config.stroke-description-s") + "z": () => getI18n().t("config.stroke-description-s"), + "Õ": () => getI18n().t("config.stroke-description-whistle-in") }, volumePresets: { diff --git a/src/services/__tests__/latencyCalibrator.test.ts b/src/services/__tests__/latencyCalibrator.test.ts new file mode 100644 index 000000000..362cde123 --- /dev/null +++ b/src/services/__tests__/latencyCalibrator.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "vitest"; +import { computeMedianAndSpread, createCalibrationSession } from "../latencyCalibrator"; + +test("computeMedianAndSpread: empty input", () => { + expect(computeMedianAndSpread([])).toEqual({ median: 0, spread: 0, count: 0 }); +}); + +test("computeMedianAndSpread: single value", () => { + expect(computeMedianAndSpread([42])).toEqual({ median: 42, spread: 0, count: 1 }); +}); + +test("computeMedianAndSpread: odd count", () => { + expect(computeMedianAndSpread([10, 20, 30])).toEqual({ + median: 20, spread: 10, count: 3, // spread = MAD: median of |v − 20| = median([10, 0, 10]) = 10 + }); +}); + +test("computeMedianAndSpread: even count averages middle two", () => { + expect(computeMedianAndSpread([10, 20, 30, 40])).toEqual({ + median: 25, spread: 10, count: 4, + }); +}); + +test("calibration session collects deltas against scheduled beats", () => { + const beats = [0, 500, 1000, 1500]; // 4 beats, 500ms apart + const session = createCalibrationSession(beats); + session.recordTap(50); // 50ms after beat 0 + session.recordTap(548); // 48ms after beat 1 (closest) + session.recordTap(1051); // 51ms after beat 2 + session.recordTap(1547); // 47ms after beat 3 + const result = session.finalize(); + expect(result.count).toBe(4); + expect(result.median).toBe(49); + expect(result.spread).toBeLessThanOrEqual(2); +}); + +test("calibration session ignores taps outside any beat's window", () => { + const beats = [0, 500]; + const session = createCalibrationSession(beats, { windowMs: 200 }); + session.recordTap(100); // within window of beat 0 + session.recordTap(5000); // far away — dropped + expect(session.finalize().count).toBe(1); +}); + +test("calibration session records negative delta for tap before beat", () => { + const session = createCalibrationSession([500]); + session.recordTap(450); // 50ms before beat + expect(session.finalize().median).toBe(-50); +}); + +test("calibration session finalize with no taps returns zeros", () => { + const session = createCalibrationSession([0, 500, 1000]); + expect(session.finalize()).toEqual({ median: 0, spread: 0, count: 0 }); +}); diff --git a/src/services/__tests__/loopbackCalibrator.test.ts b/src/services/__tests__/loopbackCalibrator.test.ts new file mode 100644 index 000000000..dde43e888 --- /dev/null +++ b/src/services/__tests__/loopbackCalibrator.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "vitest"; +import { createLoopbackCalibration, DEFAULT_LOOPBACK_QUALITY } from "../loopbackCalibrator"; + +function beatsAt(n: number, spacingMs: number, start = 100_000): number[] { + return Array.from({ length: n }, (_, i) => start + i * spacingMs); +} + +test("recovers a known constant latency", () => { + const beats = beatsAt(11, 600); + const cal = createLoopbackCalibration(beats); + for (const b of beats) cal.recordOnset(b + 40); + const r = cal.finalize(); + expect(r.medianMs).toBe(40); + expect(r.spread).toBe(0); + expect(r.count).toBe(11); + expect(r.accepted).toBe(true); + expect(r.reason).toBeUndefined(); +}); + +test("accepts a latency with small jitter", () => { + const beats = beatsAt(11, 600); + const cal = createLoopbackCalibration(beats); + const jitter = [0, 5, -5, 8, -8, 3, -3, 6, -6, 2, -2]; + beats.forEach((b, i) => cal.recordOnset(b + 40 + jitter[i])); + const r = cal.finalize(); + expect(r.accepted).toBe(true); + expect(Math.abs(r.medianMs - 40)).toBeLessThanOrEqual(5); +}); + +test("rejects too-few detections", () => { + const beats = beatsAt(11, 600); + const cal = createLoopbackCalibration(beats); + cal.recordOnset(beats[0] + 40); + cal.recordOnset(beats[1] + 40); + cal.recordOnset(beats[2] + 40); + const r = cal.finalize(); + expect(r.count).toBe(3); + expect(r.accepted).toBe(false); + expect(r.reason).toBe("too-few"); +}); + +test("rejects too-noisy spread", () => { + const beats = beatsAt(11, 600); + const cal = createLoopbackCalibration(beats); + const deltas = [0, 10, 5, 15, 8, 200, 195, 205, 190, 210, 100]; + beats.forEach((b, i) => cal.recordOnset(b + deltas[i])); + const r = cal.finalize(); + expect(r.count).toBe(11); + expect(r.accepted).toBe(false); + expect(r.reason).toBe("too-noisy"); +}); + +test("marginal spread band sits strictly inside the accept band", () => { + expect(DEFAULT_LOOPBACK_QUALITY.marginalSpread).toBeLessThan(DEFAULT_LOOPBACK_QUALITY.maxSpread); +}); + +test("ignores onsets outside the match window", () => { + const beats = beatsAt(11, 600); + const cal = createLoopbackCalibration(beats); + for (const b of beats) cal.recordOnset(b + 40); + cal.recordOnset(beats[beats.length - 1] + 400); // beyond the last beat + window → ignored + const r = cal.finalize(); + expect(r.count).toBe(11); + expect(r.medianMs).toBe(40); +}); diff --git a/src/services/__tests__/mediaPermissions.test.ts b/src/services/__tests__/mediaPermissions.test.ts new file mode 100644 index 000000000..c1735d754 --- /dev/null +++ b/src/services/__tests__/mediaPermissions.test.ts @@ -0,0 +1,33 @@ +import { expect, test, vi, beforeEach } from "vitest"; +import { createMicPermission } from "../mediaPermissions"; + +beforeEach(() => { + // happy-dom has no navigator.mediaDevices by default + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn() }, + }); +}); + +test("initial state is 'unknown'", () => { + const p = createMicPermission(); + expect(p.state.value).toBe("unknown"); +}); + +test("request → granted updates state and resolves with the stream", async () => { + const fakeStream = {} as MediaStream; + (navigator.mediaDevices.getUserMedia as any).mockResolvedValue(fakeStream); + const p = createMicPermission(); + const result = await p.request(); + expect(result).toBe(fakeStream); + expect(p.state.value).toBe("granted"); +}); + +test("request → denied sets denied state and rethrows", async () => { + (navigator.mediaDevices.getUserMedia as any).mockRejectedValue( + Object.assign(new Error("denied"), { name: "NotAllowedError" }) + ); + const p = createMicPermission(); + await expect(p.request()).rejects.toThrow(); + expect(p.state.value).toBe("denied"); +}); diff --git a/src/services/__tests__/onsetDetector.test.ts b/src/services/__tests__/onsetDetector.test.ts new file mode 100644 index 000000000..4ba7271b3 --- /dev/null +++ b/src/services/__tests__/onsetDetector.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "vitest"; +import { ctxTimeToPerfTime } from "../onsetDetector"; + +test("ctxTimeToPerfTime converts via an output-timestamp snapshot", () => { + // Snapshot: at perf=10000ms, audio ctx was at 5.123s. + // A subsequent onset at ctx=5.223s → perf = 10000 + (5.223 - 5.123) * 1000 = 10100ms + const snap = { contextTime: 5.123, performanceTime: 10000 }; + expect(ctxTimeToPerfTime(5.223, snap)).toBeCloseTo(10100, 5); +}); diff --git a/src/services/__tests__/onsetDetectorCore.test.ts b/src/services/__tests__/onsetDetectorCore.test.ts new file mode 100644 index 000000000..2b28dc17f --- /dev/null +++ b/src/services/__tests__/onsetDetectorCore.test.ts @@ -0,0 +1,179 @@ +import { expect, it, test } from "vitest"; +import { rmsOfBlock, createDetectorState, processBlock, MIN_NOISE_FLOOR, DEFAULT_DETECTOR_PARAMS, effectiveMultiplier } from "../onsetDetectorCore"; + +test("effectiveMultiplier trims the trigger inversely with user sensitivity", () => { + expect(effectiveMultiplier(3, 1)).toBe(3); // neutral + expect(effectiveMultiplier(3, 3)).toBe(1); // hottest (max sensitivity) + expect(effectiveMultiplier(3, 0.3)).toBe(10); // coldest (min sensitivity) +}); + +it("exposes the single source of host↔worklet detector defaults", () => { + expect(DEFAULT_DETECTOR_PARAMS).toEqual({ multiplier: 3, refractoryFrames: 19, userSensitivity: 1 }); +}); + +test("rmsOfBlock: silent block", () => { + expect(rmsOfBlock(new Float32Array(128))).toBe(0); +}); + +test("rmsOfBlock: constant amplitude", () => { + const block = new Float32Array(128).fill(0.5); + expect(rmsOfBlock(block)).toBeCloseTo(0.5, 5); +}); + +test("processBlock: silent input never triggers", () => { + const state = createDetectorState({ multiplier: 3, refractoryFrames: 50 }); + for (let i = 0; i < 100; i++) { + expect(processBlock(state, new Float32Array(128), i)).toBeNull(); + } +}); + +test("processBlock: loud transient on quiet baseline triggers", () => { + const state = createDetectorState({ multiplier: 3, refractoryFrames: 5 }); + + // Warm noise floor with quiet noise + const quiet = new Float32Array(128).fill(0.0001); + for (let i = 0; i < 200; i++) processBlock(state, quiet, i); + + // Loud transient + const loud = new Float32Array(128).fill(0.5); + const trigger = processBlock(state, loud, 200); + expect(trigger).not.toBeNull(); + expect(trigger!.energy).toBeGreaterThan(0.4); +}); + +test("processBlock: sustained loud input fires once, not repeatedly, until energy falls back to the floor", () => { + const state = createDetectorState({ multiplier: 3, refractoryFrames: 5 }); + const quiet = new Float32Array(128).fill(0.0001); + for (let i = 0; i < 50; i++) processBlock(state, quiet, i); + const loud = new Float32Array(128).fill(0.5); + // First loud block fires. + expect(processBlock(state, loud, 50)).not.toBeNull(); + // Sustained loud must NOT re-fire — even long past the refractory window — + // because energy never dropped back near the floor to re-arm the detector. + // (A held tone is one event; re-firing it is the "extras" bug.) + for (let i = 51; i < 200; i++) { + expect(processBlock(state, loud, i)).toBeNull(); + } + // A quiet gap re-arms the detector; the next loud block fires again. + for (let i = 200; i < 210; i++) processBlock(state, quiet, i); + expect(processBlock(state, loud, 210)).not.toBeNull(); +}); + +test("processBlock: sustained elevated audio does not ratchet the noise floor", () => { + // A resonant instrument keeps RMS well above ambient but below the trigger. + // The floor must NOT learn from it (that positive feedback is what raised the + // threshold above real hits and killed detection after a few loops). + const state = createDetectorState({ multiplier: 3, refractoryFrames: 5 }); + const sustained = new Float32Array(128).fill(0.05); // > floor×1.5 (0.0225), < old floor×4 (0.06) + for (let i = 0; i < 500; i++) processBlock(state, sustained, i); + expect(state.noiseFloor).toBe(MIN_NOISE_FLOOR); +}); + +test("processBlock: a real hit still fires after a stretch of sustained elevated audio", () => { + // Because the floor was held (previous test), the trigger threshold stays sane + // and a genuine hit after the sustained bed is still detected. + const state = createDetectorState({ multiplier: 3, refractoryFrames: 5 }); + const sustained = new Float32Array(128).fill(0.05); + for (let i = 0; i < 500; i++) processBlock(state, sustained, i); + // Brief quiet gap so the detector re-arms (the sustained block fired once at frame 0). + const quiet = new Float32Array(128).fill(0.0001); + for (let i = 500; i < 510; i++) processBlock(state, quiet, i); + const hit = new Float32Array(128).fill(0.1); + expect(processBlock(state, hit, 510)).not.toBeNull(); +}); + +test("processBlock: a single strike with a long decay tail produces exactly one onset", () => { + const state = createDetectorState({ multiplier: 3, refractoryFrames: 5 }); + const quiet = new Float32Array(128).fill(0.0001); + for (let i = 0; i < 50; i++) processBlock(state, quiet, i); + // One strike: an attack then a decay tail that stays above the trigger (0.045) + // for many blocks — longer than the refractory window. + const tail = [0.2, 0.15, 0.1, 0.08, 0.07, 0.06, 0.05, 0.048, 0.046]; + let fires = 0; + tail.forEach((amp, k) => { + if (processBlock(state, new Float32Array(128).fill(amp), 50 + k)) fires++; + }); + expect(fires).toBe(1); +}); + +test("processBlock: a stroke re-arms on a dip below the trigger, even while energy stays above the noise floor", () => { + // A resonant instrument only dips part-way between strokes: below the trigger + // (floor×3 ≈ 0.045) but well above the floor's learn window (floor×1.5 ≈ 0.0225). + // The detector must re-arm on that partial dip — otherwise sustained/resonant + // instruments stop registering after the first hit (observed: ~2 of 9 detected). + const state = createDetectorState({ multiplier: 3, refractoryFrames: 5 }); + const quiet = new Float32Array(128).fill(0.0001); + for (let i = 0; i < 50; i++) processBlock(state, quiet, i); + const loud = new Float32Array(128).fill(0.2); + const partialDip = new Float32Array(128).fill(0.03); + expect(processBlock(state, loud, 50)).not.toBeNull(); // stroke 1 + for (let i = 51; i < 60; i++) processBlock(state, partialDip, i); // dips below trigger, not to floor + expect(processBlock(state, loud, 60)).not.toBeNull(); // stroke 2 must still fire +}); + +test("processBlock: a weak secondary peak shortly after a strong hit is suppressed (decay tail)", () => { + // The originating instrument (e.g. Low Surdo) rings: ~180 ms after the attack a + // resonant bump re-crosses the trigger at a small fraction of the attack energy. + // It belongs to the same notated stroke, so it must not count as a new onset. + const state = createDetectorState({ multiplier: 3, refractoryFrames: 5 }); + const quiet = new Float32Array(128).fill(0.0001); + for (let i = 0; i < 50; i++) processBlock(state, quiet, i); + expect(processBlock(state, new Float32Array(128).fill(0.8), 50)).not.toBeNull(); // strong attack + for (let i = 51; i < 117; i++) processBlock(state, quiet, i); // energy dips (re-arms) ~180 ms + expect(processBlock(state, new Float32Array(128).fill(0.07), 117)).toBeNull(); // weak resonant bump +}); + +test("processBlock: a strong stroke soon after another strong stroke is not suppressed", () => { + // The decay gate keys off ENERGY, not time: a genuine next stroke of comparable + // force must fire even when it lands close behind (fast playing, ~120 ms here). + const state = createDetectorState({ multiplier: 3, refractoryFrames: 5 }); + const quiet = new Float32Array(128).fill(0.0001); + for (let i = 0; i < 50; i++) processBlock(state, quiet, i); + expect(processBlock(state, new Float32Array(128).fill(0.8), 50)).not.toBeNull(); // stroke 1 + for (let i = 51; i < 95; i++) processBlock(state, quiet, i); + expect(processBlock(state, new Float32Array(128).fill(0.8), 95)).not.toBeNull(); // stroke 2 +}); + +test("processBlock: a weak onset long after a hit fires once the decay gate has relaxed", () => { + // The gate is temporary, not a permanent raised threshold. + const state = createDetectorState({ multiplier: 3, refractoryFrames: 5 }); + const quiet = new Float32Array(128).fill(0.0001); + for (let i = 0; i < 50; i++) processBlock(state, quiet, i); + expect(processBlock(state, new Float32Array(128).fill(0.8), 50)).not.toBeNull(); + for (let i = 51; i < 170; i++) processBlock(state, quiet, i); // past the decay-gate window + expect(processBlock(state, new Float32Array(128).fill(0.07), 170)).not.toBeNull(); +}); + +test("processBlock: two separate strokes with a quiet dip between them both fire", () => { + // Guards that the re-arm gate does not suppress genuinely distinct strokes: + // energy returns to the floor between them, so the second one re-arms and fires. + const state = createDetectorState({ multiplier: 3, refractoryFrames: 5 }); + const quiet = new Float32Array(128).fill(0.0001); + for (let i = 0; i < 50; i++) processBlock(state, quiet, i); + const loud = new Float32Array(128).fill(0.2); + expect(processBlock(state, loud, 50)).not.toBeNull(); // stroke 1 + for (let i = 51; i < 56; i++) processBlock(state, quiet, i); // dip back to the floor + expect(processBlock(state, loud, 56)).not.toBeNull(); // stroke 2 +}); + +test("processBlock: respects noiseFloorInit", () => { + // Higher initial floor than default — the same quiet input that triggers with + // the default 0.001 floor should now be sub-threshold. + const state = createDetectorState({ multiplier: 3, refractoryFrames: 5, noiseFloorInit: 1.0 }); + const moderate = new Float32Array(128).fill(0.1); + expect(processBlock(state, moderate, 0)).toBeNull(); +}); + +test("createDetectorState: initial floor below MIN_NOISE_FLOOR is bumped up", () => { + const state = createDetectorState({ multiplier: 3, refractoryFrames: 5, noiseFloorInit: 0.0001 }); + expect(state.noiseFloor).toBe(MIN_NOISE_FLOOR); +}); + +test("processBlock: adaptive floor stays clamped at MIN_NOISE_FLOOR under prolonged silence", () => { + // Start the floor above the clamp; let it decay against very quiet input. Without the clamp + // it would settle near the input RMS (~0.0001); with the clamp it should park at MIN_NOISE_FLOOR. + const state = createDetectorState({ multiplier: 3, refractoryFrames: 5, noiseFloorInit: 0.1 }); + const silent = new Float32Array(128).fill(0.0001); + for (let i = 0; i < 10000; i++) processBlock(state, silent, i); + expect(state.noiseFloor).toBe(MIN_NOISE_FLOOR); +}); diff --git a/src/services/__tests__/practiceDriftGolden.test.ts b/src/services/__tests__/practiceDriftGolden.test.ts new file mode 100644 index 000000000..dd3646816 --- /dev/null +++ b/src/services/__tests__/practiceDriftGolden.test.ts @@ -0,0 +1,171 @@ +import { expect, test, vi } from "vitest"; +import { createPracticeEngine, PracticeConfig, PracticeEngineOpts } from "../practiceEngine"; +import { buildExpectedTimeline, ExpectedHit, SessionStats } from "../practiceScorer"; +import { normalizePattern } from "../../state/pattern"; +import type Beatbox from "beatbox.js"; +import type { BeatboxReference } from "../player"; + +// Golden end-to-end timing test. We synthesize the exact onset stream a perfect +// (or deliberately imperfect) player would produce for a known partition, feed it +// through the REAL engine timing path (perf-time → loop-relative, plus the +// latencyOffsetMs correction), and assert what avg|Δ| and drift come out. +// +// This is the oracle for "is the app counting the right latency?". The engine +// inverts time: tRel = (t_perf - latencyOffsetMs) - baseline. synthesizeOnsets +// below must therefore model how time is actually PRODUCED in the world, so the +// reported delta is exactly playerError + (acousticLatency - latencyOffsetMs). + +vi.mock("../player", () => ({ + createBeatbox: vi.fn(), + getPlayerById: vi.fn(), + patternToBeatbox: vi.fn(() => []), + stopAllPlayers: vi.fn(), +})); + +const BASELINE = 100_000; // fake loop-baseline perf time; any constant works + +function makeFakeBeatbox(): { ref: BeatboxReference; player: Beatbox } { + const player = { + setPattern: vi.fn(), + setBeatLength: vi.fn(), + setRepeat: vi.fn(), + on: vi.fn(), + play: vi.fn(), + stop: vi.fn(), + } as unknown as Beatbox; + const ref: BeatboxReference = { id: -1, playing: false, customPosition: false }; + return { ref, player }; +} + +function makeDeps() { + const stream = {} as MediaStream; + return { + micPermission: { + state: { value: "granted" } as any, + request: vi.fn(async () => stream), + release: vi.fn(), + } as any, + detector: { + start: vi.fn(async () => {}), + stop: vi.fn(async () => {}), + setSensitivity: vi.fn(), + on: vi.fn(), + off: vi.fn(), + } as any, + }; +} + +function makeConfig(snLine: string[]): PracticeConfig { + return { + pattern: normalizePattern({ length: 1, time: 4, sn: snLine }), + instrument: "sn", + speedBpm: 120, // → 125 ms/stroke, loop = 500 ms + mode: "instrument", + }; +} + +/** + * Drive a full session deterministically: configure → start → gameOn(BASELINE), + * feed the given perf-time onsets, then read live stats (un-finalised, so it + * matches against the full expected timeline with no tail-trimming). + */ +async function runSession( + config: PracticeConfig, + feedTimes: number[], + opts: PracticeEngineOpts = {}, +): Promise { + let onsetSub: ((e: { t_perf: number; energy: number }) => void) | null = null; + const deps = makeDeps(); + deps.detector.on = vi.fn((ev: string, cb: any) => { if (ev === "onset") onsetSub = cb; }); + + const engine = createPracticeEngine(deps, { beatboxFactory: () => makeFakeBeatbox(), ...opts }); + engine.configure(config); + await engine.start(); // → countIn (count-in beatbox never fires "stop" here) + await engine.advanceToGameOn(BASELINE); // → gameOn with an explicit loop baseline + + for (const t of feedTimes) onsetSub!({ t_perf: t, energy: 0.5 }); + // The session uses a synthetic loop baseline (BASELINE), so make "now" consistent + // with it — far enough past it that every stroke's scoring window has closed and + // live stats match the full expected timeline. + const nowSpy = vi.spyOn(performance, "now").mockReturnValue(BASELINE + 10_000_000); + const result = engine.stats(); + nowSpy.mockRestore(); + return result; +} + +// Physics model: one perf-time onset per expected hit. The player strikes at the +// hit's scheduled loop time (+ their own timing error), and the sound arrives at +// the mic acousticLatencyMs LATER — so both terms add to wall-clock perf time. +// The engine later subtracts latencyOffsetMs to correct for that latency. +function synthesizeOnsets( + expected: ExpectedHit[], + baselinePerf: number, + acousticLatencyMs: number, + playerErrorMs: (hit: ExpectedHit, i: number) => number, +): number[] { + return expected.map( + (hit, i) => baselinePerf + hit.t + playerErrorMs(hit, i) + acousticLatencyMs, + ); +} + +test("perfect player, zero latency → drift 0, avg|Δ| 0, full score", async () => { + const config = makeConfig(["X", ".", "X", "."]); // strokes at 0 ms and 250 ms + const { expected } = buildExpectedTimeline(config.pattern, "sn", 120); + + const feed = synthesizeOnsets(expected, BASELINE, 0, () => 0); + const stats = await runSession(config, feed); + + expect(stats.hits).toBe(2); + expect(stats.misses).toBe(0); + expect(stats.extras).toBe(0); + expect(stats.meanAbsDelta).toBe(0); + expect(stats.drift).toBe(0); + expect(stats.headlineScore).toBe(100); +}); + +test("perfect player, UNcalibrated 40 ms mic latency → surfaces as +40 drift", async () => { + // The mic adds 40 ms but the app's latencyOffsetMs is still 0, so the 40 ms is + // uncorrected. A flawless performance therefore reports pure +40 ms drift. + const config = makeConfig(["X", ".", "X", "."]); + const { expected } = buildExpectedTimeline(config.pattern, "sn", 120); + + const feed = synthesizeOnsets(expected, BASELINE, 40, () => 0); + const stats = await runSession(config, feed); // latencyOffsetMs defaults to 0 + + expect(stats.hits).toBe(2); // 40 ms < good window (60) → still matched + expect(stats.drift).toBe(40); + expect(stats.meanAbsDelta).toBe(40); +}); + +test("calibrating latencyOffsetMs to the real latency cancels the drift", async () => { + const config = makeConfig(["X", ".", "X", "."]); + const { expected } = buildExpectedTimeline(config.pattern, "sn", 120); + + const feed = synthesizeOnsets(expected, BASELINE, 40, () => 0); + const stats = await runSession(config, feed, { latencyOffsetMs: 40 }); + + expect(stats.drift).toBe(0); + expect(stats.meanAbsDelta).toBe(0); +}); + +test("latencyOffsetMs as a live getter cancels the drift just like a static number", async () => { + const config = makeConfig(["X", ".", "X", "."]); + const { expected } = buildExpectedTimeline(config.pattern, "sn", 120); + const feed = synthesizeOnsets(expected, BASELINE, 40, () => 0); + const stats = await runSession(config, feed, { latencyOffsetMs: () => 40 }); // function form + expect(stats.drift).toBe(0); + expect(stats.meanAbsDelta).toBe(0); +}); + +test("random sloppiness inflates avg|Δ| but leaves drift ≈ 0", async () => { + // Strokes at 125 ms and 375 ms (kept away from the loop edges so the ±20 ms + // jitter can't wrap across the loop boundary). Early and late cancel in drift. + const config = makeConfig([".", "X", ".", "X"]); + const { expected } = buildExpectedTimeline(config.pattern, "sn", 120); + + const feed = synthesizeOnsets(expected, BASELINE, 0, (_h, i) => (i % 2 === 0 ? +20 : -20)); + const stats = await runSession(config, feed); + + expect(stats.drift).toBe(0); + expect(stats.meanAbsDelta).toBe(20); +}); diff --git a/src/services/__tests__/practiceEngine.test.ts b/src/services/__tests__/practiceEngine.test.ts new file mode 100644 index 000000000..96cca5373 --- /dev/null +++ b/src/services/__tests__/practiceEngine.test.ts @@ -0,0 +1,500 @@ +import { expect, test, vi } from "vitest"; +import { createPracticeEngine, PracticeEngineOpts } from "../practiceEngine"; +import { normalizePattern } from "../../state/pattern"; +import type Beatbox from "beatbox.js"; +import type { BeatboxReference } from "../player"; + +// Prevent player.ts module-level AudioContext side-effects from running in happy-dom. +// patternToBeatbox is replaced with a no-op stub; createBeatbox/getPlayerById are +// never called directly in tests since all tests inject beatboxFactory. +vi.mock("../player", () => ({ + createBeatbox: vi.fn(), + getPlayerById: vi.fn(), + patternToBeatbox: vi.fn(() => []), + stopAllPlayers: vi.fn(), +})); + +function makeFakeBeatbox(): { ref: BeatboxReference; player: Beatbox } { + const player = { + setPattern: vi.fn(), + setBeatLength: vi.fn(), + setRepeat: vi.fn(), + on: vi.fn(), + play: vi.fn(), + stop: vi.fn(), + off: vi.fn(), + } as unknown as Beatbox; + const ref: BeatboxReference = { id: -1, playing: false, customPosition: false }; + return { ref, player }; +} + +function makeDefaultOpts(): PracticeEngineOpts { + return { + beatboxFactory: () => makeFakeBeatbox(), + }; +} + +test("engine starts in Idle", () => { + const engine = createPracticeEngine({ + micPermission: { state: { value: "unknown" } as any, request: async () => ({} as any), release: () => {} }, + detector: { start: async () => {}, stop: async () => {}, setSensitivity: () => {}, on: () => {}, off: () => {} }, + }); + expect(engine.state.value).toBe("idle"); +}); + +function makeDeps(grant = true) { + const stream = {} as MediaStream; + return { + micPermission: { + state: { value: grant ? "granted" : "unknown" } as any, + request: vi.fn(grant ? async () => stream : async () => { throw new Error("denied"); }), + release: vi.fn(), + } as any, + detector: { + start: vi.fn(async () => {}), + stop: vi.fn(async () => {}), + setSensitivity: vi.fn(), + on: vi.fn(), + off: vi.fn(), + } as any, + }; +} + +function makeDefaultConfig() { + return { + pattern: normalizePattern({ length: 1, time: 4, sn: ["X", ".", "X", "."] }), + instrument: "sn" as const, + speedBpm: 120, + mode: "instrument" as const, + }; +} + +test("start() transitions to requestingMic then countIn on grant", async () => { + const deps = makeDeps(true); + const engine = createPracticeEngine(deps, makeDefaultOpts()); + engine.configure(makeDefaultConfig()); + const promise = engine.start(); + expect(engine.state.value).toBe("requestingMic"); + await promise; + expect(engine.state.value).toBe("countIn"); + expect(deps.detector.start).toHaveBeenCalled(); +}); + +test("stats during countIn reports zero misses (no loop baseline yet)", async () => { + // Regression for Bug: the score rail showed a full loop's worth of misses the + // instant a session started. During count-in loopBaselinePerf is still null, so + // the engine must report 0 elapsed (loop not started) — not "unknown", which the + // scorer treats as "count the whole loop". With no hits and no time elapsed, + // nothing can yet be missed. + const deps = makeDeps(true); + const engine = createPracticeEngine(deps, makeDefaultOpts()); + engine.configure(makeDefaultConfig()); // pattern has 2 strokes (X . X .) + await engine.start(); + expect(engine.state.value).toBe("countIn"); + expect(engine.stats().hits).toBe(0); + expect(engine.stats().misses).toBe(0); +}); + +test("start() denied returns to idle", async () => { + const deps = makeDeps(false); + const engine = createPracticeEngine(deps, makeDefaultOpts()); + engine.configure(makeDefaultConfig()); + await engine.start().catch(() => {}); + expect(engine.state.value).toBe("idle"); +}); + +test("stop() resets to idle and cleans up", async () => { + const deps = makeDeps(true); + const engine = createPracticeEngine(deps, makeDefaultOpts()); + engine.configure(makeDefaultConfig()); + await engine.start(); + await engine.stop(); + expect(engine.state.value).toBe("idle"); + expect(deps.micPermission.release).toHaveBeenCalled(); + expect(deps.detector.stop).toHaveBeenCalled(); +}); + +test("start() is a no-op when not idle or results", async () => { + const deps = makeDeps(true); + const engine = createPracticeEngine(deps, makeDefaultOpts()); + engine.configure(makeDefaultConfig()); + await engine.start(); // → countIn + expect(engine.state.value).toBe("countIn"); + await engine.start(); // guard should early-return + // Mic was requested only once; second start() did not re-request + expect(deps.micPermission.request).toHaveBeenCalledTimes(1); +}); + +test("countIn completes and transitions to gameOn after configured ms", async () => { + const deps = makeDeps(true); + const fakeTimer = { + setTimeout: (cb: () => void, _ms: number) => { queueMicrotask(cb); return 0 as any; }, + clearTimeout: () => {}, + }; + const engine = createPracticeEngine(deps, { timer: fakeTimer as any, beatboxFactory: () => makeFakeBeatbox() }); + engine.configure(makeDefaultConfig()); + await engine.start(); + expect(engine.state.value).toBe("countIn"); + await engine.advanceToGameOn(); + expect(engine.state.value).toBe("gameOn"); +}); + +test("stopGame() transitions from gameOn through finalising to results", async () => { + const deps = makeDeps(true); + const engine = createPracticeEngine(deps, makeDefaultOpts()); + engine.configure(makeDefaultConfig()); + await engine.start(); + await engine.advanceToGameOn(); + await engine.stopGame(); + expect(engine.state.value).toBe("results"); +}); + +test("stop() during finalising cancels stopGame's transition to results", async () => { + const deps = makeDeps(true); + let resolveDelay!: () => void; + const fakeTimer = { + setTimeout: (cb: () => void, _ms: number) => { + resolveDelay = cb; + return 0 as any; + }, + clearTimeout: () => {}, + }; + const engine = createPracticeEngine(deps, { timer: fakeTimer as any, beatboxFactory: () => makeFakeBeatbox() }); + engine.configure(makeDefaultConfig()); + await engine.start(); + await engine.advanceToGameOn(); + const stopGamePromise = engine.stopGame(); // enters finalising, awaits delay + await engine.stop(); // → idle (during finalising) + resolveDelay(); // delay fires AFTER stop() + await stopGamePromise; + expect(engine.state.value).toBe("idle"); // guard prevented overwrite to "results" +}); + +test("advanceToGameOn() is a no-op from non-countIn states", async () => { + const deps = makeDeps(true); + const engine = createPracticeEngine(deps, makeDefaultOpts()); + engine.configure(makeDefaultConfig()); + // From idle — should not transition + await engine.advanceToGameOn(); + expect(engine.state.value).toBe("idle"); + // After full lifecycle: idle → countIn → gameOn → already gameOn, second call no-op + await engine.start(); + await engine.advanceToGameOn(); + expect(engine.state.value).toBe("gameOn"); + await engine.advanceToGameOn(); // second call from gameOn + expect(engine.state.value).toBe("gameOn"); // unchanged +}); + +test("engine pipes detected hits into the scorer after gameOn", async () => { + const deps = makeDeps(true); + // Capture the onset subscriber to drive it manually + let onsetSub: ((e: any) => void) | null = null; + deps.detector.on = vi.fn((ev: string, cb: any) => { if (ev === "onset") onsetSub = cb; }); + + const pattern = normalizePattern({ length: 1, time: 4, sn: ["X", ".", "X", "."] }); + + const engine = createPracticeEngine(deps, makeDefaultOpts()); + engine.configure({ pattern, instrument: "sn", speedBpm: 120, mode: "instrument" }); + await engine.start(); + + // The fake Beatbox's "stop" handler is a vi.fn() — registered but never invoked. + // So onCountInComplete never fires and the main Beatbox isn't created. + // Inject an explicit loop baseline directly to drive the scorer. + const baselinePerf = 1000; // arbitrary + await engine.advanceToGameOn(baselinePerf); + + // Strokes are at t=0 and t=250ms in loop-relative time (120 bpm × 4 strokes/beat → 125ms/stroke). + // Send an onset at baseline + 250ms — should land on the second expected hit. + onsetSub!({ t_perf: baselinePerf + 250, energy: 0.5 }); + + await engine.stopGame(); + expect(engine.stats().hits).toBeGreaterThanOrEqual(1); +}); + +test("verdicts(): an on-time hit shows a good verdict on its stroke", async () => { + const deps = makeDeps(true); + let onsetSub: ((e: any) => void) | null = null; + deps.detector.on = vi.fn((ev: string, cb: any) => { if (ev === "onset") onsetSub = cb; }); + + const pattern = normalizePattern({ length: 1, time: 4, sn: ["X", ".", "X", "."] }); + const engine = createPracticeEngine(deps, makeDefaultOpts()); + engine.configure({ pattern, instrument: "sn", speedBpm: 120, mode: "instrument" }); + await engine.start(); + await engine.advanceToGameOn(1000); + onsetSub!({ t_perf: 1000, energy: 0.5 }); // stroke 0, on time + expect(engine.verdicts().perStroke.get(0)?.verdict).toBe("good"); +}); + +test("difficulty scales the scoring tolerance threaded into the timeline", async () => { + const deps = makeDeps(true); + let onsetSub: ((e: any) => void) | null = null; + deps.detector.on = vi.fn((ev: string, cb: any) => { if (ev === "onset") onsetSub = cb; }); + + const pattern = normalizePattern({ length: 1, time: 4, sn: ["X", ".", "X", "."] }); + const engine = createPracticeEngine(deps, makeDefaultOpts()); + engine.configure({ pattern, instrument: "sn", speedBpm: 120, mode: "instrument", difficulty: "hard" }); + await engine.start(); + + const baselinePerf = 1000; + await engine.advanceToGameOn(baselinePerf); + + // Stroke at t=250ms; hit is 50ms late. Normal good=60 → "good"; Hard good=36 → "off". + onsetSub!({ t_perf: baselinePerf + 300, energy: 0.5 }); + + // strokeIdx 2 is at t=250ms (index 2 in ["X",".",X","."]) — 50ms late → "off" under hard. + const v = engine.verdicts().perStroke.get(2); + expect(v?.verdict).toBe("off"); +}); + +test("changing difficulty during gameOn resets to idle", async () => { + const deps = makeDeps(true); + const engine = createPracticeEngine(deps, makeDefaultOpts()); + const cfg = { + pattern: normalizePattern({ length: 1, time: 4, sn: ["X"] }), + instrument: "sn" as const, speedBpm: 120, mode: "instrument" as const, difficulty: "easy" as const, + }; + engine.configure(cfg); + await engine.start(); + await engine.advanceToGameOn(); + engine.configure({ ...cfg, difficulty: "hard" }); + expect(engine.state.value).toBe("idle"); +}); + +function makePositionedBeatboxFactory() { + // Beatbox factory that captures `on(ev, cb)` per instance, allowing tests to + // drive "play"/"beat"/"stop" with explicit arguments (notably beat positions). + type Handlers = { + play?: () => void; + beat?: (position: number) => void; + stop?: () => void; + }; + const beatboxes: Array<{ + handlers: Handlers; + player: Beatbox; + ref: BeatboxReference; + }> = []; + const factory = (_repeat: boolean) => { + const handlers: Handlers = {}; + const player = { + setPattern: vi.fn(), + setBeatLength: vi.fn(), + setRepeat: vi.fn(), + on: vi.fn((ev: keyof Handlers, cb: never) => { handlers[ev] = cb; }), + play: vi.fn(), + stop: vi.fn(), + off: vi.fn(), + } as unknown as Beatbox; + const ref: BeatboxReference = { id: -1, playing: false, customPosition: false }; + beatboxes.push({ handlers, player, ref }); + return { ref, player }; + }; + return { factory, beatboxes }; +} + +test("does not wrap on a warm-up beat with a garbage position before a full loop elapses", async () => { + // Regression: a brand-new AudioContext can report a garbage-large beat position before its + // output clock settles (live trace observed 7373, then a snap back to 0, ~15ms into the + // session). That snap-back looks like a loop-boundary position decrease, but only ~15ms has + // elapsed — far less than a loop — so the wrap's elapsed-time guard must reject it. Otherwise it + // pushes a full empty loop into the scorer → a loop's worth of phantom misses with zero input. + const deps = makeDeps(true); + const { factory, beatboxes } = makePositionedBeatboxFactory(); + let mockNow = 1000; + const engine = createPracticeEngine(deps, { beatboxFactory: factory, now: () => mockNow }); + engine.configure(makeDefaultConfig()); // loopLengthMs = 500 (120bpm × 4 strokes/beat × 1 beat) + + const loopWrapSpy = vi.fn(); + engine.on("loopWrap", loopWrapSpy); + + await engine.start(); + beatboxes[0].handlers.stop?.(); // count-in done → onCountInComplete builds the main player + await Promise.resolve(); + expect(beatboxes.length).toBeGreaterThanOrEqual(2); + + beatboxes[1].handlers.play?.(); // loop baseline anchored at mockNow = 1000 + mockNow = 1015; // 15ms in — audio clock still warming up + beatboxes[1].handlers.beat?.(7373); // garbage warm-up position + mockNow = 1019; + beatboxes[1].handlers.beat?.(0); // clock settles, position snaps to 0 (looks like a decrease) + + expect(loopWrapSpy).not.toHaveBeenCalled(); + expect(engine.stats().misses).toBe(0); +}); + +test("loop baseline tracks the audio wrap and does not drift over many loops", async () => { + // Regression for the drift introduced by time-based wrap detection (fix #4): re-anchoring the + // baseline to elapsed time (now()) at each wrap accumulated the per-loop overshoot, dragging the + // baseline progressively later so every hit read earlier and earlier (all eventually scored + // "off"/early; latency offset — a constant shift — couldn't compensate a growing error). The + // baseline must follow the real audio loop wrap (position decrease), staying within ~one beat of + // each true boundary rather than drifting hundreds of ms over a session. + const deps = makeDeps(true); + const { factory, beatboxes } = makePositionedBeatboxFactory(); + const B = 10_000; + let mockNow = B; + const engine = createPracticeEngine(deps, { beatboxFactory: factory, now: () => mockNow }); + engine.configure(makeDefaultConfig()); // loopLengthMs = 500 + const L = 500; + const beatMs = 30; // beat cadence — deliberately does NOT divide L, so a late wrap overshoots + + await engine.start(); + beatboxes[0].handlers.stop?.(); + await Promise.resolve(); + const main = beatboxes[1].handlers; + + mockNow = B; + main.play?.(); // baseline anchored at the true start B + + const STROKE_MS = 125; // L / strokes-per-loop (500 / 4); production reports a floored stroke index, not a per-mille + const LOOPS = 8; + // Feed beats at a fixed cadence; position is derived from the true audio loop phase, so it + // decreases (wraps high→0) exactly at each true boundary B + k*L. + for (let t = B + beatMs; t <= B + LOOPS * L + L; t += beatMs) { + const phase = (t - B) % L; // 0..L-1 + const position = Math.floor(phase / STROKE_MS); // stroke index 0..3 (upbeat 0); resets to 0 at each boundary + mockNow = t; + main.beat?.(position); + } + + const baseline = engine.debugLoopBaseline()!; + const r = ((baseline - B) % L + L) % L; + const distFromGrid = Math.min(r, L - r); // how far the baseline sits from the nearest true boundary + expect(distFromGrid).toBeLessThanOrEqual(beatMs); +}); + +test("engine emits 'loopWrap' on the audio loop wrap (position decrease)", async () => { + // A wrap is the audio loop boundary: the beat position climbs through the loop, then drops back. + // (A decrease before half a loop has elapsed is the warm-up transient and is rejected — see the + // garbage-position test above.) + const deps = makeDeps(true); + const { factory, beatboxes } = makePositionedBeatboxFactory(); + let mockNow = 1000; + const engine = createPracticeEngine(deps, { beatboxFactory: factory, now: () => mockNow }); + engine.configure(makeDefaultConfig()); // loopLengthMs = 500 + + const loopWrapSpy = vi.fn(); + engine.on("loopWrap", loopWrapSpy); + + await engine.start(); + beatboxes[0].handlers.stop?.(); // count-in done → onCountInComplete builds main + await Promise.resolve(); + expect(beatboxes.length).toBeGreaterThanOrEqual(2); + + beatboxes[1].handlers.play?.(); // loop baseline = 1000 + mockNow = 1200; beatboxes[1].handlers.beat?.(1); // climbing within loop 1 (stroke index 1) + mockNow = 1450; beatboxes[1].handlers.beat?.(3); // still climbing (stroke index 3) + expect(loopWrapSpy).not.toHaveBeenCalled(); + mockNow = 1510; beatboxes[1].handlers.beat?.(0); // position dropped → wrapped (elapsed 510 ≥ 250) + expect(loopWrapSpy).toHaveBeenCalledTimes(1); +}); + +test("re-anchors to the wrap and fires once per loop, not on every beat", async () => { + const deps = makeDeps(true); + const { factory, beatboxes } = makePositionedBeatboxFactory(); + let mockNow = 1000; + const engine = createPracticeEngine(deps, { beatboxFactory: factory, now: () => mockNow }); + engine.configure(makeDefaultConfig()); // loopLengthMs = 500 + + const loopWrapSpy = vi.fn(); + engine.on("loopWrap", loopWrapSpy); + + await engine.start(); + beatboxes[0].handlers.stop?.(); + await Promise.resolve(); + const main = beatboxes[1].handlers; + + main.play?.(); // baseline = 1000 + mockNow = 1450; main.beat?.(3); // climbing (stroke index 3) + mockNow = 1510; main.beat?.(0); // wrap #1 (decrease, elapsed 510 ≥ 250) + expect(loopWrapSpy).toHaveBeenCalledTimes(1); + // Positions climbing again within loop 2 must NOT re-wrap. + mockNow = 1700; main.beat?.(1); + mockNow = 1950; main.beat?.(3); + expect(loopWrapSpy).toHaveBeenCalledTimes(1); + mockNow = 2015; main.beat?.(0); // next boundary (decrease, elapsed since 1510 = 505 ≥ 250) → wrap #2 + expect(loopWrapSpy).toHaveBeenCalledTimes(2); +}); + +test("stop() detaches the main player's listeners so a stale beat can't reach a later session's scorer", async () => { + // Hygiene/defense-in-depth: teardown() stops the players but used to leave the main player's + // "play"/"beat" listeners attached. Those closures reference the module-level scorer, so a late + // beat from a previous session's closing AudioContext could reach a *new* session's scorer. + const deps = makeDeps(true); + const { factory, beatboxes } = makePositionedBeatboxFactory(); + const engine = createPracticeEngine(deps, { beatboxFactory: factory }); + engine.configure(makeDefaultConfig()); + + await engine.start(); + beatboxes[0].handlers.stop?.(); // count-in done → onCountInComplete builds the main player + await Promise.resolve(); + expect(beatboxes.length).toBeGreaterThanOrEqual(2); + const mainPlayer = beatboxes[1].player; + + await engine.stop(); // teardown() must remove the main player's listeners + + expect(mainPlayer.off).toHaveBeenCalledWith("play", expect.any(Function)); + expect(mainPlayer.off).toHaveBeenCalledWith("beat", expect.any(Function)); +}); + +test("engine.off() removes the listener", async () => { + const deps = makeDeps(true); + let onsetSub: ((e: any) => void) | null = null; + deps.detector.on = vi.fn((ev: string, cb: any) => { if (ev === "onset") onsetSub = cb; }); + + const engine = createPracticeEngine(deps, makeDefaultOpts()); + engine.configure(makeDefaultConfig()); + await engine.start(); + + const spy = vi.fn(); + engine.on("verdictsChanged", spy); + engine.off("verdictsChanged", spy); + + const baselinePerf = 1000; + await engine.advanceToGameOn(baselinePerf); + onsetSub!({ t_perf: baselinePerf + 250, energy: 0.5 }); + + expect(spy).not.toHaveBeenCalled(); +}); + +test("verdicts(): an early downbeat matches stroke 0 across the wrap", async () => { + const deps = makeDeps(true); + let onsetSub: ((e: any) => void) | null = null; + deps.detector.on = vi.fn((ev: string, cb: any) => { if (ev === "onset") onsetSub = cb; }); + const pattern = normalizePattern({ length: 1, time: 4, sn: ["X", ".", "X", "."] }); + const engine = createPracticeEngine(deps, makeDefaultOpts()); + engine.configure({ pattern, instrument: "sn", speedBpm: 120, mode: "instrument" }); + await engine.start(); + await engine.advanceToGameOn(1000); + onsetSub!({ t_perf: 1000 - 20, energy: 0.5 }); // 20ms early → raw tRel −20, matches stroke 0 (no modulo) + expect(engine.verdicts().perStroke.get(0)).toMatchObject({ verdict: "good", delta: -20 }); +}); + +test("verdictsChanged fires on each onset", async () => { + const deps = makeDeps(true); + let onsetSub: ((e: any) => void) | null = null; + deps.detector.on = vi.fn((ev: string, cb: any) => { if (ev === "onset") onsetSub = cb; }); + const engine = createPracticeEngine(deps, makeDefaultOpts()); + engine.configure(makeDefaultConfig()); + await engine.start(); + await engine.advanceToGameOn(1000); + const spy = vi.fn(); + engine.on("verdictsChanged", spy); + onsetSub!({ t_perf: 1000, energy: 0.5 }); + expect(spy).toHaveBeenCalled(); +}); + +test("configure() during gameOn forces a reset to idle", async () => { + const deps = makeDeps(true); + const engine = createPracticeEngine(deps, makeDefaultOpts()); + engine.configure({ + pattern: normalizePattern({ length: 1, time: 4, sn: ["X"] }), + instrument: "sn", speedBpm: 120, mode: "instrument", + }); + await engine.start(); + await engine.advanceToGameOn(); + engine.configure({ + pattern: normalizePattern({ length: 1, time: 4, sn: ["X"] }), + instrument: "ls", speedBpm: 120, mode: "instrument", + }); + expect(engine.state.value).toBe("idle"); +}); diff --git a/src/services/__tests__/practiceScorer.test.ts b/src/services/__tests__/practiceScorer.test.ts new file mode 100644 index 000000000..86604c2f5 --- /dev/null +++ b/src/services/__tests__/practiceScorer.test.ts @@ -0,0 +1,579 @@ +import { expect, test } from "vitest"; +import { SILENT_STROKES, buildExpectedTimeline, matchHits, DEFAULT_TOLERANCE, scoreSession, createScorer, deltaToPosition, toleranceForDifficulty, extraStrokeIdx } from "../practiceScorer"; +import { normalizePattern } from "../../state/pattern"; + +test("SILENT_STROKES matches the documented set", () => { + expect(SILENT_STROKES).toEqual(new Set([" ", "", ".", "s"])); +}); + +test("buildExpectedTimeline handles an empty pattern", () => { + const pattern = normalizePattern({ length: 4, time: 4 }); + const tl = buildExpectedTimeline(pattern, "sn", 120); + expect(tl.expected).toEqual([]); + expect(tl.loopLengthMs).toBe(60_000 / 120 * 4); // 2000 ms +}); + +test("buildExpectedTimeline derives expected hits from snare line", () => { + const pattern = normalizePattern({ + length: 1, time: 4, sn: ["X", ".", "X", "."] + }); + const tl = buildExpectedTimeline(pattern, "sn", 120); + const strokeMs = 60_000 / (120 * 4); // 125 ms + expect(tl.expected).toEqual([ + { strokeIdx: 0, t: 0 }, + { strokeIdx: 2, t: 2 * strokeMs } + ]); +}); + +test("buildExpectedTimeline aligns strokeIdx with the upbeat-adjusted partition cells", () => { + // upbeat=1 with an empty pickup slot (like real Low Surdo lines): the array is + // [pickup, main…] so raw index 0 is the pickup and the main downbeat is raw index 1. + // PatternPlayer labels the cell at raw index `i` as `stroke-i-${i - upbeat}`, and the + // engine anchors loopBaselinePerf at raw index 0, so expected `t` stays raw-indexed. + const pattern = normalizePattern({ + length: 1, time: 4, upbeat: 1, sn: [" ", "X", ".", ".", "X"] + }); + const tl = buildExpectedTimeline(pattern, "sn", 120); + const strokeMs = 60_000 / (120 * 4); // 125 ms + expect(tl.expected).toEqual([ + { strokeIdx: 0, t: 1 * strokeMs }, // first main stroke: cell stroke-i-0, not stroke-i-1 + { strokeIdx: 3, t: 4 * strokeMs } // last slot: scored at all (buggy loop dropped it) + ]); + expect(tl.loopLengthMs).toBe(5 * strokeMs); // full array incl. upbeat, not 4*strokeMs +}); + +test("matchHits with no hits and no expected", () => { + const r = matchHits([], [], DEFAULT_TOLERANCE.off + 50); + expect(r).toEqual({ matched: [], misses: [], extras: [] }); +}); + +test("matchHits: single perfect on-time hit", () => { + const e = [{ strokeIdx: 0, t: 100 }]; + const d = [{ t: 100, energy: 0.5 }]; + const r = matchHits(d, e, DEFAULT_TOLERANCE.off + 50); + expect(r.matched).toHaveLength(1); + expect(r.matched[0].delta).toBe(0); + expect(r.matched[0].verdict).toBe("good"); + expect(r.misses).toEqual([]); + expect(r.extras).toEqual([]); +}); + +test("matchHits: single off-but-attributable hit", () => { + const e = [{ strokeIdx: 0, t: 100 }]; + const d = [{ t: 195, energy: 0.5 }]; // 95ms late → off + const r = matchHits(d, e, DEFAULT_TOLERANCE.off + 50); + expect(r.matched).toHaveLength(1); + expect(r.matched[0].delta).toBe(95); + expect(r.matched[0].verdict).toBe("off"); +}); + +test("matchHits: missed expected, no detected", () => { + const e = [{ strokeIdx: 0, t: 100 }]; + const r = matchHits([], e, DEFAULT_TOLERANCE.off + 50); + expect(r.misses).toEqual(e); +}); + +test("matchHits: extra detection beyond window", () => { + const e = [{ strokeIdx: 0, t: 100 }]; + const d = [{ t: 500, energy: 0.5 }]; + const r = matchHits(d, e, DEFAULT_TOLERANCE.off + 50); + expect(r.matched).toEqual([]); + expect(r.misses).toEqual(e); + expect(r.extras).toEqual(d); +}); + +test("matchHits: two adjacent detections compete for one expected", () => { + // expected at t=100, detections at 90 and 110 → 110 wins because |10|>|10|... actually both + // are equidistant. We accept either, but in our greedy form the first one (t=90) wins. + const e = [{ strokeIdx: 0, t: 100 }]; + const d = [{ t: 90, energy: 0.5 }, { t: 110, energy: 0.5 }]; + const r = matchHits(d, e, 200); + expect(r.matched).toHaveLength(1); + expect(r.matched[0].d.t).toBe(90); + expect(r.extras).toHaveLength(1); + expect(r.extras[0].t).toBe(110); +}); + +test("matchHits: detection prefers closer next-expected over current", () => { + // detected at t=150, expected at t=100 (Δ=50) and t=200 (Δ=-50). Both within window. + // We're walking forward, so we match d with the first expected (100, Δ=+50) and the + // second expected becomes a miss. + const e = [{ strokeIdx: 0, t: 100 }, { strokeIdx: 1, t: 200 }]; + const d = [{ t: 150, energy: 0.5 }]; + const r = matchHits(d, e, 200); + expect(r.matched).toHaveLength(1); + expect(r.matched[0].e.t).toBe(100); + expect(r.misses).toHaveLength(1); + expect(r.misses[0].t).toBe(200); +}); + +test("matchHits: perfect 4-beat run", () => { + const e = Array.from({ length: 4 }, (_, i) => ({ strokeIdx: i, t: i * 125 })); + const d = Array.from({ length: 4 }, (_, i) => ({ t: i * 125, energy: 0.5 })); + const r = matchHits(d, e, 200); + expect(r.matched).toHaveLength(4); + expect(r.misses).toHaveLength(0); + expect(r.extras).toHaveLength(0); + expect(r.matched.every((m) => m.verdict === "good")).toBe(true); +}); + +test("matchHits: consistently 80ms late → all 'off'", () => { + const e = Array.from({ length: 4 }, (_, i) => ({ strokeIdx: i, t: i * 125 })); + const d = e.map((h) => ({ t: h.t + 80, energy: 0.5 })); + const r = matchHits(d, e, 200); + expect(r.matched).toHaveLength(4); + expect(r.matched.every((m) => m.verdict === "off")).toBe(true); + expect(r.matched.every((m) => m.delta === 80)).toBe(true); +}); + +test("matchHits: extra detection before all expected", () => { + const e = [{ strokeIdx: 0, t: 500 }]; + const d = [{ t: 100, energy: 0.5 }]; + const r = matchHits(d, e, 200); + expect(r.matched).toEqual([]); + expect(r.misses).toEqual(e); + expect(r.extras).toEqual(d); +}); + + +test("scoreSession: empty session", () => { + const s = scoreSession({ matched: [], misses: [], extras: [] }); + expect(s).toEqual({ + hits: 0, misses: 0, extras: 0, expectedTotal: 0, + meanAbsDelta: 0, drift: 0, headlineScore: 100, + }); +}); + +test("scoreSession: perfect 4-beat run → 100", () => { + const matched = [0, 1, 2, 3].map((i) => ({ + d: { t: i * 125, energy: 0.5 }, + e: { strokeIdx: i, t: i * 125 }, + delta: 0, + verdict: "good" as const, + })); + const s = scoreSession({ matched, misses: [], extras: [] }); + expect(s.headlineScore).toBe(100); + expect(s.meanAbsDelta).toBe(0); + expect(s.drift).toBe(0); + expect(s.hits).toBe(4); +}); + +test("scoreSession: half-missed run", () => { + const matched = [0, 1].map((i) => ({ + d: { t: i * 125, energy: 0.5 }, + e: { strokeIdx: i, t: i * 125 }, + delta: 0, + verdict: "good" as const, + })); + const misses = [{ strokeIdx: 2, t: 250 }, { strokeIdx: 3, t: 375 }]; + const s = scoreSession({ matched, misses, extras: [] }); + expect(s.hits).toBe(2); + expect(s.misses).toBe(2); + expect(s.expectedTotal).toBe(4); + // 60 * 0.5 + 40 * 1.0 = 70 + expect(s.headlineScore).toBe(70); +}); + +test("scoreSession: all-late drift indicator", () => { + const matched = [0, 1, 2, 3].map((i) => ({ + d: { t: i * 125 + 30, energy: 0.5 }, + e: { strokeIdx: i, t: i * 125 }, + delta: 30, + verdict: "good" as const, + })); + const s = scoreSession({ matched, misses: [], extras: [] }); + // all deltas are +30 → drift = meanAbsDelta = 30 + expect(s.drift).toBe(30); + expect(s.meanAbsDelta).toBe(30); +}); + +test("createScorer accumulates hits across a single loop", () => { + const timeline = { + expected: [ + { strokeIdx: 0, t: 0 }, + { strokeIdx: 1, t: 125 }, + ], + loopLengthMs: 500, + toleranceMs: DEFAULT_TOLERANCE, + }; + const s = createScorer(timeline); + s.acceptHit({ t: 0, energy: 0.5 }); + s.acceptHit({ t: 125, energy: 0.5 }); + s.finalize(); + const stats = s.stats(); + expect(stats.hits).toBe(2); + expect(stats.misses).toBe(0); +}); + +test("createScorer: an early downbeat in the closing loop is credited to the next loop's stroke 0", () => { + // The hit is filed in loop 0's bucket (engine has not wrapped yet) at t≈loopLen-70, i.e. 70 ms + // before the NEXT downbeat. After the wrap it must score loop 1's stroke 0 — not a miss + extra. + const tl = { expected: [{ strokeIdx: 0, t: 0 }, { strokeIdx: 1, t: 500 }], loopLengthMs: 1000, toleranceMs: DEFAULT_TOLERANCE }; + const s = createScorer(tl); + s.acceptHit({ t: 930, energy: 1 }); // 70 ms-early downbeat for loop 1, parked in loop 0's bucket + s.onLoopWrap(); // audio wraps → loop 0 completes, loop 1 in-progress + // Lights loop 1's stroke 0 with a negative (early) delta… + expect(s.liveVerdicts({ currentLoopElapsedMs: 5 }).perStroke.get(0)).toMatchObject({ delta: -70 }); + s.finalize(); + // …and is a HIT with no phantom extra (loop 0's own strokes are misses — nothing was played there). + expect(s.stats().hits).toBe(1); + expect(s.stats().extras).toBe(0); +}); + +test("createScorer: a hit just PAST the boundary (no modulo) is credited to the next stroke 0", () => { + const tl = { expected: [{ strokeIdx: 0, t: 0 }, { strokeIdx: 1, t: 500 }], loopLengthMs: 1000, toleranceMs: DEFAULT_TOLERANCE }; + const s = createScorer(tl); + s.acceptHit({ t: 1003, energy: 1 }); // 3 ms after the nominal boundary, parked in loop 0's bucket + s.onLoopWrap(); + expect(s.liveVerdicts({ currentLoopElapsedMs: 5 }).perStroke.get(0)).toMatchObject({ delta: 3 }); + s.finalize(); + expect(s.stats().hits).toBe(1); + expect(s.stats().extras).toBe(0); +}); + +test("createScorer: nearest unfilled wins across the boundary (late last-stroke AND early downbeat)", () => { + // Last stroke at t=875 (window 200). A 40 ms-late hit (915) is nearer it than the boundary (85 across) + // → stays on stroke 7; a 10 ms-early downbeat (990) binds to the next loop's stroke 0. Both are hits. + const tl = { expected: [{ strokeIdx: 0, t: 0 }, { strokeIdx: 7, t: 875 }], loopLengthMs: 1000, toleranceMs: DEFAULT_TOLERANCE }; + const s = createScorer(tl); + s.acceptHit({ t: 915, energy: 1 }); // 40 ms late on the last stroke + s.acceptHit({ t: 990, energy: 1 }); // 10 ms-early downbeat for the next loop + s.onLoopWrap(); + s.finalize(); + expect(s.stats().hits).toBe(2); + expect(s.stats().extras).toBe(0); +}); + +test("createScorer resets the matcher per loop", () => { + const timeline = { + expected: [{ strokeIdx: 0, t: 0 }], + loopLengthMs: 500, + toleranceMs: DEFAULT_TOLERANCE, + }; + const s = createScorer(timeline); + s.acceptHit({ t: 0, energy: 0.5 }); // matches loop 0 stroke 0 + s.onLoopWrap(); + s.acceptHit({ t: 0, energy: 0.5 }); // matches loop 1 stroke 0 + s.finalize(); + expect(s.stats().hits).toBe(2); +}); + +test("createScorer trims tail when finalize(tailMs) given", () => { + const timeline = { + expected: [ + { strokeIdx: 0, t: 0 }, + { strokeIdx: 1, t: 1000 }, // late hit in the tail + ], + loopLengthMs: 2000, + toleranceMs: DEFAULT_TOLERANCE, + }; + const s = createScorer(timeline); + s.acceptHit({ t: 0, energy: 0.5 }); + // user pressed Stop at "now = 1100", tailMs = 500 → drop expected hits in [600..1100] + s.finalize({ stopAtMs: 1100, tailMs: 500 }); + // The expected at t=1000 falls in the trim window and is dropped. + expect(s.stats().misses).toBe(0); + expect(s.stats().hits).toBe(1); +}); + +test("finalize: strokes after the stop point (never reached) are not counted as misses", () => { + // Stop 50ms into a single loop. Only stroke t=0 has come around; strokes t=100/200/300 + // are still in the future and never played. The live path at elapsed=50 correctly shows + // 0 misses — finalize must AGREE, not retroactively count the un-played future strokes. + // This is the "stop → Misses jumps to a full loop" over-count. + const s = createScorer(monoTimeline); + s.acceptHit({ t: 0, energy: 1 }); // played the only reachable stroke, on time + const live = s.stats({ currentLoopElapsedMs: 50 }); + expect(live.misses).toBe(0); // sanity: live path is correct + s.finalize({ stopAtMs: 50, tailMs: 500 }); + expect(s.stats().misses).toBe(0); // future strokes must NOT become misses at finalize +}); + +test("finalize: completed loops count in full, the partial final loop only counts reached strokes", () => { + // Unified rule: every COMPLETED loop is scored in full ("keep counting every loop"), + // but the in-progress loop at stop only counts strokes the metronome actually reached. + // monoTimeline: strokes 0/100/200/300, loopLen 400. + const s = createScorer(monoTimeline); + s.onLoopWrap(); // loop 1: played nothing → completes → 4 misses, in full + s.finalize({ stopAtMs: 150, tailMs: 0 }); // loop 2: stopped 150ms in → only strokes t=0,100 reached + // 4 (full completed loop) + 2 (reached strokes 0,100 in the final loop) = 6. + // Before the fix the final loop also counted strokes 200/300 → 8. + expect(s.stats().misses).toBe(6); +}); + +test("createScorer stats() before finalize returns live approximation", () => { + const timeline = { + expected: [{ strokeIdx: 0, t: 0 }], + loopLengthMs: 500, + toleranceMs: DEFAULT_TOLERANCE, + }; + const s = createScorer(timeline); + s.acceptHit({ t: 0, energy: 0.5 }); + const live = s.stats(); // pre-finalize: live path + expect(live.hits).toBe(1); + expect(live.misses).toBe(0); + s.finalize(); + expect(s.stats().hits).toBe(1); // finalize agrees +}); + +test("liveVerdicts: signed delta visible through perStroke (late > 0, early < 0, on-time 0)", () => { + // Isolated single-stroke timeline makes the matching deterministic. + const timeline = { + expected: [{ strokeIdx: 0, t: 0 }], + loopLengthMs: 500, + toleranceMs: DEFAULT_TOLERANCE, + }; + const scorerOnTime = createScorer(timeline); + scorerOnTime.acceptHit({ t: 0, energy: 0.5 }); // on time → delta 0 + expect(scorerOnTime.liveVerdicts().perStroke.get(0)).toMatchObject({ verdict: "good", delta: 0 }); + + const scorerLate = createScorer(timeline); + scorerLate.acceptHit({ t: 15, energy: 0.5 }); // 15ms late → delta +15 + expect(scorerLate.liveVerdicts().perStroke.get(0)).toMatchObject({ delta: 15 }); + + const scorerEarly = createScorer(timeline); + scorerEarly.acceptHit({ t: -15, energy: 0.5 }); // 15 ms early → raw negative tRel, matches stroke 0 + expect(scorerEarly.liveVerdicts().perStroke.get(0)).toMatchObject({ delta: -15 }); +}); + +test("liveVerdicts: a slightly-early downbeat (raw negative tRel) lights stroke 0", () => { + const timeline = { expected: [{ strokeIdx: 0, t: 0 }, { strokeIdx: 1, t: 500 }], loopLengthMs: 1000, toleranceMs: DEFAULT_TOLERANCE }; + const s = createScorer(timeline); + s.acceptHit({ t: -20, energy: 1 }); // 20 ms before this loop's downbeat + expect(s.liveVerdicts().perStroke.get(0)).toMatchObject({ verdict: "good", delta: -20 }); + s.acceptHit({ t: 510, energy: 1 }); // a normal in-loop hit on stroke 1 + expect(s.liveVerdicts().perStroke.get(1)).toMatchObject({ verdict: "good", delta: 10 }); +}); + + +test("deltaToPosition: centre, zone boundary, edges, clamp, direction", () => { + // on-time → centre, good zone + expect(deltaToPosition(0)).toEqual({ percent: 50, zone: "good" }); + // late (+) leans left (<50); early (-) leans right (>50) + expect(deltaToPosition(60).percent).toBeCloseTo(30, 5); // +good → 20% left of centre + expect(deltaToPosition(-60).percent).toBeCloseTo(70, 5); + // zone flips just past the good tolerance + expect(deltaToPosition(60).zone).toBe("good"); + expect(deltaToPosition(61).zone).toBe("off"); + // off tolerance lands at the edges + expect(deltaToPosition(150)).toEqual({ percent: 0, zone: "off" }); // late edge (left) + expect(deltaToPosition(-150)).toEqual({ percent: 100, zone: "off" }); // early edge (right) + // beyond off is clamped + expect(deltaToPosition(400).percent).toBe(0); + expect(deltaToPosition(-400).percent).toBe(100); +}); + +test("toleranceForDifficulty scales DEFAULT_TOLERANCE per level", () => { + expect(toleranceForDifficulty("normal")).toEqual(DEFAULT_TOLERANCE); // ×1.0 → identical to today + expect(toleranceForDifficulty("easy")).toEqual({ good: 105, off: 263 }); // ×1.75, rounded + expect(toleranceForDifficulty("hard")).toEqual({ good: 36, off: 90 }); // ×0.6 +}); + +test("deltaToPosition: scales the marker position to the given tolerance", () => { + const hard = toleranceForDifficulty("hard"); // good 36, off 90 + expect(deltaToPosition(90, hard).percent).toBe(0); // a 90ms-late hit is at the late edge under hard + expect(deltaToPosition(0, hard).percent).toBe(50); // on-time = centre + expect(deltaToPosition(90, hard).zone).toBe("off"); // 90 > good(36) → off + // same 90ms hit under the default (off=150) lands mid-meter, NOT at the edge — the bug this fixes + expect(deltaToPosition(90).percent).toBe(20); + // easy (off=263 after rounding): a 263ms-late hit reaches the edge + const easy = toleranceForDifficulty("easy"); // good 105, off 263 + expect(deltaToPosition(263, easy).percent).toBe(0); +}); + +// Live stats are monotonic: a stroke is only counted once its timing window has +// closed (t <= elapsed - windowMs). windowMs = 200 for DEFAULT_TOLERANCE. +const monoTimeline = { + expected: [ + { strokeIdx: 0, t: 0 }, + { strokeIdx: 1, t: 100 }, + { strokeIdx: 2, t: 200 }, + { strokeIdx: 3, t: 300 }, + ], + loopLengthMs: 400, + toleranceMs: DEFAULT_TOLERANCE, +}; + +test("live stats: not-yet-reached strokes are not counted as misses", () => { + const s = createScorer(monoTimeline); + // elapsed 100 → cutoff = 100 - 200 = -100 → no stroke window closed yet. + const st = s.stats({ currentLoopElapsedMs: 100 }); + expect(st.misses).toBe(0); + expect(st.expectedTotal).toBe(0); +}); + +test("live stats: a stroke becomes a miss only after its window closes", () => { + const s = createScorer(monoTimeline); + // elapsed 250 → cutoff = 50 → only stroke t=0 is closed; nothing played → 1 miss. + const st = s.stats({ currentLoopElapsedMs: 250 }); + expect(st.expectedTotal).toBe(1); + expect(st.misses).toBe(1); + expect(st.hits).toBe(0); +}); + +test("live stats: a played stroke counts as a hit once its window closes", () => { + const s = createScorer(monoTimeline); + s.acceptHit({ t: 0, energy: 1 }); // on stroke 0 → good + const st = s.stats({ currentLoopElapsedMs: 250 }); // cutoff 50 → stroke 0 closed + expect(st.hits).toBe(1); + expect(st.misses).toBe(0); + expect(st.expectedTotal).toBe(1); +}); + +test("live stats: misses are monotonic and do not jump at a loop wrap", () => { + const s = createScorer(monoTimeline); + const m1 = s.stats({ currentLoopElapsedMs: 250 }).misses; // cutoff 50 → stroke t=0 closed → 1 + const m2 = s.stats({ currentLoopElapsedMs: 399 }).misses; // cutoff 199 → strokes t=0,100 closed → 2 (loop tail not judged live yet) + s.onLoopWrap(); // loop 1 completes → its full 4 strokes (no hits) lock as misses + const m3 = s.stats({ currentLoopElapsedMs: 50 }).misses; // new loop cutoff -150 → 0 closed; completed loop contributes 4 + expect(m1).toBeLessThanOrEqual(m2); + expect(m2).toBeLessThanOrEqual(m3); + expect(m3).toBe(4); // completed loop's 4 only; new loop adds nothing yet (was 8 before the fix) +}); + +test("live stats: an on-time hit is not transiently counted as an extra (no flicker)", () => { + // windowMs = 200. Hit stroke 1 (t=1000) on time, then poll while elapsed ≈ 1000 — stroke 1's + // window [800,1200] hasn't closed yet. The hit must read as a hit, never a fleeting extra. + const timeline = { + expected: [{ strokeIdx: 0, t: 0 }, { strokeIdx: 1, t: 1000 }], + loopLengthMs: 2000, + toleranceMs: DEFAULT_TOLERANCE, + }; + const s = createScorer(timeline); + s.acceptHit({ t: 1000, energy: 1 }); + const st = s.stats({ currentLoopElapsedMs: 1000 }); + expect(st.extras).toBe(0); + expect(st.hits).toBe(1); +}); + +test("live stats: absent elapsed counts the whole current loop (regression guard)", () => { + const s = createScorer(monoTimeline); + s.acceptHit({ t: 0, energy: 1 }); // matches stroke 0 + const st = s.stats(); // no opts → legacy behaviour: match against the full timeline + expect(st.hits).toBe(1); + expect(st.misses).toBe(3); // strokes 1,2,3 unmatched + expect(st.expectedTotal).toBe(4); +}); + +const twoStroke = { + expected: [{ strokeIdx: 0, t: 0 }, { strokeIdx: 1, t: 250 }], + loopLengthMs: 500, + toleranceMs: DEFAULT_TOLERANCE, +}; + +test("liveVerdicts: a matched hit yields a good verdict with its delta", () => { + const s = createScorer(twoStroke); + s.acceptHit({ t: 10, energy: 0.5 }); // 10ms late on stroke 0 + const v = s.liveVerdicts({ currentLoopElapsedMs: 300 }); + expect(v.perStroke.get(0)).toEqual({ verdict: "good", delta: 10 }); +}); + +test("liveVerdicts: an expected stroke past its closed window is a miss", () => { + const s = createScorer(twoStroke); + // No hit. windowMs = off+50 = 200. Stroke 0 (t=0) closes at elapsed 200; ask at 300. + const v = s.liveVerdicts({ currentLoopElapsedMs: 300 }); + expect(v.perStroke.get(0)).toEqual({ verdict: "miss", delta: null }); +}); + +test("liveVerdicts: a stroke whose window has not closed is neither matched nor a miss", () => { + const s = createScorer(twoStroke); + // elapsed 100 < stroke 0 window close (200) → stroke 0 absent (not yet a miss). + const v = s.liveVerdicts({ currentLoopElapsedMs: 100 }); + expect(v.perStroke.has(0)).toBe(false); +}); + +test("liveVerdicts: a hit matching no stroke is an extra, not a verdict", () => { + // Use wider-spaced strokes so t=500 is genuinely outside windowMs=200 from both. + // Strokes at t=0 and t=1000, loopLen=2000, windowMs=200. Hit at t=500 is 500ms from each. + // Ask at elapsed=100 (cutoff=-100) so no stroke windows have closed yet → no misses in perStroke. + const wideStroke = { + expected: [{ strokeIdx: 0, t: 0 }, { strokeIdx: 1, t: 1000 }], + loopLengthMs: 2000, + toleranceMs: DEFAULT_TOLERANCE, + }; + const s = createScorer(wideStroke); + s.acceptHit({ t: 500, energy: 0.5 }); // between strokes, > windowMs=200 from both → extra + const v = s.liveVerdicts({ currentLoopElapsedMs: 100 }); + expect(v.perStroke.size).toBe(0); + expect(v.extras).toHaveLength(1); + expect(v.extras[0].t).toBe(500); +}); + +test("liveVerdicts: a closer later hit reassigns the earlier one to an extra", () => { + const s = createScorer(twoStroke); + s.acceptHit({ t: 40, energy: 0.5 }); // first, 40ms from stroke 0 + s.acceptHit({ t: 5, energy: 0.5 }); // closer to stroke 0 → wins; the 40ms hit becomes an extra + const v = s.liveVerdicts({ currentLoopElapsedMs: 300 }); + expect(v.perStroke.get(0)?.delta).toBe(5); + expect(v.extras.map((e) => e.t)).toContain(40); +}); + +test("liveVerdicts: recent trail carries the last matched deltas across loops", () => { + const s = createScorer(twoStroke); + s.acceptHit({ t: 0, energy: 0.5 }); // loop 0 stroke 0 + s.onLoopWrap(); + s.acceptHit({ t: 250, energy: 0.5 }); // loop 1 stroke 1 + const v = s.liveVerdicts({ currentLoopElapsedMs: 300 }); + expect(v.recent.map((r) => r.verdict)).toEqual(["good", "good"]); +}); + +test("liveVerdicts: with no elapsed (e.g. before gameOn) shows no misses", () => { + const s = createScorer(twoStroke); + // No hits and no elapsed → we can't know any window has closed, so nothing is flagged missed + // yet (otherwise the highlight would flash every unplayed stroke as a miss before play starts). + const v = s.liveVerdicts(); + expect(v.perStroke.size).toBe(0); +}); + +test("scoreSession: extras dock the score, clamped and never negative", () => { + const matched = [0, 1, 2, 3].map((i) => ({ + d: { t: i * 125, energy: 0.5 }, e: { strokeIdx: i, t: i * 125 }, delta: 0, verdict: "good" as const, + })); + const clean = scoreSession({ matched, misses: [], extras: [] }); + const sloppy = scoreSession({ matched, misses: [], extras: [{ t: 60, energy: 0.5 }, { t: 190, energy: 0.5 }] }); + expect(clean.headlineScore).toBe(100); + // 4 expected, 2 extras → penalty = round(20 * 2/4) = 10 → 100 - 10 = 90 + expect(sloppy.headlineScore).toBe(90); + expect(sloppy.headlineScore).toBeGreaterThanOrEqual(0); +}); + +test("scoreSession: the extras penalty is capped so a spray can't dominate the score", () => { + const matched = [0, 1].map((i) => ({ + d: { t: i * 125, energy: 0.5 }, e: { strokeIdx: i, t: i * 125 }, delta: 0, verdict: "good" as const, + })); + const clean = scoreSession({ matched, misses: [], extras: [] }); + const flooded = scoreSession({ matched, misses: [], extras: Array.from({ length: 50 }, (_, i) => ({ t: i, energy: 0.5 })) }); + // 2 expected, 50 extras → penalty capped at 20 → 100 - 20 = 80 + expect(flooded.headlineScore).toBe(80); + expect(clean.headlineScore - flooded.headlineScore).toBeLessThanOrEqual(20); + expect(flooded.headlineScore).toBeGreaterThanOrEqual(0); +}); + +test("scoreSession: extras penalty denominator uses hits+misses, not hits alone", () => { + // 2 hits + 2 misses → expectedTotal 4; 2 extras → penalty round(20 * 2/4) = 10. + const matched = [0, 1].map((i) => ({ + d: { t: i * 125, energy: 0.5 }, e: { strokeIdx: i, t: i * 125 }, delta: 0, verdict: "good" as const, + })); + const misses = [{ strokeIdx: 2, t: 250 }, { strokeIdx: 3, t: 375 }]; + const s = scoreSession({ matched, misses, extras: [{ t: 60, energy: 0.5 }, { t: 190, energy: 0.5 }] }); + expect(s.expectedTotal).toBe(4); + // base = round(60 * 2/4 + 40 * 1) = 70; − penalty 10 = 60 + expect(s.headlineScore).toBe(60); +}); + +test("extraStrokeIdx: rounds a stray hit to its nearest cell, upbeat-adjusted", () => { + // strokeMs 125, upbeat 0, 4 cells. A hit at 130ms is nearest cell 1. + expect(extraStrokeIdx(130, 125, 0, 4)).toBe(1); + expect(extraStrokeIdx(60, 125, 0, 4)).toBe(0); // rounds down to 0 +}); + +test("extraStrokeIdx: subtracts the upbeat to match the verdicts-Map key", () => { + // upbeat 1, strokeMs 125. A hit at 250ms → raw cell 2 → key 2-1 = 1. + expect(extraStrokeIdx(250, 125, 1, 5)).toBe(1); + expect(extraStrokeIdx(10, 125, 1, 5)).toBe(-1); // early hit on the upbeat cell → key -1 +}); + +test("extraStrokeIdx: returns null when the hit rounds outside the loop's cells", () => { + expect(extraStrokeIdx(99999, 125, 0, 4)).toBeNull(); + expect(extraStrokeIdx(-50, 125, 0, 4)).toBeNull(); +}); diff --git a/src/services/__tests__/router.test.ts b/src/services/__tests__/router.test.ts new file mode 100644 index 000000000..2033ebcef --- /dev/null +++ b/src/services/__tests__/router.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "vitest"; +import { ref } from "vue"; +import { useRouter } from "../router"; + +test("practice route is recognized", () => { + const path = ref("/practice/Funk/Tune"); + const route = useRouter(path); + expect(route.value.tab).toBe("practice"); + if (route.value.tab === "practice") { + expect(route.value.tuneName).toBe("Funk"); + expect(route.value.patternName).toBe("Tune"); + } +}); + +test("practice root", () => { + const path = ref("/practice/"); + const route = useRouter(path); + expect(route.value.tab).toBe("practice"); +}); + +test("practice tune-only (no pattern)", () => { + const path = ref("/practice/Funk/"); + const route = useRouter(path); + expect(route.value.tab).toBe("practice"); + if (route.value.tab === "practice") { + expect(route.value.tuneName).toBe("Funk"); + expect(route.value.patternName).toBeUndefined(); + } +}); diff --git a/src/services/latencyCalibrator.ts b/src/services/latencyCalibrator.ts new file mode 100644 index 000000000..569cdf3d8 --- /dev/null +++ b/src/services/latencyCalibrator.ts @@ -0,0 +1,50 @@ +export interface MedianStats { + median: number; + /** Median absolute deviation, used as "spread" indicator */ + spread: number; + count: number; +} + +export function computeMedianAndSpread(values: number[]): MedianStats { + if (values.length === 0) return { median: 0, spread: 0, count: 0 }; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const median = sorted.length % 2 === 1 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; + + const deviations = sorted.map((v) => Math.abs(v - median)).sort((a, b) => a - b); + const devMid = Math.floor(deviations.length / 2); + const mad = deviations.length % 2 === 1 + ? deviations[devMid] + : (deviations[devMid - 1] + deviations[devMid]) / 2; + + return { median, spread: mad, count: values.length }; +} + +export interface CalibrationSession { + recordTap(timestampMs: number): void; + finalize(): MedianStats; +} + +export function createCalibrationSession( + beats: number[], + opts: { windowMs?: number } = {}, +): CalibrationSession { + const windowMs = opts.windowMs ?? 300; + const deltas: number[] = []; + return { + recordTap(t) { + let bestAbs = Infinity; + let bestDelta = 0; + for (const b of beats) { + const d = t - b; + if (Math.abs(d) < bestAbs) { bestAbs = Math.abs(d); bestDelta = d; } + } + if (bestAbs <= windowMs) deltas.push(bestDelta); + }, + finalize() { + return computeMedianAndSpread(deltas); + }, + }; +} diff --git a/src/services/loopbackCalibrator.ts b/src/services/loopbackCalibrator.ts new file mode 100644 index 000000000..c86ebfe5d --- /dev/null +++ b/src/services/loopbackCalibrator.ts @@ -0,0 +1,52 @@ +import { createCalibrationSession } from "./latencyCalibrator"; + +export interface LoopbackResult { + /** median signed delta (detected onset − beat), ms — the round-trip latency */ + medianMs: number; + /** median absolute deviation of the deltas, ms */ + spread: number; + /** onsets that matched a beat within the window */ + count: number; + accepted: boolean; + reason?: "too-few" | "too-noisy"; +} + +export interface LoopbackQuality { + minCount: number; + maxSpread: number; + /** Spread above this (and ≤ maxSpread) shows a "noisy calibration" warning. Display-only; does not affect accept/reject. */ + marginalSpread: number; +} + +export const DEFAULT_LOOPBACK_QUALITY: LoopbackQuality = { minCount: 8, maxSpread: 25, marginalSpread: 15 }; + +export interface LoopbackCalibration { + recordOnset(tPerf: number): void; + finalize(): LoopbackResult; +} + +export function createLoopbackCalibration( + beats: number[], + opts: { windowMs?: number; quality?: LoopbackQuality } = {}, +): LoopbackCalibration { + const session = createCalibrationSession(beats, { windowMs: opts.windowMs }); + const quality = opts.quality ?? DEFAULT_LOOPBACK_QUALITY; + return { + recordOnset(tPerf) { + session.recordTap(tPerf); + }, + finalize() { + const { median, spread, count } = session.finalize(); + let accepted = true; + let reason: LoopbackResult["reason"]; + if (count < quality.minCount) { + accepted = false; + reason = "too-few"; + } else if (spread > quality.maxSpread) { + accepted = false; + reason = "too-noisy"; + } + return { medianMs: median, spread, count, accepted, reason }; + }, + }; +} diff --git a/src/services/mediaPermissions.ts b/src/services/mediaPermissions.ts new file mode 100644 index 000000000..043dde658 --- /dev/null +++ b/src/services/mediaPermissions.ts @@ -0,0 +1,37 @@ +import { ref, Ref } from "vue"; + +export type MicPermissionState = "unknown" | "granted" | "denied"; + +export interface MicPermission { + state: Ref; + request(): Promise; + release(stream: MediaStream): void; +} + +const MIC_CONSTRAINTS: MediaTrackConstraints = { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + channelCount: 1, + sampleRate: 48000, +}; + +export function createMicPermission(): MicPermission { + const state = ref("unknown"); + return { + state, + async request() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: MIC_CONSTRAINTS }); + state.value = "granted"; + return stream; + } catch (err: any) { + state.value = "denied"; + throw err; + } + }, + release(stream) { + for (const track of stream.getTracks()) track.stop(); + }, + }; +} diff --git a/src/services/onsetDetector.ts b/src/services/onsetDetector.ts new file mode 100644 index 000000000..60792a911 --- /dev/null +++ b/src/services/onsetDetector.ts @@ -0,0 +1,93 @@ +/// +// eslint-disable-next-line import/default +import workletUrl from "./onsetDetector.worklet.ts?worker&url"; +import mitt, { Emitter } from "mitt"; +import { DEFAULT_DETECTOR_PARAMS } from "./onsetDetectorCore"; + +export interface ClockSnapshot { + contextTime: number; // seconds + performanceTime: number; // ms +} + +export type OnsetDetectorEvents = { + onset: { t_perf: number; energy: number }; + error: { message: string }; +} & Record; + +export interface OnsetDetector { + start(stream: MediaStream, params: { multiplier?: number; refractoryFrames?: number; userSensitivity?: number }): Promise; + stop(): Promise; + setSensitivity(value: number): void; + on(ev: K, h: (e: OnsetDetectorEvents[K]) => void): void; + off(ev: K, h: (e: OnsetDetectorEvents[K]) => void): void; +} + +export function ctxTimeToPerfTime(t_ctx: number, snap: ClockSnapshot): number { + return snap.performanceTime + (t_ctx - snap.contextTime) * 1000; +} + +export function createOnsetDetector(): OnsetDetector { + const events: Emitter = mitt(); + let ctx: AudioContext | null = null; + let source: MediaStreamAudioSourceNode | null = null; + let node: AudioWorkletNode | null = null; + let baseMultiplier: number = DEFAULT_DETECTOR_PARAMS.multiplier; + + return { + async start(stream, params) { + // Guard against double-start: close any previous context first. + if (ctx) { + await ctx.close(); + ctx = null; + } + ctx = new AudioContext({ latencyHint: "interactive" }); + try { + await ctx.audioWorklet.addModule(workletUrl); + } catch (e: any) { + events.emit("error", { message: `Worklet load failed: ${e?.message ?? e}` }); + try { await ctx.close(); } catch { /* best-effort */ } + ctx = null; + throw e; + } + baseMultiplier = params.multiplier ?? DEFAULT_DETECTOR_PARAMS.multiplier; + node = new AudioWorkletNode(ctx, "onset-detector", { + numberOfInputs: 1, + numberOfOutputs: 0, + processorOptions: { + multiplier: baseMultiplier, + refractoryFrames: params.refractoryFrames ?? DEFAULT_DETECTOR_PARAMS.refractoryFrames, + userSensitivity: params.userSensitivity ?? DEFAULT_DETECTOR_PARAMS.userSensitivity, + }, + }); + + node.port.onmessage = (e: MessageEvent) => { + if (e.data?.type === "onset") { + const ts = (ctx as any).getOutputTimestamp ? ctx!.getOutputTimestamp() : null; + const snap: ClockSnapshot = (ts?.contextTime != null && ts?.performanceTime != null) + ? { contextTime: ts.contextTime, performanceTime: ts.performanceTime } + : { contextTime: ctx!.currentTime, performanceTime: performance.now() }; + const t_perf = ctxTimeToPerfTime(e.data.t_ctx, snap); + events.emit("onset", { t_perf, energy: e.data.energy }); + } + }; + + source = ctx.createMediaStreamSource(stream); + source.connect(node); + }, + async stop() { + try { source?.disconnect(); } catch { /* ignore disconnect errors */ } + try { node?.disconnect(); } catch { /* ignore disconnect errors */ } + if (ctx) { + await ctx.close(); + ctx = null; + } + source = null; + node = null; + }, + setSensitivity(value) { + node?.port.postMessage({ type: "setSensitivity", value, baseMultiplier }); + }, + on: (ev, h) => events.on(ev, h), + off: (ev, h) => events.off(ev, h), + }; +} diff --git a/src/services/onsetDetector.worklet.ts b/src/services/onsetDetector.worklet.ts new file mode 100644 index 000000000..e22b3b6be --- /dev/null +++ b/src/services/onsetDetector.worklet.ts @@ -0,0 +1,47 @@ +/// +import { createDetectorState, processBlock, DetectorParams, DetectorState, DEFAULT_DETECTOR_PARAMS, effectiveMultiplier } from "./onsetDetectorCore"; + +interface WorkletMessage { + type: "onset"; + t_ctx: number; + energy: number; +} + +class OnsetDetectorProcessor extends AudioWorkletProcessor { + state: DetectorState; + frame: number = 0; + userSensitivity: number; + baseMultiplier: number; + + constructor(options: AudioWorkletNodeOptions) { + super(); + const params = (options.processorOptions ?? {}) as Partial & { userSensitivity?: number }; + this.userSensitivity = params.userSensitivity ?? DEFAULT_DETECTOR_PARAMS.userSensitivity; + this.baseMultiplier = params.multiplier ?? DEFAULT_DETECTOR_PARAMS.multiplier; + this.state = createDetectorState({ + multiplier: effectiveMultiplier(this.baseMultiplier, this.userSensitivity), + refractoryFrames: params.refractoryFrames ?? DEFAULT_DETECTOR_PARAMS.refractoryFrames, // ~50 ms at 48kHz, 128-sample blocks + }); + + this.port.onmessage = (e: MessageEvent) => { + if (e.data?.type === "setSensitivity") { + this.userSensitivity = e.data.value; + this.state.params.multiplier = effectiveMultiplier(e.data.baseMultiplier ?? this.baseMultiplier, this.userSensitivity); + } + }; + } + + process(inputs: Float32Array[][]): boolean { + const ch = inputs[0]?.[0]; + if (!ch) return true; + const event = processBlock(this.state, ch, this.frame); + this.frame++; + if (event) { + const msg: WorkletMessage = { type: "onset", t_ctx: currentTime, energy: event.energy }; + this.port.postMessage(msg); + } + return true; + } +} + +registerProcessor("onset-detector", OnsetDetectorProcessor); diff --git a/src/services/onsetDetectorCore.ts b/src/services/onsetDetectorCore.ts new file mode 100644 index 000000000..7621e3c2e --- /dev/null +++ b/src/services/onsetDetectorCore.ts @@ -0,0 +1,124 @@ +export interface DetectorParams { + multiplier: number; // how many × noise-floor to trigger + refractoryFrames: number; // minimum gap between triggers, in blocks + noiseFloorInit?: number; +} + +export interface DetectorState { + params: DetectorParams; + noiseFloor: number; + lastFireFrame: number; + lastHitEnergy: number; + armed: boolean; +} + +export interface OnsetEvent { + /** Frame index of the trigger */ + frame: number; + /** RMS energy of the triggering block */ + energy: number; +} + +// Minimum value the adaptive noise floor can decay to. Without this clamp the +// floor adapts down to ambient room RMS (~0.001) and the trigger threshold +// drops with it, firing on breathing/fan noise. Empirically chosen so the +// threshold (floor × multiplier) lands cleanly between observed ambient peaks +// (≤0.034) and the softest real percussion hits (≥0.05). +export const MIN_NOISE_FLOOR = 0.015; + +// Ceiling (× noise floor) below which the floor is allowed to LEARN. Kept well +// under the trigger (floor × multiplier) so a hit's loud body or decay tail can +// never drag the floor — and thus the threshold — upward; that positive feedback +// is what silenced detection after a few loops. processBlock caps the learn window at the live +// (effective) multiplier — base ÷ userSensitivity, which drops toward 1 at max sensitivity — so +// the floor can never learn up to the trigger even when sensitivity pulls that multiplier < 1.5. +export const LEARN_RATIO = 1.5; + +// Post-hit decay gate. A resonant drum (Surdo) or a flam/tail (Repi, snare) +// emits weak secondary onsets 50–200 ms after the attack that belong to the SAME +// notated stroke. Right after a hit of energy E, a new onset must clear +// E × DECAY_GATE_RATIO; that bar relaxes linearly to 0 over DECAY_GATE_FRAMES. +// Energy-relative (not a blanket time window) so a genuine next stroke of similar +// force still fires even when played close behind — only much weaker echoes drop. +// Frames assume ~48 kHz / 128-sample blocks (≈300 ms); tuned against captured logs. +export const DECAY_GATE_RATIO = 0.5; +export const DECAY_GATE_FRAMES = 112; + +/** Trigger multiplier after the user's sensitivity trim: threshold = noiseFloor × this. Higher + * sensitivity → lower multiplier → hotter (catches softer hits, admits more false positives). */ +export function effectiveMultiplier(baseMultiplier: number, userSensitivity: number): number { + return baseMultiplier / userSensitivity; +} + +/** Single source of the three detector params. A caller may omit any of them + * (e.g. `detector.start(stream, {})`); the host resolves the fallback here and + * forwards explicit values to the worklet, so the worklet's own `??` defaults + * never run in production. Both sides import this instead of hardcoding copies. */ +export const DEFAULT_DETECTOR_PARAMS = { + multiplier: 3, + refractoryFrames: 19, + userSensitivity: 1, +} as const; + +export function rmsOfBlock(block: Float32Array): number { + if (block.length === 0) return 0; + let sumSq = 0; + for (let i = 0; i < block.length; i++) sumSq += block[i] * block[i]; + return Math.sqrt(sumSq / block.length); +} + +export function createDetectorState(params: DetectorParams): DetectorState { + return { + params, + noiseFloor: Math.max(MIN_NOISE_FLOOR, params.noiseFloorInit ?? 0.001), + lastFireFrame: -Infinity, + lastHitEnergy: 0, + armed: true, + }; +} + +/** + * Processes one audio block. Mutates `state` in place (noiseFloor, lastFireFrame). + * `state` must not be shared across concurrent callers. + */ +export function processBlock( + state: DetectorState, + block: Float32Array, + currentFrame: number, +): OnsetEvent | null { + const rms = rmsOfBlock(block); + const exceeds = rms > state.noiseFloor * state.params.multiplier; + + // Learn the floor only from genuinely-quiet blocks (see LEARN_RATIO). + if (rms < state.noiseFloor * Math.min(LEARN_RATIO, state.params.multiplier)) { + state.noiseFloor = Math.max( + MIN_NOISE_FLOOR, + state.noiseFloor * 0.995 + rms * 0.005, + ); + } + + // Re-arm once energy leaves the fireable zone. One strike's sustained decay + // stays above the trigger and so fires only once, but a genuine next stroke — + // which only has to dip below the trigger, not all the way to ambient — re-arms. + if (!exceeds) { + state.armed = true; + } + + const pastRefractory = (currentFrame - state.lastFireFrame) > state.params.refractoryFrames; + + // Decay gate: weak onsets in the wake of a recent strong hit are that hit's + // resonance/flam/tail, not a new stroke (see DECAY_GATE_RATIO). + const sinceFire = currentFrame - state.lastFireFrame; + const decayGate = + sinceFire < DECAY_GATE_FRAMES + ? state.lastHitEnergy * DECAY_GATE_RATIO * (1 - sinceFire / DECAY_GATE_FRAMES) + : 0; + + if (exceeds && pastRefractory && state.armed && rms > decayGate) { + state.lastFireFrame = currentFrame; + state.lastHitEnergy = rms; + state.armed = false; + return { frame: currentFrame, energy: rms }; + } + return null; +} diff --git a/src/services/practiceEngine.ts b/src/services/practiceEngine.ts new file mode 100644 index 000000000..ae12fa203 --- /dev/null +++ b/src/services/practiceEngine.ts @@ -0,0 +1,329 @@ +import { Ref, ref } from "vue"; +import mitt, { Emitter } from "mitt"; +import { MicPermission } from "./mediaPermissions"; +import { OnsetDetector } from "./onsetDetector"; +import { createScorer, ScorerHandle, buildExpectedTimeline, SessionStats, LiveVerdicts, toleranceForDifficulty, Difficulty } from "./practiceScorer"; +import config, { Instrument } from "../config"; +import { Pattern, normalizePattern } from "../state/pattern"; +import type Beatbox from "beatbox.js"; +import { patternToBeatbox, createBeatbox, getPlayerById, BeatboxReference } from "./player"; +import { normalizePlaybackSettings } from "../state/playbackSettings"; + +export type PracticeState = + | "idle" + | "requestingMic" + | "countIn" + | "gameOn" + | "finalising" + | "results"; + +export type PracticeMode = "instrument" | "band"; + +export interface PracticeConfig { + pattern: Pattern; + instrument: Instrument; + speedBpm: number; + mode: PracticeMode; + difficulty?: Difficulty; + sensitivity?: number; +} + +export interface PracticeEngineDeps { + micPermission: MicPermission; + detector: OnsetDetector; +} + +export interface PracticeEngineOpts { + timer?: { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout }; + finaliseDelayMs?: number; + beatboxFactory?: (repeat: boolean) => { ref: BeatboxReference; player: Beatbox }; + /** Latency offset in ms. A function is re-read on every onset, so a slider/calibration change + * takes effect live (parity with detector.setSensitivity). A bare number is captured once. */ + latencyOffsetMs?: number | (() => number); + /** Injectable monotonic clock (ms). Defaults to performance.now(). Lets tests drive + * loop-elapsed deterministically — notably the time-based loop-wrap detection. */ + now?: () => number; +} + +export type PracticeEngineEvents = { + /** Fired whenever the live verdict state may have changed: on each onset, and at each loop wrap. */ + verdictsChanged: object; + /** The audio loop wrapped (boundary crossed). NB: `verdictsChanged` also co-fires at every wrap. */ + loopWrap: object; +} & Record; + +export interface PracticeEngine { + state: Ref; + start(): Promise; + stop(): Promise; + stopGame(): Promise; + /** @internal Test-only seam — no production callers (the UI reaches gameOn via the count-in "stop" path). */ + advanceToGameOn(baselineOverride?: number): Promise; + configure(c: PracticeConfig): void; + stats(): SessionStats; + verdicts(): LiveVerdicts; + /** @internal Test-only — exposes the loop baseline for deterministic timing assertions. */ + debugLoopBaseline(): number | null; + on(ev: K, h: (e: PracticeEngineEvents[K]) => void): void; + off(ev: K, h: (e: PracticeEngineEvents[K]) => void): void; +} + +function buildCountInPattern(speedBpm: number) { + return normalizePattern({ + length: 8, // 8 beats total (2 bars at 4/4) + time: 1, // 1 stroke per beat (just on-beat events) + speed: speedBpm, + ot: [ + "w", "w", "w", "w", // bar 1 — metronome clicks on each beat + "Õ", " ", " ", " ", // bar 2 — whistle-in on beat 1 + ], + }); +} + +export function createPracticeEngine(deps: PracticeEngineDeps, opts: PracticeEngineOpts = {}): PracticeEngine { + const state = ref("idle"); + const finaliseDelayMs = opts.finaliseDelayMs ?? 500; + // Thunked to keep the window-method `this` binding — Firefox throws + // "called on an object that does not implement interface Window" if these + // globals are invoked as methods of a plain object. + const timer = opts.timer ?? { + setTimeout: (cb: () => void, ms: number) => window.setTimeout(cb, ms), + clearTimeout: (id: number) => window.clearTimeout(id), + }; + const now = opts.now ?? (() => performance.now()); + const resolveLatency = () => + typeof opts.latencyOffsetMs === "function" ? opts.latencyOffsetMs() : (opts.latencyOffsetMs ?? 0); + const events: Emitter = mitt(); + let activeStream: MediaStream | null = null; + + let cfg: PracticeConfig | null = null; + let scorer: ScorerHandle | null = null; + let loopBaselinePerf: number | null = null; + let loopLengthMs = 0; // current session's loop length (ms); set when the scorer/timeline is built + let onsetHandler: ((e: { t_perf: number; energy: number }) => void) | null = null; + let detachMainListeners: (() => void) | null = null; // removes the main player's play/beat handlers on teardown + + let countIn: { ref: BeatboxReference; player: Beatbox } | null = null; + let main: { ref: BeatboxReference; player: Beatbox } | null = null; + + function makeBeatboxPair(repeat: boolean): { ref: BeatboxReference; player: Beatbox } { + if (opts.beatboxFactory) return opts.beatboxFactory(repeat); + const ref = createBeatbox(repeat); + return { ref, player: getPlayerById(ref.id) }; + } + + function configure(c: PracticeConfig) { + const oldCfg = cfg; + cfg = c; + if (oldCfg && state.value !== "idle") { + const changed = + oldCfg.pattern !== c.pattern || + oldCfg.instrument !== c.instrument || + oldCfg.speedBpm !== c.speedBpm || + oldCfg.mode !== c.mode || + oldCfg.difficulty !== c.difficulty; + if (changed) void stop(); + } + } + + function setupScorerAndDetector() { + if (!cfg) throw new Error("Practice not configured"); + const timeline = buildExpectedTimeline( + cfg.pattern, cfg.instrument, cfg.speedBpm, + toleranceForDifficulty(cfg.difficulty ?? "normal"), + ); + scorer = createScorer(timeline); + loopLengthMs = timeline.loopLengthMs; + + onsetHandler = (e: { t_perf: number; energy: number }) => { + if (state.value !== "gameOn" || loopBaselinePerf === null || !scorer) return; + // Raw monotonic loop-relative time — NO modulo. Folding into [0, loopLen) would pin a hit played + // just before the next downbeat to the CLOSING loop; the scorer's unrolled matcher needs the + // true offset to credit it to the next loop's stroke 0 (the early-downbeat case). A hit slightly + // before the baseline is correctly negative (early); one just past the boundary stays > loopLen. + const tRel = (e.t_perf - resolveLatency()) - loopBaselinePerf; + scorer.acceptHit({ t: tRel, energy: e.energy }); + events.emit("verdictsChanged", {}); + }; + deps.detector.on("onset", onsetHandler); + } + + function teardown() { + if (onsetHandler) { + deps.detector.off("onset", onsetHandler); + onsetHandler = null; + } + if (detachMainListeners) { detachMainListeners(); detachMainListeners = null; } + if (countIn) { void countIn.player.stop(); countIn = null; } + if (main) { void main.player.stop(); main = null; } + scorer = null; + loopBaselinePerf = null; + } + + async function onCountInComplete() { + if (state.value !== "countIn" || !cfg) return; + const ps = normalizePlaybackSettings({ + speed: cfg.speedBpm, + loop: true, + headphones: cfg.mode === "instrument" ? [cfg.instrument] : [], + mute: cfg.mode === "band" ? { [cfg.instrument]: true } : {}, + }); + const mainRaw = patternToBeatbox(cfg.pattern, ps); + + const mainPair = makeBeatboxPair(true); + main = mainPair; + const mainPlayer = mainPair.player; + mainPlayer.setPattern(mainRaw); + mainPlayer.setBeatLength(60_000 / cfg.speedBpm / config.playTime); + mainPlayer.setRepeat(true); + // Re-anchor the loop baseline on the REAL audio loop wrap — the beat whose position drops + // (the audio clock wrapped to the top of the loop). Anchoring to elapsed time instead let the + // per-loop overshoot accumulate, dragging the baseline progressively late so every hit read + // earlier and earlier. The position signal IS the authoritative loop clock; its one hazard is + // the AudioContext warm-up, where the first beats can report a garbage position (live trace: + // 7373) that snaps to 0 ~15ms in and looks like a wrap — so only accept a wrap once at least + // half a loop has actually elapsed, which rejects that startup transient. + let lastBeatPosition = -1; + const onPlay = () => { + loopBaselinePerf = now(); + lastBeatPosition = -1; + }; + const onBeat = (position: number) => { + if ( + loopBaselinePerf !== null && + lastBeatPosition >= 0 && + position < lastBeatPosition && + now() - loopBaselinePerf >= loopLengthMs * 0.5 + ) { + loopBaselinePerf = now(); + scorer?.onLoopWrap(); + events.emit("loopWrap", {}); + events.emit("verdictsChanged", {}); + } + lastBeatPosition = position; + }; + mainPlayer.on("play", onPlay); + mainPlayer.on("beat", onBeat); + // teardown() removes these so a late beat from this player's closing AudioContext can't reach + // a *later* session's scorer (both handlers close over the module-level scorer/baseline). + detachMainListeners = () => { + mainPlayer.off("play", onPlay); + mainPlayer.off("beat", onBeat); + }; + mainPlayer.play(); + + state.value = "gameOn"; + } + + async function start() { + if (state.value !== "idle" && state.value !== "results") return; + if (!cfg) throw new Error("configure() must be called first"); + teardown(); // Defensive: clean up any leftover from prior session before re-setup + state.value = "requestingMic"; + try { + activeStream = await deps.micPermission.request(); + // stop() may have been called while we were awaiting the permission prompt + if ((state.value as PracticeState) === "idle") { + deps.micPermission.release(activeStream); + activeStream = null; + return; + } + await deps.detector.start(activeStream, { userSensitivity: cfg?.sensitivity }); + // stop() may have been called while we were awaiting the detector + if ((state.value as PracticeState) === "idle") return; + setupScorerAndDetector(); + + // Build the count-in pattern + Beatbox + const countInPattern = buildCountInPattern(cfg.speedBpm); + const countInRaw = patternToBeatbox( + countInPattern, + normalizePlaybackSettings({ headphones: ["ot"], whistle: false }) + ); + const countInPair = makeBeatboxPair(false); + countIn = countInPair; + const countInPlayer = countInPair.player; + countInPlayer.setPattern(countInRaw); + countInPlayer.setBeatLength(60_000 / cfg.speedBpm / config.playTime); + countInPlayer.on("stop", () => { + void onCountInComplete(); + }); + countInPlayer.play(); + + state.value = "countIn"; + } catch (err) { + state.value = "idle"; + teardown(); + if (activeStream) { + deps.micPermission.release(activeStream); + activeStream = null; + } + throw err; + } + } + + async function advanceToGameOn(baselineOverride?: number) { + if (state.value !== "countIn") return; + loopBaselinePerf = baselineOverride ?? now(); + state.value = "gameOn"; + } + + async function stopGame() { + if (state.value !== "gameOn" && state.value !== "countIn") return; + const stopAtMs = loopBaselinePerf === null ? 0 : now() - loopBaselinePerf; + state.value = "finalising"; + + // Stop Beatboxes immediately so user doesn't hear audio during the finalising delay + if (countIn) { void countIn.player.stop(); countIn = null; } + if (main) { void main.player.stop(); main = null; } + + await new Promise((resolve) => timer.setTimeout(resolve, finaliseDelayMs)); + // stop() may have fired during the finalising delay + if ((state.value as PracticeState) === "idle") return; + scorer?.finalize({ stopAtMs, tailMs: 500 }); + state.value = "results"; + if (activeStream) { + deps.micPermission.release(activeStream); + activeStream = null; + } + await deps.detector.stop(); + } + + async function stop() { + state.value = "idle"; + teardown(); + if (activeStream) { + deps.micPermission.release(activeStream); + activeStream = null; + } + await deps.detector.stop(); + } + + function stats(): SessionStats { + // No baseline yet (count-in, or the brief gap before the main loop's "play" fires) + // means the scored loop hasn't started — 0 ms have elapsed, so no stroke window has + // closed and nothing can be missed. Passing null here instead would trip the scorer's + // "absent elapsed → count the whole loop" path and flash a full loop of phantom misses. + const currentLoopElapsedMs = loopBaselinePerf === null ? 0 : now() - loopBaselinePerf; + return scorer?.stats({ currentLoopElapsedMs }) + ?? { hits: 0, misses: 0, extras: 0, expectedTotal: 0, meanAbsDelta: 0, drift: 0, headlineScore: 100 }; + } + + function verdicts(): LiveVerdicts { + // Unlike stats() above, pass null when not in a baseline-anchored gameOn loop: liveVerdicts maps + // absent-elapsed to a −Infinity cutoff (show NO misses), the right default for the highlight. Do + // NOT "align" this to stats()'s 0-guard — the two have opposite safe defaults (stats() passes 0 + // because its scorer path treats null as "judge the whole loop"). + const currentLoopElapsedMs = (state.value === "gameOn" && loopBaselinePerf !== null) + ? now() - loopBaselinePerf : null; + return scorer?.liveVerdicts({ currentLoopElapsedMs }) + ?? { perStroke: new Map(), extras: [], recent: [] }; + } + + function debugLoopBaseline(): number | null { return loopBaselinePerf; } + + return { + state, start, stop, stopGame, advanceToGameOn, configure, stats, verdicts, debugLoopBaseline, + on: events.on.bind(events), + off: events.off.bind(events), + }; +} diff --git a/src/services/practiceScorer.ts b/src/services/practiceScorer.ts new file mode 100644 index 000000000..b71dc89e5 --- /dev/null +++ b/src/services/practiceScorer.ts @@ -0,0 +1,327 @@ +import { Instrument } from "../config"; +import { Pattern } from "../state/pattern"; + +export const SILENT_STROKES = new Set([" ", "", ".", "s"]); + +export interface ExpectedHit { + strokeIdx: number; + /** ms from start of current loop iteration */ + t: number; +} + +export interface ExpectedTimeline { + expected: ExpectedHit[]; + loopLengthMs: number; + toleranceMs: { good: number; off: number }; +} + +export interface DetectedHit { + /** ms from the loop baseline, after the latency offset — raw and signed: negative for a hit a + * hair before the downbeat (early), or > loopLen for one just past the boundary before the wrap. */ + t: number; + energy: number; +} + +export type Verdict = "good" | "off" | "miss" | "extra"; + +export interface MatchResult { + matched: Array<{ d: DetectedHit; e: ExpectedHit; delta: number; verdict: "good" | "off" }>; + misses: ExpectedHit[]; + extras: DetectedHit[]; +} + +export interface LiveVerdicts { + /** current in-progress loop, per stroke: good/off (with delta) or miss (delta null) */ + perStroke: Map; + /** current-loop detected hits that matched no stroke (for the extra markers — Task 2.4) */ + extras: DetectedHit[]; + /** rolling last-N matched hits across the session, for the timing-meter trail */ + recent: Array<{ delta: number; verdict: "good" | "off" }>; +} + +export interface SessionStats { + hits: number; + misses: number; + extras: number; + expectedTotal: number; + meanAbsDelta: number; + drift: number; + headlineScore: number; +} + +export const DEFAULT_TOLERANCE = { good: 60, off: 150 } as const; + +export type Difficulty = "easy" | "normal" | "hard"; + +// Lower difficulty widens the windows; "normal" is ×1 so it equals DEFAULT_TOLERANCE. +const DIFFICULTY_FACTOR: Record = { easy: 1.75, normal: 1, hard: 0.6 }; + +export function toleranceForDifficulty(difficulty: Difficulty): { good: number; off: number } { + const f = DIFFICULTY_FACTOR[difficulty]; + return { + good: Math.round(DEFAULT_TOLERANCE.good * f), + off: Math.round(DEFAULT_TOLERANCE.off * f), + }; +} + +/** + * Maps a signed timing delta (ms; + = late, − = early) to a marker position on the + * timing meter. `percent` is 0 (left/late edge) … 50 (centre/on-time) … 100 (right/ + * early edge), clamped. `zone` is "good" within the good tolerance, else "off". + */ +export function deltaToPosition( + delta: number, + tolerance: { good: number; off: number } = DEFAULT_TOLERANCE, +): { percent: number; zone: "good" | "off" } { + const percent = Math.max(0, Math.min(100, 50 - (delta / tolerance.off) * 50)); + const zone: "good" | "off" = Math.abs(delta) <= tolerance.good ? "good" : "off"; + return { percent, zone }; +} + +/** + * Maps a stray hit's loop time to the partition cell it lands nearest, as the upbeat-adjusted + * strokeIdx key the verdicts Map uses — or null if it rounds outside the loop's cells. Lets the + * partition tint "where the stray note landed." + */ +export function extraStrokeIdx( + extraTimeMs: number, + strokeMs: number, + upbeat: number, + slotCount: number, +): number | null { + // Guard the time, not rawIdx: Math.round(-0.4) is 0, so a pre-loop hit would otherwise land on + // cell 0. After this, rawIdx is always >= 0. + if (extraTimeMs < 0) return null; + const rawIdx = Math.round(extraTimeMs / strokeMs); + if (rawIdx >= slotCount) return null; // rounds onto/after the loop boundary — no cell there + return rawIdx - upbeat; +} + +export function buildExpectedTimeline( + pattern: Pattern, + instrument: Instrument, + speedBpm: number, + tolerance: { good: number; off: number } = DEFAULT_TOLERANCE, +): ExpectedTimeline { + const strokeMs = 60_000 / (speedBpm * pattern.time); + // pattern[instrument] is [pickup(upbeat slots) … main(length*time slots)]; iterate the + // FULL array so the last `upbeat` main strokes are scored, and so the played loop length + // (which includes the pickup) matches. + const slotCount = pattern.length * pattern.time + pattern.upbeat; + const line = pattern[instrument] ?? []; + const expected: ExpectedHit[] = []; + for (let i = 0; i < slotCount; i++) { + const stroke = line[i]; + if (!SILENT_STROKES.has(stroke ?? "")) { + // strokeIdx matches PatternPlayer's cell label `stroke-i-${i - upbeat}`; t stays + // raw-indexed because loopBaselinePerf is anchored at raw index 0, not the downbeat. + expected.push({ strokeIdx: i - pattern.upbeat, t: i * strokeMs }); + } + } + return { + expected, + loopLengthMs: slotCount * strokeMs, + toleranceMs: { ...tolerance }, + }; +} + +function classifyDelta(delta: number, tolerance: { good: number; off: number }): "good" | "off" { + return Math.abs(delta) <= tolerance.good ? "good" : "off"; +} + +export function matchHits( + detected: DetectedHit[], + expected: ExpectedHit[], + windowMs: number, + tolerance: { good: number; off: number } = DEFAULT_TOLERANCE, +): MatchResult { + // Walking-pointer greedy nearest-neighbour. Both arrays MUST be time-sorted by the caller. + const matched: MatchResult["matched"] = []; + const misses: ExpectedHit[] = []; + const extras: DetectedHit[] = []; + + let ei = 0; // expected pointer + let di = 0; // detected pointer + + while (ei < expected.length && di < detected.length) { + const e = expected[ei]; + const d = detected[di]; + const delta = d.t - e.t; + + if (delta < -windowMs) { + extras.push(d); // detected too far before expected + di++; + } else if (delta > windowMs) { + misses.push(e); // expected too far before detected + ei++; + } else { + const dNext = detected[di + 1]; + if (dNext && Math.abs(dNext.t - e.t) < Math.abs(delta) && Math.abs(dNext.t - e.t) <= windowMs) { + extras.push(d); // a strictly closer detected follows → this one is an extra + di++; + } else { + matched.push({ d, e, delta, verdict: classifyDelta(delta, tolerance) }); + ei++; + di++; + } + } + } + + while (ei < expected.length) misses.push(expected[ei++]); + while (di < detected.length) extras.push(detected[di++]); + + return { matched, misses, extras }; +} + +// Max points the extras penalty can dock (out of 100). The penalty ramps linearly to this at +// extras == expectedTotal, then caps — so a spray of false positives can't bury an otherwise-good +// score. Penalising extras is fair because the sensitivity slider lets a player cool a too-hot +// detector (its false positives are what surface as extras). +const EXTRAS_PENALTY_MAX = 20; + +export function scoreSession( + match: MatchResult, + tolerance: { good: number; off: number } = DEFAULT_TOLERANCE, +): SessionStats { + const hits = match.matched.length; + const misses = match.misses.length; + const extras = match.extras.length; + const expectedTotal = hits + misses; + + const meanAbsDelta = hits === 0 + ? 0 + : match.matched.reduce((s, m) => s + Math.abs(m.delta), 0) / hits; + const drift = hits === 0 + ? 0 + : match.matched.reduce((s, m) => s + m.delta, 0) / hits; + + const hitRatio = expectedTotal === 0 ? 1 : hits / expectedTotal; + const timingTightness = Math.max(0, Math.min(1, 1 - meanAbsDelta / tolerance.off)); + // Spurious notes are a real fault for a timing trainer — dock a penalty proportional to extras + // as a fraction of the expected strokes (capped at EXTRAS_PENALTY_MAX). Gated on expectedTotal>0 + // so an empty pattern can't divide by zero; the final score is clamped non-negative. + const extrasPenalty = expectedTotal === 0 + ? 0 + : Math.min(EXTRAS_PENALTY_MAX, Math.round(EXTRAS_PENALTY_MAX * (extras / expectedTotal))); + const headlineScore = Math.max(0, Math.round(60 * hitRatio + 40 * timingTightness) - extrasPenalty); + + return { + hits, misses, extras, expectedTotal, + meanAbsDelta, drift, headlineScore, + }; +} + +export interface ScorerHandle { + acceptHit(hit: DetectedHit): void; + onLoopWrap(): void; + finalize(opts?: { stopAtMs: number; tailMs: number }): void; + stats(opts?: { currentLoopElapsedMs?: number | null }): SessionStats; + liveVerdicts(opts?: { currentLoopElapsedMs?: number | null }): LiveVerdicts; +} + +interface UnrolledExpected extends ExpectedHit { loopIdx: number; } +interface UnrolledDetected extends DetectedHit { loopIdx: number; } + +export function createScorer(timeline: ExpectedTimeline): ScorerHandle { + let currentLoopDetected: DetectedHit[] = []; + const completedLoops: DetectedHit[][] = []; + let finalized = false; + let finalMatch: MatchResult = { matched: [], misses: [], extras: [] }; + const windowMs = timeline.toleranceMs.off + 50; + const loopLen = timeline.loopLengthMs; + + // Match EVERY loop on one monotonic timeline (loop k at [k·loopLen, (k+1)·loopLen)). This lets a + // near-boundary hit bind to the NEXT loop's stroke 0 — a per-loop matcher could never reach it, + // because the hit and the stroke it should satisfy sit in different loops. loopIdx is carried on + // each unrolled hit (not recomputed by division) so float error can't misattribute a boundary. + function rawUnrolledMatch() { + const L = completedLoops.length; // index of the in-progress loop + const detected: UnrolledDetected[] = []; + completedLoops.forEach((bucket, k) => { + for (const d of bucket) detected.push({ ...d, t: d.t + k * loopLen, loopIdx: k }); + }); + for (const d of currentLoopDetected) detected.push({ ...d, t: d.t + L * loopLen, loopIdx: L }); + detected.sort((a, b) => a.t - b.t); + + const expected: UnrolledExpected[] = []; + for (let k = 0; k <= L; k++) { + for (const e of timeline.expected) expected.push({ strokeIdx: e.strokeIdx, t: e.t + k * loopLen, loopIdx: k }); + } + // matchHits returns the same object references it was given, so loopIdx survives the round-trip. + return matchHits(detected, expected, windowMs, timeline.toleranceMs) as unknown as { + matched: Array<{ d: UnrolledDetected; e: UnrolledExpected; delta: number; verdict: "good" | "off" }>; + misses: UnrolledExpected[]; + extras: UnrolledDetected[]; + }; + } + + // Turn the unrolled match into the scored MatchResult. COMPLETED loops are scored in full; the + // IN-PROGRESS loop (loopIdx === L) only books a MISS once the stroke's window has closed by + // currentMissCutoffMs. Pass Infinity to judge the whole in-progress loop. scoreSession reads only + // counts + delta, so the unrolled times in the returned result are immaterial to the score. + function buildSessionMatch(currentMissCutoffMs: number): MatchResult { + const L = completedLoops.length; + const r = rawUnrolledMatch(); + const misses = r.misses.filter((e) => e.loopIdx < L || (e.t - L * loopLen) <= currentMissCutoffMs); + return { matched: r.matched, misses, extras: r.extras }; + } + + const RECENT_TRAIL = 5; + + function liveVerdicts(opts?: { currentLoopElapsedMs?: number | null }): LiveVerdicts { + const L = completedLoops.length; + // A miss only lights once its window has PROVABLY closed; with no elapsed yet (before gameOn + // anchors the baseline) we cannot know what has closed, so show NO misses (−Infinity). + const cutoff = finalized + ? Infinity + : (opts?.currentLoopElapsedMs == null ? -Infinity : opts.currentLoopElapsedMs - windowMs); + const full = rawUnrolledMatch(); + const perStroke = new Map(); + // In-progress loop only — the partition shows the loop being played. A hit from the just-closed + // loop that binds forward to this loop's stroke 0 (the early downbeat) lights it here. + for (const m of full.matched) { + if (m.e.loopIdx === L) perStroke.set(m.e.strokeIdx, { verdict: m.verdict, delta: m.delta }); + } + for (const e of full.misses) { + if (e.loopIdx === L && (e.t - L * loopLen) <= cutoff && !perStroke.has(e.strokeIdx)) { + perStroke.set(e.strokeIdx, { verdict: "miss", delta: null }); + } + } + // Extras of the in-progress loop, mapped back to in-loop time so extraStrokeIdx tints the right cell. + const extras = full.extras + .filter((d) => d.loopIdx === L) + .map((d) => ({ t: d.t - L * loopLen, energy: d.energy })); + // Rolling meter trail: last N matched across all loops, ordered by loop then stroke. + const recent = full.matched.slice(-RECENT_TRAIL).map((m) => ({ delta: m.delta, verdict: m.verdict })); + return { perStroke, extras, recent }; + } + + return { + acceptHit(hit) { + if (finalized) return; + currentLoopDetected.push(hit); + }, + onLoopWrap() { + if (finalized) return; + completedLoops.push(currentLoopDetected); + currentLoopDetected = []; + }, + finalize(opts) { + if (finalized) return; + finalized = true; + // Stop grace: a stroke within tailMs before the stop isn't judged (the user stopped + // mid-flow), and strokes after the stop never played — both fall outside the cutoff at + // stopAtMs - tailMs. No opts → judge the whole in-progress loop. + finalMatch = buildSessionMatch(opts ? opts.stopAtMs - opts.tailMs : Infinity); + }, + stats(opts) { + if (finalized) return scoreSession(finalMatch, timeline.toleranceMs); + // Live: a stroke is a miss only once its full timing window has closed (elapsed - windowMs). + // Absent elapsed (e.g. not in gameOn) → judge the whole loop (legacy guard). + const elapsed = opts?.currentLoopElapsedMs ?? null; + const cutoff = elapsed === null ? Infinity : elapsed - windowMs; + return scoreSession(buildSessionMatch(cutoff), timeline.toleranceMs); + }, + liveVerdicts, + }; +} diff --git a/src/services/router.ts b/src/services/router.ts index 5b7968c28..7cdf3fe20 100644 --- a/src/services/router.ts +++ b/src/services/router.ts @@ -10,6 +10,10 @@ export type Route = { tuneName?: string; patternName?: string; importData?: string; +} | { + tab: "practice"; + tuneName?: string; + patternName?: string; }; const ROUTES = { @@ -22,6 +26,9 @@ const ROUTES = { "compose-importAndTune": "/compose/:importData/:tuneName/", "compose-importAndPattern": "/compose/:importData/:tuneName/:patternName", "compose-import": "/compose/:importData", + "practice": "/practice/", + "practice-tune": "/practice/:tuneName/", + "practice-pattern": "/practice/:tuneName/:patternName", "legacy-tune": "/:tuneName/", "legacy-pattern": "/:tuneName/:patternName", "legacy-importAndTune": "/:importData/:tuneName/", @@ -57,6 +64,15 @@ function pathToRoute(path: string): Route { patternName: match.params?.patternName }; + case "practice": + case "practice-tune": + case "practice-pattern": + return { + tab: "practice", + tuneName: match.params?.tuneName, + patternName: match.params?.patternName, + }; + case "compose": case "compose-tune": case "compose-pattern": @@ -101,6 +117,17 @@ function routeToPath(route: Route): string { } else { match = { name: route.importData ? "compose-importAndPattern" : "compose-pattern", params: { importData: route.importData, tuneName: route.tuneName, patternName: route.patternName } }; } + break; + + case "practice": + if (!route.tuneName) { + match = { name: "practice" }; + } else if (!route.patternName) { + match = { name: "practice-tune", params: { tuneName: route.tuneName } }; + } else { + match = { name: "practice-pattern", params: { tuneName: route.tuneName, patternName: route.patternName } }; + } + break; } if (!match) { diff --git a/src/state/__tests__/practiceSettings.test.ts b/src/state/__tests__/practiceSettings.test.ts new file mode 100644 index 000000000..f90636120 --- /dev/null +++ b/src/state/__tests__/practiceSettings.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, test } from "vitest"; +import { loadPracticeSettings, normalizePracticeSettings } from "../practiceSettings"; + +describe("loadPracticeSettings", () => { + it("returns defaults and flags recovery on corrupt JSON", () => { + const r = loadPracticeSettings("{ not json"); + expect(r.recovered).toBe(true); + expect(r.settings).toEqual(normalizePracticeSettings()); + }); + + it("returns defaults and flags recovery on wrong-type field", () => { + // difficulty must be an enum; a number is non-coercible + const r = loadPracticeSettings(JSON.stringify({ difficulty: 42 })); + expect(r.recovered).toBe(true); + expect(r.settings.difficulty).toBe("easy"); + }); + + it("parses a valid blob without flagging recovery", () => { + const r = loadPracticeSettings(JSON.stringify({ difficulty: "hard" })); + expect(r.recovered).toBe(false); + expect(r.settings.difficulty).toBe("hard"); + }); + + it("treats null (absent key) as a clean default, not a recovery", () => { + const r = loadPracticeSettings(null); + expect(r.recovered).toBe(false); + expect(r.settings).toEqual(normalizePracticeSettings()); + }); + + it("returns defaults and flags recovery on a valid JSON non-object", () => { + const r = loadPracticeSettings(JSON.stringify("hello")); + expect(r.recovered).toBe(true); + expect(r.settings).toEqual(normalizePracticeSettings()); + }); +}); + +test("normalizePracticeSettings defaults", () => { + expect(normalizePracticeSettings()).toEqual({ + latencyOffsetMs: 0, + sensitivity: 1, + micPromptAcked: false, + headphonesWarningAcked: false, + lastMode: "instrument", + difficulty: "easy" + }); +}); + +test("normalizePracticeSettings clamps sensitivity", () => { + expect(normalizePracticeSettings({ sensitivity: 10 }).sensitivity).toBe(3); + expect(normalizePracticeSettings({ sensitivity: 0.01 }).sensitivity).toBe(0.3); +}); + +test("normalizePracticeSettings preserves last instrument and tune", () => { + expect(normalizePracticeSettings({ + lastInstrument: "sn", + lastTuneName: "Funk", + lastPatternName: "Tune" + })).toMatchObject({ + lastInstrument: "sn", + lastTuneName: "Funk", + lastPatternName: "Tune" + }); +}); + +test("normalizePracticeSettings preserves difficulty", () => { + expect(normalizePracticeSettings({ difficulty: "hard" }).difficulty).toBe("hard"); +}); diff --git a/src/state/practiceSettings.ts b/src/state/practiceSettings.ts new file mode 100644 index 000000000..b67a2d0c8 --- /dev/null +++ b/src/state/practiceSettings.ts @@ -0,0 +1,40 @@ +import * as z from "zod"; +import { instrumentValidator } from "../config"; + +export const practiceSettingsValidator = z.object({ + latencyOffsetMs: z.number().default(0), + latencyCalibratedAt: z.number().optional(), + sensitivity: z.preprocess( + (v) => typeof v === "number" ? Math.min(3, Math.max(0.3, v)) : v, + z.number().default(1) + ), + difficulty: z.enum(["easy", "normal", "hard"]).default("easy"), + micPromptAcked: z.boolean().default(false), + headphonesWarningAcked: z.boolean().default(false), + lastInstrument: instrumentValidator.optional(), + lastMode: z.enum(["instrument", "band"]).default("instrument"), + lastTuneName: z.string().optional(), + lastPatternName: z.string().optional(), +}).default(() => ({})); + +export type PracticeSettings = z.infer; +export type PracticeSettingsOptional = z.input; + +export function normalizePracticeSettings(data?: PracticeSettingsOptional): PracticeSettings { + return practiceSettingsValidator.parse(data); +} + +export function loadPracticeSettings( + raw: string | null, +): { settings: PracticeSettings; recovered: boolean } { + if (raw == null) return { settings: normalizePracticeSettings(), recovered: false }; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { settings: normalizePracticeSettings(), recovered: true }; + } + const result = practiceSettingsValidator.safeParse(parsed); + if (result.success) return { settings: result.data, recovered: false }; + return { settings: normalizePracticeSettings(), recovered: true }; +} diff --git a/src/ui/overview.vue b/src/ui/overview.vue index 69c47f10e..4ae09c841 100644 --- a/src/ui/overview.vue +++ b/src/ui/overview.vue @@ -7,6 +7,7 @@ import { History } from "../services/history"; import { Route, useRouter } from "../services/router"; import Compose from "./compose/compose.vue"; + import Practice from "./practice/practice.vue"; import { useRefWithOverride } from "../utils"; import { useI18n } from "../services/i18n"; @@ -51,6 +52,7 @@ {{i18n.t('overview.listen')}} {{i18n.t('overview.compose')}} + {{i18n.t('overview.practice')}}
@@ -72,6 +74,15 @@ :sidebarToggleContainer="sidebarToggleContainer" /> + +
diff --git a/src/ui/pattern-player/pattern-player.vue b/src/ui/pattern-player/pattern-player.vue index 8ead53d8a..1872e7f1d 100644 --- a/src/ui/pattern-player/pattern-player.vue +++ b/src/ui/pattern-player/pattern-player.vue @@ -9,23 +9,24 @@ + + diff --git a/src/ui/practice/__tests__/practiceParts.test.ts b/src/ui/practice/__tests__/practiceParts.test.ts new file mode 100644 index 000000000..878063e3e --- /dev/null +++ b/src/ui/practice/__tests__/practiceParts.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "vitest"; +import { listParts, defaultPartName, resolvePartName } from "../practiceParts"; + +test("defaultPartName prefers 'Tune' when present", () => { + expect(defaultPartName(["Break 1", "Tune", "Break 2"])).toBe("Tune"); +}); + +test("defaultPartName falls back to the first part when 'Tune' is absent", () => { + // e.g. the "General Breaks" category has no "Tune" pattern + expect(defaultPartName(["Karla Break", "8 up", "8 down"])).toBe("Karla Break"); +}); + +test("defaultPartName returns undefined for an empty tune", () => { + expect(defaultPartName([])).toBeUndefined(); +}); + +test("resolvePartName keeps a still-valid requested part", () => { + expect(resolvePartName(["Tune", "Break 1"], "Break 1")).toBe("Break 1"); +}); + +test("resolvePartName falls back to default when the requested part is gone (the crash case)", () => { + // Reproduces the reported bug: lastPatternName 'Tune' on a tune that has no 'Tune' + expect(resolvePartName(["Karla Break", "8 up"], "Tune")).toBe("Karla Break"); +}); + +test("resolvePartName falls back to default when requested is undefined", () => { + expect(resolvePartName(["Tune", "Break 1"], undefined)).toBe("Tune"); +}); + +test("listParts maps keys to localized labels, preferring a pattern's displayName", () => { + const tune = { + patterns: { + "Tune": {}, + "Break 1": {}, + "Call Break Oi": { displayName: "Oi Break" }, + }, + }; + // inject an identity localizer so the test doesn't depend on i18n setup + expect(listParts(tune, (n) => n)).toEqual([ + { key: "Tune", label: "Tune" }, + { key: "Break 1", label: "Break 1" }, + { key: "Call Break Oi", label: "Oi Break" }, + ]); +}); + +test("listParts returns [] for an undefined tune", () => { + expect(listParts(undefined, (n) => n)).toEqual([]); +}); + +test("listParts treats an empty displayName as absent and uses the key", () => { + const tune = { patterns: { "Break 1": { displayName: "" } } }; + expect(listParts(tune, (n) => n)).toEqual([{ key: "Break 1", label: "Break 1" }]); +}); diff --git a/src/ui/practice/calibration-wizard.vue b/src/ui/practice/calibration-wizard.vue new file mode 100644 index 000000000..b51951f21 --- /dev/null +++ b/src/ui/practice/calibration-wizard.vue @@ -0,0 +1,220 @@ + + + diff --git a/src/ui/practice/difficulty-selector.vue b/src/ui/practice/difficulty-selector.vue new file mode 100644 index 000000000..9500863f7 --- /dev/null +++ b/src/ui/practice/difficulty-selector.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/src/ui/practice/headphones-warning.vue b/src/ui/practice/headphones-warning.vue new file mode 100644 index 000000000..04b6b170a --- /dev/null +++ b/src/ui/practice/headphones-warning.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/ui/practice/latency-slider.vue b/src/ui/practice/latency-slider.vue new file mode 100644 index 000000000..9064c2ae8 --- /dev/null +++ b/src/ui/practice/latency-slider.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/src/ui/practice/permission-dialog.vue b/src/ui/practice/permission-dialog.vue new file mode 100644 index 000000000..0b86ea2f1 --- /dev/null +++ b/src/ui/practice/permission-dialog.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/ui/practice/practice-partition.vue b/src/ui/practice/practice-partition.vue new file mode 100644 index 000000000..037e92d75 --- /dev/null +++ b/src/ui/practice/practice-partition.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/src/ui/practice/practice-score-rail.vue b/src/ui/practice/practice-score-rail.vue new file mode 100644 index 000000000..fcbf75243 --- /dev/null +++ b/src/ui/practice/practice-score-rail.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/src/ui/practice/practice-toolbar.vue b/src/ui/practice/practice-toolbar.vue new file mode 100644 index 000000000..a5e16a441 --- /dev/null +++ b/src/ui/practice/practice-toolbar.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/src/ui/practice/practice.vue b/src/ui/practice/practice.vue new file mode 100644 index 000000000..7b4f35419 --- /dev/null +++ b/src/ui/practice/practice.vue @@ -0,0 +1,370 @@ + + + + + diff --git a/src/ui/practice/practiceParts.ts b/src/ui/practice/practiceParts.ts new file mode 100644 index 000000000..b528aa012 --- /dev/null +++ b/src/ui/practice/practiceParts.ts @@ -0,0 +1,41 @@ +import { getLocalizedDisplayName } from "../../services/i18n"; + +export interface PartOption { + /** The pattern key within the tune (e.g. "Tune", "Break 1", "Tune (Variant 1)"). */ + key: string; + /** The localized, user-facing label for the part. */ + label: string; +} + +/** + * The part selected by default for a tune: "Tune" when that pattern exists, otherwise + * the tune's first pattern. Returns undefined for a tune with no patterns. + */ +export function defaultPartName(patternKeys: string[]): string | undefined { + return patternKeys.includes("Tune") ? "Tune" : patternKeys[0]; +} + +/** + * Resolve a requested part against the tune's available parts: keep it if it still + * exists, otherwise fall back to the default. This is what prevents a stale pattern + * name (e.g. "Tune" carried onto a tune that has no "Tune" part) from dangling. + */ +export function resolvePartName(patternKeys: string[], requested: string | undefined): string | undefined { + return requested && patternKeys.includes(requested) ? requested : defaultPartName(patternKeys); +} + +/** + * Build the ordered list of selectable parts for a tune. Labels prefer a pattern's + * own `displayName`, then fall back to its key, run through the localizer. + * `localize` is injectable for testing; it defaults to the app's display-name localizer. + */ +export function listParts( + tune: { patterns: Record } | undefined, + localize: (name: string) => string = getLocalizedDisplayName, +): PartOption[] { + if (!tune) return []; + return Object.entries(tune.patterns).map(([key, pattern]) => ({ + key, + label: localize(pattern.displayName || key), + })); +} diff --git a/src/ui/practice/sensitivity-slider.vue b/src/ui/practice/sensitivity-slider.vue new file mode 100644 index 000000000..c25cc8bee --- /dev/null +++ b/src/ui/practice/sensitivity-slider.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/src/ui/practice/speed-slider.vue b/src/ui/practice/speed-slider.vue new file mode 100644 index 000000000..9bd78f8cf --- /dev/null +++ b/src/ui/practice/speed-slider.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/src/ui/utils/hybrid-sidebar.vue b/src/ui/utils/hybrid-sidebar.vue index 2b8c266b0..1ef38a033 100644 --- a/src/ui/utils/hybrid-sidebar.vue +++ b/src/ui/utils/hybrid-sidebar.vue @@ -8,19 +8,22 @@