From ff4699f9235acfa7a54e69b8ec3b424bce528d72 Mon Sep 17 00:00:00 2001 From: Ashton Honnecke Date: Mon, 27 Jul 2026 15:21:37 -0600 Subject: [PATCH 1/7] feat(coaching): practice/coaching plugin suite (coaching, metronome_lock, mute_master, pitch_match, practice_journal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five self-contained, additive plugins ported onto feedBack's own contracts (window.feedBack bus + the native minigames framework — gamekit was NOT ported, it's redundant with the minigames plugin): - coaching — real-time coaching feedback + skill-drill panel - metronome_lock — timing-lock minigame - mute_master — muting/dynamics minigame - pitch_match — intonation minigame - practice_journal— per-song practice metrics dashboard (has routes.py) All 5 register cleanly on current upstream (backend + practice_journal routes load with no errors); coaching's suite passes 66/66 node tests. New plugin dirs only — no core changes, so it can't conflict with core churn. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/coaching/README.md | 135 ++ plugins/coaching/coaching.js | 1536 +++++++++++++++++ plugins/coaching/plugin.json | 9 + plugins/coaching/settings.html | 108 ++ plugins/coaching/test/coach.test.js | 482 ++++++ plugins/coaching/test/feedback-schema.test.js | 215 +++ .../coaching/test/panel_integration.test.js | 189 ++ plugins/coaching/test/skill-drills.test.js | 139 ++ plugins/coaching/tools/headless-emit-check.js | 72 + plugins/metronome_lock/game.js | 272 +++ plugins/metronome_lock/plugin.json | 16 + plugins/mute_master/README.md | 27 + plugins/mute_master/game.js | 208 +++ plugins/mute_master/plugin.json | 16 + plugins/pitch_match/game.js | 214 +++ plugins/pitch_match/plugin.json | 16 + plugins/practice_journal/.gitignore | 2 + plugins/practice_journal/README.md | 41 + plugins/practice_journal/plugin.json | 12 + plugins/practice_journal/routes.py | 187 ++ plugins/practice_journal/screen.html | 47 + plugins/practice_journal/screen.js | 207 +++ 22 files changed, 4150 insertions(+) create mode 100644 plugins/coaching/README.md create mode 100644 plugins/coaching/coaching.js create mode 100644 plugins/coaching/plugin.json create mode 100644 plugins/coaching/settings.html create mode 100644 plugins/coaching/test/coach.test.js create mode 100644 plugins/coaching/test/feedback-schema.test.js create mode 100644 plugins/coaching/test/panel_integration.test.js create mode 100644 plugins/coaching/test/skill-drills.test.js create mode 100644 plugins/coaching/tools/headless-emit-check.js create mode 100644 plugins/metronome_lock/game.js create mode 100644 plugins/metronome_lock/plugin.json create mode 100644 plugins/mute_master/README.md create mode 100644 plugins/mute_master/game.js create mode 100644 plugins/mute_master/plugin.json create mode 100644 plugins/pitch_match/game.js create mode 100644 plugins/pitch_match/plugin.json create mode 100644 plugins/practice_journal/.gitignore create mode 100644 plugins/practice_journal/README.md create mode 100644 plugins/practice_journal/plugin.json create mode 100644 plugins/practice_journal/routes.py create mode 100644 plugins/practice_journal/screen.html create mode 100644 plugins/practice_journal/screen.js diff --git a/plugins/coaching/README.md b/plugins/coaching/README.md new file mode 100644 index 00000000..b1258bc9 --- /dev/null +++ b/plugins/coaching/README.md @@ -0,0 +1,135 @@ +# Coaching plugin + +Turns each play into **actionable feedback**. It consumes note_detect's per-note +judgments, builds one structured `coaching.play_feedback.v1` object per play, and +(optionally) sends that to an LLM — using *your own* Anthropic API key, from the +browser — to produce a prioritized, encouraging practice plan. + +The plugin never forks note_detect: it listens for the `notedetect:hit` / +`notedetect:miss` / `notedetect:session` window events and emits its own events +on the slopsmith bus. It also never sends your API key to the slopsmith server. + +## Pipeline + +``` +notedetect:hit/miss ──┐ + ├─▶ buildPlayFeedback() ──▶ coaching:feedback (+ window.__coachingLastFeedback) +notedetect:session ──┘ │ + └─▶ requestCoaching() (if a key is set) + │ + ├─▶ session.summary (the practice plan) + └─▶ coaching:summary (+ end-of-song panel, window.__coachingLastSummary) +``` + +## The contract: `coaching.play_feedback.v1` + +Three levels — per-mistake atoms, hotspots that group them by reference, and a +session wrapper. The two load-bearing fields: + +- **`mistake.faultVerdict`** (`player_error` | `detector_suspect` | + `confirmed_detector_bug`) — routes a miss to *drill it* vs *feed it to the + harness*. `no_detection` → `detector_suspect`; a detection that fired but was + off → `player_error`; `confirmed_detector_bug` is only ever set by an external + harness replay. +- **`hotspot.signal.kind`** (`systematic` | `random`) — a consistent skew + (median clears a floor *and* dominates the spread) is a tool/calibration + problem (e.g. an A/V offset), not a skill gap. Keeps the drill loop from + training the player on the detector's blind spots. + +See the schema + builder in [`coaching.js`](coaching.js); it's covered by +[`test/feedback-schema.test.js`](test/feedback-schema.test.js). + +## The LLM coach (task #14) + +`session.summary` is filled by a **client-side** Anthropic Messages API call: + +- **Model** `claude-opus-4-8`, a frozen + prompt-cached system prompt, and + `output_config.format` (json_schema) so the reply parses deterministically. +- The request **distills** the feedback to the session block + hotspots + + failure/fault tallies — raw mistake atoms are not sent. +- The system prompt teaches the model the two load-bearing fields above, so it + routes systematic skews / `detector_suspect` misses into *tooling notes* + instead of scolding the player. +- The key is read from `localStorage` and sent straight to Anthropic with the + `anthropic-dangerous-direct-browser-access` header. **It never touches the + slopsmith server.** + +Pure pieces (`summarizeForCoach`, `buildCoachRequest`, `parseCoachSummary`, +`renderSummaryHtml`, `makeSettings`) and the fetch seam (`requestCoaching`, with +an injectable `fetchImpl`) are covered by [`test/coach.test.js`](test/coach.test.js). + +### Summary shape + +```jsonc +{ + "headline": "one-line encouraging takeaway", + "priorities": [ + { "focus": "...", "why": "the evidence", "drill": "a concrete action", "hotspotKey": "song|arr|secRange" } + ], + "toolNotes": "systematic-skew / detector_suspect routing", + "encouragement": "closing motivation", + "_model": "claude-opus-4-8" +} +``` + +## Setup + +Settings → **Coaching**: enable the coach and paste your Anthropic API key +(stored only in this browser). Or from the console: + +```js +coaching.setApiKey('sk-ant-...'); // stored in localStorage, browser-only +coaching.setModel('claude-opus-4-8'); // optional; default is opus-4-8 +coaching.setEnabled(true); +coaching.coachLast(); // re-run the coach on the last play +``` + +`localStorage` keys: `coaching.anthropicApiKey`, `coaching.model`, +`coaching.llmEnabled`. + +## Events + +| Event | Payload | When | +|---|---|---| +| `coaching:feedback` | `PlayFeedback` | every play (after `notedetect:session`) | +| `coaching:summary` | `{ feedback, summary }` | when the LLM plan lands (also renders the panel) | +| `coaching:summary-error` | `{ feedback, error }` | the LLM call failed (missing/invalid key, network, etc.) | + +A failed LLM call is warn-only — it never breaks the play; `coaching:feedback` +still fires with `session.summary` left null. + +## Tests + +```bash +cd plugins/coaching && node --test +``` + +## End-of-song panel (no API key required) + +Every play with Detect on shows a free **"Practice spots"** panel built straight +from `play_feedback.v1` (`renderFeedbackHtml`): this play's clean-%, each hotspot +(time range, note count, miss rate, a `systematic → may be calibration` flag), and +a **▶ Practice this** button per hotspot. **No API key needed** — the panel and the +drill loop do not depend on the LLM coach. If a key is set, the LLM-written plan +(`renderSummaryHtml`) upgrades the same panel in place when it lands. Pop the last +play's panel from the console with `coaching.showLast()`. + +## Drill loop + +The **▶ Practice this** button hands the hotspot's `{loopA, loopB, speedMul, goal}` +to note_detect's drill conductor (`window.noteDetect.startDrill`, note_detect ≥ +1.16.0), which runs the slow → goal-gate → graduate practice loop. Coaching supplies +only the *where/how-slow* (`speedMul` → speed ladder via `drillLadderFromSpeedMul`); +the conductor owns the A-B loop, speed ramp, and HUD. The button degrades to an +inline hint if note_detect is missing or too old. note_detect also has its own +multi-play **finder** banner (≥1.17.0) that auto-picks a recurring hotspot across +plays — a second entry point into the same conductor. + +## Next + +- Persist the plan to the Practice Journal plugin. +- Musical bar/section bounds for hotspots. + +(LLM auth uses an Anthropic API key — `coaching.setApiKey('sk-ant-…')` or Settings → +Coaching. A Claude Max/Pro subscription does **not** grant Messages-API access, so it +can't drive the coach; the drill loop itself needs no key.) diff --git a/plugins/coaching/coaching.js b/plugins/coaching/coaching.js new file mode 100644 index 00000000..a17cf501 --- /dev/null +++ b/plugins/coaching/coaching.js @@ -0,0 +1,1536 @@ +/* + * Coaching feedback — the schema CONTRACT + the builder that emits it. + * + * This is the data model the coaching UI / LLM coach / drill loop will all + * build on, defined and emitted from a play BEFORE any UI exists. It consumes + * note_detect's per-note judgments (the `notedetect:hit` / `notedetect:miss` + * window CustomEvents) plus the `notedetect:session` aggregate, and produces + * ONE `PlayFeedback` object per play: the per-mistake atoms, the hotspots that + * group them, and a session wrapper. + * + * Design (per FEEDBACK.md). The two load-bearing fields: + * - `faultVerdict` per mistake — routes a miss to DRILL IT (player_error) vs + * FEED IT TO THE HARNESS (detector_suspect / confirmed_detector_bug). Without + * it the player-fault/tool-fault split the whole narrative hinges on isn't + * expressible. + * - `signal.kind` per hotspot — systematic (a consistent timing/pitch skew → + * a tool/calibration problem, e.g. the 188 ms A/V offset that masqueraded as + * misses) vs random (scatter → a real skill gap). Keeps the drill loop from + * training the player on the detector's blind spots. + * + * Hotspots reference mistakes by id (no nested copies) so one mistake can feed + * both a hotspot and the session-level tool-fault list. + * + * The builder is pure (no DOM) so it runs under plain `node --test`; the + * browser event wiring is an opt-in block at the bottom, guarded on `window`. + */ + +// ── Skill-drills: failure type → skill → game (the practice bridge) ──── +// Turns "what you missed" into "go practice THIS" — see +// docs/TEACHING_NARRATIVE.md. Inlined here (not a sibling module) because the +// plugin loader serves a single script per plugin. Two pure pieces: the +// practice TAXONOMY (failure → skill + cue + game) and the skill-drill ENGINE +// (the fast rep/streak loop a drill game scores against). Both are exported and +// node-tested; nothing here touches the DOM. +// +// gameId is the minigame plugin id the skill drills against (the minigames SDK +// indexes games by what they `train`). null gameId = no game yet → show the cue +// only. Keep ids stable — coaching deep-links to them. +const SKILL_DRILLS = { + mute_fail: { skill: 'muting', gameId: 'mute_master', cue: 'mute the string you are not fretting — let the charted note ring alone', blurb: 'an open string rang in place of the fretted note' }, + late: { skill: 'timing', gameId: 'metronome_lock', cue: 'lock to the click — anticipate the beat instead of chasing it', blurb: 'notes landing behind the beat' }, + early: { skill: 'timing', gameId: 'metronome_lock', cue: 'lock to the click — stop rushing the attack', blurb: 'notes landing ahead of the beat' }, + sharp: { skill: 'intonation', gameId: 'pitch_match', cue: 'press straight down behind the fret; check your tuning', blurb: 'notes reading sharp' }, + flat: { skill: 'intonation', gameId: 'pitch_match', cue: 'press firmly behind the fret; check your tuning', blurb: 'notes reading flat' }, + // no_detection is ambiguous (a real flub OR the low-string detector blind + // spot), so there is no skill game yet — just a clarity cue. + no_detection: { skill: 'clarity', gameId: null, cue: 'play it cleaner and a touch louder (some low notes are hard to hear)', blurb: 'no pitch registered — could be a flub or a detection gap' }, +}; + +function practiceForFailure(failureType) { + return SKILL_DRILLS[failureType] || null; +} + +// Foundational "vitamins" — drills loved regardless of any specific failure, +// because they transfer across several skills at once (which is why they're +// universally endorsed). Surfaced as always-available warm-ups, NOT gated behind +// a logged failure (`trains: []`). See docs/bass-drills-research.md. gameId is +// null until the game exists; the cue still stands as advice. +const FOUNDATIONAL_DRILLS = [ + { id: 'chromatic_spider', skill: 'finger independence', gameId: null, trains: [], cue: 'chromatic 1-2-3-4 across the strings with a click — even, economical, one finger per fret' }, + { id: 'scale_shapes', skill: 'fretboard', gameId: null, trains: [], cue: 'major scale shapes in position, ascending/descending to the click' }, + { id: 'slow_metronome', skill: 'precision', gameId: null, trains: [], cue: 'the meta-drill: slow it down until every rep is perfect, then nudge the tempo up' }, + { id: 'play_along', skill: 'groove', gameId: null, trains: [], cue: 'play the whole tune with the track — time, ear, and endurance at once' }, +]; + +// The dominant failure type across a set of mistakes — the one skill worth +// drilling for a hotspot. Ties break toward the first seen. +function dominantFailure(mistakes) { + const tally = {}; + for (const m of (mistakes || [])) { + const t = m && m.failureType; + if (t) tally[t] = (tally[t] || 0) + 1; + } + let best = null; + let bestN = 0; + for (const t of Object.keys(tally)) { + if (tally[t] > bestN) { best = t; bestN = tally[t]; } + } + return best; +} + +// Deep link into the minigames hub, pre-loaded with the game and the trouble +// spot (so the drill is your actual passage, not a generic exercise). +function deepLinkForGame(gameId, ctx) { + ctx = ctx || {}; + // Built by hand (not URLSearchParams) so it works identically in node, the + // browser, AND the vm sandbox the panel tests run coaching.js in. + const parts = ['game=' + encodeURIComponent(gameId)]; + if (ctx.failureType) parts.push('skill=' + encodeURIComponent(ctx.failureType)); + if (Number.isFinite(ctx.loopA)) parts.push('loopA=' + ctx.loopA.toFixed(2)); + if (Number.isFinite(ctx.loopB)) parts.push('loopB=' + ctx.loopB.toFixed(2)); + if (ctx.song) parts.push('song=' + encodeURIComponent(ctx.song)); + return '#/plugin-minigames?' + parts.join('&'); +} + +// Resolve a hotspot → the "work on this thing here " recommendation. +// `mistakesById` maps mistake id → the mistake atom (carries failureType). +function practiceForHotspot(hotspot, mistakesById) { + if (!hotspot) return null; + const ms = (hotspot.mistakeIds || []).map(id => (mistakesById || {})[id]).filter(Boolean); + const failureType = dominantFailure(ms); + const drill = practiceForFailure(failureType); + if (!drill) return null; + const b = hotspot.bounds || {}; + return { + failureType, + skill: drill.skill, + cue: drill.cue, + blurb: drill.blurb, + gameId: drill.gameId, + deepLink: drill.gameId + ? deepLinkForGame(drill.gameId, { failureType, loopA: b.startSec, loopB: b.endSec, song: hotspot.song }) + : null, + }; +} + +// Skill-drill engine — the game mechanics. A drill game runs ONE skill as a fast +// rep loop. A "rep" is a target note the drill judged; it is "clean" for this +// skill when the targeted failure did NOT occur (a hit, or a miss of a DIFFERENT +// type — we train one skill at a time). Pass = `goalStreak` clean reps in a row. +// Pure: feed { hit, failureType } events, read skillDrillView(); the game skin +// renders the view and never touches these internals. +function createSkillDrill(opts) { + opts = opts || {}; + return { + failureType: opts.failureType || null, + goalStreak: Number.isFinite(opts.goalStreak) ? Math.max(1, opts.goalStreak) : 5, + reps: 0, clean: 0, streak: 0, bestStreak: 0, lastClean: null, passed: false, + }; +} +function skillDrillStep(state, ev) { + if (!state || !ev) return state; + const hit = !!ev.hit; + const type = ev.failureType || null; + // An ambiguous no_detection (detector blind spot, not a judgeable attempt) is + // not a rep for any OTHER skill — skip it so a detection gap can't tank a + // muting/timing drill. When the drill IS no_detection, it counts. + if (!hit && type === 'no_detection' && state.failureType !== 'no_detection') return state; + state.reps++; + const failed = !hit && type === state.failureType; + state.lastClean = !failed; + if (failed) { + state.streak = 0; + } else { + state.clean++; + state.streak++; + if (state.streak > state.bestStreak) state.bestStreak = state.streak; + if (state.streak >= state.goalStreak) state.passed = true; + } + return state; +} +function skillDrillView(state) { + if (!state) return null; + return { + failureType: state.failureType, + reps: state.reps, clean: state.clean, streak: state.streak, bestStreak: state.bestStreak, + goalStreak: state.goalStreak, + progress: Math.min(1, state.streak / Math.max(1, state.goalStreak)), + accuracy: state.reps ? Math.round((state.clean / state.reps) * 1000) / 1000 : 0, + passed: state.passed, + }; +} +// Back-compat handle used by the builder below (was an external module). +const _skillDrills = { SKILL_DRILLS, FOUNDATIONAL_DRILLS, practiceForFailure, dominantFailure, deepLinkForGame, practiceForHotspot, createSkillDrill, skillDrillStep, skillDrillView }; + +// ── Contract: enums ─────────────────────────────────────────────────── +// Derivable from a note_detect judgment TODAY: +const FAILURE_TYPES_LIVE = ['no_detection', 'early', 'late', 'sharp', 'flat', 'mute_fail']; +// Reserved until the data exists (need detected-pitch alternatives for +// wrong_note/wrong_string, or per-instrument REST markers in the chart for +// string_noise / muted_ghosted / played_during_rest — see TEACHING_NARRATIVE): +const FAILURE_TYPES_RESERVED = ['wrong_note', 'wrong_string', 'string_noise', 'muted_ghosted', 'played_during_rest']; +const FAILURE_TYPES = FAILURE_TYPES_LIVE.concat(FAILURE_TYPES_RESERVED); +const FAULT_VERDICTS = ['player_error', 'detector_suspect', 'confirmed_detector_bug']; + +/** + * @typedef {Object} Mistake The atom. Addressable + harness-routable. + * @property {string} id // stable within a play: "m{index}" + * @property {{t:number,s:number,f:number,expectedMidi:?number}} chart // chart anchor + * @property {?{midi:number,confidence:?number}} detected // null = nothing fired + * @property {?number} timingErrorMs // signed: + late / - early + * @property {?number} pitchErrorCents // signed: + sharp / - flat (octave-folded) + * @property {string} failureType // one of FAILURE_TYPES + * @property {string} faultVerdict // one of FAULT_VERDICTS + */ + +/** + * @typedef {Object} Hotspot + * @property {string} key // stable identity: song|arr|secRange + * @property {{startSec:number,endSec:number,section:?string,bars:?number[]}} bounds + * @property {string[]} mistakeIds // reference by id, NOT nested + * @property {{missRate:number,noteCount:number,misses:number,plays:number}} evidence + * @property {{medianTimingMs:?number,medianPitchCents:?number,kind:string}} signal + * @property {number} severity // 0..1, ranked (drill worst first) + * @property {{loopA:number,loopB:number,speedMul:number,goal:number}} drill // drives the loop directly + */ + +/** + * @typedef {Object} PlayFeedback + * @property {string} schema + * @property {Object} session // song/arrangement/tuning/capo/settings/recording/score/sections/summary + * @property {Mistake[]} mistakes + * @property {Hotspot[]} hotspots + */ + +// ── Small stats helpers ─────────────────────────────────────────────── +function _median(xs) { + if (!xs.length) return null; + const s = xs.slice().sort((a, b) => a - b); + const m = s.length >> 1; + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +} +function _mad(xs, med) { + if (!xs.length || med == null) return null; + return _median(xs.map(x => Math.abs(x - med))); +} + +// ── Per-mistake ─────────────────────────────────────────────────────── +function _classifyFailure(j) { + if (j.muteFail) return 'mute_fail'; // open string rang instead of the fretted note + if (j.detectedMidi == null) return 'no_detection'; // window expired, no pitch + if (j.timingState === 'EARLY') return 'early'; + if (j.timingState === 'LATE') return 'late'; + if (j.pitchState === 'SHARP') return 'sharp'; + if (j.pitchState === 'FLAT') return 'flat'; + // A detection fired and matched but wasn't flagged clean on either axis — + // shouldn't reach here for a miss, but stay defensive. + return 'no_detection'; +} +// A note_detect judgment → the lightweight { hit, failureType } event a drill +// game feeds the skill-drill engine. Hits carry no failureType. This is the one +// classification a skill-drill game needs, exported so games don't re-derive it. +function classifyFailureForDrill(j) { + if (!j) return { hit: false, failureType: null }; + if (j.hit === true) return { hit: true, failureType: null }; + return { hit: false, failureType: _classifyFailure(j) }; +} +function _faultVerdict(failureType, j) { + // A detection that fired but was off means the detector HEARD the player → + // player_error. `no_detection` is the ambiguous case (didn't play / wrong + // note / detector missed) → detector_suspect by default. But when note_detect + // measured the expected note as PRESENT in the audio at miss time + // (`notePresent`, the post-miss harmonic-presence check), the player DID play + // it and the tool dropped it — promote the guess to a measurement: + // `confirmed_detector_bug`. (Previously only an external harness replay set + // this; the in-app presence check is that replay, moved in-app.) + if (failureType === 'no_detection') { + return (j && j.notePresent) ? 'confirmed_detector_bug' : 'detector_suspect'; + } + return 'player_error'; +} +function mistakeFromJudgment(j, idx) { + const failureType = _classifyFailure(j); + return { + id: 'm' + idx, + chart: { + t: (typeof j.noteTime === 'number') ? j.noteTime : null, + s: j.note ? j.note.s : null, + f: j.note ? j.note.f : null, + expectedMidi: (j.expectedMidi != null) ? j.expectedMidi : null, + }, + detected: (j.detectedMidi != null) + ? { midi: j.detectedMidi, confidence: (typeof j.confidence === 'number') ? j.confidence : null } + : null, + timingErrorMs: (typeof j.timingError === 'number') ? j.timingError : null, + pitchErrorCents: (typeof j.pitchError === 'number') ? j.pitchError : null, + // Per-string energy at this note (fraction of spectral energy in each + // string's band, index = string). From note_detect's `se` — the raw + // signal for wrong-string / ambient / not-played on a miss. May be + // absent (non-bass, or audio unavailable at the expected time). + stringEnergy: Array.isArray(j.stringEnergy) ? j.stringEnergy : null, + // note_detect measured the expected note as present in the audio despite + // the miss → the player played it, the tool dropped it (drives the + // confirmed_detector_bug verdict). Absent on hits / non-bass / no audio. + notePresent: j.notePresent === true, + // note_detect measured the input as SILENT here → the player stopped / + // didn't play (a confident "not played"), NOT the detector's blind spot. + // Lets the coach say "you stopped here" instead of hedging. + silent: j.silent === true, + failureType, + faultVerdict: _faultVerdict(failureType, j), + }; +} + +// ── Loose hits ───────────────────────────────────────────────────────── +// A loose hit is a note note_detect scored as a HIT (inside the wide hit +// window) but graded NOT clean — it landed outside the tight quality band on +// timing or pitch (`j.clean === false`, `j.looseReason`). It is unambiguously +// the player (the detector heard it fine — no low-string-blind-spot caveat), +// so it routes to a confident timing/intonation drill, NOT counted as a miss. +function _classifyLoose(j) { + const lr = j.looseReason; + // Pitch-only loose → sharp/flat. Timing or 'both' → timing primary (timing + // is the reliably-measured axis on bass; pitch is the noisier one). + if (lr === 'pitch') return (j.pitchError > 0 ? 'sharp' : 'flat'); + if (Number.isFinite(j.timingError)) return (j.timingError > 0 ? 'late' : 'early'); + if (Number.isFinite(j.pitchError)) return (j.pitchError > 0 ? 'sharp' : 'flat'); + return 'late'; +} +function looseFromJudgment(j, idx) { + const failureType = _classifyLoose(j); + return { + id: 'L' + idx, + chart: { + t: (typeof j.noteTime === 'number') ? j.noteTime : null, + s: j.note ? j.note.s : null, + f: j.note ? j.note.f : null, + expectedMidi: (j.expectedMidi != null) ? j.expectedMidi : null, + }, + detected: (j.detectedMidi != null) + ? { midi: j.detectedMidi, confidence: (typeof j.confidence === 'number') ? j.confidence : null } + : null, + timingErrorMs: (typeof j.timingError === 'number') ? j.timingError : null, + pitchErrorCents: (typeof j.pitchError === 'number') ? j.pitchError : null, + failureType, + faultVerdict: 'player_error', // detector heard it; the imprecision is the player's + loose: true, + looseReason: j.looseReason || null, + }; +} + +// ── Hotspots ────────────────────────────────────────────────────────── +function _signalKind(tes, pes, medT, medP) { + // systematic = a consistent, ACTIONABLE skew on either axis (a tool / + // calibration problem, e.g. an A/V offset); random = scatter (a skill gap). + // Systematic requires both: the median magnitude clears a floor (a 5 ms / + // 4 cent constant error is not worth "fix your tooling"), AND it dominates + // the spread (MAD). If neither axis qualifies, it's random. + function isSystematic(xs, med, minMag) { + if (!xs.length || med == null) return false; + if (Math.abs(med) < minMag) return false; + const mad = _mad(xs, med); + return Math.abs(med) >= 2 * (mad + 1e-9); + } + const sysT = isSystematic(tes, medT, 25); // ms + const sysP = isSystematic(pes, medP, 15); // cents + return (sysT || sysP) ? 'systematic' : 'random'; +} +function findHotspots(mistakes, allJudgments, opts) { + opts = opts || {}; + // Cluster misses that fall within `gap` seconds of the previous one, and + // flag a run of at least `minMisses`. These started stricter (3 within + // 2s), which only caught tightly-bunched fumbles and returned NOTHING for + // a sloppy-but-spread play (e.g. a 75% bass take whose misses sit >2s + // apart) — so the panel wrongly said "no trouble spots". Loosened to 2 + // within 3s: two flubs a phrase apart is a real spot worth a slow loop. + const gap = (opts.gapSec != null) ? opts.gapSec : 3.0; // cluster misses within this gap (s) + const minMisses = (opts.minMisses != null) ? opts.minMisses : 2; + // NB: no run-in/run-out here. The drill loop is the raw musical span of + // the cluster; note_detect.startDrill owns the audible lead-in + first- + // note runway. Adding our own padding double-stacked it (1s here + 3s + // there), pushing the real notes ~4s into the loop and desyncing the + // count-in. Single ownership = note_detect. + + const sorted = mistakes.filter(m => Number.isFinite(m.chart.t)).sort((a, b) => a.chart.t - b.chart.t); + const clusters = []; + let cur = null; + for (const m of sorted) { + if (cur && m.chart.t - cur.lastT <= gap) { cur.items.push(m); cur.lastT = m.chart.t; } + else { cur = { items: [m], lastT: m.chart.t }; clusters.push(cur); } + } + const noteTimes = allJudgments.map(j => j.noteTime).filter(Number.isFinite); + + const out = []; + for (const c of clusters) { + if (c.items.length < minMisses) continue; + const startSec = c.items[0].chart.t; + const endSec = c.items[c.items.length - 1].chart.t; + const noteCount = noteTimes.filter(t => t >= startSec && t <= endSec).length || c.items.length; + const missRate = Math.min(1, c.items.length / Math.max(1, noteCount)); + const tes = c.items.map(m => m.timingErrorMs).filter(x => typeof x === 'number'); + const pes = c.items.map(m => m.pitchErrorCents).filter(x => typeof x === 'number'); + const medT = tes.length ? _median(tes) : null; + const medP = pes.length ? _median(pes) : null; + out.push({ + key: (opts.song || '?') + '|' + (opts.arrangement || '?') + '|' + Math.round(startSec) + '-' + Math.round(endSec), + bounds: { startSec, endSec, section: null, bars: null }, // musical bars/section TODO (need getSections) + mistakeIds: c.items.map(m => m.id), + evidence: { missRate: Math.round(missRate * 100) / 100, noteCount, misses: c.items.length, plays: 1 }, + signal: { medianTimingMs: medT, medianPitchCents: medP, kind: _signalKind(tes, pes, medT, medP) }, + severity: Math.round(1000 * missRate * Math.min(1, c.items.length / 8)) / 1000, + drill: { loopA: startSec, loopB: endSec, speedMul: 0.7, goal: 0.85 }, // raw bounds; note_detect adds the lead-in + }); + } + out.sort((a, b) => b.severity - a.severity); + return out; +} + +// ── Top-level builder ───────────────────────────────────────────────── +// One note can be judged more than once in a session — when the player backs +// the track up and replays a section, note_detect re-opens and re-scores those +// notes (see _ndKeysToReopenOnSeek). The note's verdict for the play is its +// LAST judgment: nail the intro on the retry and the earlier miss must not +// still surface as a hotspot. Collapse to the latest judgment per chart note +// (time+string+fret), preserving order. Notes without a usable key pass through. +function _latestPerNote(judgments) { + const byKey = new Map(); + const passthrough = []; + for (const j of judgments) { + const n = j && j.note; + const t = j && j.noteTime; + if (!n || typeof t !== 'number' || n.s == null || n.f == null) { passthrough.push(j); continue; } + byKey.set(`${t}_${n.s}_${n.f}`, j); // later judgment overwrites earlier + } + return passthrough.concat(Array.from(byKey.values())); +} + +function buildPlayFeedback(judgments, sessionMeta) { + sessionMeta = sessionMeta || {}; + const raw = Array.isArray(judgments) ? judgments.filter(Boolean) : []; + const all = _latestPerNote(raw); + const mistakes = all.filter(j => j.hit !== true).map((j, i) => mistakeFromJudgment(j, i)); + // Hotspots = where YOU had trouble, not where the detector did. Cluster + // only player-fault mistakes (late/early/sharp/flat — detector heard you + // and you were off); detector_suspect misses are unheard dropouts (the + // tool's sub-60 Hz blind spot), which would otherwise plant "practice + // spots" next to a 100% clean score. A clean play has no player faults → + // no hotspots → "nice, clean". Timing faults are reliably detected even + // on bass, so a rushed/dragged section still surfaces. + const playerMistakes = mistakes.filter(m => m.faultVerdict === 'player_error'); + const hotspots = findHotspots(playerMistakes, all, { song: sessionMeta.song, arrangement: sessionMeta.arrangement }); + // Unheard runs: clusters of no_detection (detector_suspect) misses, kept + // DISTINCT from `hotspots` (confirmed player faults). A poor bass play is + // ALL no_detection, so without these it would get no practice targets at + // all. They carry a caveat in the UI: some may be the detector's low-string + // blind spot, not a flub. Gentler drill goal (0.6) — the detector may + // under-score the run even when played right, so 0.85 could never graduate. + const detectorSuspect = mistakes.filter(m => m.faultVerdict === 'detector_suspect'); + const unheardSpots = findHotspots(detectorSuspect, all, { song: sessionMeta.song, arrangement: sessionMeta.arrangement }); + for (const s of unheardSpots) { if (s.drill) s.drill.goal = 0.6; } + // Loose hits — notes scored as hits but graded not-clean (sloppy timing / + // intonation inside the wide hit window). These never appear in `mistakes` + // (they ARE hits), so without this a high-accuracy-but-sloppy take gets no + // practice targets. Cluster them like misses, but require a denser run + // (minMisses 3) since loose hits are individually minor — we only want + // SECTIONS the player is consistently loose through, not one rushed note. + const looseMistakes = all + .filter(j => j.hit === true && j.clean === false) + .map((j, i) => looseFromJudgment(j, i)); + const looseHotspots = findHotspots(looseMistakes, all, + { song: sessionMeta.song, arrangement: sessionMeta.arrangement, minMisses: 3 }); + for (const h of looseHotspots) { h.kind = 'loose'; if (h.drill) h.drill.goal = 0.9; } + // Mark an unheard run as a confident "you STOPPED here" when the input was + // measured silent across most of it (note_detect's `silent`), vs the + // ambiguous low-string blind spot. A stopped run is unambiguously the player + // — drill it confidently (no "maybe the tool" caveat) and at the normal goal. + { + const byId = {}; + for (const m of mistakes) byId[m.id] = m; + for (const s of unheardSpots) { + const ms = (s.mistakeIds || []).map(id => byId[id]).filter(Boolean); + const silentN = ms.filter(m => m.silent).length; + s.stopped = ms.length > 0 && silentN >= Math.ceil(ms.length / 2); + if (s.stopped && s.drill) s.drill.goal = 0.85; // you genuinely stopped → hold the real bar + } + } + // Attach the "work on THIS thing here " recommendation: resolve each + // hotspot's dominant failure → skill + cue + drill game + deep link. This is + // the bone that turns a diagnosis into a thing to go practice (see + // docs/TEACHING_NARRATIVE.md). Pure metadata — the panel renders the link. + if (_skillDrills) { + const byId = {}; + for (const m of mistakes) byId[m.id] = m; + for (const m of looseMistakes) byId[m.id] = m; + for (const h of hotspots) { h.song = sessionMeta.song || null; h.practice = _skillDrills.practiceForHotspot(h, byId); } + for (const s of unheardSpots) { s.song = sessionMeta.song || null; s.practice = _skillDrills.practiceForHotspot(s, byId); } + for (const h of looseHotspots) { h.song = sessionMeta.song || null; h.practice = _skillDrills.practiceForHotspot(h, byId); } + } + const hits = all.filter(j => j.hit === true).length; + // Of the hits, how many cleared the tight clean band. A judgment without a + // `clean` field (older note_detect) counts as clean → no regression. + const looseHits = looseMistakes.length; + const cleanHits = hits - looseHits; + const total = all.length; + // Two-score split (the "fix detection vs fix my playing" question). + // player_error = the detector HEARD you and you were late/early/sharp/flat. + // detector_suspect = no_detection — the detector never registered a pitch + // (e.g. sub-60 Hz bass fundamentals it can't resolve). Counting those + // against the player is a lie: a note it never heard isn't a note you + // missed. So: + // playerAccuracy = hits / notes-the-detector-could-verify + // detectionCoverage = notes-it-verified / total + // Low coverage + high accuracy ⇒ clean playing the tool couldn't see. + const playerMisses = mistakes.filter(m => m.faultVerdict === 'player_error').length; + // Both detector verdicts are tool misses, not player misses: detector_suspect + // (ambiguous) + confirmed_detector_bug (measured present-but-dropped). + const confirmedBugs = mistakes.filter(m => m.faultVerdict === 'confirmed_detector_bug').length; + const detectorMisses = mistakes.filter(m => m.faultVerdict === 'detector_suspect').length + confirmedBugs; + const heard = hits + playerMisses; // notes the detector verified (hit or heard-but-wrong) + const missByType = { late: 0, early: 0, sharp: 0, flat: 0, no_detection: 0, mute_fail: 0 }; + for (const m of mistakes) if (missByType[m.failureType] != null) missByType[m.failureType]++; + return { + schema: 'coaching.play_feedback.v1', + session: { + song: sessionMeta.song || null, + arrangement: sessionMeta.arrangement || null, + tuning: sessionMeta.tuning || null, + capo: (sessionMeta.capo != null) ? sessionMeta.capo : 0, + settings: sessionMeta.settings || null, // {method, frameSize, avOffsetMs, tolerances, pluginVersion} + recording: sessionMeta.recording || null, // {wavPath, judgmentStream} + score: total ? Math.round((hits / total) * 1000) / 1000 : 0, // legacy: hits/total + playerAccuracy: heard > 0 ? Math.round((hits / heard) * 1000) / 1000 : null, + detectionCoverage: total > 0 ? Math.round((heard / total) * 1000) / 1000 : 0, + // Hit-quality: of the notes scored as hits, the fraction that were + // tight (clean). Low cleanRate next to high playerAccuracy = the + // "technically hit but sloppy" take — drill timing/intonation, not + // notes. cleanRate is null when there are no hits to grade. + cleanRate: hits > 0 ? Math.round((cleanHits / hits) * 1000) / 1000 : null, + looseHits, + faultSplit: { player: playerMisses, detector: detectorMisses, confirmedToolMisses: confirmedBugs }, + missByType, + sections: sessionMeta.sections || [], + summary: null, // free-form — filled by the LLM coach later + }, + mistakes, + hotspots, + looseHotspots, + unheardSpots, + timing: _buildTimingReport(all), // rush/drag histogram over all heard notes + }; +} + +// ── LLM coach (task #14) ────────────────────────────────────────────── +// Turn a PlayFeedback into session.summary: a prioritized, specific, +// encouraging practice plan. The call is CLIENT-SIDE — the user's own +// Anthropic key, read from localStorage, posted straight from the browser +// to the Messages API. The key never touches the slopsmith server. +// +// Split into pure pieces (distill → build request → parse response) so the +// prompt assembly and the robust parsing are node-testable without a network +// or a real key, exactly like the builder above. requestCoaching() is the one +// impure seam and takes an injectable fetch so even it is testable. + +const COACH_MODEL = 'claude-haiku-4-5'; // cheapest tier ($1/$5 per 1M) while we prove out the flow; supports structured outputs + prompt caching. Bump to claude-opus-4-8 once coaching quality is validated. +const COACH_ENDPOINT = 'https://api.anthropic.com/v1/messages'; +const COACH_API_VERSION = '2023-06-01'; + +// localStorage keys. The API key is a SECRET and lives only here, browser-side. +const LS_KEY = 'coaching.anthropicApiKey'; +const LS_MODEL = 'coaching.model'; +const LS_ENABLED = 'coaching.llmEnabled'; + +// Static system prompt — frozen so the prompt-cache prefix is stable across +// plays (cache_control breakpoint goes on this block). Keep volatile per-play +// data out of here; it rides in the user turn. +const COACH_SYSTEM = [ + 'You are a bass and guitar practice coach embedded in a Rocksmith-style practice tool.', + 'After each play you receive a `coaching.play_feedback.v1` distillation with:', + '- the session: song, arrangement, tuning, and TWO scores — `playerAccuracy` (hits over notes the', + ' detector could verify) and `detectionCoverage` (fraction of all charted notes it registered);', + '- the full PER-NOTE mistake stream `mistakes[]` — each with time `t`, string `s`/fret `f`, `note`', + ' name, timing error `te` (ms; +late/-early), pitch error `pe` (cents; +sharp/-flat), `type`, `fault`,', + ' and (bass, when available) a string-energy signal: `seSelf` = how much the string you SHOULD have', + ' played rang (0..1), `seTop` = the loudest string that actually rang, `seTopStr` = which string that was;', + '- `missedNotes` — the dropped notes grouped by fretboard position with counts;', + '- HOTSPOTS — time-clustered PLAYER-fault groups with median errors, `severity`, and `signal.kind`;', + '- `cleanRate` + `looseHits` — of the notes scored as HITS, the fraction that were TIGHT vs how many', + ' were "loose hits" (right note, but timing/intonation outside the tight quality band — still counted a hit);', + '- `looseHotspots` — time-clustered runs of those loose hits (sloppy-but-hit sections);', + '- `unheardSpots` — time-clustered runs of `no_detection` misses (the detector registered no pitch).', + '', + 'Load-bearing rules you MUST honor:', + '1. TOOL vs PLAYER. The detector is UNRELIABLE on low bass: below ~60 Hz (roughly open E up through', + ' ~B on the E and A strings) a "no_detection" / `detector_suspect` miss is AMBIGUOUS — it may be the', + ' TOOL failing to hear a note the player actually played, OR a note the player genuinely flubbed. You', + ' CANNOT tell which. So: never SCOLD the player for no_detection, but do NOT stay silent on a poor play', + ' either. When `unheardSpots` are present (or `detectionCoverage` is low), name them as HONEST practice', + ' targets WITH the caveat — e.g. "the detector lost the run at 0:40–0:42; if you know you flubbed it,', + ' drill it slow; if you played it clean, that\'s the low-string detection gap." Emit `loops` for the', + ' densest unheardSpots too, not just hotspots. Coach CONFIDENTLY (skill language) only on `player_error`', + ' evidence: late/early (`te`), sharp/flat (`pe`).', + '1a. STOPPED / NOT PLAYED. A miss with `silent: true`, or an unheardSpot with `stopped: true`, means note_detect', + ' MEASURED the input as quiet there — the player STOPPED or didn\'t play, NOT the low-string blind spot. Here you', + ' CAN tell: drop the "maybe the tool" caveat and name it plainly ("you stopped for a few seconds around 2:30 —', + ' that\'s where the song got away from you; drill the lead-in into it"). It is the player, but it is a lost-the-thread', + ' moment, not a botched fret — coach it as a confidence/recovery spot, not a skill error.', + '1b. CONFIRMED TOOL MISS. A mistake with `fault` = `confirmed_detector_bug` (count in `faultSplit.confirmedToolMisses`)', + ' is a no_detection where note_detect MEASURED your note present in the audio — you played it, the tool dropped it.', + ' This is not a guess. Reassure confidently and explicitly ("the detector lost N notes you actually played — not your', + ' fault"); never drill these or count them against the player.', + '2. `signal.kind` "systematic" = a consistent skew (a TOOL/CALIBRATION issue like an A/V offset), not a', + ' skill gap — flag it as calibration, do not tell the player to "practice" it. "random" = a real gap.', + '3. STRING-ENERGY (the `seSelf`/`seTop`/`seTopStr` signal on a miss, bass) disambiguates a no_detection:', + ' - `seSelf` HIGH (the right string rang) but still a miss → likely the TOOL\'s blind spot; reassure, don\'t scold.', + ' - `seSelf` LOW and `seTop` HIGH on a DIFFERENT string (`seTopStr` ≠ `s`) → WRONG STRING: say it plainly,', + ' e.g. "at 1:04 you hit string {seTopStr} but the chart wanted string {s}." This IS player-actionable.', + ' - everything LOW → the note simply wasn\'t played (a drop), not a wrong note.', + ' - everything HIGH / broad → ringing or unmuted strings; suggest muting. Only use `se` when it\'s present.', + '4. MUTE FAIL. A `mute_fail` type (fault `player_error`) means the detector confirmed the OPEN string rang', + ' where the chart wanted a FRETTED note — the player didn\'t fret/mute it. This is UNAMBIGUOUS and', + ' player-actionable (not a detector blind spot): say it plainly, e.g. "at 0:64 the open A rang but the', + ' chart wanted fret 2 — fret it cleanly or mute the open string." Coach it confidently.', + '5. LOOSE HITS / TIGHTNESS. A high `playerAccuracy` does NOT mean a clean take. If `cleanRate` is low', + ' (say < 0.85) the player is hitting the right notes but loosely — rushed/dragged timing or shaky', + ' intonation. This is UNAMBIGUOUS and player-actionable (the detector heard every note — never caveat it', + ' as a tool issue). Do NOT call a low-cleanRate take "clean"; name the tightness gap and drill the', + ' `looseHotspots` (use their `signal.medianTimingMs`/`medianPitchCents` to say which way it skews — e.g.', + ' "you\'re landing ~50 ms late through the 1:10-1:30 run; loop it at 80% with a metronome"). Treat', + ' looseHotspots as real drill targets alongside hotspots, worst-first by `severity`.', + '', + 'Produce a SHORT, prioritized, specific, encouraging plan, worst-first by severity. Every priority must', + 'be concrete and tied to evidence — the section time, the median error, the missed positions ("drill', + '1:40-1:55 at 70% to a 90% goal", not "play better"). Set `hotspotKey` to the mapped hotspot.key or "".', + '', + 'ALSO emit `loops`: the concrete drill loops to practice. Each has a `label`, `startSec`/`endSec` (lift', + 'tight bounds from the hotspots or the clustered mistake `t` times), a `speedMul` (0.5-1.0; slower for', + 'harder spots), and a `goal` accuracy (0-1). These drive the practice loop directly, so make the bounds', + 'real and tight. Lift loop bounds from hotspots AND looseHotspots. Only return an empty `loops` array when', + 'the play was clean on BOTH axes — no hotspots AND a high `cleanRate` (>= ~0.9); then suggest the next', + 'stretch goal. A high accuracy with a low cleanRate is NOT clean: emit tightness loops.', +].join('\n'); + +// JSON-schema for output_config.format — guarantees the response is one valid +// JSON object so parsing is deterministic. Constraints kept inside the +// structured-outputs supported subset (additionalProperties:false everywhere, +// no min/max/length keywords). +const COACH_SUMMARY_SCHEMA = { + type: 'object', + properties: { + headline: { type: 'string' }, // one-line encouraging takeaway + priorities: { + type: 'array', + items: { + type: 'object', + properties: { + focus: { type: 'string' }, // what to work on + why: { type: 'string' }, // the evidence / why it matters + drill: { type: 'string' }, // a concrete practice action + hotspotKey: { type: 'string' }, // the hotspot.key it maps to, or "" + }, + required: ['focus', 'why', 'drill', 'hotspotKey'], + additionalProperties: false, + }, + }, + loops: { // the drill loops to practice — LLM-defined + type: 'array', + items: { + type: 'object', + properties: { + label: { type: 'string' }, // what this loop drills + startSec: { type: 'number' }, + endSec: { type: 'number' }, + speedMul: { type: 'number' }, // 0.5..1.0 playback speed + goal: { type: 'number' }, // target accuracy 0..1 + }, + required: ['label', 'startSec', 'endSec', 'speedMul', 'goal'], + additionalProperties: false, + }, + }, + toolNotes: { type: 'string' }, // systematic-signal / detector_suspect routing + encouragement: { type: 'string' }, // closing motivation + }, + required: ['headline', 'priorities', 'loops', 'toolNotes', 'encouragement'], + additionalProperties: false, +}; + +// Distill a PlayFeedback into the compact, privacy-light payload the model +// actually needs. We send the session block + hotspots (already the summarized, +// actionable layer) + failureType/faultVerdict tallies — NOT every raw mistake +// atom (those can be large and the hotspots already group them). +function summarizeForCoach(feedback) { + const fb = feedback || {}; + const s = fb.session || {}; + const mistakes = Array.isArray(fb.mistakes) ? fb.mistakes : []; + const tally = (arr, key) => arr.reduce((m, x) => { const k = x[key]; m[k] = (m[k] || 0) + 1; return m; }, {}); + const r2 = (x) => (typeof x === 'number') ? Math.round(x * 100) / 100 : null; + const noteName = (m) => (m.chart && m.chart.expectedMidi != null) ? _midiName(m.chart.expectedMidi) : null; + // The PER-NOTE mistake stream — the LLM reasons over the actual notes, not + // just counts. Compact; capped so a pathological take can't blow the turn. + // Compact the per-string energy into the wrong-string signal: energy on + // the string you SHOULD have played (seSelf) vs the loudest string that + // actually rang (seTop on string seTopStr). Cheaper than the raw array and + // already framed for the model. Omitted when energy is unavailable. + const seSignal = (m) => { + const se = Array.isArray(m.stringEnergy) ? m.stringEnergy : null; + const cs = m.chart ? m.chart.s : null; + if (!se || cs == null || se[cs] == null) return {}; + let top = 0; + for (let i = 1; i < se.length; i++) if (se[i] > se[top]) top = i; + return { seSelf: r2(se[cs]), seTop: r2(se[top]), seTopStr: top }; + }; + const perNote = mistakes.slice(0, 150).map((m) => ({ + t: (m.chart && typeof m.chart.t === 'number') ? r2(m.chart.t) : null, + s: m.chart ? m.chart.s : null, + f: m.chart ? m.chart.f : null, + note: noteName(m), + te: m.timingErrorMs, // +late / -early ms + pe: m.pitchErrorCents, // +sharp / -flat cents (octave-folded) + type: m.failureType, + fault: m.faultVerdict, + silent: m.silent || undefined, // input was quiet here = player stopped/didn't play (not the tool) + ...seSignal(m), // seSelf / seTop / seTopStr when available + })); + // "What you missed" grouped by fretboard position, most-missed first. + const posMap = new Map(); + for (const m of mistakes) { + const c = m.chart || {}; + if (c.s == null || c.f == null) continue; + const k = c.s + '_' + c.f; + const g = posMap.get(k) || { s: c.s, f: c.f, note: noteName(m), n: 0 }; + g.n++; posMap.set(k, g); + } + const missedNotes = [...posMap.values()].sort((a, b) => b.n - a.n).slice(0, 12); + return { + schema: fb.schema || 'coaching.play_feedback.v1', + session: { + song: s.song || null, + arrangement: s.arrangement || null, + tuning: s.tuning || null, + capo: (s.capo != null) ? s.capo : 0, + // Two-score split: playerAccuracy (of notes the detector could + // verify) vs detectionCoverage (fraction it registered at all). + // Low coverage + high accuracy = the tool dropped notes you played. + playerAccuracy: r2(s.playerAccuracy), + detectionCoverage: r2(s.detectionCoverage), + // Hit-quality: fraction of hits that were tight. Low next to a high + // playerAccuracy = sloppy-but-technically-hit (drill timing, not notes). + cleanRate: r2(s.cleanRate), + looseHits: (typeof s.looseHits === 'number') ? s.looseHits : null, + faultSplit: s.faultSplit || null, + missByType: s.missByType || null, + score: (typeof s.score === 'number') ? s.score : null, + settings: s.settings || null, + }, + totals: { + mistakes: mistakes.length, + byFailureType: tally(mistakes, 'failureType'), + byFaultVerdict: tally(mistakes, 'faultVerdict'), + }, + missedNotes, + mistakes: perNote, + // hotspots are the pre-clustered actionable layer — drill straight off these + hotspots: (Array.isArray(fb.hotspots) ? fb.hotspots : []).map(h => ({ + key: h.key, + bounds: h.bounds, + evidence: h.evidence, + signal: h.signal, + severity: h.severity, + drill: h.drill, + })), + // Loose-hit runs — sections the player HIT but consistently sloppily + // (timing/intonation outside the tight clean band). The detector heard + // every note, so these are unambiguously player-actionable: drill for + // tightness, never caveated as a tool issue. `evidence.missRate` here is + // the loose RATE through the run, and `signal.medianTimingMs` / + // `medianPitchCents` say which way it skews. + looseHotspots: (Array.isArray(fb.looseHotspots) ? fb.looseHotspots : []).map(h => ({ + key: h.key, + bounds: h.bounds, + evidence: h.evidence, + signal: h.signal, + severity: h.severity, + drill: h.drill, + })), + // Unheard runs — clusters of no_detection misses. `stopped: true` means + // the input was measured SILENT across the run (the player stopped / + // lost the thread — confident, not the tool); otherwise it's the + // ambiguous low-string blind spot (real flubs mixed with tool misses). + unheardSpots: (Array.isArray(fb.unheardSpots) ? fb.unheardSpots : []).map(h => ({ + key: h.key, + bounds: h.bounds, + evidence: h.evidence, + drill: h.drill, + stopped: h.stopped || undefined, + })), + }; +} + +// Pure: assemble the Messages API request body. Static system block carries the +// cache_control breakpoint; the per-play distilled JSON rides in the user turn +// (after the breakpoint) so the cached prefix stays byte-stable across plays. +function buildCoachRequest(feedback, opts) { + opts = opts || {}; + return { + model: opts.model || COACH_MODEL, + max_tokens: opts.maxTokens || 2048, + system: [{ type: 'text', text: COACH_SYSTEM, cache_control: { type: 'ephemeral' } }], + messages: [{ + role: 'user', + content: 'Here is the play feedback. Return the practice plan.\n\n' + + JSON.stringify(summarizeForCoach(feedback)), + }], + output_config: { format: { type: 'json_schema', schema: COACH_SUMMARY_SCHEMA } }, + }; +} + +// Pure: pull the structured summary out of a Messages API response, defensively. +// output_config.format guarantees the first text block is valid JSON, but we +// still handle refusals, truncation, and a stray ```json fence as fallbacks. +function parseCoachSummary(apiJson) { + const r = apiJson || {}; + if (r.type === 'error') throw new Error('coach API error: ' + ((r.error && r.error.message) || 'unknown')); + if (r.stop_reason === 'refusal') throw new Error('coach refused to generate a plan'); + const blocks = Array.isArray(r.content) ? r.content : []; + const textBlock = blocks.find(b => b && b.type === 'text' && typeof b.text === 'string'); + if (!textBlock) throw new Error('coach response had no text block'); + let raw = textBlock.text.trim(); + let parsed; + try { + parsed = JSON.parse(raw); + } catch (_) { + // fallback: a fenced or prose-wrapped object — grab the outermost braces + const m = raw.match(/\{[\s\S]*\}/); + if (!m) throw new Error('coach response was not JSON' + (r.stop_reason === 'max_tokens' ? ' (truncated — raise max_tokens)' : '')); + parsed = JSON.parse(m[0]); + } + if (!parsed || typeof parsed.headline !== 'string' || !Array.isArray(parsed.priorities)) { + throw new Error('coach response missing required fields (headline/priorities)'); + } + return parsed; +} + +// Impure seam: do the actual browser → Anthropic call. fetchImpl injectable for +// tests. Returns the parsed summary object (also stamps the model used). +async function requestCoaching(feedback, opts) { + opts = opts || {}; + const apiKey = opts.apiKey; + if (!apiKey) throw new Error('no Anthropic API key (set it in coaching settings)'); + const f = opts.fetchImpl || (typeof fetch !== 'undefined' ? fetch : null); + if (!f) throw new Error('no fetch available'); + const model = opts.model || COACH_MODEL; + const body = buildCoachRequest(feedback, { model, maxTokens: opts.maxTokens }); + + const resp = await f(COACH_ENDPOINT, { + method: 'POST', + signal: opts.signal, + headers: { + 'content-type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': COACH_API_VERSION, + // required for direct browser-origin calls (CORS); key stays client-side + 'anthropic-dangerous-direct-browser-access': 'true', + }, + body: JSON.stringify(body), + }); + + let json; + try { json = await resp.json(); } catch (_) { json = null; } + if (!resp.ok) { + const msg = (json && json.error && json.error.message) || ('HTTP ' + resp.status); + if (resp.status === 401) throw new Error('coach auth failed (check your API key): ' + msg); + if (resp.status === 429) throw new Error('coach rate-limited — try again shortly: ' + msg); + throw new Error('coach request failed: ' + msg); + } + const summary = parseCoachSummary(json); + summary._model = model; + return summary; +} + +// ── Settings (the API key lives client-side, never on the server) ───── +// Factory over a Storage-like object so the secret-handling logic is pure and +// node-testable with a fake map. The browser passes window.localStorage; the +// settings.html panel writes the same keys directly so wiring doesn't depend on +// this module's load order. +function makeSettings(storage) { + const get = (k) => { try { return storage && storage.getItem(k); } catch (_) { return null; } }; + const set = (k, v) => { try { if (!storage) return; if (v == null || v === '') storage.removeItem(k); else storage.setItem(k, v); } catch (_) {} }; + return { + getApiKey: () => get(LS_KEY) || null, + setApiKey: (k) => set(LS_KEY, k), + getModel: () => get(LS_MODEL) || COACH_MODEL, + setModel: (m) => set(LS_MODEL, m), + // enabled by default once a key is present; explicit "false" opts out + isEnabled: () => !!(get(LS_KEY)) && get(LS_ENABLED) !== 'false', + setEnabled: (on) => set(LS_ENABLED, on ? 'true' : 'false'), + }; +} + +// ── Rendering (pure → HTML string; the DOM shell just injects it) ────── +function escapeHtml(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + )); +} + +// Map a hotspot's single `speedMul` into the conductor's ascending speed +// ladder (slow → full). Two intermediate steps so the drill ramps rather +// than jumping straight to full speed. Pure — node-testable. +function drillLadderFromSpeedMul(speedMul) { + const base = (Number.isFinite(speedMul) && speedMul > 0 && speedMul < 1) ? speedMul : 0.7; + const mid = Math.round(((base + 1) / 2) * 100) / 100; + return [base, mid, 1.0]; +} + +// Pure: translate a "Practice this" button's data-attrs (a DOMStringMap-like +// object) into the argument triple for window.noteDetect.startDrill. Returns +// null when there's no usable drill range, so the click handler can show a +// hint instead of calling the conductor with garbage. Exported + node-tested; +// _startDrillFromButton is the thin DOM wrapper around it. +function drillArgsFromDataset(ds) { + ds = ds || {}; + const a = parseFloat(ds.loopA); + const b = parseFloat(ds.loopB); + if (!Number.isFinite(a) || !Number.isFinite(b)) return null; + const goal = parseFloat(ds.goal); + const speedMul = parseFloat(ds.speedMul); + const focus = ds.focus || null; + return { + start: a, + end: b, + opts: { + label: ds.key || focus || 'Hotspot', + focus, + // undefined → startDrill applies its own default goal. + goal: Number.isFinite(goal) ? goal : undefined, + speedLadder: drillLadderFromSpeedMul(speedMul), + // Once the tight trouble spot is nailed, widen the loop a bar each + // side and re-earn it — practice playing INTO and OUT of the hard + // passage, not just the isolated bar (bass-drill research, l.134). + expandContext: true, + }, + }; +} + +// Render a coach summary object into a safe HTML fragment for the end-of-song +// panel. ALL model-produced text is escaped — it's untrusted output injected via +// innerHTML. Uses only core Tailwind utilities so no plugin stylesheet is needed. +// +// `hotspots` (optional, from the PlayFeedback) lets a priority that maps to a +// hotspot (via hotspotKey) sprout a "Practice this" button carrying that +// hotspot's drill range as data-attrs; _showSummaryPanel wires the click to +// window.noteDetect.startDrill. Omit it (e.g. in unit tests) and no button is +// rendered — the summary text is unchanged. +function renderSummaryHtml(summary, hotspots) { + const s = summary || {}; + const prios = Array.isArray(s.priorities) ? s.priorities : []; + // Index drillable hotspots by key for O(1) priority → drill lookup. + const drillByKey = new Map(); + if (Array.isArray(hotspots)) { + for (const h of hotspots) { + const d = h && h.drill; + if (h && h.key && d && Number.isFinite(d.loopA) && Number.isFinite(d.loopB) && d.loopB > d.loopA) { + drillByKey.set(h.key, d); + } + } + } + const items = prios.map((p, i) => { + const key = p && p.hotspotKey ? `${escapeHtml(p.hotspotKey)}` : ''; + const d = (p && p.hotspotKey) ? drillByKey.get(p.hotspotKey) : null; + const drillBtn = d + ? `` + : ''; + return `
  • +
    + ${i + 1}. ${escapeHtml(p && p.focus)} + ${key} +
    +

    ${escapeHtml(p && p.why)}

    +

    Drill: ${escapeHtml(p && p.drill)}

    + ${drillBtn} +
  • `; + }).join(''); + const tool = s.toolNotes + ? `

    Tooling: ${escapeHtml(s.toolNotes)}

    ` + : ''; + const enc = s.encouragement + ? `

    ${escapeHtml(s.encouragement)}

    ` + : ''; + // LLM-defined drill loops — directly drillable via the same data-attrs the + // hotspot Practice buttons use (drillArgsFromDataset / startDrill). + const loops = Array.isArray(s.loops) ? s.loops : []; + const mmss = (x) => (Number.isFinite(x) ? `${Math.floor(x / 60)}:${String(Math.floor(x % 60)).padStart(2, '0')}` : '?'); + const loopItems = loops + .filter(l => l && Number.isFinite(l.startSec) && Number.isFinite(l.endSec) && l.endSec > l.startSec) + .map(l => `
  • + ${escapeHtml(l.label)} ${mmss(l.startSec)}–${mmss(l.endSec)} + +
  • `).join(''); + const loopsHtml = loopItems + ? `
    Drill loops
      ${loopItems}
    ` + : ''; + return `

    ${escapeHtml(s.headline) || 'Practice plan'}

    +
      ${items}
    ${loopsHtml}${tool}${enc}`; +} + +function _midiName(m) { + if (m == null || !Number.isFinite(m)) return '?'; + const names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + const r = Math.round(m); + return names[((r % 12) + 12) % 12] + (Math.floor(r / 12) - 1); +} + +// The honest two-score split + a failure-type histogram. Pure → node-testable. +// "Played clean" is your hit rate over notes the detector COULD verify; +// "Detector heard" is how many of all charted notes it registered at all. A +// high played-clean next to a low detector-heard = clean playing the tool +// couldn't see (e.g. sub-60 Hz bass) — NOT a skill gap. Returns '' when the +// feedback predates the split (no playerAccuracy/detectionCoverage) so the +// legacy panel still works unchanged. +// ── Post-play timing report (rush/drag) ──────────────────────────────── +// "Record and zoom the waveform to see how close to the beat" is a top bass- +// drill technique; slopsmith already measures per-note timing error, so we can +// hand it back automatically. Buckets every heard note's signed timing error +// (+late / -early ms) into a small histogram + a median-based verdict. Returns +// null when too few notes carry a timing measurement to be meaningful. +function _buildTimingReport(judgments) { + const tes = []; + for (const j of (judgments || [])) { + if (j && typeof j.timingError === 'number' && Number.isFinite(j.timingError)) tes.push(j.timingError); + } + if (tes.length < 4) return null; + const sorted = tes.slice().sort((a, b) => a - b); + const q = (p) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.round(p * (sorted.length - 1))))]; + const median = q(0.5); + // Seven bins centred on the ±15 ms "in the pocket" window. + const edges = [-80, -40, -15, 15, 40, 80]; + const labels = ['≤−80', '−80…−40', '−40…−15', '±15', '15…40', '40…80', '>80']; + const buckets = new Array(edges.length + 1).fill(0); + for (const t of tes) { + let bi = edges.length; + for (let i = 0; i < edges.length; i++) { if (t <= edges[i]) { bi = i; break; } } + buckets[bi]++; + } + const early = tes.filter(t => t < -15).length; + const late = tes.filter(t => t > 15).length; + const on = tes.length - early - late; + let verdict; + if (Math.abs(median) <= 12) verdict = { kind: 'on', text: `Solid pocket — median ${median > 0 ? '+' : ''}${median} ms` }; + else if (median > 0) verdict = { kind: 'drag', text: `You DRAG — median +${median} ms behind the beat` }; + else verdict = { kind: 'rush', text: `You RUSH — median ${median} ms ahead of the beat` }; + return { count: tes.length, median, p10: q(0.1), p90: q(0.9), buckets, labels, early, on, late, verdict }; +} + +function renderTimingHtml(feedback) { + const t = feedback && feedback.timing; + if (!t || !t.count) return ''; + const H = 40; + const CENTER = 3; // index of the ±15 bucket + const max = Math.max(1, ...t.buckets); + const bars = t.buckets.map((n, i) => { + const h = Math.round((n / max) * H); + const color = i < CENTER ? '#4080e0' : (i > CENTER ? '#e05a5a' : '#7ddb7d'); + return `
    ` + + `
    `; + }).join(''); + const vColor = t.verdict.kind === 'on' ? '#7ddb7d' : (t.verdict.kind === 'drag' ? '#e05a5a' : '#4080e0'); + return `

    Timing

    ` + + `
    ` + + `
    ${bars}
    ` + + `
    earlyon beatlate
    ` + + `

    ${escapeHtml(t.verdict.text)} · ${t.on}/${t.count} in the pocket

    ` + + `
    `; +} + +function renderBreakdownHtml(feedback) { + const s = (feedback && feedback.session) || {}; + if (!Number.isFinite(s.playerAccuracy) && !Number.isFinite(s.detectionCoverage)) return ''; + const pa = Number.isFinite(s.playerAccuracy) ? Math.round(s.playerAccuracy * 100) : null; + const dc = Number.isFinite(s.detectionCoverage) ? Math.round(s.detectionCoverage * 100) : null; + const mistakes = Array.isArray(feedback && feedback.mistakes) ? feedback.mistakes : []; + const fs = s.faultSplit || { player: 0, detector: 0 }; + const mt = s.missByType || {}; + + let html = `
    +
    +
    ${pa == null ? '—' : pa + '%'}
    +
    played clean
    +
    of notes it could verify
    +
    +
    +
    ${dc == null ? '—' : dc + '%'}
    +
    detector heard
    +
    of all charted notes
    +
    +
    `; + + // Verdict: tool-fault dominant vs player-fault dominant. + if (fs.detector > fs.player && (dc == null || dc < 90)) { + const dropped = mistakes.filter(m => m.faultVerdict === 'detector_suspect' && m.chart && m.chart.expectedMidi != null); + const names = [...new Set(dropped.map(m => _midiName(m.chart.expectedMidi)))].slice(0, 4); + html += `

    Tool, not you: the detector never registered ${fs.detector} note${fs.detector === 1 ? '' : 's'} you likely played fine${names.length ? ` (${escapeHtml(names.join(', '))})` : ''} — below what the detector reliably hears, not misses you made.

    `; + } else if (fs.player > 0) { + html += `

    ${fs.player} thing${fs.player === 1 ? '' : 's'} to work on — breakdown below.

    `; + } + + // Failure-type histogram. Player faults (you can fix these) then the + // tool fault (unheard). Sharp/flat shown as the user's words: high/low. + const rows = [ + { label: 'Late', cls: 'bg-amber-500', n: mt.late || 0, tool: false }, + { label: 'Early', cls: 'bg-amber-500', n: mt.early || 0, tool: false }, + { label: 'High', cls: 'bg-rose-500', n: mt.sharp || 0, tool: false }, + { label: 'Low', cls: 'bg-rose-500', n: mt.flat || 0, tool: false }, + { label: 'Unheard', cls: 'bg-gray-600', n: mt.no_detection || 0, tool: true }, + ].filter(r => r.n > 0); + if (rows.length) { + const max = Math.max(1, ...rows.map(r => r.n)); + const bars = rows.map(r => { + const pct = Math.round((r.n / max) * 100); + const tag = r.tool ? ' tool' : ''; + return `
    +
    ${r.label}${tag}
    +
    +
    ${r.n}
    +
    `; + }).join(''); + html += `
    ${bars}
    `; + } + return html; +} + +// Render a basic, NO-LLM panel straight from PlayFeedback: this play's +// score + the hotspots, each with a "Practice this" button. This is what +// shows after every play even without an API key — the drill loop must not +// be gated behind the paid LLM coach. When the LLM plan lands it replaces +// this with the richer renderSummaryHtml. Pure → node-testable. +// Open-string label by index, low→high. Bass is 4 strings (EADG); guitar 6. +function _stringLabel(s, arrangement) { + const bass = ['E', 'A', 'D', 'G', 'C', 'B']; + const gtr = ['E', 'A', 'D', 'G', 'B', 'e']; + const names = (arrangement && /bass/i.test(arrangement)) ? bass : gtr; + return (names[s] != null) ? names[s] : `string ${s}`; +} + +// "What you missed": the actual notes you dropped, grouped by fretboard +// position and counted, so you know WHERE to look — not just a percentage. +// Pure → node-testable. +function renderMissedNotesHtml(feedback) { + const fb = feedback || {}; + const mistakes = Array.isArray(fb.mistakes) ? fb.mistakes : []; + if (!mistakes.length) return ''; + const arr = fb.session && fb.session.arrangement; + const groups = new Map(); + for (const m of mistakes) { + const c = m.chart || {}; + if (c.s == null || c.f == null) continue; + const key = c.s + '_' + c.f; + const g = groups.get(key) || { s: c.s, f: c.f, midi: c.expectedMidi, n: 0 }; + g.n++; + groups.set(key, g); + } + if (!groups.size) return ''; + const rows = [...groups.values()].sort((a, b) => b.n - a.n).slice(0, 8); + const items = rows.map((g) => { + const note = (g.midi != null) ? _midiName(g.midi) : '?'; + const pos = `${_stringLabel(g.s, arr)} string · fret ${g.f}`; + return `
  • + ${escapeHtml(note)} ${escapeHtml(pos)} + ${g.n}× +
  • `; + }).join(''); + return `
    +
    What you missed
    +
      ${items}
    +
    `; +} + +function renderFeedbackHtml(feedback) { + const fb = feedback || {}; + const hotspots = Array.isArray(fb.hotspots) ? fb.hotspots : []; + const score = (fb.session && Number.isFinite(fb.session.score)) + ? Math.round(fb.session.score * 100) : null; + const missCount = Array.isArray(fb.mistakes) ? fb.mistakes.length : 0; + const mmss = (x) => `${Math.floor(x / 60)}:${String(Math.floor(x % 60)).padStart(2, '0')}`; + // Two-score breakdown owns the score display when present; fall back to + // the legacy "% clean" subline for pre-split feedback (and the tests + // that exercise it). + const breakdown = renderBreakdownHtml(fb); + const head = breakdown + + renderMissedNotesHtml(fb) + + renderTimingHtml(fb) + + `

    Practice spots

    ` + + ((!breakdown && score != null) ? `

    This play: ${score}% clean${missCount ? ` · ${missCount} miss${missCount === 1 ? '' : 'es'}` : ''}

    ` : ''); + const unheardHtml = renderUnheardSpotsHtml(fb, mmss); + const unheardCount = (Array.isArray(fb.unheardSpots) ? fb.unheardSpots : []).length; + if (!hotspots.length) { + // Be honest about the empty state. DON'T say "nice" when the play + // wasn't clean: + // - score < 100% but ZERO mistakes recorded → no notes were judged. + // - unheard runs exist → those ARE the practice targets (below); say + // so rather than "nice"/"consistency gap". + // - real player misses that just don't bunch → a consistency gap. + // - genuinely clean → "nice". + const noData = missCount === 0 && score != null && score < 100; + let body; + if (noData) { + body = `No notes were scored — the detector wasn't running for this play (Detect off, input dropped, or a looped/drill pass). Turn Detect on and replay to score it.`; + } else if (unheardCount) { + body = `No timing/pitch faults the detector could pin on you — but it lost whole runs of notes (below). On bass that's a mix of notes you flubbed and its low-string blind spot.`; + } else if (missCount >= 1) { + body = `Your misses are spread across the song rather than bunched in one spot — a consistency gap, not a single hotspot. A slower full pass (drop the speed slider) helps more than looping one bar.`; + } else { + body = `No recurring trouble spots this play — nice. Keep going, or raise the difficulty.`; + } + return head + `

    ${body}

    ` + unheardHtml; + } + const items = hotspots.map((h, i) => { + const b = h.bounds || {}; const d = h.drill || {}; const ev = h.evidence || {}; + const range = (Number.isFinite(b.startSec) ? mmss(b.startSec) : '?') + + '–' + (Number.isFinite(b.endSec) ? mmss(b.endSec) : '?'); + const drillable = d && Number.isFinite(d.loopA) && Number.isFinite(d.loopB) && d.loopB > d.loopA; + const sys = (h.signal && h.signal.kind === 'systematic') + ? ` systematic — may be calibration, not a skill gap` : ''; + const btn = drillable + ? `` + : ''; + // "Work on THIS thing here " — the skill behind the misses + a link + // into its drill game (docs/TEACHING_NARRATIVE.md). Cue always; game link + // when one trains the skill. + const p = h.practice; + const skillBlock = p + ? `
    + Skill: ${escapeHtml(p.skill)} +
    ${escapeHtml(p.cue)}
    + ${p.gameId && p.deepLink + ? `🎮 Drill ${escapeHtml(p.skill)}` + : ''} +
    ` + : ''; + return `
  • +
    ${i + 1}. ${escapeHtml(range)}
    +

    ${ev.noteCount || 0} note${ev.noteCount === 1 ? '' : 's'} · ${Math.round((ev.missRate || 0) * 100)}% miss${sys}

    + ${btn} + ${skillBlock} +
  • `; + }).join(''); + return head + `
      ${items}
    ` + unheardHtml; +} + +// Honest "unheard runs" section — clusters of no_detection misses surfaced as +// drillable targets, with the caveat that on bass the detector can't tell a +// flub from its own low-string blind spot. Reuses the .coaching-drill-btn +// contract (drillArgsFromDataset → startDrill), so the run loops slow on click. +function renderUnheardSpotsHtml(feedback, mmss) { + const fb = feedback || {}; + const spots = Array.isArray(fb.unheardSpots) ? fb.unheardSpots : []; + if (!spots.length) return ''; + mmss = mmss || ((x) => `${Math.floor(x / 60)}:${String(Math.floor(x % 60)).padStart(2, '0')}`); + const items = spots.map((h, i) => { + const b = h.bounds || {}; const d = h.drill || {}; const ev = h.evidence || {}; + const range = (Number.isFinite(b.startSec) ? mmss(b.startSec) : '?') + + '–' + (Number.isFinite(b.endSec) ? mmss(b.endSec) : '?'); + const drillable = d && Number.isFinite(d.loopA) && Number.isFinite(d.loopB) && d.loopB > d.loopA; + const btn = drillable + ? `` + : ''; + return `
  • +
    ${i + 1}. ${escapeHtml(range)}
    +

    ${ev.misses || 0} note${ev.misses === 1 ? '' : 's'} unregistered

    + ${btn} +
  • `; + }).join(''); + return `

    Unregistered runs

    ` + + `

    The detector heard no note here. On bass this is a mix of notes you flubbed and its low-string blind spot — it can't yet tell which. Drill the ones you know you missed; if you played them clean, it's the detection gap we're improving.

    ` + + `
      ${items}
    `; +} + +// ── Exports for tests ───────────────────────────────────────────────── +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + buildPlayFeedback, mistakeFromJudgment, findHotspots, + FAILURE_TYPES, FAILURE_TYPES_LIVE, FAULT_VERDICTS, + // LLM coach + summarizeForCoach, buildCoachRequest, parseCoachSummary, requestCoaching, + COACH_MODEL, COACH_SUMMARY_SCHEMA, COACH_SYSTEM, + // settings + rendering + makeSettings, escapeHtml, renderSummaryHtml, renderFeedbackHtml, + renderBreakdownHtml, renderMissedNotesHtml, renderUnheardSpotsHtml, renderTimingHtml, _buildTimingReport, _midiName, _stringLabel, + drillLadderFromSpeedMul, drillArgsFromDataset, + // Skill-drill bridge: practice taxonomy (failure → skill → game) + the + // skill-drill rep/streak engine. See docs/TEACHING_NARRATIVE.md. + SKILL_DRILLS, FOUNDATIONAL_DRILLS, practiceForFailure, dominantFailure, deepLinkForGame, practiceForHotspot, + createSkillDrill, skillDrillStep, skillDrillView, classifyFailureForDrill, + }; +} + +// ── Browser wiring: accumulate judgments, build + emit on song end ──── +// Opt-in (guarded on window). Consumes note_detect's existing events so this +// plugin never forks note_detect — exactly the "separate plugin consuming +// notedetect:* events" boundary. No UI yet: it emits `coaching:feedback`, +// logs the object, and stashes it on `window.__coachingLastFeedback`. +if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + (function () { + // Claim the post-play surface: tell note_detect to stand down its + // full-screen end-of-song modal (it still fires notedetect:session, + // which feeds the panel below). Our dismissable panel is the single + // post-play surface now; the diagnostic download lives on it. + try { window.slopsmithSuppressNoteDetectSummary = true; } catch (_) {} + + // Publish the skill-drill taxonomy + mechanics for drill-game plugins. + // Cross-plugin sharing only travels via a global (one script per plugin), + // and coaching loads before the games alphabetically. See + // docs/TEACHING_NARRATIVE.md. Games read window.slopsmithSkillDrills (or + // the minigames SDK's `skillDrill`, which bridges to this). + try { + window.slopsmithSkillDrills = { + SKILL_DRILLS, FOUNDATIONAL_DRILLS, practiceForFailure, dominantFailure, deepLinkForGame, practiceForHotspot, + createSkillDrill, skillDrillStep, skillDrillView, classifyFailureForDrill, + }; + } catch (_) {} + + let _judgments = []; + const _reset = () => { _judgments = []; }; + // Accumulate this play's judgments — but NOT during a drill. A drill + // loops one passage, re-judging the same notes every pass, so counting + // them inflates a later full play's feedback (a note looped 16× showed + // up as "missed 16×"). note_detect flags the drill via + // window._ndAnyDrillActive; skip while it's set so the panel only ever + // reflects a real full play, not the practice reps. + const _push = (e) => { + if (!e || !e.detail) return; + try { if (window._ndAnyDrillActive) return; } catch (_) {} + _judgments.push(e.detail); + }; + window.addEventListener('notedetect:hit', _push); + window.addEventListener('notedetect:miss', _push); + if (window.slopsmith && typeof window.slopsmith.on === 'function') { + window.slopsmith.on('song:loaded', _reset); + } + window.addEventListener('notedetect:session', (e) => { + const agg = (e && e.detail) || {}; + const hadJudgments = _judgments.length > 0; + const feedback = buildPlayFeedback(_judgments, _gatherSessionMeta(agg)); + _reset(); + // A zero-judgment session is a false-start (press play, stop) or a + // detect-off pass. Don't let it CLOBBER a panel that's already + // showing a scored play — that overwrite ("99% play, then a 0% / + // no-notes-scored panel appears") is what read as broken. If a + // panel is already up, leave it; a fresh detect-off play with no + // panel still gets the single calm message below. + if (!hadJudgments && typeof document !== 'undefined' + && document.getElementById('coaching-summary-panel')) { + return; + } + try { if (window.slopsmith && window.slopsmith.emit) window.slopsmith.emit('coaching:feedback', feedback); } catch (_) {} + try { console.log('[coaching] play feedback:', feedback); } catch (_) {} + try { window.__coachingLastFeedback = feedback; } catch (_) {} + // Always surface the free, no-key panel (score + hotspots + + // Practice buttons) so the drill loop is visible without an API + // key. If the LLM coach is enabled, it upgrades this panel in + // place when the plan lands. + _showFeedbackPanel(feedback); + // Make the LLM path observable, and don't fire (or hang on + // "Generating…") when there's nothing to coach. If the detector + // heard nothing this play (coverage 0 — Detect off, dead input, a + // silent/looped take) there's no signal for the coach: say so + // instead of spending a call and leaving the status spinning. + const heard = !!(feedback.session && feedback.session.detectionCoverage > 0); + if (!heard) { + // The feedback panel already explains "no notes scored" in one + // place — don't stack a second alarm here (the double-message + // read as "broken"). Just don't fire the coach: nothing to coach. + _setLlmStatus(''); + } else if (settings.isEnabled()) { + _setLlmStatus('Generating coaching plan with ' + settings.getModel() + '…'); + // Fire-and-forget. Never throws into the event loop; mutates + // feedback.session.summary in place and emits when it lands. + _coachIfEnabled(feedback); + } else { + _setLlmStatus('LLM coach off — add an Anthropic key in Settings → Coaching for a practice plan.'); + } + }); + + // localStorage-backed settings — the API key is a secret and stays here. + // Same keys the settings.html panel reads/writes (see makeSettings()). + let _settingsStore = null; + try { _settingsStore = window.localStorage; } catch (_) {} + const settings = makeSettings(_settingsStore); + + async function _coachIfEnabled(feedback) { + if (!settings.isEnabled()) return null; + // Bound the call so "Generating…" can't spin forever on a stalled + // network / hung fetch. + const ctrl = (typeof AbortController !== 'undefined') ? new AbortController() : null; + const timer = ctrl ? setTimeout(() => { try { ctrl.abort(); } catch (_) {} }, 30000) : null; + try { + const summary = await requestCoaching(feedback, { + apiKey: settings.getApiKey(), model: settings.getModel(), + signal: ctrl ? ctrl.signal : undefined, + }); + feedback.session.summary = summary; + try { window.__coachingLastSummary = summary; } catch (_) {} + try { if (window.slopsmith && window.slopsmith.emit) window.slopsmith.emit('coaching:summary', { feedback, summary }); } catch (_) {} + try { console.log('[coaching] LLM practice plan:', summary); } catch (_) {} + return summary; + } catch (err) { + const aborted = err && (err.name === 'AbortError' || /abort/i.test(err.message || '')); + const msg = aborted ? 'timed out after 30s — try again' : ((err && err.message) || 'unknown error'); + try { console.warn('[coaching] LLM coach failed:', msg); } catch (_) {} + try { _setLlmStatus('Coaching plan failed: ' + msg, true); } catch (_) {} + try { if (window.slopsmith && window.slopsmith.emit) window.slopsmith.emit('coaching:summary-error', { feedback, error: msg }); } catch (_) {} + return null; + } finally { + if (timer) clearTimeout(timer); + } + } + + // Visible LLM state — a single status line appended to the panel so + // the coach is never a silent no-op. Idempotent: reuses the node. + function _setLlmStatus(text, isError) { + try { + const panel = document.getElementById('coaching-summary-panel'); + if (!panel) return; + let el = panel.querySelector('#coaching-llm-status'); + if (!el) { + el = document.createElement('div'); + el.id = 'coaching-llm-status'; + panel.appendChild(el); + } + el.className = 'mt-3 text-xs rounded-lg px-3 py-2 border ' + + (isError ? 'border-red-700 bg-red-900/30 text-red-300' : 'border-gray-700 bg-dark-600 text-gray-400'); + el.textContent = text || ''; + el.style.display = text ? '' : 'none'; + } catch (_) {} + } + + // End-of-song panel. Shared shell — create/update the single panel + // element, then wire the close button and every "Practice this" + // button to the drill conductor. Defensive + idempotent; uses only + // core utilities so no plugin stylesheet is needed. Returns the + // panel (or null if there's no host yet). + function _renderPanel(innerHtml) { + const host = document.getElementById('player') || document.body; + if (!host) return null; + let panel = document.getElementById('coaching-summary-panel'); + if (!panel) { + panel = document.createElement('div'); + panel.id = 'coaching-summary-panel'; + panel.className = 'fixed top-16 right-4 z-[150] w-96 max-w-[calc(100vw-2rem)] max-h-[calc(100vh-6rem)] overflow-y-auto bg-dark-700 border border-gray-700 rounded-xl p-4 shadow-2xl'; + host.appendChild(panel); + } + // Diagnostic download lives here now (note_detect's modal, which + // used to carry it, is suppressed). Only show it when the export + // is actually reachable on the note_detect API. + const canDiag = typeof window !== 'undefined' && window.noteDetect + && typeof window.noteDetect.downloadDiagnostic === 'function'; + const diagBtn = canDiag + ? '' + : ''; + panel.innerHTML = '' + + diagBtn + innerHtml; + const close = panel.querySelector('#coaching-summary-close'); + if (close) close.onclick = () => { try { panel.remove(); } catch (_) {} }; + const diag = panel.querySelector('#coaching-diag-dl'); + if (diag) diag.onclick = () => { try { window.noteDetect.downloadDiagnostic(); } catch (_) {} }; + panel.querySelectorAll('.coaching-drill-btn').forEach((btn) => { + btn.onclick = () => _startDrillFromButton(btn); + }); + return panel; + } + + // The free, no-key panel built straight from PlayFeedback (score + + // hotspots + Practice buttons). Shown at every song end so the drill + // loop is reachable without the LLM coach. + function _showFeedbackPanel(feedback) { + try { if (feedback) _renderPanel(renderFeedbackHtml(feedback)); } + catch (err) { try { console.warn('[coaching] feedback panel failed:', err && err.message); } catch (_) {} } + } + + // The richer LLM-plan panel. Replaces the feedback panel in place + // when the plan lands. Model output is escaped in renderSummaryHtml. + function _showSummaryPanel(summary, feedback) { + try { + const fb = feedback || (typeof window !== 'undefined' ? window.__coachingLastFeedback : null); + const hotspots = (fb && Array.isArray(fb.hotspots)) ? fb.hotspots : []; + _renderPanel(renderSummaryHtml(summary, hotspots)); + } catch (err) { + try { console.warn('[coaching] panel render failed:', err && err.message); } catch (_) {} + } + } + + // Click handler for a "Practice this" button: read the hotspot's drill + // range off the data-attrs and hand it to note_detect's conductor. The + // conductor (window.noteDetect.startDrill) owns the slowed A-B loop + + // goal-gated speed ramp; coaching just supplies the where/how-slow. + function _startDrillFromButton(btn) { + const nd = (typeof window !== 'undefined') ? window.noteDetect : null; + if (!nd || typeof nd.startDrill !== 'function') { + btn.textContent = 'Drill needs note_detect ≥ 1.16'; + return; + } + const args = drillArgsFromDataset(btn.dataset); + if (!args) { btn.textContent = 'No drill range'; return; } + Promise.resolve(nd.startDrill(args.start, args.end, args.opts)).then((ok) => { + // Collapse the panel so the highway is visible while drilling. + if (ok) { try { const p = document.getElementById('coaching-summary-panel'); if (p) p.remove(); } catch (_) {} } + else { btn.textContent = 'Couldn’t start drill'; } + }).catch(() => { btn.textContent = 'Drill failed'; }); + } + if (window.slopsmith && typeof window.slopsmith.on === 'function') { + window.slopsmith.on('coaching:summary', (ev) => { + // feedBack's window.feedBack bus is an EventTarget: on() + // delivers a CustomEvent with the payload on `.detail`. + // slopsmith's bus passed the payload directly. Accept both. + const p = (ev && ev.detail) ? ev.detail : ev; + if (p && p.summary) _showSummaryPanel(p.summary, p && p.feedback); + }); + } + + // Console / inter-plugin API. Settings + manual triggers; the panel + // mirrors what the settings.html UI writes. + try { + window.coaching = Object.assign(window.coaching || {}, { + getApiKey: settings.getApiKey, setApiKey: settings.setApiKey, + getModel: settings.getModel, setModel: settings.setModel, + isEnabled: settings.isEnabled, setEnabled: settings.setEnabled, + requestCoaching, summarizeForCoach, buildCoachRequest, parseCoachSummary, renderSummaryHtml, + showSummaryPanel: _showSummaryPanel, + showFeedbackPanel: _showFeedbackPanel, + renderFeedbackHtml, + // Manually pop the panel for the last play — handy for testing + // the drill buttons without replaying. + showLast: () => _showFeedbackPanel(window.__coachingLastFeedback), + coachLast: () => _coachIfEnabled(window.__coachingLastFeedback), + }); + } catch (_) {} + + function _gatherSessionMeta(agg) { + const nd = window.noteDetect; + let settings = null, song = agg.song || null, arrangement = agg.arrangement || null; + let recording = null, tuning = agg.tuning || null, capo = agg.capo; + try { + if (nd && nd.getDiagnostic) { + const d = nd.getDiagnostic() || {}; + settings = d.settings || null; + if (d.song) { + song = song || d.song.title || null; + arrangement = arrangement || d.song.arrangement || null; + tuning = tuning || d.song.tuning || null; + if (capo == null) capo = d.song.capo; + } + } + } catch (_) {} + try { if (nd && nd.getRecordingState) { const r = nd.getRecordingState() || {}; recording = { wavPath: r.lastSavePath || null }; } } catch (_) {} + return { song, arrangement, settings, recording, tuning, capo, sections: agg.sections || [] }; + } + })(); +} diff --git a/plugins/coaching/plugin.json b/plugins/coaching/plugin.json new file mode 100644 index 00000000..6b76c2ca --- /dev/null +++ b/plugins/coaching/plugin.json @@ -0,0 +1,9 @@ +{ + "id": "coaching", + "name": "Coaching", + "version": "0.13.0", + "description": "Turns each play into an actionable practice plan from note_detect's per-note judgments.", + "category": "practice", + "script": "coaching.js", + "settings": { "html": "settings.html" } +} diff --git a/plugins/coaching/settings.html b/plugins/coaching/settings.html new file mode 100644 index 00000000..22e6fd02 --- /dev/null +++ b/plugins/coaching/settings.html @@ -0,0 +1,108 @@ + + +
    +

    + After each play, the coaching plugin can send the play feedback to the Anthropic API using + your own API key and get back a prioritized practice plan (shown in a panel over the player). + Your key is stored only in this browser and is sent directly to Anthropic — it never reaches the slopsmith server. +

    + + + +
    +
    + + +
    + No key set. + +
    +

    + Stored in localStorage on this device only. Get a key at console.anthropic.com. +

    +
    + +
    + + +

    Default: claude-haiku-4-5 (cheapest). Use claude-opus-4-8 for best coaching quality.

    +
    +
    +
    + + diff --git a/plugins/coaching/test/coach.test.js b/plugins/coaching/test/coach.test.js new file mode 100644 index 00000000..2597419f --- /dev/null +++ b/plugins/coaching/test/coach.test.js @@ -0,0 +1,482 @@ +// LLM coach tests — the client-side Anthropic call that fills session.summary. +// Pure pieces (distill / build request / parse response) are tested directly; +// the one impure seam (requestCoaching) is tested with an injected fake fetch, +// so nothing here touches the network or needs a real key. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { + buildPlayFeedback, + summarizeForCoach, buildCoachRequest, parseCoachSummary, requestCoaching, + COACH_MODEL, COACH_SUMMARY_SCHEMA, + makeSettings, escapeHtml, renderSummaryHtml, renderFeedbackHtml, + renderBreakdownHtml, renderMissedNotesHtml, renderTimingHtml, + drillLadderFromSpeedMul, drillArgsFromDataset, +} = require('../coaching.js'); + +// Minimal Storage-like fake for settings tests. +function fakeStorage(seed) { + const m = new Map(Object.entries(seed || {})); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => { m.set(k, String(v)); }, + removeItem: (k) => { m.delete(k); }, + _map: m, + }; +} + +function hit(t) { + return { hit: true, note: { s: 1, f: 5 }, noteTime: t, expectedMidi: 38, detectedMidi: 38, + confidence: 0.8, timingState: 'OK', pitchState: 'OK', timingError: 5, pitchError: 3 }; +} +function lateMiss(t, te) { + return { hit: false, note: { s: 1, f: 5 }, noteTime: t, expectedMidi: 38, detectedMidi: 38, + confidence: 0.5, timingState: 'LATE', pitchState: 'OK', timingError: te, pitchError: 4 }; +} +function noDetMiss(t) { + return { hit: false, note: { s: 0, f: 3 }, noteTime: t, expectedMidi: 31, detectedMidi: null, + confidence: 0, timingState: null, pitchState: null, timingError: null, pitchError: null }; +} +function sampleFeedback() { + const js = []; + for (let i = 0; i < 20; i++) js.push(hit(i * 2)); + [[20, 118], [20.5, 122], [21, 120], [21.5, 124]].forEach(([t, te]) => js.push(lateMiss(t, te))); + [30, 30.5, 31].forEach(t => js.push(noDetMiss(t))); + return buildPlayFeedback(js, { song: 'Gasoline', arrangement: 'bass' }); +} + +// A well-formed Messages API response carrying the structured summary. +function apiOk(summary) { + return { type: 'message', stop_reason: 'end_turn', content: [{ type: 'text', text: JSON.stringify(summary) }] }; +} +const goodSummary = { + headline: 'Strong run — one timing skew to chase.', + priorities: [ + { focus: 'Bars around 0:20', why: 'consistent +120ms late', drill: 'Metronome at 70%', hotspotKey: 'Gasoline|bass|20-21' }, + ], + toolNotes: 'The 0:20 skew looks systematic — check A/V calibration before drilling it.', + encouragement: 'Great consistency overall, keep it up!', +}; + +test('summarizeForCoach surfaces the wrong-string signal (seSelf/seTop/seTopStr) on a miss', () => { + // A no-detection miss on charted string 0, but the per-string energy shows + // string 1 rang loudest → the coach should be handed "wrong string". + const wrong = { hit: false, note: { s: 0, f: 3 }, noteTime: 42, expectedMidi: 31, detectedMidi: null, + confidence: 0, timingState: null, pitchState: null, timingError: null, pitchError: null, + stringEnergy: [0.1, 0.9, 0.2, 0.2] }; + const fb = buildPlayFeedback([hit(0), hit(2), wrong], { song: 'X', arrangement: 'bass' }); + const d = summarizeForCoach(fb); + const note = d.mistakes.find((m) => m.t === 42); + assert.ok(note, 'the miss is in the per-note stream'); + assert.equal(note.seSelf, 0.1, 'energy on the string the player should have hit'); + assert.equal(note.seTop, 0.9, 'loudest string energy'); + assert.equal(note.seTopStr, 1, 'loudest string was string 1 — not the charted string 0'); +}); + +test('summarizeForCoach sends session + totals + hotspots + the per-note mistake stream', () => { + const d = summarizeForCoach(sampleFeedback()); + assert.equal(d.schema, 'coaching.play_feedback.v1'); + assert.equal(d.session.song, 'Gasoline'); + assert.equal(d.totals.mistakes, 7); + assert.equal(d.totals.byFailureType.late, 4); + assert.equal(d.totals.byFailureType.no_detection, 3); + assert.equal(d.totals.byFaultVerdict.detector_suspect, 3); + assert.ok(Array.isArray(d.hotspots) && d.hotspots.length >= 1); + assert.ok('signal' in d.hotspots[0] && 'drill' in d.hotspots[0]); + // The LLM now reasons over the actual notes, not just counts. + assert.ok(Array.isArray(d.mistakes) && d.mistakes.length === 7, 'per-note mistake stream sent'); + assert.ok('t' in d.mistakes[0] && 's' in d.mistakes[0] && 'type' in d.mistakes[0] && 'fault' in d.mistakes[0]); + assert.ok(Array.isArray(d.missedNotes), 'missed-by-position grouping sent'); +}); + +test('buildCoachRequest: model, cache_control on static system, structured-output schema, play data in user turn', () => { + const req = buildCoachRequest(sampleFeedback()); + assert.equal(req.model, COACH_MODEL); + assert.equal(req.system[0].cache_control.type, 'ephemeral'); + assert.deepEqual(req.output_config.format.schema, COACH_SUMMARY_SCHEMA); + // distilled play data rides AFTER the cached system prefix + assert.equal(req.messages[0].role, 'user'); + assert.match(req.messages[0].content, /play_feedback\.v1/); + // model override honored + assert.equal(buildCoachRequest(sampleFeedback(), { model: 'claude-haiku-4-5' }).model, 'claude-haiku-4-5'); +}); + +test('summary schema stays within the structured-outputs supported subset', () => { + const walk = (s) => { + if (s.type === 'object') { + assert.equal(s.additionalProperties, false, 'objects must set additionalProperties:false'); + for (const k of Object.keys(s.properties || {})) walk(s.properties[k]); + } else if (s.type === 'array') { + walk(s.items); + } + for (const banned of ['minLength', 'maxLength', 'minimum', 'maximum', 'multipleOf', 'minItems', 'maxItems']) { + assert.ok(!(banned in s), 'unsupported keyword: ' + banned); + } + }; + walk(COACH_SUMMARY_SCHEMA); +}); + +test('parseCoachSummary: valid structured JSON', () => { + const out = parseCoachSummary(apiOk(goodSummary)); + assert.equal(out.headline, goodSummary.headline); + assert.equal(out.priorities.length, 1); +}); + +test('parseCoachSummary: recovers a fenced / prose-wrapped object', () => { + const fenced = { stop_reason: 'end_turn', content: [{ type: 'text', text: '```json\n' + JSON.stringify(goodSummary) + '\n```' }] }; + assert.equal(parseCoachSummary(fenced).headline, goodSummary.headline); +}); + +test('parseCoachSummary: throws on refusal, error, missing fields, no text', () => { + assert.throws(() => parseCoachSummary({ stop_reason: 'refusal', content: [] }), /refused/); + assert.throws(() => parseCoachSummary({ type: 'error', error: { message: 'bad key' } }), /bad key/); + assert.throws(() => parseCoachSummary(apiOk({ priorities: [] })), /required fields/); + assert.throws(() => parseCoachSummary({ content: [{ type: 'thinking', thinking: 'x' }] }), /no text block/); +}); + +test('requestCoaching: posts with browser-direct headers, returns parsed summary, stamps model', async () => { + let captured = null; + const fakeFetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, json: async () => apiOk(goodSummary) }; + }; + const out = await requestCoaching(sampleFeedback(), { apiKey: 'sk-ant-test', fetchImpl: fakeFetch }); + assert.equal(out.headline, goodSummary.headline); + assert.equal(out._model, COACH_MODEL); + assert.match(captured.url, /api\.anthropic\.com\/v1\/messages/); + assert.equal(captured.init.headers['x-api-key'], 'sk-ant-test'); + assert.equal(captured.init.headers['anthropic-dangerous-direct-browser-access'], 'true'); + assert.equal(captured.init.headers['anthropic-version'], '2023-06-01'); + const body = JSON.parse(captured.init.body); + assert.equal(body.model, COACH_MODEL); + assert.ok(body.output_config.format.schema); +}); + +test('requestCoaching: no key throws before any fetch', async () => { + let called = false; + await assert.rejects( + requestCoaching(sampleFeedback(), { fetchImpl: async () => { called = true; return {}; } }), + /no Anthropic API key/); + assert.equal(called, false); +}); + +test('requestCoaching: surfaces 401 as an auth error', async () => { + const fakeFetch = async () => ({ ok: false, status: 401, json: async () => ({ error: { message: 'invalid x-api-key' } }) }); + await assert.rejects(requestCoaching(sampleFeedback(), { apiKey: 'bad', fetchImpl: fakeFetch }), /auth failed/); +}); + +test('requestCoaching: surfaces 429 as rate-limit', async () => { + const fakeFetch = async () => ({ ok: false, status: 429, json: async () => ({ error: { message: 'slow down' } }) }); + await assert.rejects(requestCoaching(sampleFeedback(), { apiKey: 'k', fetchImpl: fakeFetch }), /rate-limited/); +}); + +test('makeSettings: round-trips key/model, defaults, and enable semantics', () => { + const st = fakeStorage(); + const s = makeSettings(st); + // defaults with no key + assert.equal(s.getApiKey(), null); + assert.equal(s.getModel(), COACH_MODEL); + assert.equal(s.isEnabled(), false, 'no key → disabled even if flag unset'); + // set a key → enabled by default + s.setApiKey('sk-ant-xyz'); + assert.equal(s.getApiKey(), 'sk-ant-xyz'); + assert.equal(s.isEnabled(), true, 'key present + flag unset → enabled'); + // explicit opt-out + s.setEnabled(false); + assert.equal(s.isEnabled(), false); + s.setEnabled(true); + assert.equal(s.isEnabled(), true); + // model override + clear + s.setModel('claude-haiku-4-5'); + assert.equal(s.getModel(), 'claude-haiku-4-5'); + s.setModel(''); + assert.equal(s.getModel(), COACH_MODEL, 'empty model clears back to default'); + // clearing the key + s.setApiKey(''); + assert.equal(s.getApiKey(), null); + assert.equal(st.getItem('coaching.anthropicApiKey'), null); +}); + +test('makeSettings: tolerates a null storage (private mode / no localStorage)', () => { + const s = makeSettings(null); + assert.doesNotThrow(() => s.setApiKey('x')); + assert.equal(s.getApiKey(), null); + assert.equal(s.getModel(), COACH_MODEL); + assert.equal(s.isEnabled(), false); +}); + +test('escapeHtml: neutralizes angle brackets, quotes, ampersands', () => { + assert.equal(escapeHtml(''), '<img src=x onerror=alert(1)>'); + assert.equal(escapeHtml('a & "b" \'c\''), 'a & "b" 'c''); + assert.equal(escapeHtml(null), ''); +}); + +test('renderSummaryHtml: includes fields and escapes untrusted model text', () => { + const html = renderSummaryHtml({ + headline: 'Nice run', + priorities: [{ focus: 'Bars 0:20', why: 'late', drill: 'metronome', hotspotKey: 'Gasoline|bass|20-21' }], + toolNotes: 'check A/V', + encouragement: 'keep going', + }); + assert.match(html, /Nice <b>run<\/b>/, 'headline escaped, no raw tags'); + assert.ok(!/run<\/b>/.test(html), 'no unescaped tag leaks through'); + assert.match(html, /Bars 0:20/); + assert.match(html, /metronome/); + assert.match(html, /Gasoline\|bass\|20-21/); + assert.match(html, /check A\/V/); + assert.match(html, /keep going/); +}); + +test('renderSummaryHtml: safe on empty/partial summary', () => { + assert.match(renderSummaryHtml({}), /Practice plan/); + assert.doesNotThrow(() => renderSummaryHtml(null)); + assert.doesNotThrow(() => renderSummaryHtml({ priorities: [{}] })); +}); + +test('renderFeedbackHtml: no-key panel shows score + a Practice button per hotspot', () => { + const fb = { + session: { score: 0.62 }, + hotspots: [{ + key: 'Gasoline|bass|20-21', + bounds: { startSec: 20, endSec: 22 }, + evidence: { noteCount: 4, missRate: 0.75 }, + signal: { kind: 'random' }, + drill: { loopA: 18.5, loopB: 23, speedMul: 0.7, goal: 0.85 }, + }], + }; + const html = renderFeedbackHtml(fb); + assert.match(html, /Practice spots/); + assert.match(html, /62% clean/); + assert.match(html, /coaching-drill-btn/, 'drill button present without any LLM summary'); + assert.match(html, /data-loop-a="18\.5"/); + assert.match(html, /0:20–0:22/, 'mm:ss range'); + assert.match(html, /4 notes/); +}); + +test('buildPlayFeedback: two-score split separates tool-fault from player-fault', () => { + const js = []; + for (let i = 0; i < 8; i++) js.push({ hit: true, noteTime: i, note: { s: 1, f: 5 }, expectedMidi: 45, detectedMidi: 45 }); + js.push({ hit: false, noteTime: 8, note: { s: 1, f: 5 }, expectedMidi: 45, detectedMidi: 45, timingState: 'LATE', timingError: 130 }); + for (const t of [9, 10, 11]) js.push({ hit: false, noteTime: t, note: { s: 0, f: 3 }, expectedMidi: 31, detectedMidi: null }); + const fb = buildPlayFeedback(js, { song: 'Gasoline', arrangement: 'bass' }); + const s = fb.session; + // hits=8, player-fault=1 (late), tool-fault=3 (unheard). heard=9, total=12. + assert.equal(s.faultSplit.player, 1); + assert.equal(s.faultSplit.detector, 3); + assert.equal(s.playerAccuracy, Math.round((8 / 9) * 1000) / 1000); // of verifiable notes + assert.equal(s.detectionCoverage, Math.round((9 / 12) * 1000) / 1000); // of all notes + assert.equal(s.score, Math.round((8 / 12) * 1000) / 1000); // legacy stays hits/total + assert.equal(s.missByType.late, 1); + assert.equal(s.missByType.no_detection, 3); +}); + +test('renderSummaryHtml: LLM loops render as drillable Practice buttons', () => { + const summary = { + headline: 'Solid', priorities: [], toolNotes: '', encouragement: 'nice', + loops: [{ label: 'Chorus run', startSec: 100, endSec: 115, speedMul: 0.7, goal: 0.9 }], + }; + const html = renderSummaryHtml(summary, []); + assert.match(html, /Drill loops/); + assert.match(html, /Chorus run/); + assert.match(html, /1:40.1:55/); + assert.match(html, /data-loop-a="100"/); + assert.match(html, /data-loop-b="115"/); + assert.match(html, /coaching-drill-btn/); +}); + +test('renderMissedNotesHtml: groups missed notes by string/fret with counts', () => { + const js = []; + for (let i = 0; i < 5; i++) js.push({ hit: true, noteTime: i, note: { s: 1, f: 5 }, expectedMidi: 38, detectedMidi: 38 }); + for (const t of [9, 10, 11]) js.push({ hit: false, noteTime: t, note: { s: 0, f: 5 }, expectedMidi: 33, detectedMidi: null }); // A1 x3 + js.push({ hit: false, noteTime: 12, note: { s: 0, f: 2 }, expectedMidi: 30, detectedMidi: null }); // F#1 x1 + const fb = buildPlayFeedback(js, { song: 'Whyd', arrangement: 'bass' }); + const html = renderMissedNotesHtml(fb); + assert.match(html, /What you missed/); + assert.match(html, /A1/); + assert.match(html, /E string . fret 5/); + assert.match(html, /F#1/); + assert.ok(html.indexOf('A1') < html.indexOf('F#1'), 'most-missed first'); + assert.equal(renderMissedNotesHtml({ mistakes: [] }), ''); +}); + +test('renderBreakdownHtml: shows both scores, the tool-fault verdict, and an Unheard bar', () => { + const js = []; + for (let i = 0; i < 8; i++) js.push({ hit: true, noteTime: i, note: { s: 1, f: 5 }, expectedMidi: 45, detectedMidi: 45 }); + for (const t of [9, 10, 11]) js.push({ hit: false, noteTime: t, note: { s: 0, f: 3 }, expectedMidi: 31, detectedMidi: null }); + const fb = buildPlayFeedback(js, { song: 'Gasoline', arrangement: 'bass' }); + const html = renderBreakdownHtml(fb); + assert.match(html, /played clean/); + assert.match(html, /detector heard/); + assert.match(html, /Tool, not you/, 'tool-fault dominant verdict'); + assert.match(html, /Unheard/); + assert.match(html, /G1/, 'names the dropped low pitch'); + // Pre-split feedback (legacy shape) renders nothing. + assert.equal(renderBreakdownHtml({ session: { score: 0.5 } }), ''); +}); + +test('findHotspots: loosely-spread misses (a sloppy 75% play) still surface a hotspot', () => { + // 20 notes every 2s; miss every other one from t=20 (misses ~4s apart in + // pairs ~2s where adjacent) — the old 3-within-2s rule found nothing. + const js = []; + for (let i = 0; i < 10; i++) js.push({ hit: true, noteTime: i * 2, note: { s: 1, f: 0 }, expectedMidi: 40 }); + // Two misses 3s apart — a phrase flubbed twice. Under the new 2-within-3s + // rule this is a hotspot; under the old rule it was invisible. These are + // PLAYER faults (late) — hotspots cluster player faults, not unheard + // dropouts. + [{ t: 21 }, { t: 24 }].forEach(({ t }) => + js.push({ hit: false, noteTime: t, note: { s: 1, f: 5 }, expectedMidi: 45, detectedMidi: 45, timingState: 'LATE', timingError: 130 })); + const fb = buildPlayFeedback(js, { song: 'Oxygen', arrangement: 'bass' }); + assert.ok(fb.hotspots.length >= 1, 'a 2-miss spread now yields a hotspot'); +}); + +test('buildPlayFeedback: loose hits (sloppy-but-hit) cluster into looseHotspots and drop cleanRate', () => { + // A note_detect hit that graded NOT clean: still hit:true, but clean:false + // with a looseReason. The detector heard it — it's a tightness gap. + function looseHit(t, te, reason) { + return { hit: true, clean: false, looseReason: reason, note: { s: 1, f: 5 }, + noteTime: t, expectedMidi: 38, detectedMidi: 38, confidence: 0.8, + timingState: 'OK', pitchState: 'OK', timingError: te, pitchError: 4 }; + } + const js = []; + // 12 tight hits scattered, then a run of consistently-late-but-hit notes. + for (let i = 0; i < 12; i++) js.push(hit(i)); + [13, 13.6, 14.2, 14.8, 15.4].forEach(t => js.push(looseHit(t, 70, 'timing'))); + const fb = buildPlayFeedback(js, { song: 'Tight', arrangement: 'bass' }); + + // Loose hits never become misses (they ARE hits) — so accuracy stays high… + assert.equal(fb.mistakes.length, 0, 'loose hits are not misses'); + assert.equal(fb.session.playerAccuracy, 1, 'all notes hit'); + // …but cleanRate exposes the sloppiness, and a looseHotspot is raised. + assert.equal(fb.session.looseHits, 5, 'five loose hits counted'); + assert.ok(fb.session.cleanRate < 0.8, `cleanRate reflects loose hits (got ${fb.session.cleanRate})`); + assert.ok(fb.looseHotspots.length >= 1, 'the loose run surfaces a looseHotspot'); + assert.equal(fb.looseHotspots[0].kind, 'loose'); + assert.ok(fb.looseHotspots[0].practice, 'looseHotspot carries a drill recommendation'); +}); + +test('buildPlayFeedback: timing report buckets heard notes and calls consistent drag', () => { + const js = []; + // 12 heard notes, all ~+35ms behind the beat → a DRAG verdict. + for (let i = 0; i < 12; i++) js.push({ hit: true, note: { s: 1, f: 5 }, noteTime: i, + expectedMidi: 38, detectedMidi: 38, confidence: 0.8, timingState: 'OK', pitchState: 'OK', timingError: 35, pitchError: 2 }); + const fb = buildPlayFeedback(js, { song: 'X', arrangement: 'bass' }); + assert.ok(fb.timing, 'timing report attached'); + assert.equal(fb.timing.count, 12); + assert.equal(fb.timing.verdict.kind, 'drag'); + assert.equal(fb.timing.median, 35); + const html = renderTimingHtml(fb); + assert.match(html, /Timing/); + assert.match(html, /DRAG/); +}); + +test('buildPlayFeedback: too few timing samples → no report, empty render', () => { + const fb = buildPlayFeedback([{ hit: true, note: { s: 1, f: 5 }, noteTime: 0, timingError: 5, timingState: 'OK' }], {}); + assert.equal(fb.timing, null); + assert.equal(renderTimingHtml(fb), ''); +}); + +test('buildPlayFeedback: a clean take has cleanRate 1 and no looseHotspots', () => { + const js = []; + for (let i = 0; i < 10; i++) js.push({ ...hit(i), clean: true }); + const fb = buildPlayFeedback(js, { song: 'Clean', arrangement: 'bass' }); + assert.equal(fb.session.cleanRate, 1); + assert.equal(fb.session.looseHits, 0); + assert.equal(fb.looseHotspots.length, 0); +}); + +test('renderFeedbackHtml: a play with spread misses says consistency, not "nice"', () => { + // No cluster (misses far apart) but several of them — honest message. + const fb = { session: { score: 0.75 }, mistakes: [{}, {}, {}, {}], hotspots: [] }; + const html = renderFeedbackHtml(fb); + assert.match(html, /4 misses/); + assert.match(html, /consistency/); + assert.ok(!/nice/.test(html), 'no "nice" when there are real misses'); +}); + +test('renderFeedbackHtml: a non-clean play with zero recorded misses says "no notes scored", not "nice"', () => { + // The 0%-clean-but-no-mistakes case: nothing was judged. Must NOT say nice. + const html = renderFeedbackHtml({ session: { score: 0 }, mistakes: [], hotspots: [] }); + assert.match(html, /No notes were scored/); + assert.ok(!/nice/.test(html), 'no false "nice" at 0% with no data'); +}); + +test('renderFeedbackHtml: clean play and empty/degenerate input are safe', () => { + assert.match(renderFeedbackHtml({ session: { score: 1 }, hotspots: [] }), /No recurring trouble spots/); + assert.doesNotThrow(() => renderFeedbackHtml(null)); + assert.doesNotThrow(() => renderFeedbackHtml({})); + // A hotspot with a degenerate drill range renders the row but no button. + const html = renderFeedbackHtml({ hotspots: [{ key: 'k', bounds: { startSec: 5, endSec: 6 }, evidence: {}, drill: { loopA: 5, loopB: 5 } }] }); + assert.ok(!/coaching-drill-btn/.test(html), 'no button for an unusable drill range'); +}); + +test('renderFeedbackHtml: flags a systematic hotspot as possible calibration', () => { + const html = renderFeedbackHtml({ + hotspots: [{ key: 'k', bounds: { startSec: 1, endSec: 2 }, evidence: { noteCount: 3, missRate: 0.9 }, + signal: { kind: 'systematic' }, drill: { loopA: 0, loopB: 3, speedMul: 0.7, goal: 0.85 } }], + }); + assert.match(html, /systematic/); +}); + +test('drillArgsFromDataset: parses a Practice button into startDrill args', () => { + const args = drillArgsFromDataset({ + loopA: '18.5', loopB: '22', speedMul: '0.7', goal: '0.85', + focus: 'Bars 0:20', key: 'Gasoline|bass|20-21', + }); + assert.equal(args.start, 18.5); + assert.equal(args.end, 22); + assert.equal(args.opts.label, 'Gasoline|bass|20-21'); + assert.equal(args.opts.focus, 'Bars 0:20'); + assert.equal(args.opts.goal, 0.85); + assert.deepEqual(args.opts.speedLadder, [0.7, 0.85, 1.0]); +}); + +test('drillArgsFromDataset: null when the range is unusable; goal defaults when absent', () => { + assert.equal(drillArgsFromDataset({ loopB: '22' }), null, 'missing loopA → null'); + assert.equal(drillArgsFromDataset({ loopA: 'x', loopB: '22' }), null, 'non-numeric loopA → null'); + assert.equal(drillArgsFromDataset(null), null, 'no dataset → null'); + // No goal attr → undefined so startDrill applies its own default. Label + // falls back to focus, then 'Hotspot'. + const a = drillArgsFromDataset({ loopA: '1', loopB: '3' }); + assert.equal(a.opts.goal, undefined); + assert.equal(a.opts.label, 'Hotspot'); + assert.deepEqual(a.opts.speedLadder, [0.7, 0.85, 1.0], 'junk speedMul → default ladder'); +}); + +test('drillLadderFromSpeedMul: ascending ladder slow → full, defaults on junk', () => { + assert.deepEqual(drillLadderFromSpeedMul(0.7), [0.7, 0.85, 1.0]); + assert.deepEqual(drillLadderFromSpeedMul(0.5), [0.5, 0.75, 1.0]); + // >=1 / 0 / NaN / negative all fall back to the 0.7 base. + assert.deepEqual(drillLadderFromSpeedMul(1.0), [0.7, 0.85, 1.0]); + assert.deepEqual(drillLadderFromSpeedMul(0), [0.7, 0.85, 1.0]); + assert.deepEqual(drillLadderFromSpeedMul(NaN), [0.7, 0.85, 1.0]); + assert.deepEqual(drillLadderFromSpeedMul(-0.5), [0.7, 0.85, 1.0]); +}); + +test('renderSummaryHtml: a priority mapped to a drillable hotspot sprouts a Practice button', () => { + const summary = { + headline: 'Plan', + priorities: [{ focus: 'Bars 0:20', why: 'late', drill: 'metronome', hotspotKey: 'Gasoline|bass|20-21' }], + }; + const hotspots = [{ + key: 'Gasoline|bass|20-21', + drill: { loopA: 18.5, loopB: 22.0, speedMul: 0.7, goal: 0.85 }, + }]; + const html = renderSummaryHtml(summary, hotspots); + assert.match(html, /coaching-drill-btn/, 'button rendered for the matched hotspot'); + assert.match(html, /data-loop-a="18\.5"/); + assert.match(html, /data-loop-b="22"/); + assert.match(html, /data-goal="0\.85"/); + assert.match(html, /Practice this/); +}); + +test('renderSummaryHtml: no button without hotspots (backward-compatible) or on key mismatch / bad range', () => { + const summary = { + priorities: [{ focus: 'Bars 0:20', why: 'late', drill: 'metronome', hotspotKey: 'Gasoline|bass|20-21' }], + }; + // No hotspots arg at all — the original one-arg call site. + assert.ok(!/coaching-drill-btn/.test(renderSummaryHtml(summary))); + // Key mismatch. + assert.ok(!/coaching-drill-btn/.test(renderSummaryHtml(summary, [{ key: 'other', drill: { loopA: 1, loopB: 2, speedMul: 0.7, goal: 0.85 } }]))); + // Degenerate range (loopB <= loopA) is not drillable. + assert.ok(!/coaching-drill-btn/.test(renderSummaryHtml(summary, [{ key: 'Gasoline|bass|20-21', drill: { loopA: 5, loopB: 5, speedMul: 0.7, goal: 0.85 } }]))); +}); diff --git a/plugins/coaching/test/feedback-schema.test.js b/plugins/coaching/test/feedback-schema.test.js new file mode 100644 index 00000000..199456be --- /dev/null +++ b/plugins/coaching/test/feedback-schema.test.js @@ -0,0 +1,215 @@ +// Coaching feedback schema/builder tests — the contract is only real if it's +// verified. Pure builder, so we feed synthetic note_detect judgments and assert +// the PlayFeedback shape + the two load-bearing fields (faultVerdict per +// mistake, systematic-vs-random per hotspot). + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildPlayFeedback, FAILURE_TYPES, FAULT_VERDICTS, renderFeedbackHtml } = require('../coaching.js'); + +// Judgment shaped like note_detect's: { note:{s,f}, noteTime, expectedMidi, +// detectedMidi, confidence, hit, timingState, pitchState, timingError(ms), +// pitchError(cents) }. +function hit(t) { + return { hit: true, note: { s: 1, f: 5 }, noteTime: t, expectedMidi: 38, detectedMidi: 38, + confidence: 0.8, timingState: 'OK', pitchState: 'OK', timingError: 5, pitchError: 3 }; +} +function lateMiss(t, te) { + return { hit: false, note: { s: 1, f: 5 }, noteTime: t, expectedMidi: 38, detectedMidi: 38, + confidence: 0.5, timingState: 'LATE', pitchState: 'OK', timingError: te, pitchError: 4 }; +} +function noDetMiss(t) { + return { hit: false, note: { s: 0, f: 3 }, noteTime: t, expectedMidi: 31, detectedMidi: null, + confidence: 0, timingState: null, pitchState: null, timingError: null, pitchError: null }; +} + +function muteFailMiss(t) { + // note_detect flagged the open string ringing in place of a fretted note. + return { hit: false, note: { s: 1, f: 2 }, noteTime: t, expectedMidi: 35, detectedMidi: null, + confidence: 0, timingState: null, pitchState: null, timingError: null, pitchError: null, muteFail: true }; +} + +function presentDropMiss(t) { + // no_detection, but note_detect MEASURED the note present in the audio: + // the player played it, the detector dropped it. + return { hit: false, note: { s: 0, f: 3 }, noteTime: t, expectedMidi: 31, detectedMidi: null, + confidence: 0, timingState: null, pitchState: null, timingError: null, pitchError: null, notePresent: true }; +} + +function sample() { + const js = []; + for (let i = 0; i < 20; i++) js.push(hit(i * 2)); // 20 hits, t=0..38 + // Late cluster at non-hit timestamps (hits are at even t; 20 would collide + // with hit(20) for the same note, which can't happen in one real pass). + [ [19.5, 118], [20.5, 122], [21, 120], [21.5, 124] ].forEach(([t, te]) => js.push(lateMiss(t, te))); // systematic late + [30, 30.5, 31].forEach(t => js.push(noDetMiss(t))); // no-detection cluster + return js; +} + +test('builds the PlayFeedback shape with the documented top-level keys', () => { + const fb = buildPlayFeedback(sample(), { song: 'Gasoline', arrangement: 'bass' }); + assert.equal(fb.schema, 'coaching.play_feedback.v1'); + assert.ok(fb.session && Array.isArray(fb.mistakes) && Array.isArray(fb.hotspots)); + assert.equal(fb.mistakes.length, 7); // 4 late + 3 no-detection + // session score = hits / total + assert.equal(fb.session.score, Math.round((20 / 27) * 1000) / 1000); + assert.equal(fb.session.song, 'Gasoline'); +}); + +test('every mistake carries the atom: id, chart anchor, failureType, faultVerdict', () => { + const fb = buildPlayFeedback(sample(), {}); + for (const m of fb.mistakes) { + assert.match(m.id, /^m\d+$/); + assert.ok(m.chart && typeof m.chart.t === 'number' && m.chart.expectedMidi != null); + assert.ok(FAILURE_TYPES.includes(m.failureType)); + assert.ok(FAULT_VERDICTS.includes(m.faultVerdict)); + } + const late = fb.mistakes.find(m => m.failureType === 'late'); + const noDet = fb.mistakes.find(m => m.failureType === 'no_detection'); + assert.equal(late.faultVerdict, 'player_error', 'a detection that fired but was late = player_error'); + assert.equal(noDet.faultVerdict, 'detector_suspect', 'no detection = detector_suspect (route to harness)'); + assert.equal(noDet.detected, null); +}); + +test('mute_fail judgment → failureType mute_fail, player_error (not detector_suspect)', () => { + const fb = buildPlayFeedback([hit(0), muteFailMiss(2)], {}); + const mf = fb.mistakes.find(m => m.failureType === 'mute_fail'); + assert.ok(mf, 'a mute_fail mistake is classified'); + assert.equal(mf.faultVerdict, 'player_error', 'open string rang instead of fretting = the player, not the detector'); + assert.equal(mf.detected, null); +}); + +test('notePresent promotes a no_detection miss to confirmed_detector_bug (not a player/drill target)', () => { + const fb = buildPlayFeedback([hit(0), presentDropMiss(2), presentDropMiss(4), noDetMiss(6)], {}); + const present = fb.mistakes.find(m => m.notePresent); + assert.equal(present.failureType, 'no_detection'); + assert.equal(present.faultVerdict, 'confirmed_detector_bug', 'measured present → confirmed tool miss, not a guess'); + // The ambiguous no_detection (no presence signal) stays detector_suspect. + const ambiguous = fb.mistakes.find(m => m.failureType === 'no_detection' && !m.notePresent); + assert.equal(ambiguous.faultVerdict, 'detector_suspect'); + // Confirmed tool misses are counted as detector misses and surfaced distinctly… + assert.equal(fb.session.faultSplit.confirmedToolMisses, 2); + assert.equal(fb.session.faultSplit.player, 0, 'a note the tool dropped is never a player miss'); + // …and never become a player hotspot to drill. + assert.equal(fb.hotspots.length, 0, 'confirmed tool misses do not create player drill targets'); +}); + +test('a note missed then nailed on a replay (seek-back) scores as a HIT, not a hotspot', () => { + // note_detect re-judges replayed notes; coaching keeps the LATEST verdict. + const sameNote = { s: 0, f: 3, t: 2 }; + const miss = { hit: false, note: { s: 0, f: 3 }, noteTime: 2, expectedMidi: 31, detectedMidi: null, + confidence: 0, timingState: null, pitchState: null }; + const hitOnReplay = hit(2); hitOnReplay.note = { s: 0, f: 3 }; hitOnReplay.expectedMidi = 31; hitOnReplay.detectedMidi = 31; + const fb = buildPlayFeedback([miss, hitOnReplay], {}); + assert.equal(fb.mistakes.length, 0, 'the replay hit supersedes the earlier miss'); + assert.equal(fb.session.faultSplit.player, 0); + void sameNote; +}); + +test('latest-verdict wins both ways: a hit then a later miss scores as a miss', () => { + const earlyHit = hit(5); earlyHit.note = { s: 1, f: 5 }; earlyHit.expectedMidi = 38; + const laterMiss = { hit: false, note: { s: 1, f: 5 }, noteTime: 5, expectedMidi: 38, detectedMidi: 38, + confidence: 0.5, timingState: 'LATE', pitchState: 'OK', timingError: 120, pitchError: 2 }; + const fb = buildPlayFeedback([earlyHit, laterMiss], {}); + assert.equal(fb.mistakes.length, 1, 'the later miss is the verdict that stands'); + assert.equal(fb.mistakes[0].failureType, 'late'); +}); + +function silentMiss(t) { + // no_detection AND note_detect measured the input silent → player stopped. + return { hit: false, note: { s: 0, f: 3 }, noteTime: t, expectedMidi: 31, detectedMidi: null, + confidence: 0, timingState: null, pitchState: null, silent: true }; +} + +test('a silent no_detection run is flagged stopped (you stopped — not the tool)', () => { + const fb = buildPlayFeedback([hit(0), silentMiss(30), silentMiss(31), silentMiss(32)], {}); + assert.ok(fb.unheardSpots.length >= 1, 'a clustered unheard run is found'); + assert.equal(fb.unheardSpots[0].stopped, true, 'measured-silent run → confident "you stopped"'); + assert.ok(fb.mistakes.some(m => m.silent === true), 'the silent flag rides the mistake atom'); +}); + +test('an energetic no_detection run is NOT flagged stopped (blind-spot stays hedged)', () => { + const fb = buildPlayFeedback([hit(0), noDetMiss(30), noDetMiss(31), noDetMiss(32)], {}); + assert.ok(fb.unheardSpots.length >= 1); + assert.ok(!fb.unheardSpots[0].stopped, 'no silence signal → ambiguous blind spot, not "stopped"'); +}); + +test('systematic late cluster → one hotspot, signal=systematic, drill params present', () => { + const fb = buildPlayFeedback(sample(), { song: 'Gasoline', arrangement: 'bass' }); + const late = fb.hotspots.find(h => h.bounds.startSec >= 19 && h.bounds.startSec <= 21); + assert.ok(late, 'late cluster surfaced as a hotspot'); + assert.equal(late.mistakeIds.length, 4); + assert.equal(late.signal.kind, 'systematic'); // consistent +120 ms skew + assert.ok(late.signal.medianTimingMs >= 118 && late.signal.medianTimingMs <= 124); + assert.equal(late.drill.speedMul, 0.7); + assert.equal(late.drill.goal, 0.85); + // The drill loop is the raw musical span — note_detect.startDrill owns + // the audible lead-in + first-note runway (no coaching-side run-in, which + // used to double-stack with note_detect's own lead-in). + assert.equal(late.drill.loopA, late.bounds.startSec, 'drill loopA = cluster start (no coaching run-in)'); + assert.equal(late.drill.loopB, late.bounds.endSec, 'drill loopB = cluster end (no coaching run-out)'); + assert.ok(late.severity > 0); + assert.match(late.key, /Gasoline\|bass\|/); +}); + +test('no-detection cluster surfaces as an UNHEARD spot, never a player hotspot', () => { + // Player hotspots cluster only player faults (late/early/sharp/flat). A run + // of unheard notes must NOT contaminate those — but it DOES surface as a + // distinct `unheardSpots` entry: an honest drill target with the caveat + // that it may be the detector's low-string blind spot, not a flub. + const fb = buildPlayFeedback(sample(), {}); + const inHotspots = fb.hotspots.find(h => h.bounds.startSec >= 29 && h.bounds.startSec <= 31); + assert.equal(inHotspots, undefined, 'no-detection cluster must NOT become a player hotspot'); + const unheard = fb.unheardSpots.find(h => h.bounds.startSec >= 29 && h.bounds.startSec <= 31); + assert.ok(unheard, 'no-detection cluster surfaces as an unheard spot'); + assert.equal(unheard.mistakeIds.length, 3, 'all three unheard notes clustered'); + // Drillable on raw bounds, with the gentler goal (detector may under-score). + assert.ok(unheard.drill && unheard.drill.loopB > unheard.drill.loopA, 'unheard spot is drillable'); + assert.equal(unheard.drill.goal, 0.6, 'gentler goal for an unheard run'); + // Still tracked as detector_suspect mistakes for the breakdown / coverage. + const ndMistakes = fb.mistakes.filter(m => m.faultVerdict === 'detector_suspect'); + assert.equal(ndMistakes.length, 3, 'detector-suspect misses still tracked'); +}); + +test('hotspots reference mistakes by id, not nested copies', () => { + const fb = buildPlayFeedback(sample(), {}); + for (const h of fb.hotspots) { + for (const id of h.mistakeIds) { + assert.equal(typeof id, 'string'); + assert.ok(fb.mistakes.some(m => m.id === id), 'id resolves to a real mistake'); + } + } +}); + +test('scattered timing → random (not systematic)', () => { + const js = []; + for (let i = 0; i < 10; i++) js.push(hit(i)); + [[20, -90], [20.5, 110], [21, -40], [21.5, 130]].forEach(([t, te]) => js.push(lateMiss(t, te))); // big scatter + const fb = buildPlayFeedback(js, {}); + const h = fb.hotspots[0]; + assert.ok(h); + assert.equal(h.signal.kind, 'random'); +}); + +test('a poor all-unheard play renders drillable Unregistered runs, never "nice"', () => { + // The real bass case: the player flubbed a section and EVERY miss came back + // no_detection (zero player faults). Old behaviour: no hotspots → "nice"/ + // consistency-gap → no coaching. New: an honest, drillable Unregistered run. + const js = []; + for (let i = 0; i < 12; i++) js.push(hit(i)); // some clean hits + [40, 40.5, 41, 41.5].forEach(t => js.push(noDetMiss(t))); // a flubbed run, all unheard + const fb = buildPlayFeedback(js, { song: 'Mexico', arrangement: 'bass' }); + assert.equal(fb.hotspots.length, 0, 'no player hotspots (no late/early/sharp/flat)'); + assert.equal(fb.unheardSpots.length, 1, 'the unheard run surfaces as a spot'); + const html = renderFeedbackHtml(fb); + assert.match(html, /Unregistered runs/, 'shows the unheard section'); + assert.match(html, /Drill this run/, 'with a drill button'); + assert.doesNotMatch(html, /nice/i, 'must NOT say "nice" on a flubbed play'); +}); + +test('empty / all-hits input is safe', () => { + assert.equal(buildPlayFeedback([], {}).mistakes.length, 0); + const fb = buildPlayFeedback([hit(0), hit(1)], {}); + assert.equal(fb.hotspots.length, 0); + assert.equal(fb.session.score, 1); +}); diff --git a/plugins/coaching/test/panel_integration.test.js b/plugins/coaching/test/panel_integration.test.js new file mode 100644 index 00000000..b4a1f0be --- /dev/null +++ b/plugins/coaching/test/panel_integration.test.js @@ -0,0 +1,189 @@ +// Headless integration test for the no-key end-of-song panel. Loads the +// REAL coaching.js browser IIFE in a vm against a minimal DOM/event shim, +// then drives the actual note_detect event sequence and asserts: +// 1. with NO API key, a "Practice spots" panel appears at song end, and +// 2. clicking its "Practice this" button calls window.noteDetect.startDrill +// with the hotspot's drill range. +// This is what proves the fix without anyone playing a song. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const SRC = fs.readFileSync(path.join(__dirname, '..', 'coaching.js'), 'utf8'); + +// camelCase a data-* attribute name: loop-a → loopA. +const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + +function makeEl(tag) { + const el = { + tagName: tag, id: '', className: '', textContent: '', + style: {}, dataset: {}, parentNode: null, + _children: [], _html: '', + classList: { add() {}, remove() {}, toggle() {} }, + appendChild(c) { this._children.push(c); c.parentNode = this; return c; }, + removeChild(c) { const i = this._children.indexOf(c); if (i >= 0) this._children.splice(i, 1); }, + remove() { if (this.parentNode) this.parentNode.removeChild(this); this.parentNode = null; }, + addEventListener() {}, + set innerHTML(html) { this._html = html; this._children = []; this._parse(html); }, + get innerHTML() { return this._html; }, + _parse(html) { + // Build a child stub per `; + } + + function _render(ev, passed) { + if (!_container) return; + const view = (_sd && _drill) ? _sd.skillDrillView(_drill) : { streak: 0, goalStreak: GOAL_STREAK, clean: 0, reps: 0, bestStreak: 0 }; + const pips = Array.from({ length: view.goalStreak }, (_, i) => + ``).join(''); + const shown = ev || _lastEv; + let flash = `
     
    `; + if (shown && shown.hit) { + flash = `
    ✓ on it (${shown.err > 0 ? '+' : ''}${shown.err}ms)
    `; + } else if (shown && shown.failureType === 'timing') { + flash = `
    ✗ ${shown.err > 0 ? '+' : ''}${shown.err}ms ${shown.dir === 'late' ? 'late — drag less / anticipate' : 'early — stop rushing'}
    `; + } + const tempo = BPMS.map(b => _btn(String(b), b === _bpm, `window.__metronomeLock&&window.__metronomeLock.setBpm(${b})`)).join(''); + const modes = [['straight', 'Straight'], ['backbeat', 'Backbeat'], ['eighths', '8ths']] + .map(([m, label]) => _btn(label, _clickMode === m, `window.__metronomeLock&&window.__metronomeLock.setMode('${m}')`)).join(''); + const drop = _btn(_dropOn ? 'Drop: 2+2 ✓' : 'Drop beats', _dropOn, `window.__metronomeLock&&window.__metronomeLock.toggleDrop()`); + const prompt = passed + ? `
    ✓ Locked to the click!
    ` + : `
    Play one note on every ${_clickMode === 'eighths' ? '8th' : 'beat'}
    +
    ${_dropOn ? 'hold your pocket through the silent bars — the click returns to check you' : 'any note — land it right on the grid'}
    `; + _container.innerHTML = ` +
    +
    Metronome Lock
    + ${prompt} +
    tempo ${tempo} bpm
    +
    click ${modes}   ${drop}
    +
    ${pips}
    +
    streak ${view.streak}/${view.goalStreak} + · clean ${view.clean}/${view.reps} + · best ${view.bestStreak}
    +
    ${flash}
    +
    `; + if (typeof window !== 'undefined') { + window.__metronomeLock = { setBpm: _setBpm, setMode: _setMode, toggleDrop: _toggleDrop }; + } + } + + // ── Register (safe late-binding: we may load before the SDK) ─────────── + const spec = { + id: GAME_ID, + title: 'Metronome Lock', + tagline: 'Lock every note to the click', + skill: 'timing', + trains: ['late', 'early'], + start, + stop, + }; + if (typeof window !== 'undefined') { + if (window.slopsmithMinigames && typeof window.slopsmithMinigames.register === 'function') { + window.slopsmithMinigames.register(spec); + } else { + (window.__slopsmithMinigamesPending = window.__slopsmithMinigamesPending || []).push(spec); + } + } +})(); diff --git a/plugins/metronome_lock/plugin.json b/plugins/metronome_lock/plugin.json new file mode 100644 index 00000000..3dcf577a --- /dev/null +++ b/plugins/metronome_lock/plugin.json @@ -0,0 +1,16 @@ +{ + "id": "metronome_lock", + "name": "Metronome Lock", + "version": "0.2.0", + "description": "Lock every note to the click — a chart-free timing drill.", + "category": "game", + "script": "game.js", + "minigame": { + "title": "Metronome Lock", + "tagline": "Lock every note to the click", + "type": "chart-free", + "scoring": "pitch-continuous", + "skill": "timing", + "trains": ["late", "early"] + } +} diff --git a/plugins/mute_master/README.md b/plugins/mute_master/README.md new file mode 100644 index 00000000..00ecc745 --- /dev/null +++ b/plugins/mute_master/README.md @@ -0,0 +1,27 @@ +# Mute Master + +The first **skill-drill game** for Slopsmith — see +[`docs/TEACHING_NARRATIVE.md`](../../docs/TEACHING_NARRATIVE.md). + +It trains **muting**: when the chart wants a fretted note, the open string must +not ring. note_detect flags that exact failure as `mute_fail`; coaching's +practice taxonomy points a `mute_fail` hotspot here, deep-linking into the +passage you fumbled. The game scores your muting off the live note_detect +judgment stream using the **shared** skill-drill mechanics +(`createSkillDrill` / `skillDrillStep` / `skillDrillView`, published by the +coaching plugin and bridged through the minigames SDK as `sdk.skillDrill`). + +**Chart-free and self-paced** (per the "fast, no lead-in, no slow animations" +steer): it prompts one fret target at a time and scores each note the instant +you play it — target pitch sounds → a **clean** muting rep; the **open string** +rings instead → a **mute_fail**. New target immediately, no count-in, no +transitions. Eight clean in a row graduates. The rep/streak/pass loop is the +**shared** engine, node-tested in `plugins/coaching/test/skill-drills.test.js`; +this file is a deliberately **thin reference skin** — swap it freely. + +## Status / next + +- ✅ Registers with the minigames SDK declaring `trains: ['mute_fail']` + `skill: 'muting'`, so `gamesForFailure('mute_fail')` resolves to it and coaching's "Drill muting" link launches it. +- ✅ Chart-free, self-paced, fast: scores muting off `sdk.scoring.createContinuous` (its own mic + YIN — no song, no lead-in). Each played note is an instant clean / mute-fail rep. +- ▢ Deferred: aim the target frets at the strings/frets from the fumbled passage the deep link carries (`skill`/`loopA`/`loopB`/`song` params) instead of the fixed E/A/D rotation. +- ▢ Deferred: a real skin (functional-but-plain on purpose) and rotating across all four strings / more frets. diff --git a/plugins/mute_master/game.js b/plugins/mute_master/game.js new file mode 100644 index 00000000..e1319779 --- /dev/null +++ b/plugins/mute_master/game.js @@ -0,0 +1,208 @@ +// Mute Master — the first skill-drill game (docs/TEACHING_NARRATIVE.md). +// +// Trains MUTING on bass: when you fret a note, the OPEN string under it must not +// ring. note_detect flags that exact slip as `mute_fail`; coaching's taxonomy +// points a `mute_fail` hotspot here. This game proves the contract end to end — +// detection → skill → game → SHARED rep/streak/pass engine. +// +// Design per the "fast iteration, no lead-in, no slow animations" steer: it is +// CHART-FREE and self-paced. It uses the SDK's self-contained pitch scorer +// (`scoring.createContinuous` — its own mic + YIN, no song needed), prompts one +// fret target at a time, and scores each note the instant you play it: +// - you hit the target pitch → a CLEAN muting rep +// - the OPEN string rings instead → a mute_fail (you didn't mute it) +// New target immediately, no count-in, no transitions. Eight clean in a row wins. +// +// The rep/streak/pass loop is the SHARED engine (createSkillDrill / step / view) +// published by coaching and bridged through the minigames SDK as `sdk.skillDrill` +// — this file owns only the wiring + a thin skin. Swap the skin freely. +(function () { + 'use strict'; + + const GAME_ID = 'mute_master'; + const GOAL_STREAK = 8; + // YIN confidence floor to accept a note. Was 0.85, which low bass notes + // rarely clear (note_detect's own floor is 0.20) — so every attempt got + // silently dropped and the game looked dead. 0.5 keeps out noise while + // accepting real bass. The live input readout shows the actual confidence. + const CONF_MIN = 0.5; + const NEAR_SEMITONES = 0.6; // how close counts as "that pitch" + + // Standard 4-string bass open strings (MIDI). The low strings are where + // open-string bleed under a fretted note actually bites. + const STRINGS = [ + { name: 'E', openMidi: 28 }, + { name: 'A', openMidi: 33 }, + { name: 'D', openMidi: 38 }, + ]; + const FRETS = [3, 5, 7]; // small, clearly-fretted targets + const NOTE = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + const midiName = (m) => NOTE[((Math.round(m) % 12) + 12) % 12] + (Math.floor(Math.round(m) / 12) - 1); + // Nearest-OCTAVE semitone distance. The continuous scorer's YIN (minHz 70) + // can't lock a bass fundamental (open E ≈ 41 Hz) and reports the octave-up + // harmonic instead, so a played note reads ~12 semitones off its true MIDI. + // Fold to the nearest octave (as note_detect does) so a correct note still + // matches; target and open stay 3+ semitones apart after folding, so this + // never confuses them. + const _foldSemi = (a, b) => { let d = a - b; d -= Math.round(d / 12) * 12; return Math.abs(d); }; + + let _sdk = null, _cont = null, _drill = null, _sd = null; + let _startedAt = 0, _finished = false; + let _target = null; // { stringName, fret, targetMidi, openMidi } + let _armed = true; // ready to count the next attack (debounce) + let _lastPitch = null; // last pitch frame, for the live "heard" readout + let _container = null; + + // Live input indicator so the player can see the scorer IS hearing them. + function _updateHeard(p) { + const el = _container && _container.querySelector('#mm-heard'); + if (!el) return; + if (p && p.freqHz > 0 && isFinite(p.midiFloat) && p.confidence >= CONF_MIN) { + el.textContent = `🎧 heard ${midiName(p.midiFloat)} · ${p.freqHz.toFixed(0)} Hz`; + el.style.color = '#7ddb7d'; + } else { + el.textContent = '🎧 listening…'; + el.style.color = '#6b7280'; + } + } + + function _mechanics(sdk) { + return (sdk && sdk.skillDrill) || (typeof window !== 'undefined' ? window.slopsmithSkillDrills : null); + } + + function _nextTarget() { + // Vary by index (no Math.random dependency, deterministic-enough rotation). + const i = _drill ? _drill.reps : 0; + const s = STRINGS[i % STRINGS.length]; + const fret = FRETS[(Math.floor(i / STRINGS.length)) % FRETS.length]; + _target = { stringName: s.name, fret, targetMidi: s.openMidi + fret, openMidi: s.openMidi }; + _armed = true; + _render(); + } + + function scoreFromView(v) { + if (!v) return 0; + return (v.clean * 100) + (v.bestStreak * 50) + (v.passed ? 500 : 0); + } + + async function start(ctx) { + _sdk = (ctx && ctx.sdk) || (typeof window !== 'undefined' ? window.slopsmithMinigames : null); + _container = ctx && ctx.container; + _finished = false; + _startedAt = (typeof performance !== 'undefined' && performance.now) ? performance.now() : 0; + _sd = _mechanics(_sdk); + if (!_sd || !_sdk || !_sdk.scoring || typeof _sdk.scoring.createContinuous !== 'function') { + _message('Mute Master needs the coaching + minigames plugins to score muting.'); + return; + } + _drill = _sd.createSkillDrill({ failureType: 'mute_fail', goalStreak: GOAL_STREAK }); + _nextTarget(); + + _cont = _sdk.scoring.createContinuous({}); + _cont.on('pitch', _onPitch); + _cont.on('end', () => { if (!_finished) _finish(); }); + } + + function _onPitch(p) { + if (_finished || !p) return; + _lastPitch = p; + _updateHeard(p); + if (!_target) return; + const midi = p.midiFloat; + const conf = p.confidence; + // Re-arm on a clear release (quiet / unconfident) so a sustained note is + // ONE rep, and the next attack is counted fresh. No timers, no delay. + if (!(conf >= CONF_MIN) || !isFinite(midi)) { _armed = true; return; } + if (!_armed) return; + + // Octave-tolerant: fold to the nearest octave so an octave-up harmonic + // still matches the target / open string (see _foldSemi). + const dTarget = _foldSemi(midi, _target.targetMidi); + const dOpen = _foldSemi(midi, _target.openMidi); + let ev = null; + if (dTarget <= NEAR_SEMITONES) ev = { hit: true, failureType: null }; // muted clean → fretted note sounds + else if (_target.targetMidi !== _target.openMidi && dOpen <= NEAR_SEMITONES) ev = { hit: false, failureType: 'mute_fail' }; // open rang + if (!ev) return; // some other pitch (wrong fret / noise) — not a muting rep + + _armed = false; // consumed this attack; wait for release + _sd.skillDrillStep(_drill, ev); + const view = _sd.skillDrillView(_drill); + _render(ev); + if (view.passed) _finish(); + else _nextTarget(); + } + + function _finish() { + if (_finished) return; + _finished = true; + const view = (_sd && _drill) ? _sd.skillDrillView(_drill) : null; + const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : 0; + try { if (_cont) _cont.stop(); } catch (_) {} + _cont = null; + if (view) _render(null, view.passed); + if (_sdk && typeof _sdk.end === 'function') { + _sdk.end({ + score: scoreFromView(view), + durationMs: Math.max(0, Math.round(now - _startedAt)), + meta: { skill: 'muting', failureType: 'mute_fail', view }, + }); + } + } + + function stop() { + try { if (_cont) _cont.stop(); } catch (_) {} + _cont = null; _drill = null; _finished = true; + } + + // ── Thin reference skin (instant updates, no transitions) ────────────── + function _message(msg) { + if (!_container) return; + _container.innerHTML = `
    ${msg}
    `; + } + + function _render(ev, passed) { + if (!_container) return; + const view = (_sd && _drill) ? _sd.skillDrillView(_drill) : { streak: 0, goalStreak: GOAL_STREAK, clean: 0, reps: 0, bestStreak: 0 }; + const pips = Array.from({ length: view.goalStreak }, (_, i) => + ``).join(''); + const flash = ev && ev.failureType === 'mute_fail' + ? `
    ✗ open ${_target ? _target.stringName : ''} rang — mute it
    ` + : (ev && ev.hit ? `
    ✓ clean
    ` : `
     
    `); + const prompt = passed + ? `
    ✓ Muting locked in!
    ` + : (_target + ? `
    ${_target.stringName} string · fret ${_target.fret}
    +
    play ${midiName(_target.targetMidi)} — keep the open ${_target.stringName} silent
    ` + : ''); + _container.innerHTML = ` +
    +
    Mute Master
    + ${prompt} +
    ${pips}
    +
    streak ${view.streak}/${view.goalStreak} + · clean ${view.clean}/${view.reps} + · best ${view.bestStreak}
    +
    ${flash}
    +
    🎧 listening…
    +
    `; + _updateHeard(_lastPitch); + } + + // ── Register (safe late-binding: we may load before the SDK) ─────────── + const spec = { + id: GAME_ID, + title: 'Mute Master', + tagline: 'Kill the open-string ring', + skill: 'muting', + trains: ['mute_fail'], + start, + stop, + }; + if (typeof window !== 'undefined') { + if (window.slopsmithMinigames && typeof window.slopsmithMinigames.register === 'function') { + window.slopsmithMinigames.register(spec); + } else { + (window.__slopsmithMinigamesPending = window.__slopsmithMinigamesPending || []).push(spec); + } + } +})(); diff --git a/plugins/mute_master/plugin.json b/plugins/mute_master/plugin.json new file mode 100644 index 00000000..9f987b9c --- /dev/null +++ b/plugins/mute_master/plugin.json @@ -0,0 +1,16 @@ +{ + "id": "mute_master", + "name": "Mute Master", + "version": "0.1.1", + "description": "Kill the open-string ring — a chart-free muting drill.", + "category": "game", + "script": "game.js", + "minigame": { + "title": "Mute Master", + "tagline": "Kill the open-string ring", + "type": "chart-free", + "scoring": "pitch-continuous", + "skill": "muting", + "trains": ["mute_fail"] + } +} diff --git a/plugins/pitch_match/game.js b/plugins/pitch_match/game.js new file mode 100644 index 00000000..420665da --- /dev/null +++ b/plugins/pitch_match/game.js @@ -0,0 +1,214 @@ +// Pitch Match — the intonation skill-drill game (docs/TEACHING_NARRATIVE.md). +// +// Trains INTONATION: coaching flags notes that read `sharp` / `flat` (off the +// charted pitch) and points an intonation hotspot here. This is the second +// game proving the failure → skill → game → shared-rep-engine contract, after +// mute_master. +// +// Design mirrors mute_master (chart-free, self-paced, the SDK's own mic+YIN via +// `scoring.createContinuous`, and the SHARED rep/streak/pass engine bridged as +// `sdk.skillDrill`). One target note at a time; you hold it, and on release we +// judge the MEDIAN cents over the held note — landing within ±CLEAN_CENTS is a +// clean rep, sharp/flat is a miss. Judging the held median (not the attack +// transient, which is noisy) is the one design difference from mute_master. +// Eight clean in a row wins. Skin is thin; swap it freely. +(function () { + 'use strict'; + + const GAME_ID = 'pitch_match'; + const GOAL_STREAK = 8; + const CONF_MIN = 0.5; // YIN confidence floor — low bass rarely clears 0.85 (note_detect's floor is 0.20) + const CLEAN_CENTS = 15; // within this of the target = in tune + const CAPTURE_CENTS = 70; // within this of the target (octave-folded) = an attempt AT this note + const MIN_HOLD = 3; // frames needed before a note is judged + + // A few comfortable fretted bass targets (MIDI). Rotated deterministically + // so the prompt sequence is stable (no Math.random dependency). + const STRINGS = [ + { name: 'E', openMidi: 28 }, + { name: 'A', openMidi: 33 }, + { name: 'D', openMidi: 38 }, + { name: 'G', openMidi: 43 }, + ]; + const FRETS = [0, 2, 3, 5, 7]; + const NOTE = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + const midiName = (m) => NOTE[((Math.round(m) % 12) + 12) % 12] + (Math.floor(Math.round(m) / 12) - 1); + const median = (xs) => { + if (!xs.length) return 0; + const s = xs.slice().sort((a, b) => a - b); + const m = s.length >> 1; + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; + }; + // Cents from the target, folded to the NEAREST octave. The scorer's YIN + // (minHz 70) reports the octave-up harmonic on low bass, so raw cents can be + // ~1200 off; folding makes a correct note read near 0 (as note_detect does). + const _foldCents = (midi, targetMidi) => { const c = (midi - targetMidi) * 100; return c - Math.round(c / 1200) * 1200; }; + + let _sdk = null, _cont = null, _drill = null, _sd = null; + let _startedAt = 0, _finished = false; + let _target = null; // { stringName, fret, targetMidi } + let _hold = []; // cents-from-target samples for the current note + let _lastPitch = null; // last pitch frame, for the live "heard" readout + let _container = null; + + function _updateHeard(p) { + const el = _container && _container.querySelector('#pm-heard'); + if (!el) return; + if (p && p.freqHz > 0 && isFinite(p.midiFloat) && p.confidence >= CONF_MIN) { + el.textContent = `🎧 heard ${midiName(p.midiFloat)} · ${p.freqHz.toFixed(0)} Hz`; + el.style.color = '#7ddb7d'; + } else { + el.textContent = '🎧 listening…'; + el.style.color = '#6b7280'; + } + } + + function _mechanics(sdk) { + return (sdk && sdk.skillDrill) || (typeof window !== 'undefined' ? window.slopsmithSkillDrills : null); + } + + function _nextTarget() { + const i = _drill ? _drill.reps : 0; + const s = STRINGS[i % STRINGS.length]; + const fret = FRETS[(Math.floor(i / STRINGS.length)) % FRETS.length]; + _target = { stringName: s.name, fret, targetMidi: s.openMidi + fret }; + _hold = []; + _render(); + } + + function scoreFromView(v) { + if (!v) return 0; + return (v.clean * 100) + (v.bestStreak * 50) + (v.passed ? 500 : 0); + } + + async function start(ctx) { + _sdk = (ctx && ctx.sdk) || (typeof window !== 'undefined' ? window.slopsmithMinigames : null); + _container = ctx && ctx.container; + _finished = false; + _startedAt = (typeof performance !== 'undefined' && performance.now) ? performance.now() : 0; + _sd = _mechanics(_sdk); + if (!_sd || !_sdk || !_sdk.scoring || typeof _sdk.scoring.createContinuous !== 'function') { + _message('Pitch Match needs the coaching + minigames plugins to score intonation.'); + return; + } + // One canonical drill key for both sharp & flat — the engine trains one + // axis; the skin shows the direction. (createSkillDrill compares a single + // failureType, so sharp/flat must collapse to one key here.) + _drill = _sd.createSkillDrill({ failureType: 'intonation', goalStreak: GOAL_STREAK }); + _nextTarget(); + + _cont = _sdk.scoring.createContinuous({}); + _cont.on('pitch', _onPitch); + _cont.on('end', () => { if (!_finished) _finish(); }); + } + + function _onPitch(p) { + if (_finished || !p) return; + _lastPitch = p; + _updateHeard(p); + if (!_target) return; + const midi = p.midiFloat; + const cents = _foldCents(midi, _target.targetMidi); // octave-folded deviation + const onTarget = isFinite(midi) && p.confidence >= CONF_MIN && Math.abs(cents) <= CAPTURE_CENTS; + if (onTarget) { + // Accumulate octave-folded cents while the note is held & confident. + _hold.push(cents); + return; + } + // Release / off-target: judge the held note (median cents) if we got a + // real sustain; otherwise discard (a blip, not an attempt). + if (_hold.length >= MIN_HOLD) { + const cents = Math.round(median(_hold)); + const clean = Math.abs(cents) <= CLEAN_CENTS; + const ev = clean + ? { hit: true, cents } + : { hit: false, failureType: 'intonation', dir: cents > 0 ? 'sharp' : 'flat', cents }; + _hold = []; + _sd.skillDrillStep(_drill, ev); + const view = _sd.skillDrillView(_drill); + _render(ev); + if (view.passed) _finish(); + else _nextTarget(); + } else { + _hold = []; + } + } + + function _finish() { + if (_finished) return; + _finished = true; + const view = (_sd && _drill) ? _sd.skillDrillView(_drill) : null; + const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : 0; + try { if (_cont) _cont.stop(); } catch (_) {} + _cont = null; + if (view) _render(null, view.passed); + if (_sdk && typeof _sdk.end === 'function') { + _sdk.end({ + score: scoreFromView(view), + durationMs: Math.max(0, Math.round(now - _startedAt)), + meta: { skill: 'intonation', failureType: 'intonation', view }, + }); + } + } + + function stop() { + try { if (_cont) _cont.stop(); } catch (_) {} + _cont = null; _drill = null; _finished = true; + } + + // ── Thin reference skin ──────────────────────────────────────────────── + function _message(msg) { + if (!_container) return; + _container.innerHTML = `
    ${msg}
    `; + } + + function _render(ev, passed) { + if (!_container) return; + const view = (_sd && _drill) ? _sd.skillDrillView(_drill) : { streak: 0, goalStreak: GOAL_STREAK, clean: 0, reps: 0, bestStreak: 0 }; + const pips = Array.from({ length: view.goalStreak }, (_, i) => + ``).join(''); + let flash = `
     
    `; + if (ev && ev.hit) { + flash = `
    ✓ in tune (${ev.cents > 0 ? '+' : ''}${ev.cents}c)
    `; + } else if (ev && ev.failureType === 'intonation') { + const word = ev.dir === 'sharp' ? 'sharp' : 'flat'; + flash = `
    ✗ ${ev.cents > 0 ? '+' : ''}${ev.cents}c ${word} — ${ev.dir === 'sharp' ? 'ease off / tune down' : 'press firmly / tune up'}
    `; + } + const prompt = passed + ? `
    ✓ Intonation locked in!
    ` + : (_target + ? `
    ${_target.stringName} string · ${_target.fret === 0 ? 'open' : 'fret ' + _target.fret}
    +
    play ${midiName(_target.targetMidi)} and hold it — land it dead in tune
    ` + : ''); + _container.innerHTML = ` +
    +
    Pitch Match
    + ${prompt} +
    ${pips}
    +
    streak ${view.streak}/${view.goalStreak} + · clean ${view.clean}/${view.reps} + · best ${view.bestStreak}
    +
    ${flash}
    +
    🎧 listening…
    +
    `; + _updateHeard(_lastPitch); + } + + // ── Register (safe late-binding: we may load before the SDK) ─────────── + const spec = { + id: GAME_ID, + title: 'Pitch Match', + tagline: 'Land it dead in tune', + skill: 'intonation', + trains: ['sharp', 'flat'], + start, + stop, + }; + if (typeof window !== 'undefined') { + if (window.slopsmithMinigames && typeof window.slopsmithMinigames.register === 'function') { + window.slopsmithMinigames.register(spec); + } else { + (window.__slopsmithMinigamesPending = window.__slopsmithMinigamesPending || []).push(spec); + } + } +})(); diff --git a/plugins/pitch_match/plugin.json b/plugins/pitch_match/plugin.json new file mode 100644 index 00000000..2e281760 --- /dev/null +++ b/plugins/pitch_match/plugin.json @@ -0,0 +1,16 @@ +{ + "id": "pitch_match", + "name": "Pitch Match", + "version": "0.1.1", + "description": "Land it dead in tune — a chart-free intonation drill.", + "category": "game", + "script": "game.js", + "minigame": { + "title": "Pitch Match", + "tagline": "Land it dead in tune", + "type": "chart-free", + "scoring": "pitch-continuous", + "skill": "intonation", + "trains": ["sharp", "flat"] + } +} diff --git a/plugins/practice_journal/.gitignore b/plugins/practice_journal/.gitignore new file mode 100644 index 00000000..7a60b85e --- /dev/null +++ b/plugins/practice_journal/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/plugins/practice_journal/README.md b/plugins/practice_journal/README.md new file mode 100644 index 00000000..24618876 --- /dev/null +++ b/plugins/practice_journal/README.md @@ -0,0 +1,41 @@ +# Slopsmith Plugin: Practice Journal + +A plugin for [Slopsmith](https://github.com/byrongamatos/slopsmith) that automatically tracks your practice sessions and shows progress over time. + +## Features + +- **Auto-tracking** — practice time is recorded automatically when you play songs. No manual start/stop needed. +- **Dashboard** with: + - Today / This Week / All Time practice time + - Total songs practiced + - 30-day activity chart + - Most practiced songs (ranked by total time) + - Recent sessions with duration, speed, and arrangement +- **Speed tracking** — records the average playback speed used per session +- **Loop tracking** — records which saved loops you used during practice +- **Per-song history** — API endpoint for detailed practice history per song + +## Installation + +```bash +cd /path/to/slopsmith/plugins +git clone https://github.com/byrongamatos/slopsmith-plugin-practice.git practice_journal +docker compose restart +``` + +The "Practice" link will appear in the navigation bar. + +## How It Works + +The plugin hooks into the Slopsmith player. When you open a song, a practice session starts automatically. When you leave the player (navigate away, close the song, or close the browser), the session is saved with: + +- Song details (title, artist, arrangement) +- Duration +- Average playback speed +- Which saved loops were activated + +Sessions under 5 seconds are ignored. + +## License + +MIT diff --git a/plugins/practice_journal/plugin.json b/plugins/practice_journal/plugin.json new file mode 100644 index 00000000..b92f1f69 --- /dev/null +++ b/plugins/practice_journal/plugin.json @@ -0,0 +1,12 @@ +{ + "id": "practice_journal", + "name": "Practice Journal", + "version": "1.0.0", + "private": false, + "description": "Auto-tracks practice sessions and charts your time, streaks, and most-practiced songs.", + "category": "practice", + "nav": { "label": "Practice", "screen": "plugin-practice_journal" }, + "screen": "screen.html", + "script": "screen.js", + "routes": "routes.py" +} diff --git a/plugins/practice_journal/routes.py b/plugins/practice_journal/routes.py new file mode 100644 index 00000000..0210b2bc --- /dev/null +++ b/plugins/practice_journal/routes.py @@ -0,0 +1,187 @@ +"""Practice Journal plugin — tracks practice sessions and provides stats.""" + +import json +import sqlite3 +import threading +from datetime import datetime, timedelta +from pathlib import Path + +_db_path = None +_conn = None +_lock = threading.Lock() + + +def _get_conn(): + global _conn + if _conn is None: + _conn = sqlite3.connect(_db_path, check_same_thread=False) + _conn.execute("PRAGMA journal_mode=WAL") + _conn.execute(""" + CREATE TABLE IF NOT EXISTS practice_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL, + title TEXT, + artist TEXT, + started_at TEXT NOT NULL, + duration_seconds REAL NOT NULL DEFAULT 0, + avg_speed REAL NOT NULL DEFAULT 1.0, + loops_used TEXT DEFAULT '[]', + arrangement TEXT + ) + """) + _conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_practice_filename + ON practice_sessions(filename) + """) + _conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_practice_started + ON practice_sessions(started_at) + """) + _conn.commit() + return _conn + + +def setup(app, context): + global _db_path + config_dir = context["config_dir"] + _db_path = str(config_dir / "practice_journal.db") + + @app.post("/api/plugins/practice_journal/session") + def record_session(data: dict): + """Record a completed practice session.""" + filename = data.get("filename", "") + if not filename: + return {"error": "No filename"} + + duration = data.get("duration", 0) + if duration < 5: # ignore sessions under 5 seconds + return {"ok": True, "skipped": True} + + conn = _get_conn() + with _lock: + conn.execute( + "INSERT INTO practice_sessions " + "(filename, title, artist, started_at, duration_seconds, avg_speed, loops_used, arrangement) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + filename, + data.get("title", ""), + data.get("artist", ""), + data.get("started_at", datetime.utcnow().isoformat()), + duration, + data.get("avg_speed", 1.0), + json.dumps(data.get("loops_used", [])), + data.get("arrangement", ""), + ), + ) + conn.commit() + return {"ok": True} + + @app.get("/api/plugins/practice_journal/stats") + def practice_stats(): + """Overall practice statistics.""" + conn = _get_conn() + now = datetime.utcnow() + today = now.strftime("%Y-%m-%d") + week_ago = (now - timedelta(days=7)).isoformat() + month_ago = (now - timedelta(days=30)).isoformat() + + total_time = conn.execute( + "SELECT COALESCE(SUM(duration_seconds), 0) FROM practice_sessions" + ).fetchone()[0] + today_time = conn.execute( + "SELECT COALESCE(SUM(duration_seconds), 0) FROM practice_sessions WHERE started_at >= ?", + (today,) + ).fetchone()[0] + week_time = conn.execute( + "SELECT COALESCE(SUM(duration_seconds), 0) FROM practice_sessions WHERE started_at >= ?", + (week_ago,) + ).fetchone()[0] + total_sessions = conn.execute( + "SELECT COUNT(*) FROM practice_sessions" + ).fetchone()[0] + unique_songs = conn.execute( + "SELECT COUNT(DISTINCT filename) FROM practice_sessions" + ).fetchone()[0] + + # Most practiced songs (by total time) + top_songs = conn.execute( + "SELECT title, artist, filename, SUM(duration_seconds) as total, COUNT(*) as sessions " + "FROM practice_sessions GROUP BY filename ORDER BY total DESC LIMIT 10" + ).fetchall() + + # Daily practice time for the last 30 days + daily = conn.execute( + "SELECT DATE(started_at) as day, SUM(duration_seconds) as total " + "FROM practice_sessions WHERE started_at >= ? " + "GROUP BY day ORDER BY day", + (month_ago,) + ).fetchall() + + # Recent sessions + recent = conn.execute( + "SELECT title, artist, filename, started_at, duration_seconds, avg_speed, arrangement " + "FROM practice_sessions ORDER BY started_at DESC LIMIT 20" + ).fetchall() + + return { + "total_time": total_time, + "today_time": today_time, + "week_time": week_time, + "total_sessions": total_sessions, + "unique_songs": unique_songs, + "top_songs": [ + {"title": r[0], "artist": r[1], "filename": r[2], + "total_time": r[3], "sessions": r[4]} + for r in top_songs + ], + "daily": [{"date": r[0], "seconds": r[1]} for r in daily], + "recent": [ + {"title": r[0], "artist": r[1], "filename": r[2], + "started_at": r[3], "duration": r[4], "speed": r[5], + "arrangement": r[6]} + for r in recent + ], + } + + @app.get("/api/plugins/practice_journal/song/{filename:path}") + def song_practice_history(filename: str): + """Practice history for a specific song.""" + conn = _get_conn() + + total_time = conn.execute( + "SELECT COALESCE(SUM(duration_seconds), 0) FROM practice_sessions WHERE filename = ?", + (filename,) + ).fetchone()[0] + session_count = conn.execute( + "SELECT COUNT(*) FROM practice_sessions WHERE filename = ?", + (filename,) + ).fetchone()[0] + + # Speed progression over time + speed_history = conn.execute( + "SELECT started_at, avg_speed, duration_seconds FROM practice_sessions " + "WHERE filename = ? ORDER BY started_at", + (filename,) + ).fetchall() + + # Sessions + sessions = conn.execute( + "SELECT started_at, duration_seconds, avg_speed, loops_used, arrangement " + "FROM practice_sessions WHERE filename = ? ORDER BY started_at DESC LIMIT 50", + (filename,) + ).fetchall() + + return { + "total_time": total_time, + "session_count": session_count, + "speed_history": [ + {"date": r[0], "speed": r[1], "duration": r[2]} + for r in speed_history + ], + "sessions": [ + {"started_at": r[0], "duration": r[1], "speed": r[2], + "loops": json.loads(r[3]) if r[3] else [], "arrangement": r[4]} + for r in sessions + ], + } diff --git a/plugins/practice_journal/screen.html b/plugins/practice_journal/screen.html new file mode 100644 index 00000000..825efb32 --- /dev/null +++ b/plugins/practice_journal/screen.html @@ -0,0 +1,47 @@ +
    + +

    Practice Journal

    + + +
    +
    +

    --

    +

    Today

    +
    +
    +

    --

    +

    This Week

    +
    +
    +

    --

    +

    All Time

    +
    +
    +

    --

    +

    Songs Practiced

    +
    +
    + + +
    +

    Last 30 Days

    +
    +
    + + +
    + +
    +

    Most Practiced

    +
    +
    + + +
    +

    Recent Sessions

    +
    +
    +
    +
    diff --git a/plugins/practice_journal/screen.js b/plugins/practice_journal/screen.js new file mode 100644 index 00000000..789d3ef0 --- /dev/null +++ b/plugins/practice_journal/screen.js @@ -0,0 +1,207 @@ +// Practice Journal plugin + +// ── Auto-tracking (feedBack-native) ───────────────────────────────────── +// The legacy build wrapped window.playSong / showScreen / setSpeed / +// loadSavedLoop. In feedBack those are LOCAL functions in app.js — core +// calls the locals, never the window globals — so wrappers never fire. We +// drive tracking off the core event bus (`window.feedBack`, an EventTarget: +// handlers get a CustomEvent with the payload on `.detail`) instead: +// song:play / song:loaded → begin a session +// song:ended / song:stop → close it +// screen:changed → leaving the player closes it; entering the +// journal refreshes the dashboard. + +let _pjSessionStart = null; +let _pjFilename = null; +let _pjTitle = null; +let _pjArtist = null; +let _pjArrangement = null; + +function _pjSongMeta() { + // The canonical current-song identity lives on window.feedBack.currentSong + // (highway.js sets it from the WS path — { filename, title, artist, + // duration, arrangement, ... }). getSongInfo() does NOT carry `filename` + // (the song_info WS message has no filename field), so currentSong is the + // reliable source; getSongInfo() + the HUD DOM are fallbacks. + const cur = (window.feedBack && window.feedBack.currentSong) || null; + let info = {}; + try { + info = (window.highway && typeof window.highway.getSongInfo === 'function' + && window.highway.getSongInfo()) || {}; + } catch (_) {} + const dom = (id) => document.getElementById(id)?.textContent || ''; + return { + filename: (cur && cur.filename) || info.filename || null, + title: (cur && cur.title) || info.title || dom('hud-title'), + artist: (cur && cur.artist) || info.artist || dom('hud-artist'), + arrangement: (cur && cur.arrangement) || info.arrangement || dom('hud-arrangement'), + }; +} + +function _pjCurrentSpeed() { + try { + if (window._juceMode && window.jucePlayer && window.jucePlayer._speed) { + return window.jucePlayer._speed; + } + return document.getElementById('audio')?.playbackRate || 1.0; + } catch (_) { return 1.0; } +} + +function _pjStartSession() { + const meta = _pjSongMeta(); + if (!meta.filename) return; // nothing playable yet + if (_pjSessionStart && _pjFilename === meta.filename) return; // already tracking it + _pjEndSession(); // close a prior song's session + _pjFilename = meta.filename; + _pjTitle = meta.title; + _pjArtist = meta.artist; + _pjArrangement = meta.arrangement; + _pjSessionStart = new Date().toISOString(); +} + +function _pjEndSession() { + if (!_pjSessionStart || !_pjFilename) return; + + const duration = (Date.now() - new Date(_pjSessionStart).getTime()) / 1000; + const avgSpeed = Math.round(_pjCurrentSpeed() * 100) / 100; + + // Fire and forget (backend ignores sessions under 5s) + fetch('/api/plugins/practice_journal/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filename: _pjFilename, + title: _pjTitle, + artist: _pjArtist, + started_at: _pjSessionStart, + duration: duration, + avg_speed: avgSpeed, + loops_used: [], + arrangement: _pjArrangement, + }), + }).catch(() => {}); + + _pjSessionStart = null; + _pjFilename = null; +} + +(function _pjInstall() { + // Idempotency: don't double-register listeners on a re-eval / hot reload. + const HOOK_KEY = '__feedBackPracticeHooksInstalled'; + if (window[HOOK_KEY]) return; + window[HOOK_KEY] = true; + + const bus = window.feedBack || window.slopsmith; + if (bus && typeof bus.on === 'function') { + bus.on('song:play', _pjStartSession); + bus.on('song:loaded', _pjStartSession); + bus.on('song:ended', _pjEndSession); + bus.on('song:stop', _pjEndSession); + bus.on('screen:changed', (ev) => { + const id = (ev && ev.detail && ev.detail.id) || null; + if (id && id !== 'player') _pjEndSession(); + if (id === 'plugin-practice_journal') _pjLoadDashboard(); + }); + } + // Backstop for closing the tab mid-session. + window.addEventListener('beforeunload', _pjEndSession); +})(); + +// ── Dashboard ─────────────────────────────────────────────────────────── + +function _pjFormatDuration(seconds) { + if (seconds < 60) return `${Math.round(seconds)}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return m > 0 ? `${h}h ${m}m` : `${h}h`; +} + +async function _pjLoadDashboard() { + try { + const resp = await fetch('/api/plugins/practice_journal/stats'); + const data = await resp.json(); + + // Stats cards + document.getElementById('pj-today').textContent = _pjFormatDuration(data.today_time); + document.getElementById('pj-week').textContent = _pjFormatDuration(data.week_time); + document.getElementById('pj-total').textContent = _pjFormatDuration(data.total_time); + document.getElementById('pj-songs').textContent = data.unique_songs; + + // Daily chart (bar chart) + const chart = document.getElementById('pj-chart'); + if (data.daily.length === 0) { + chart.innerHTML = '

    No practice data yet

    '; + } else { + const maxVal = Math.max(...data.daily.map(d => d.seconds), 1); + // Fill in missing days + const days = []; + const now = new Date(); + for (let i = 29; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + const key = d.toISOString().split('T')[0]; + const found = data.daily.find(x => x.date === key); + days.push({ date: key, seconds: found ? found.seconds : 0 }); + } + chart.innerHTML = days.map(d => { + const pct = Math.max(2, (d.seconds / maxVal) * 100); + const day = new Date(d.date + 'T12:00:00').toLocaleDateString('en', { weekday: 'narrow' }); + const title = `${d.date}: ${_pjFormatDuration(d.seconds)}`; + return `
    +
    + ${day} +
    `; + }).join(''); + } + + // Top songs + const top = document.getElementById('pj-top'); + if (data.top_songs.length === 0) { + top.innerHTML = '

    No songs practiced yet

    '; + } else { + const maxTime = data.top_songs[0]?.total_time || 1; + top.innerHTML = data.top_songs.map(s => { + const pct = (s.total_time / maxTime) * 100; + return `
    +
    +
    + ${esc(s.title || s.filename)} + ${esc(s.artist)} +
    + ${_pjFormatDuration(s.total_time)} · ${s.sessions}x +
    +
    +
    `; + }).join(''); + } + + // Recent sessions + const recent = document.getElementById('pj-recent'); + if (data.recent.length === 0) { + recent.innerHTML = '

    No sessions yet. Play a song to start tracking!

    '; + } else { + recent.innerHTML = data.recent.map(s => { + const date = new Date(s.started_at); + const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + + ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }); + const speedStr = s.speed !== 1.0 ? ` · ${s.speed.toFixed(2)}x` : ''; + const arrStr = s.arrangement ? ` · ${s.arrangement}` : ''; + return `
    +
    + ${esc(s.title || s.filename)} + ${esc(s.artist)}${arrStr} +
    +
    + ${_pjFormatDuration(s.duration)}${speedStr} + ${timeStr} +
    +
    `; + }).join(''); + } + + } catch (e) { + console.error('Practice Journal load failed:', e); + } +} From 517e8b5274c82f8ed18a4c3d18b44721ca3b34cb Mon Sep 17 00:00:00 2001 From: Ashton Honnecke Date: Tue, 28 Jul 2026 08:17:39 -0600 Subject: [PATCH 2/7] fix(coaching-suite): CodeRabbit quick-wins across the 5 plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - metronome_lock: guard _startMetronome against overlapping async starts. Rapid BPM/mode/drop changes (or a stop mid-resume) could leave two schedulers ticking; a run token invalidates the stale context after the awaited AudioContext.resume(). - pitch_match: thread the judged event through _nextTarget so the in-tune / sharp-flat feedback flash survives the immediate advance to the next target instead of being blanked by the re-render. - practice_journal: double-checked locking in _get_conn (only one thread builds the connection, published only once fully built); validate that `duration` is numeric before comparing so a bad client can't raise. - coaching: honest _stringLabel comment (extended-bass indices 4–5 are a best-effort fallback, not canonical); README fixes — confirmed_detector_bug is now set in-app on a note-present miss (not harness-only) and the coach model is claude-haiku-4-5; correct stale minMisses/gap threshold comment in panel_integration.test.js. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/coaching/README.md | 11 ++-- plugins/coaching/coaching.js | 5 +- .../coaching/test/panel_integration.test.js | 2 +- plugins/metronome_lock/game.js | 12 +++- plugins/pitch_match/game.js | 9 ++- plugins/practice_journal/routes.py | 58 +++++++++++-------- 6 files changed, 59 insertions(+), 38 deletions(-) diff --git a/plugins/coaching/README.md b/plugins/coaching/README.md index b1258bc9..e080dbc5 100644 --- a/plugins/coaching/README.md +++ b/plugins/coaching/README.md @@ -28,9 +28,10 @@ session wrapper. The two load-bearing fields: - **`mistake.faultVerdict`** (`player_error` | `detector_suspect` | `confirmed_detector_bug`) — routes a miss to *drill it* vs *feed it to the - harness*. `no_detection` → `detector_suspect`; a detection that fired but was - off → `player_error`; `confirmed_detector_bug` is only ever set by an external - harness replay. + harness*. A detection that fired but was off → `player_error`. `no_detection` + → `detector_suspect`, **unless** the miss carries a note-present signal + (note_detect heard the pitch yet still scored a miss) → `confirmed_detector_bug`, + set in-app; an external harness replay also sets it. - **`hotspot.signal.kind`** (`systematic` | `random`) — a consistent skew (median clears a floor *and* dominates the spread) is a tool/calibration problem (e.g. an A/V offset), not a skill gap. Keeps the drill loop from @@ -43,7 +44,9 @@ See the schema + builder in [`coaching.js`](coaching.js); it's covered by `session.summary` is filled by a **client-side** Anthropic Messages API call: -- **Model** `claude-opus-4-8`, a frozen + prompt-cached system prompt, and +- **Model** `claude-haiku-4-5` (cheapest tier while the flow is proven out — + bumps to `claude-opus-4-8` once coaching quality is validated), a frozen + + prompt-cached system prompt, and `output_config.format` (json_schema) so the reply parses deterministically. - The request **distills** the feedback to the session block + hotspots + failure/fault tallies — raw mistake atoms are not sent. diff --git a/plugins/coaching/coaching.js b/plugins/coaching/coaching.js index a17cf501..e9bb05bf 100644 --- a/plugins/coaching/coaching.js +++ b/plugins/coaching/coaching.js @@ -1107,7 +1107,10 @@ function renderBreakdownHtml(feedback) { // shows after every play even without an API key — the drill loop must not // be gated behind the paid LLM coach. When the LLM plan lands it replaces // this with the richer renderSummaryHtml. Pure → node-testable. -// Open-string label by index, low→high. Bass is 4 strings (EADG); guitar 6. +// Open-string label by index, low→high. Standard 4-string bass is EADG and +// standard 6-string guitar EADGBe; indices 4–5 in the bass row (C, B) are a +// best-effort fallback for extended-range basses whose true string set the +// arrangement name doesn't disclose — treat them as approximate, not canonical. function _stringLabel(s, arrangement) { const bass = ['E', 'A', 'D', 'G', 'C', 'B']; const gtr = ['E', 'A', 'D', 'G', 'B', 'e']; diff --git a/plugins/coaching/test/panel_integration.test.js b/plugins/coaching/test/panel_integration.test.js index b4a1f0be..9715dcb9 100644 --- a/plugins/coaching/test/panel_integration.test.js +++ b/plugins/coaching/test/panel_integration.test.js @@ -142,7 +142,7 @@ test('no-key: a zero-judgment false-start does NOT clobber a scored play\'s pane test('no-key: a clustered miss play surfaces a Practice panel at song end', () => { const { host, fireWin } = harness(); - // 3 misses within 2s → one hotspot (findHotspots minMisses=3, gap=2s). + // 3 misses within 0.8s → one hotspot (findHotspots default minMisses=2, gap=3s). fireWin('notedetect:miss', missJudgment(20.0)); fireWin('notedetect:miss', missJudgment(20.4)); fireWin('notedetect:miss', missJudgment(20.8)); diff --git a/plugins/metronome_lock/game.js b/plugins/metronome_lock/game.js index 78ddec45..0cf3d431 100644 --- a/plugins/metronome_lock/game.js +++ b/plugins/metronome_lock/game.js @@ -51,6 +51,7 @@ // Metronome state. let _clickCtx = null, _bpm = 80, _beatPeriodMs = 750; + let _metronomeRun = 0; // token: a newer _startMetronome/_stopMetronome supersedes older awaits let _clickMode = 'straight'; // 'straight' | 'backbeat' | 'eighths' let _dropOn = false; // dropped-beat modifier let _subdivPerBeat = 1, _subdivPeriodMs = 750; // scoring/tick grid @@ -109,13 +110,19 @@ async function _startMetronome(bpm) { _stopMetronome(); + const run = ++_metronomeRun; // claim this run; a later start/stop invalidates us _bpm = bpm; _beatPeriodMs = 60000 / bpm; _subdivPerBeat = (_clickMode === 'eighths') ? 2 : 1; _subdivPeriodMs = _beatPeriodMs / _subdivPerBeat; const AC = window.AudioContext || window.webkitAudioContext; - _clickCtx = new AC(); - try { await _clickCtx.resume(); } catch (_) {} + const ctx = new AC(); + try { await ctx.resume(); } catch (_) {} + // A newer _startMetronome (rapid BPM/mode/drop changes) or a _stopMetronome + // ran while we awaited resume() — abandon this stale context so we don't + // leave two schedulers ticking at once. + if (run !== _metronomeRun) { try { ctx.close(); } catch (_) {} return; } + _clickCtx = ctx; const lead = 0.25; // small runway before tick 0 _anchorAudio = _clickCtx.currentTime + lead; _anchorPerf = performance.now() + lead * 1000; @@ -126,6 +133,7 @@ } function _stopMetronome() { + _metronomeRun++; // invalidate any _startMetronome currently awaiting resume() if (_schedTimer) { clearInterval(_schedTimer); _schedTimer = null; } try { if (_clickCtx) _clickCtx.close(); } catch (_) {} _clickCtx = null; diff --git a/plugins/pitch_match/game.js b/plugins/pitch_match/game.js index 420665da..3b5316cc 100644 --- a/plugins/pitch_match/game.js +++ b/plugins/pitch_match/game.js @@ -67,13 +67,13 @@ return (sdk && sdk.skillDrill) || (typeof window !== 'undefined' ? window.slopsmithSkillDrills : null); } - function _nextTarget() { + function _nextTarget(ev) { const i = _drill ? _drill.reps : 0; const s = STRINGS[i % STRINGS.length]; const fret = FRETS[(Math.floor(i / STRINGS.length)) % FRETS.length]; _target = { stringName: s.name, fret, targetMidi: s.openMidi + fret }; _hold = []; - _render(); + _render(ev); // keep the just-judged feedback flash while showing the next target } function scoreFromView(v) { @@ -126,9 +126,8 @@ _hold = []; _sd.skillDrillStep(_drill, ev); const view = _sd.skillDrillView(_drill); - _render(ev); - if (view.passed) _finish(); - else _nextTarget(); + if (view.passed) { _render(ev); _finish(); } + else _nextTarget(ev); } else { _hold = []; } diff --git a/plugins/practice_journal/routes.py b/plugins/practice_journal/routes.py index 0210b2bc..51990ee7 100644 --- a/plugins/practice_journal/routes.py +++ b/plugins/practice_journal/routes.py @@ -13,31 +13,37 @@ def _get_conn(): global _conn + # Double-checked locking: the cheap unlocked read fast-paths the common case, + # the lock + re-check guarantees exactly one thread builds the connection. if _conn is None: - _conn = sqlite3.connect(_db_path, check_same_thread=False) - _conn.execute("PRAGMA journal_mode=WAL") - _conn.execute(""" - CREATE TABLE IF NOT EXISTS practice_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - filename TEXT NOT NULL, - title TEXT, - artist TEXT, - started_at TEXT NOT NULL, - duration_seconds REAL NOT NULL DEFAULT 0, - avg_speed REAL NOT NULL DEFAULT 1.0, - loops_used TEXT DEFAULT '[]', - arrangement TEXT - ) - """) - _conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_practice_filename - ON practice_sessions(filename) - """) - _conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_practice_started - ON practice_sessions(started_at) - """) - _conn.commit() + with _lock: + if _conn is None: + conn = sqlite3.connect(_db_path, check_same_thread=False) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute(""" + CREATE TABLE IF NOT EXISTS practice_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL, + title TEXT, + artist TEXT, + started_at TEXT NOT NULL, + duration_seconds REAL NOT NULL DEFAULT 0, + avg_speed REAL NOT NULL DEFAULT 1.0, + loops_used TEXT DEFAULT '[]', + arrangement TEXT + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_practice_filename + ON practice_sessions(filename) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_practice_started + ON practice_sessions(started_at) + """) + conn.commit() + # Publish only once fully built — no partial connection is visible. + _conn = conn return _conn @@ -54,7 +60,9 @@ def record_session(data: dict): return {"error": "No filename"} duration = data.get("duration", 0) - if duration < 5: # ignore sessions under 5 seconds + # Guard against non-numeric payloads (a bad client sending a string would + # raise on the comparison); also ignore sessions under 5 seconds. + if not isinstance(duration, (int, float)) or duration < 5: return {"ok": True, "skipped": True} conn = _get_conn() From 6eed8db22c45ca5502a99c3449761c4b06e707b8 Mon Sep 17 00:00:00 2001 From: Ashton Honnecke Date: Tue, 28 Jul 2026 08:54:37 -0600 Subject: [PATCH 3/7] feat(practice_journal): implement real loop tracking (was hardcoded []) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit screen.js posted `loops_used: []` on every session while claiming to track practice loops. Wire it to the actual A–B loop the player worked: - Accumulate distinct A–B ranges into a per-session Map, fed by the core `loop:restart` bus event (each wrap increments that loop's count), and snapshot a still-set loop from window.feedBack.getLoop() at session close (count 0 = set but never cycled). - POST the collected loops instead of []. The backend already persisted loops_used and the /song history endpoint already returned it, so this completes an end-to-end pipeline that had a dead frontend leg. - Surface it: /stats recent now returns per-session loops, and the journal dashboard shows a "🔁 N" badge on sessions that used loops. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/practice_journal/routes.py | 4 +-- plugins/practice_journal/screen.js | 44 ++++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/plugins/practice_journal/routes.py b/plugins/practice_journal/routes.py index 51990ee7..e76aa0ed 100644 --- a/plugins/practice_journal/routes.py +++ b/plugins/practice_journal/routes.py @@ -128,7 +128,7 @@ def practice_stats(): # Recent sessions recent = conn.execute( - "SELECT title, artist, filename, started_at, duration_seconds, avg_speed, arrangement " + "SELECT title, artist, filename, started_at, duration_seconds, avg_speed, arrangement, loops_used " "FROM practice_sessions ORDER BY started_at DESC LIMIT 20" ).fetchall() @@ -147,7 +147,7 @@ def practice_stats(): "recent": [ {"title": r[0], "artist": r[1], "filename": r[2], "started_at": r[3], "duration": r[4], "speed": r[5], - "arrangement": r[6]} + "arrangement": r[6], "loops": json.loads(r[7]) if r[7] else []} for r in recent ], } diff --git a/plugins/practice_journal/screen.js b/plugins/practice_journal/screen.js index 789d3ef0..3c9b0c9b 100644 --- a/plugins/practice_journal/screen.js +++ b/plugins/practice_journal/screen.js @@ -16,6 +16,21 @@ let _pjFilename = null; let _pjTitle = null; let _pjArtist = null; let _pjArrangement = null; +// Distinct A–B loops the player actually practiced this session, keyed by a +// rounded "a-b" string so repeated wraps of the same loop collapse to one +// entry carrying its restart count. Fed from `loop:restart` (each wrap) plus a +// final snapshot of a still-set loop at session close. +let _pjLoops = new Map(); + +function _pjRecordLoop(a, b) { + if (a == null || b == null || !(b > a)) return; + const ra = Math.round(a * 100) / 100; + const rb = Math.round(b * 100) / 100; + const key = ra + '-' + rb; + const prev = _pjLoops.get(key); + if (prev) prev.count += 1; + else _pjLoops.set(key, { a: ra, b: rb, count: 1 }); +} function _pjSongMeta() { // The canonical current-song identity lives on window.feedBack.currentSong @@ -57,6 +72,7 @@ function _pjStartSession() { _pjArtist = meta.artist; _pjArrangement = meta.arrangement; _pjSessionStart = new Date().toISOString(); + _pjLoops = new Map(); // fresh loop tally per song } function _pjEndSession() { @@ -65,6 +81,21 @@ function _pjEndSession() { const duration = (Date.now() - new Date(_pjSessionStart).getTime()) / 1000; const avgSpeed = Math.round(_pjCurrentSpeed() * 100) / 100; + // Capture a still-set loop that never wrapped (set, then practiced without + // reaching B) so a configured loop still counts — with count 0 to mark it + // as set-but-not-cycled. + try { + const gl = (window.feedBack && typeof window.feedBack.getLoop === 'function') + ? window.feedBack.getLoop({ reason: 'practice-journal' }) : null; + if (gl && gl.loopA != null && gl.loopB != null && gl.loopB > gl.loopA) { + const ra = Math.round(gl.loopA * 100) / 100; + const rb = Math.round(gl.loopB * 100) / 100; + const key = ra + '-' + rb; + if (!_pjLoops.has(key)) _pjLoops.set(key, { a: ra, b: rb, count: 0 }); + } + } catch (_) {} + const loops = Array.from(_pjLoops.values()); + // Fire and forget (backend ignores sessions under 5s) fetch('/api/plugins/practice_journal/session', { method: 'POST', @@ -76,13 +107,14 @@ function _pjEndSession() { started_at: _pjSessionStart, duration: duration, avg_speed: avgSpeed, - loops_used: [], + loops_used: loops, arrangement: _pjArrangement, }), }).catch(() => {}); _pjSessionStart = null; _pjFilename = null; + _pjLoops = new Map(); } (function _pjInstall() { @@ -97,6 +129,12 @@ function _pjEndSession() { bus.on('song:loaded', _pjStartSession); bus.on('song:ended', _pjEndSession); bus.on('song:stop', _pjEndSession); + // Each A–B loop wrap during an active session is a real practiced loop. + bus.on('loop:restart', (ev) => { + if (!_pjSessionStart) return; + const d = (ev && ev.detail) || {}; + _pjRecordLoop(d.loopA, d.loopB); + }); bus.on('screen:changed', (ev) => { const id = (ev && ev.detail && ev.detail.id) || null; if (id && id !== 'player') _pjEndSession(); @@ -188,13 +226,15 @@ async function _pjLoadDashboard() { + ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }); const speedStr = s.speed !== 1.0 ? ` · ${s.speed.toFixed(2)}x` : ''; const arrStr = s.arrangement ? ` · ${s.arrangement}` : ''; + const loopN = Array.isArray(s.loops) ? s.loops.length : 0; + const loopStr = loopN ? ` · 🔁 ${loopN}` : ''; return `
    ${esc(s.title || s.filename)} ${esc(s.artist)}${arrStr}
    - ${_pjFormatDuration(s.duration)}${speedStr} + ${_pjFormatDuration(s.duration)}${speedStr}${loopStr} ${timeStr}
    `; From a5b6929b9ebb754c5becd6df0eed4c00df752e3c Mon Sep 17 00:00:00 2001 From: Ashton Honnecke Date: Tue, 28 Jul 2026 10:36:52 -0600 Subject: [PATCH 4/7] fix(mute_master): kill open-string-alias false positives; honest scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The game scored a CLEAN muting rep whenever it heard the target pitch — but hearing the target proves you played the right note, not that the open string is silent. Worse, it uses a MONOPHONIC YIN scorer with octave folding, which collapses pitch to pitch-class: a target sharing a class with any open string (E-fret5 == open A; E-fret3 == open G an octave up; A-fret7 == open E; …) is indistinguishable from that open ringing, so a FAILED mute scored as clean. 6 of the old 9 targets were structurally unscoreable. - Precompute VALID_TARGETS: exclude every (string, fret) whose folded pitch aliases an open string (< 1 semitone). Widen the candidate fret range so the survivor set is a healthy 17 well-separated targets instead of 3. - Rewrite the header + README to be honest: this infers muting from a self-contained mono pitch scorer, NOT the note_detect judgment stream — note_detect emits no `mute_fail` today, so that leg of the taxonomy is aspirational, and the score is a best-effort proxy (true mute verification needs a polyphonic/spectral signal note_detect would have to produce). Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/mute_master/README.md | 22 ++++++++++---- plugins/mute_master/game.js | 57 +++++++++++++++++++++++++++-------- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/plugins/mute_master/README.md b/plugins/mute_master/README.md index 00ecc745..558e14bf 100644 --- a/plugins/mute_master/README.md +++ b/plugins/mute_master/README.md @@ -4,12 +4,22 @@ The first **skill-drill game** for Slopsmith — see [`docs/TEACHING_NARRATIVE.md`](../../docs/TEACHING_NARRATIVE.md). It trains **muting**: when the chart wants a fretted note, the open string must -not ring. note_detect flags that exact failure as `mute_fail`; coaching's -practice taxonomy points a `mute_fail` hotspot here, deep-linking into the -passage you fumbled. The game scores your muting off the live note_detect -judgment stream using the **shared** skill-drill mechanics -(`createSkillDrill` / `skillDrillStep` / `skillDrillView`, published by the -coaching plugin and bridged through the minigames SDK as `sdk.skillDrill`). +not ring. coaching's practice taxonomy points a `mute_fail` hotspot here, +deep-linking into the passage you fumbled, and the game runs the **shared** +skill-drill mechanics (`createSkillDrill` / `skillDrillStep` / `skillDrillView`, +published by the coaching plugin and bridged through the minigames SDK as +`sdk.skillDrill`). + +**How it actually scores (honest scope):** the game uses its **own** +self-contained *monophonic* pitch scorer (`scoring.createContinuous` — mic + +YIN), **not** the note_detect judgment stream — note_detect does not currently +emit a `mute_fail`, so the detector→`mute_fail` leg of the taxonomy is +aspirational. A mono detector can't truly prove an open string is silent, and +octave folding collapses pitch to pitch-class, so muting is only scoreable on +targets whose pitch class differs from every open string (E-string fret 5 = +open A, E-string fret 3 = open G an octave up, … are excluded). Treat the score +as a best-effort proxy; true mute verification needs a polyphonic/spectral +signal note_detect would have to produce. **Chart-free and self-paced** (per the "fast, no lead-in, no slow animations" steer): it prompts one fret target at a time and scores each note the instant diff --git a/plugins/mute_master/game.js b/plugins/mute_master/game.js index e1319779..4e296e32 100644 --- a/plugins/mute_master/game.js +++ b/plugins/mute_master/game.js @@ -1,14 +1,23 @@ // Mute Master — the first skill-drill game (docs/TEACHING_NARRATIVE.md). // // Trains MUTING on bass: when you fret a note, the OPEN string under it must not -// ring. note_detect flags that exact slip as `mute_fail`; coaching's taxonomy -// points a `mute_fail` hotspot here. This game proves the contract end to end — -// detection → skill → game → SHARED rep/streak/pass engine. +// ring. coaching's taxonomy points a `mute_fail` hotspot here, so this is the +// game a muting slip deep-links into — the skill → game → SHARED +// rep/streak/pass half of the contract. +// +// HONEST SCOPE — this game infers muting from a self-contained MONOPHONIC pitch +// scorer (`scoring.createContinuous` — its own mic + YIN), NOT from note_detect: +// note_detect does not currently emit a `mute_fail` judgment, so the detector→ +// mute_fail leg of the taxonomy is aspirational. A mono detector can't truly +// prove an open string is silent (it reports one dominant pitch), and octave +// folding collapses pitch to pitch-class, so muting is only scoreable on +// targets whose pitch class differs from every open string (see VALID_TARGETS) +// — a best-effort proxy, not ground truth. Real mute verification needs a +// polyphonic/spectral signal note_detect would have to produce. // // Design per the "fast iteration, no lead-in, no slow animations" steer: it is -// CHART-FREE and self-paced. It uses the SDK's self-contained pitch scorer -// (`scoring.createContinuous` — its own mic + YIN, no song needed), prompts one -// fret target at a time, and scores each note the instant you play it: +// CHART-FREE and self-paced. It prompts one fret target at a time and scores +// each note the instant you play it: // - you hit the target pitch → a CLEAN muting rep // - the OPEN string rings instead → a mute_fail (you didn't mute it) // New target immediately, no count-in, no transitions. Eight clean in a row wins. @@ -35,17 +44,39 @@ { name: 'A', openMidi: 33 }, { name: 'D', openMidi: 38 }, ]; - const FRETS = [3, 5, 7]; // small, clearly-fretted targets + const CAND_FRETS = [2, 3, 4, 5, 6, 7, 8, 9]; // candidate frets, filtered below const NOTE = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; const midiName = (m) => NOTE[((Math.round(m) % 12) + 12) % 12] + (Math.floor(Math.round(m) / 12) - 1); // Nearest-OCTAVE semitone distance. The continuous scorer's YIN (minHz 70) // can't lock a bass fundamental (open E ≈ 41 Hz) and reports the octave-up // harmonic instead, so a played note reads ~12 semitones off its true MIDI. // Fold to the nearest octave (as note_detect does) so a correct note still - // matches; target and open stay 3+ semitones apart after folding, so this - // never confuses them. + // matches. The catch: folding collapses pitch to pitch-CLASS, so a target + // that shares a class with ANY open string is indistinguishable from that + // open ringing — the muting signal we're trying to score. See VALID_TARGETS. const _foldSemi = (a, b) => { let d = a - b; d -= Math.round(d / 12) * 12; return Math.abs(d); }; + // Every open string, for collision checks (E A D G — G isn't prompted but + // its open can still bleed and alias a fretted target). + const BASS_OPENS = [28, 33, 38, 43]; + // Exclude only EXACT pitch-class unisons with an open string (< 1 semitone + // after folding). Anything ≥1 semitone from every open is safely + // distinguishable at the NEAR_SEMITONES=0.6 match threshold. + const SEP_SEMITONES = 1.0; + // A monophonic detector under octave-folding can only score muting on a + // target whose pitch class differs from every open string — otherwise + // "fretted note, opens muted" reads identically to "that open rang", and a + // failed mute scores as clean. Precompute the scoreable (string, fret) set + // once (E-fret5 == open A, E-fret3 == open G one octave up, etc. drop out). + const VALID_TARGETS = []; + for (const s of STRINGS) { + for (const fret of CAND_FRETS) { + const midi = s.openMidi + fret; + const collides = BASS_OPENS.some(o => o !== s.openMidi && _foldSemi(midi, o) < SEP_SEMITONES); + if (!collides) VALID_TARGETS.push({ stringName: s.name, fret, targetMidi: midi, openMidi: s.openMidi }); + } + } + let _sdk = null, _cont = null, _drill = null, _sd = null; let _startedAt = 0, _finished = false; let _target = null; // { stringName, fret, targetMidi, openMidi } @@ -71,11 +102,11 @@ } function _nextTarget() { - // Vary by index (no Math.random dependency, deterministic-enough rotation). + // Rotate through the scoreable targets by rep index (no Math.random + // dependency, deterministic rotation). VALID_TARGETS already excludes + // any pitch that aliases an open string under octave folding. const i = _drill ? _drill.reps : 0; - const s = STRINGS[i % STRINGS.length]; - const fret = FRETS[(Math.floor(i / STRINGS.length)) % FRETS.length]; - _target = { stringName: s.name, fret, targetMidi: s.openMidi + fret, openMidi: s.openMidi }; + _target = VALID_TARGETS[i % VALID_TARGETS.length]; _armed = true; _render(); } From 030fc5515bd0950dd593221cde49fc7dc144a4eb Mon Sep 17 00:00:00 2001 From: Ashton Honnecke Date: Wed, 29 Jul 2026 16:12:27 -0600 Subject: [PATCH 5/7] fix(coaching-suite): stored-XSS in journal + phantom onsets in metronome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two remaining CodeRabbit findings: - SECURITY (critical): practice_journal recent-sessions rendered `s.arrangement` unescaped into innerHTML while its neighbour used esc(). arrangement flows from song metadata → DB → here, so a crafted song file was a stored-XSS vector. Wrap it in esc(). - CORRECTNESS: metronome_lock re-armed the attack detector on a SINGLE sub-CONF_MIN frame, so a momentary confidence dropout mid-note let one played note score as two grid hits. Require the release to persist RELEASE_MS (60ms) before re-arming; reset the release clock on each run. (The same re-arm pattern exists in mute_master/pitch_match but is far less harmful there — an extra rep, not a phantom rhythm onset — so left as-is.) Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/metronome_lock/game.js | 14 ++++++++++++-- plugins/practice_journal/screen.js | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/plugins/metronome_lock/game.js b/plugins/metronome_lock/game.js index 0cf3d431..7625b00a 100644 --- a/plugins/metronome_lock/game.js +++ b/plugins/metronome_lock/game.js @@ -34,6 +34,7 @@ const GAME_ID = 'metronome_lock'; const GOAL_STREAK = 8; const CONF_MIN = 0.85; // YIN confidence to accept a note onset + const RELEASE_MS = 60; // sub-threshold must persist this long to re-arm const WINDOW_MS = 70; // within this of the grid point = on time (generous v1) const DETECT_LATENCY_MS = 45; // approx pluck → first-confident-frame lag, removed const BPMS = [60, 80, 100]; // selectable tempos @@ -47,6 +48,7 @@ let _startedAt = 0, _finished = false; let _container = null; let _armed = true; // ready to count the next attack (debounce) + let _releaseSince = null; // tMs the note first dropped below CONF_MIN (release debounce) let _lastEv = null; // last judged event, for the skin // Metronome state. @@ -129,6 +131,7 @@ _nextTickAudio = _anchorAudio; _tickNum = 0; _armed = true; + _releaseSince = null; _schedTimer = setInterval(_scheduler, SCHED_INTERVAL_MS); } @@ -167,8 +170,15 @@ function _onPitch(p) { if (_finished || !p || !_clickCtx) return; - // Re-arm on a clear release so each grid point's note is one rep. - if (!(p.confidence >= CONF_MIN) || !isFinite(p.midiFloat)) { _armed = true; return; } + // Re-arm only after a SUSTAINED release: a single sub-threshold frame is a + // dropout mid-note, not a new attack. Requiring RELEASE_MS below the floor + // keeps one played note from scoring as two grid hits. + if (!(p.confidence >= CONF_MIN) || !isFinite(p.midiFloat)) { + if (_releaseSince == null) _releaseSince = p.tMs; + if (p.tMs - _releaseSince >= RELEASE_MS) _armed = true; + return; + } + _releaseSince = null; // confident frame — we're inside a note again if (!_armed) return; _armed = false; // consume this attack until release diff --git a/plugins/practice_journal/screen.js b/plugins/practice_journal/screen.js index 3c9b0c9b..fcb39b81 100644 --- a/plugins/practice_journal/screen.js +++ b/plugins/practice_journal/screen.js @@ -225,7 +225,7 @@ async function _pjLoadDashboard() { const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }); const speedStr = s.speed !== 1.0 ? ` · ${s.speed.toFixed(2)}x` : ''; - const arrStr = s.arrangement ? ` · ${s.arrangement}` : ''; + const arrStr = s.arrangement ? ` · ${esc(s.arrangement)}` : ''; const loopN = Array.isArray(s.loops) ? s.loops.length : 0; const loopStr = loopN ? ` · 🔁 ${loopN}` : ''; return `
    From 3302ecaffcdd81e54bc030f01b6e1353198b3ca9 Mon Sep 17 00:00:00 2001 From: Ashton Honnecke Date: Thu, 30 Jul 2026 09:33:04 -0600 Subject: [PATCH 6/7] =?UTF-8?q?feat(coaching):=20"Drill=20all"=20=E2=80=94?= =?UTF-8?q?=20chain=20the=20worst-first=20miss=20spots=20into=20one=20sess?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-play panel already lists miss hotspots (worst-first) each with a one-tap "Practice this" → note_detect.startDrill. This adds the missing session flow: a "▶ Drill all N spots (worst first)" button that drills them in sequence — spot 1 → (nail, or Skip) → spot 2 → … → recap — via a compact inline-styled stepper (Skip ▶ / ✕ End / ▶ Again). Advancement is orchestrated by polling note_detect's public isDrilling() (a spot ends when it graduates or the user closes it), so there are ZERO note_detect changes and no new drill-end event needed. The "Drill all" button is injected in _renderPanel (covers both the no-key and LLM panels) and only appears with ≥2 drillable spots; its distinct class keeps the per-spot button count (and tests) unchanged. Exposed window.coaching.drillAll/endDrillSession for manual use. plugin.json 0.13.0 → 0.14.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/coaching/coaching.js | 177 +++++++++++++++++++++++++++++++++++ plugins/coaching/plugin.json | 2 +- 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/plugins/coaching/coaching.js b/plugins/coaching/coaching.js index e9bb05bf..986f8460 100644 --- a/plugins/coaching/coaching.js +++ b/plugins/coaching/coaching.js @@ -1447,6 +1447,24 @@ if (typeof window !== 'undefined' && typeof window.addEventListener === 'functio panel.querySelectorAll('.coaching-drill-btn').forEach((btn) => { btn.onclick = () => _startDrillFromButton(btn); }); + // If this play has ≥2 drillable trouble spots, offer a one-tap + // session that drills them worst-first in sequence. Injected here + // (not in the pure render fns) so both the no-key and LLM panels + // get it; distinct class from .coaching-drill-btn so the per-spot + // button count / tests are unaffected. + try { + const drillable = _drillableHotspots(); + if (drillable.length >= 2 && !panel.querySelector('#coaching-drill-all')) { + const allBtn = document.createElement('button'); + allBtn.id = 'coaching-drill-all'; + allBtn.className = 'block w-full mb-3 px-3 py-2 bg-accent hover:bg-accent-light text-white rounded-lg text-sm font-bold transition'; + allBtn.innerHTML = '▶ Drill all ' + drillable.length + ' spots (worst first)'; + allBtn.onclick = () => _startDrillAll(); + const anchor = panel.querySelector('#coaching-diag-dl') || panel.querySelector('#coaching-summary-close'); + if (anchor && anchor.nextSibling) panel.insertBefore(allBtn, anchor.nextSibling); + else panel.appendChild(allBtn); + } + } catch (_) {} return panel; } @@ -1488,6 +1506,162 @@ if (typeof window !== 'undefined' && typeof window.addEventListener === 'functio else { btn.textContent = 'Couldn’t start drill'; } }).catch(() => { btn.textContent = 'Drill failed'; }); } + + // ── "Drill all" session ────────────────────────────────────────── + // Chain the worst-first hotspots into one guided run: drill spot 1 → + // (nail, or Skip) → spot 2 → … → recap. Each spot reuses the SAME + // note_detect conductor; we only orchestrate advancement by polling + // its public isDrilling() — no note_detect changes, no drill-end event + // needed. The stepper is inline-styled so a runtime-installed plugin + // needs no stylesheet (arbitrary Tailwind values aren't in core's CSS). + let _session = null; + + function _drillableHotspots() { + const fb = (typeof window !== 'undefined') ? window.__coachingLastFeedback : null; + const hs = (fb && Array.isArray(fb.hotspots)) ? fb.hotspots : []; + // findHotspots already sorts by severity desc → worst-first. + return hs.filter((h) => h && h.drill + && Number.isFinite(h.drill.loopA) && Number.isFinite(h.drill.loopB) + && h.drill.loopB > h.drill.loopA); + } + + function _sessionStepperEl() { + let el = document.getElementById('coaching-drill-session'); + if (!el) { + el = document.createElement('div'); + el.id = 'coaching-drill-session'; + el.style.cssText = 'position:fixed;top:64px;left:50%;transform:translateX(-50%);' + + 'z-index:160;background:#1f2430;border:1px solid #4080e0;border-radius:12px;' + + 'padding:8px 14px;box-shadow:0 10px 30px rgba(0,0,0,.5);color:#e5e7eb;' + + 'font:14px system-ui;display:flex;align-items:center;gap:10px'; + (document.getElementById('player') || document.body).appendChild(el); + } + return el; + } + + function _sessionRenderStepper(msg, opts) { + opts = opts || {}; + const el = _sessionStepperEl(); + const b = (id, label, bg) => ``; + el.innerHTML = '' + msg + '' + + (opts.showSkip ? b('coaching-sess-skip', 'Skip ▶', '#3a4150') : '') + + (opts.showAgain ? b('coaching-sess-again', '▶ Again', '#4080e0') : '') + + b('coaching-sess-end', '× End', '#3a4150'); + const wire = (id, fn) => { const n = el.querySelector('#' + id); if (n) n.onclick = fn; }; + wire('coaching-sess-skip', _sessionSkip); + wire('coaching-sess-again', _startDrillAll); + wire('coaching-sess-end', () => _sessionEnd()); + } + + function _sessionClearStepper() { + const el = document.getElementById('coaching-drill-session'); + if (el) { try { el.remove(); } catch (_) {} } + } + + function _startDrillAll() { + const nd = (typeof window !== 'undefined') ? window.noteDetect : null; + if (!nd || typeof nd.startDrill !== 'function') return; + const queue = _drillableHotspots(); + if (!queue.length) return; + // Collapse the feedback panel so the highway is visible for the run. + try { const p = document.getElementById('coaching-summary-panel'); if (p) p.remove(); } catch (_) {} + _sessionStop(); + _session = { queue, idx: 0, ended: false, sawDrilling: false, timer: null, advTimer: null }; + _sessionStartSpot(0); + } + + function _sessionStartSpot(i) { + const nd = window.noteDetect; + const s = _session; + if (!s || !nd) return; + s.idx = i; + s.sawDrilling = false; + const n = s.queue.length; + const h = s.queue[i]; + const args = drillArgsFromDataset({ + loopA: h.drill.loopA, loopB: h.drill.loopB, + speedMul: h.drill.speedMul, goal: h.drill.goal, + key: 'Spot ' + (i + 1) + '/' + n, + }); + _sessionRenderStepper('🎯 Drilling spot ' + (i + 1) + ' of ' + n + '…', { showSkip: true }); + Promise.resolve(args ? nd.startDrill(args.start, args.end, args.opts) : false).then((ok) => { + if (!_session || _session !== s) return; + if (!ok) { _sessionAdvance(); return; } // couldn't arm this spot — skip it + _sessionStartPoll(); + }).catch(() => { if (_session === s) _sessionAdvance(); }); + } + + function _sessionStartPoll() { + const s = _session; + if (!s) return; + if (s.timer) { clearInterval(s.timer); s.timer = null; } + s.timer = setInterval(() => { + const nd = window.noteDetect; + if (!_session || _session !== s || !nd) return; + const drilling = (typeof nd.isDrilling === 'function') ? nd.isDrilling() : false; + if (drilling) { s.sawDrilling = true; return; } + if (s.sawDrilling) { // was drilling, now stopped → this spot ended (nailed or closed) + clearInterval(s.timer); s.timer = null; + _sessionSpotEnded(); + } + }, 600); + } + + function _sessionSpotEnded() { + const s = _session; + if (!s || s.ended) return; + if (s.idx + 1 >= s.queue.length) { _sessionFinish(); return; } + _sessionRenderStepper('✓ Spot ' + (s.idx + 1) + ' done — next in a moment…', { showSkip: false }); + s.advTimer = setTimeout(() => { if (_session === s && !s.ended) _sessionAdvance(); }, 1100); + } + + function _sessionAdvance() { + const s = _session; + if (!s || s.ended) return; + if (s.advTimer) { clearTimeout(s.advTimer); s.advTimer = null; } + const next = s.idx + 1; + if (next >= s.queue.length) { _sessionFinish(); return; } + _sessionStartSpot(next); + } + + function _sessionSkip() { + const nd = window.noteDetect; + try { if (nd && typeof nd.endDrill === 'function') nd.endDrill('session-skip'); } catch (_) {} + const s = _session; + if (!s) return; + if (s.timer) { clearInterval(s.timer); s.timer = null; } + _sessionAdvance(); + } + + function _sessionFinish() { + const s = _session; + if (!s) return; + if (s.timer) { clearInterval(s.timer); s.timer = null; } + if (s.advTimer) { clearTimeout(s.advTimer); s.advTimer = null; } + const n = s.queue.length; + s.ended = true; + _sessionRenderStepper('✓ Session done — drilled ' + n + ' spot' + (n === 1 ? '' : 's') + '.', { showAgain: true }); + _session = null; + } + + function _sessionEnd() { + const nd = window.noteDetect; + try { if (nd && typeof nd.endDrill === 'function') nd.endDrill('session-end'); } catch (_) {} + _sessionStop(); + _sessionClearStepper(); + } + + function _sessionStop() { + const s = _session; + if (s) { + if (s.timer) { clearInterval(s.timer); s.timer = null; } + if (s.advTimer) { clearTimeout(s.advTimer); s.advTimer = null; } + s.ended = true; + } + _session = null; + } + if (window.slopsmith && typeof window.slopsmith.on === 'function') { window.slopsmith.on('coaching:summary', (ev) => { // feedBack's window.feedBack bus is an EventTarget: on() @@ -1513,6 +1687,9 @@ if (typeof window !== 'undefined' && typeof window.addEventListener === 'functio // the drill buttons without replaying. showLast: () => _showFeedbackPanel(window.__coachingLastFeedback), coachLast: () => _coachIfEnabled(window.__coachingLastFeedback), + // Kick the worst-first drill-all session for the last play. + drillAll: () => _startDrillAll(), + endDrillSession: () => _sessionEnd(), }); } catch (_) {} diff --git a/plugins/coaching/plugin.json b/plugins/coaching/plugin.json index 6b76c2ca..9b1f2c86 100644 --- a/plugins/coaching/plugin.json +++ b/plugins/coaching/plugin.json @@ -1,7 +1,7 @@ { "id": "coaching", "name": "Coaching", - "version": "0.13.0", + "version": "0.14.0", "description": "Turns each play into an actionable practice plan from note_detect's per-note judgments.", "category": "practice", "script": "coaching.js", From d698e78541d7e44accc765cf32ced4560567204a Mon Sep 17 00:00:00 2001 From: Ashton Honnecke Date: Sun, 2 Aug 2026 10:32:56 -0600 Subject: [PATCH 7/7] =?UTF-8?q?fix(coaching):=20surface=20a=20drill=20targ?= =?UTF-8?q?et=20for=20ANY=20miss,=20not=20just=20=E2=89=A52=20clusters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured against the note_detect recording corpus: 13% of real played-with- misses takes rendered ZERO drill targets — you'd miss a few scattered notes and get no way to drill them. Root cause: hotspots/unheard/loose each require ≥2 misses within 3s to cluster, so sparse misses surfaced nothing. - buildPlayFeedback now also builds `missedSpots`: EVERY missed run grouped tightly (1.2s, a single miss counts), worst-first, capped at 8. - findHotspots gains `minWidthSec` so a single-miss run (a POINT, startSec === endSec) becomes a real, drillable window instead of a zero-width loop — that degenerate range was the reason the first cut still rendered no button. - renderFeedbackHtml renders a "Missed spots" launcher when nothing clustered into the richer lists, so "I missed some" always yields a 🎯 Drill target. - "Drill all" now aggregates every drillable spot type (hotspots + unheard + loose + missedSpots), deduped worst-first — a hotspots-only source starved bass takes, which are mostly unheard/scattered. Verified via the recording corpus: zero-target takes 13% → 0%. Tests 67/67 (the old "single miss → no button" test encoded the bug; updated to the new contract + a truly-clean-play case). plugin.json 0.14.0 → 0.15.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/coaching/coaching.js | 77 +++++++++++++++++-- plugins/coaching/plugin.json | 2 +- .../coaching/test/panel_integration.test.js | 20 ++++- 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/plugins/coaching/coaching.js b/plugins/coaching/coaching.js index 986f8460..808bd54b 100644 --- a/plugins/coaching/coaching.js +++ b/plugins/coaching/coaching.js @@ -372,14 +372,20 @@ function findHotspots(mistakes, allJudgments, opts) { const pes = c.items.map(m => m.pitchErrorCents).filter(x => typeof x === 'number'); const medT = tes.length ? _median(tes) : null; const medP = pes.length ? _median(pes) : null; + // A single-miss cluster is a POINT (startSec === endSec) → a zero-width + // loop that isn't drillable. `minWidthSec` guarantees a real window for + // the loop bounds (note_detect's startDrill still adds its own lead-in + // on top). Evidence keeps the true span; only the drill window widens. + const minW = (opts.minWidthSec != null) ? opts.minWidthSec : 0; + const drillEnd = (minW > 0 && (endSec - startSec) < minW) ? startSec + minW : endSec; out.push({ key: (opts.song || '?') + '|' + (opts.arrangement || '?') + '|' + Math.round(startSec) + '-' + Math.round(endSec), - bounds: { startSec, endSec, section: null, bars: null }, // musical bars/section TODO (need getSections) + bounds: { startSec, endSec: drillEnd, section: null, bars: null }, // musical bars/section TODO (need getSections) mistakeIds: c.items.map(m => m.id), evidence: { missRate: Math.round(missRate * 100) / 100, noteCount, misses: c.items.length, plays: 1 }, signal: { medianTimingMs: medT, medianPitchCents: medP, kind: _signalKind(tes, pes, medT, medP) }, severity: Math.round(1000 * missRate * Math.min(1, c.items.length / 8)) / 1000, - drill: { loopA: startSec, loopB: endSec, speedMul: 0.7, goal: 0.85 }, // raw bounds; note_detect adds the lead-in + drill: { loopA: startSec, loopB: drillEnd, speedMul: 0.7, goal: 0.85 }, // raw bounds; note_detect adds the lead-in }); } out.sort((a, b) => b.severity - a.severity); @@ -489,6 +495,16 @@ function buildPlayFeedback(judgments, sessionMeta) { const heard = hits + playerMisses; // notes the detector verified (hit or heard-but-wrong) const missByType = { late: 0, early: 0, sharp: 0, flat: 0, no_detection: 0, mute_fail: 0 }; for (const m of mistakes) if (missByType[m.failureType] != null) missByType[m.failureType]++; + // Always-available drill targets: EVERY missed run, grouped tightly (1.2s, + // a single miss counts), worst-first, capped at 8. The rich hotspot/unheard/ + // loose lists above each need ≥2 misses clustered within 3s, so a play with a + // few SCATTERED misses surfaced NO drill target at all (measured against the + // recording corpus: ~13% of real played-with-misses takes). This restores + // the original "drill any spot you missed" behaviour. + const missedSpots = findHotspots(mistakes, all, + { song: sessionMeta.song, arrangement: sessionMeta.arrangement, minMisses: 1, gapSec: 1.2, minWidthSec: 1.5 }) + .slice(0, 8); + return { schema: 'coaching.play_feedback.v1', session: { @@ -516,6 +532,7 @@ function buildPlayFeedback(judgments, sessionMeta) { hotspots, looseHotspots, unheardSpots, + missedSpots, timing: _buildTimingReport(all), // rush/drag histogram over all heard notes }; } @@ -1151,6 +1168,30 @@ function renderMissedNotesHtml(feedback) {
    `; } +// Fallback drill launcher: list EVERY missed run (worst-first, single miss OK) +// as a 🎯 Drill button. Shown when the play had misses but none clustered into +// the rich hotspot/unheard lists — so "I missed some" always yields a target. +function renderMissedSpotsHtml(fb, mmss) { + const spots = Array.isArray(fb.missedSpots) ? fb.missedSpots : []; + if (!spots.length) return ''; + const rows = spots.map((h) => { + const b = h.bounds || {}; const d = h.drill || {}; const ev = h.evidence || {}; + const range = (Number.isFinite(b.startSec) ? mmss(b.startSec) : '?') + + '–' + (Number.isFinite(b.endSec) ? mmss(b.endSec) : '?'); + const n = ev.misses || 1; + const drillable = d && Number.isFinite(d.loopA) && Number.isFinite(d.loopB) && d.loopB > d.loopA; + const btn = drillable + ? `` + : ''; + return `
    ` + + `${escapeHtml(range)} · ${n} miss${n === 1 ? '' : 'es'}${btn}
    `; + }).join(''); + return `

    Missed spots

    ` + + `

    Drill any spot you missed — worst first.

    ` + rows; +} + function renderFeedbackHtml(feedback) { const fb = feedback || {}; const hotspots = Array.isArray(fb.hotspots) ? fb.hotspots : []; @@ -1188,7 +1229,11 @@ function renderFeedbackHtml(feedback) { } else { body = `No recurring trouble spots this play — nice. Keep going, or raise the difficulty.`; } - return head + `

    ${body}

    ` + unheardHtml; + // When nothing clustered into unheard runs either, fall back to the + // per-miss "Missed spots" launcher so scattered misses still get a + // drill target (the reported "I missed some, no drill" gap). + const missedHtml = unheardCount ? '' : renderMissedSpotsHtml(fb, mmss); + return head + `

    ${body}

    ` + missedHtml + unheardHtml; } const items = hotspots.map((h, i) => { const b = h.bounds || {}; const d = h.drill || {}; const ev = h.evidence || {}; @@ -1518,11 +1563,27 @@ if (typeof window !== 'undefined' && typeof window.addEventListener === 'functio function _drillableHotspots() { const fb = (typeof window !== 'undefined') ? window.__coachingLastFeedback : null; - const hs = (fb && Array.isArray(fb.hotspots)) ? fb.hotspots : []; - // findHotspots already sorts by severity desc → worst-first. - return hs.filter((h) => h && h.drill - && Number.isFinite(h.drill.loopA) && Number.isFinite(h.drill.loopB) - && h.drill.loopB > h.drill.loopA); + if (!fb) return []; + // Aggregate EVERY drillable spot type — not just player hotspots. + // Bass takes are mostly "unheard" runs and scattered misses land in + // missedSpots; a hotspots-only Drill-all starved those takes. + const lists = [fb.hotspots, fb.unheardSpots, fb.looseHotspots, fb.missedSpots]; + const seen = new Set(); + const out = []; + for (const list of lists) { + if (!Array.isArray(list)) continue; + for (const h of list) { + if (!h || !h.drill) continue; + const a = h.drill.loopA, b = h.drill.loopB; + if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) continue; + const key = Math.round(a * 10) + '-' + Math.round(b * 10); + if (seen.has(key)) continue; + seen.add(key); + out.push(h); + } + } + out.sort((x, y) => (y.severity || 0) - (x.severity || 0)); // worst-first + return out; } function _sessionStepperEl() { diff --git a/plugins/coaching/plugin.json b/plugins/coaching/plugin.json index 9b1f2c86..30cdb9b0 100644 --- a/plugins/coaching/plugin.json +++ b/plugins/coaching/plugin.json @@ -1,7 +1,7 @@ { "id": "coaching", "name": "Coaching", - "version": "0.14.0", + "version": "0.15.0", "description": "Turns each play into an actionable practice plan from note_detect's per-note judgments.", "category": "practice", "script": "coaching.js", diff --git a/plugins/coaching/test/panel_integration.test.js b/plugins/coaching/test/panel_integration.test.js index 9715dcb9..4633bf7f 100644 --- a/plugins/coaching/test/panel_integration.test.js +++ b/plugins/coaching/test/panel_integration.test.js @@ -178,12 +178,24 @@ test('no-key: clicking Practice this calls noteDetect.startDrill with the drill assert.ok(call.opts.goal === 0.85 || call.opts.goal === undefined); }); -test('no-key: a clean play shows the panel but no drill button', () => { +test('no-key: a single scattered miss still yields a drill target', () => { const { host, fireWin } = harness(); - // Fewer than minMisses → no hotspot, but the panel still appears. + // One miss — too few to cluster into a hotspot — but "drill any spot you + // missed" means it MUST still surface a drillable Missed-spots target + // (previously it showed the panel with NO button; ~13% of real takes hit + // this dead end). fireWin('notedetect:miss', missJudgment(20.0)); + fireWin('notedetect:session', { song: 'Sparse', arrangement: 'bass', sections: [] }); + const panel = host._children.find(c => c.id === 'coaching-summary-panel'); + assert.ok(panel, 'panel shown'); + assert.equal(panel.querySelectorAll('.coaching-drill-btn').length, 1, 'the single miss is drillable'); +}); + +test('no-key: a truly clean play shows the panel with no drill button', () => { + const { host, fireWin } = harness(); + fireWin('notedetect:hit', { hit: true, noteTime: 20, note: { s: 1, f: 5 }, clean: true }); fireWin('notedetect:session', { song: 'Clean', arrangement: 'bass', sections: [] }); const panel = host._children.find(c => c.id === 'coaching-summary-panel'); - assert.ok(panel, 'panel still shown on a near-clean play'); - assert.equal(panel.querySelectorAll('.coaching-drill-btn').length, 0, 'no drill button without a hotspot'); + assert.ok(panel, 'panel still shown on a clean play'); + assert.equal(panel.querySelectorAll('.coaching-drill-btn').length, 0, 'no drill button when nothing was missed'); });