From da7a5319806d2b6e2053bf605e14aa3cfe87229e Mon Sep 17 00:00:00 2001 From: pliski Date: Fri, 5 Jun 2026 00:13:53 +0200 Subject: [PATCH 1/6] feat(practice): scoring, calibration stats, and settings schema --- .../__tests__/latencyCalibrator.test.ts | 54 ++ .../__tests__/loopbackCalibrator.test.ts | 65 ++ src/services/__tests__/practiceScorer.test.ts | 579 ++++++++++++++++++ src/services/latencyCalibrator.ts | 50 ++ src/services/loopbackCalibrator.ts | 52 ++ src/services/practiceScorer.ts | 327 ++++++++++ src/state/__tests__/practiceSettings.test.ts | 67 ++ src/state/practiceSettings.ts | 40 ++ 8 files changed, 1234 insertions(+) create mode 100644 src/services/__tests__/latencyCalibrator.test.ts create mode 100644 src/services/__tests__/loopbackCalibrator.test.ts create mode 100644 src/services/__tests__/practiceScorer.test.ts create mode 100644 src/services/latencyCalibrator.ts create mode 100644 src/services/loopbackCalibrator.ts create mode 100644 src/services/practiceScorer.ts create mode 100644 src/state/__tests__/practiceSettings.test.ts create mode 100644 src/state/practiceSettings.ts 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__/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/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/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/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 }; +} From 2e2ad340a2f487421b84573ab971e0553f033f08 Mon Sep 17 00:00:00 2001 From: pliski Date: Fri, 5 Jun 2026 00:14:26 +0200 Subject: [PATCH 2/6] feat(practice): microphone onset detection via AudioWorklet --- assets/audio/ot_d5.mp3 | Bin 0 -> 38870 bytes package.json | 4 + src/config.ts | 6 +- .../__tests__/mediaPermissions.test.ts | 33 ++++ src/services/__tests__/onsetDetector.test.ts | 9 + .../__tests__/onsetDetectorCore.test.ts | 179 ++++++++++++++++++ src/services/mediaPermissions.ts | 37 ++++ src/services/onsetDetector.ts | 93 +++++++++ src/services/onsetDetector.worklet.ts | 47 +++++ src/services/onsetDetectorCore.ts | 124 ++++++++++++ yarn.lock | 20 +- 11 files changed, 539 insertions(+), 13 deletions(-) create mode 100644 assets/audio/ot_d5.mp3 create mode 100644 src/services/__tests__/mediaPermissions.test.ts create mode 100644 src/services/__tests__/onsetDetector.test.ts create mode 100644 src/services/__tests__/onsetDetectorCore.test.ts create mode 100644 src/services/mediaPermissions.ts create mode 100644 src/services/onsetDetector.ts create mode 100644 src/services/onsetDetector.worklet.ts create mode 100644 src/services/onsetDetectorCore.ts diff --git a/assets/audio/ot_d5.mp3 b/assets/audio/ot_d5.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..a5409c281773d32d1ad9b63d0ab21ab40f095f48 GIT binary patch literal 38870 zcmeF4Wl$VV+wX_P-QC?C5?q2?@Zdp$I{|_v?Bee37J>vPK!6Yk7J>x`5=aOZoCJ5+ zIm>?r;5@)(A9UzI0zmu*8d@lKJ#0 zEpcmK-iDj6=EU)fDd*q9KKF57{H_aZ&20<>gY@q$p62O&d&S?e>W!ae09|1lU&hi7 zJxKHTny?Z|%UJkKvKxfo7Cx9dQp@{QbF-#CKDswN1z})dNufhTpP>M2;@y2C4?h`5 zeBNy^!Xm|j^d_X%(6HD^)xH3;HJkfkNAXGI}P_pEd!~TXe_%bTL%Cj5)3#P zOcMfMMT-|-Fi7rqsT(2H_kh=_ws@f|&m^YLongTJl5cpMFM*`;EThrl7GNyfNt}T$ z*%KZ+Foh-bHUrrXsZ0R!A&i9JC8uMP3DZxVH%d`Ft)@)nO&>@^f?n-P`NZW4rN2zm za>`CanI~u0h}ukYz|WVEs2I;`E;Nd-C5*Yab zSr$Z;t1ym2LqhCfangBVKLP*~o9)L-)m1KKil!q4ylYigUM3rPLFGgJzScVU25~-B zG`bSMbt)s(44G!*Vn<`!6g=dDxBj}0k%2wXHZRA-T0oMtrP?Teg)024rEvd3s6@3r zcW7H7cOiTKnauXrbPMyu$3qNHOK_AWd5Okj%MFyd@tYcQDp?c(fUF-QkLo9hW9;FK zX&&eOXTj>Jp;#d^IGWgGh){X%LDsvEmhPgSyEW9KeQV||fWR87l|AGXV6l#q^lqAH zV<29ZK4SQljd%8Rr0lmlLPW?YF{WRRJ$S7I*J8(|leq|=@9U2KdWta-&EtR2mCA*p z`Sx9}p=Q_C5nX}~BUx-H)E<@V0u#%BtAo1 zsp5_H3l-}IM9_RRVfJDzSqXkV?zTa0`6KI~cj%VNN)_yxbcqKDe=|OEO5j&TiYBtF zX`kcEfEIjESWIC%v`VBJc2GVdxRs@}7}u1=_bOlvdKt@U%$Y_^!6bMdnj^r0Lm2(j z>agbRaf<3jC;hL?hhEVb5DKY+gvq;*$pp7^=6gsbjlxM?qd-qFHUUa~C09mX$0Ryk z8bSm5U1l$qA4?{i{Gdy;*Xb|WPd@taoOe~tFH7?Zxn?kR>>B(O`PnSKTuznia zkH$9PcEpG=;@wrzHjA7#M^ zPrxVKPVI~ik+`KQ`=h-|g~{JAdY`0wwx{;hMNiQvRlB7%`kGG-BO*j8lPj)!JwBTH z;5oX-r*ZWaDje95<;}jO0`NE;iH!1z?A_1(@w+NA|0Bg*ggP@9%>kD)~&*@wjzBM>N>&Parwl_NddR?#g8ty$qhLElHEC~ z+Grm<^0vcubhK13>IO@?k5wl(Lg)n2oZfr{U)ct6YiRFpq?!bMyggP4o)Qr6`|d{{ zPfxk_p!ozmNFz7n9#P7fR;m6#>*_7Fi^IxtE>MHUv4ws=ZKi zIbLW;Rx1gfX32{dO3$_+qU7RFVfdBdqG! zd{7KqKNSr}2Lw-?+CQ8?##>k|R6KO*q(>mO(y{NAB*pywi;A+!m$>N48~3E;a>y

ohO@V0Z)r8%TnMp= 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 WP{1jn6B_i_hffLpbp-$I1^f$=jvK51 literal 0 HcmV?d00001 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/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__/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/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/yarn.lock b/yarn.lock index 5bb5a570c..b4ee37e2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -922,6 +922,13 @@ __metadata: languageName: node linkType: hard +"@types/audioworklet@npm:^0.0.100": + version: 0.0.100 + resolution: "@types/audioworklet@npm:0.0.100" + checksum: 10/c876f026ab33d102f4f64597b55c9a45b3678ccd5fbb69e248f819f2ee9686948e8c2e76e3c19af508cc6abb1782dc35388192c3f3e68f164a6141aa94f424b5 + languageName: node + linkType: hard + "@types/bootstrap@npm:^5.2.10": version: 5.2.10 resolution: "@types/bootstrap@npm:5.2.10" @@ -1780,17 +1787,7 @@ __metadata: languageName: node linkType: hard -"beatbox.js@npm:^5.0.0": - version: 5.0.0 - resolution: "beatbox.js@npm:5.0.0" - dependencies: - "@types/events": "npm:^3.0.3" - events: "npm:^3.3.0" - checksum: 10/a515ef9e99bb775b637470c991770b947eed18936b3d07daedc2cc7539a8e00e7fc31c52f466ae71cc6ce898b941dac6ff548faffd4ad7fa94683ba5f998ec3c - languageName: node - linkType: hard - -"beatbox.js@npm:^5.0.1": +"beatbox.js@npm:5.0.1": version: 5.0.1 resolution: "beatbox.js@npm:5.0.1" dependencies: @@ -4351,6 +4348,7 @@ __metadata: "@fortawesome/free-solid-svg-icons": "npm:^7.1.0" "@fortawesome/vue-fontawesome": "npm:^3.1.3" "@popperjs/core": "npm:^2.11.8" + "@types/audioworklet": "npm:^0.0.100" "@types/bootstrap": "npm:^5.2.10" "@types/file-saver": "npm:^2.0.7" "@types/lodash-es": "npm:^4.17.12" From 669c810cc22b0e4046ee36b55ef5ef510c0bf563 Mon Sep 17 00:00:00 2001 From: pliski Date: Fri, 5 Jun 2026 00:14:44 +0200 Subject: [PATCH 3/6] feat(practice): the practice engine driving count-in, game, and results --- .../__tests__/practiceDriftGolden.test.ts | 171 ++++++ src/services/__tests__/practiceEngine.test.ts | 500 ++++++++++++++++++ src/services/practiceEngine.ts | 329 ++++++++++++ 3 files changed, 1000 insertions(+) create mode 100644 src/services/__tests__/practiceDriftGolden.test.ts create mode 100644 src/services/__tests__/practiceEngine.test.ts create mode 100644 src/services/practiceEngine.ts 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/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), + }; +} From d0acbeff12d94eebe0d8a831b927de41aad03c55 Mon Sep 17 00:00:00 2001 From: pliski Date: Fri, 5 Jun 2026 00:15:03 +0200 Subject: [PATCH 4/6] feat(pattern-player): opt-in props for the practice partition (default-off; Listen/Compose unchanged) --- src/ui/pattern-player/pattern-player.vue | 212 ++++++++++++++++++++--- src/ui/pattern-player/stroke-cell.vue | 23 +++ src/ui/utils/hybrid-sidebar.vue | 11 +- 3 files changed, 220 insertions(+), 26 deletions(-) create mode 100644 src/ui/pattern-player/stroke-cell.vue 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/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 @@ + + 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/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 @@ + + + + + From 2793775e07d4cc50d36861e899b2ef08282f7ea9 Mon Sep 17 00:00:00 2001 From: pliski Date: Fri, 5 Jun 2026 00:15:40 +0200 Subject: [PATCH 6/6] feat(practice): the Practice page, routes, i18n, and overview tab --- assets/i18n/en.json | 88 +++++- src/services/__tests__/router.test.ts | 29 ++ src/services/router.ts | 27 ++ src/ui/overview.vue | 11 + src/ui/practice/practice.vue | 370 ++++++++++++++++++++++++++ 5 files changed, 524 insertions(+), 1 deletion(-) create mode 100644 src/services/__tests__/router.test.ts create mode 100644 src/ui/practice/practice.vue 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/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/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/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/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 @@ + + + + +