From 981b77991853422783e6c8143ae6cc920e7b3a32 Mon Sep 17 00:00:00 2001 From: Kris Anderson Date: Tue, 14 Jul 2026 17:45:27 -0400 Subject: [PATCH 01/11] feat(highway_3d): fret wires flash on a confirmed hit The fret wires were static scenery: gold inside the anchor lane, grey outside, and nothing tied them to what the player was actually doing. Give them a job. Widen the lane/neck contrast so the wires around the active lane read as a focus cue, and flash the wires bracketing a note when a scorer confirms it. A fretted note lights the wire behind it and the wire it is pressed against; a chord lights only the outermost wires of its shape, so it reads as one bracketed block rather than a picket fence; an open string has no fret of its own and its gem is drawn as a slab spanning the lane, so it lights the lane's edge wires instead. Gated on the provider verdict, never the proximity heuristic -- the latter only means "near the strike line", so it would flash on every passing note whether or not it was played. With no scorer attached the neck behaves exactly as before. Emissive (and emissiveIntensity) carry the flash, not albedo: these are MeshStandard materials in a scene with no envMap, so raising albedo alone barely brightens them. Every value is a named constant -- see FRET_WIRE_* -- because the look is a taste call that wants tuning by eye, not a derivation. Signed-off-by: Kris Anderson --- CHANGELOG.md | 10 ++ plugins/highway_3d/plugin.json | 2 +- plugins/highway_3d/screen.js | 192 +++++++++++++++++++++++++++++++-- 3 files changed, 192 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58dda222..e862280b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 data-driven bar (avg ≥ 75%) — into the career state; abandoned sets never log (no fail state: the gig you finished is the gig you played). Passports carry their gig log; instruments their gig count. +- **3D Highway: fret wires flash on a confirmed hit** — when a scorer (note_detect) + confirms a note through the `getNoteState` provider (feedBack#254), the fret wires + bracketing it light up. A fretted note lights the wire behind it and the wire it's + pressed against; a chord lights only the outermost wires of its shape; an open + string lights the anchor lane's edge wires (its gem is drawn as a slab spanning the + lane, so those are the wires it sits between). With no scorer attached, nothing + changes. ### Changed - **Folder library renders only the songs on screen** (#965) — a song list used to @@ -54,6 +61,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 tree — which is how the song-preview menu check ended up eating ~50% of the renderer and dropping the app to 2.7 fps. Lists longer than 200 songs are now windowed (25–31 rows in the DOM instead of 50,000); shorter lists are unchanged. +- **3D Highway: fret wires read as a focus cue** — the contrast between the active + anchor lane and the rest of the neck is widened (the lane's wires brighter, the rest + dimmer), and the wires themselves are slightly thicker. - **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a top-level manifest key this repo invented (#583) that the feedpak spec never had. The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0 diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index e3f017c7..dcc9d277 100644 --- a/plugins/highway_3d/plugin.json +++ b/plugins/highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "highway_3d", "name": "3D Highway", - "version": "3.31.5", + "version": "3.32.0", "type": "visualization", "bundled": true, "script": "screen.js", diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 577726f9..c5ba0d63 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -882,9 +882,12 @@ // frets reads as wrapping a cylindrical neck — chart-format depth cue. // Negative Z = away from camera (into the highway). All tunable. const FRET_BOW_DZ = -1.2 * K; // middle-of-span Z offset - const FRET_TUBE_RADIUS = STR_THICK * 0.55; // ~matches old box thickness + const FRET_TUBE_RADIUS = STR_THICK * 0.75; // slightly thicker than a string const FRET_TUBE_SEG = 12; // tubular segments along the curve - const FRET_TUBE_RADIAL = 6; // radial segments (cross-section) + // Radial segments (cross-section). 8 rather than 6: at FRET_TUBE_RADIUS the + // hexagonal facets of a 6-segment tube are visible along the top highlight. + // One shared geometry for all frets, so the extra segments are ~free. + const FRET_TUBE_RADIAL = 8; // metalness kept moderate, NOT ~1.0: MeshStandardMaterial is PBR and the // scene has no envMap, so a full-metal fret would reflect black and render // dark (the nut/headstock use metalness 0.02 for the same reason). At ~0.4 @@ -895,6 +898,31 @@ const FRET_ROUGHNESS = 0.3; const FRET_EMISSIVE = 0x12141a; // cool dim floor, never fully black + // Fret-wire tiers. Wires inside the active anchor lane (the frets the player + // is actually reading) sit bright; everything outside recedes. Kept far + // apart on purpose — a narrow gap reads as noise rather than as a focus cue. + const FRET_WIRE_ACTIVE_HEX = 0xD8A636; // gold; numeric twin of FRET_LABEL_GOLD_HEX + const FRET_WIRE_ACTIVE_OP = 0.9; + const FRET_WIRE_IDLE_HEX = 0x4A4A60; + const FRET_WIRE_IDLE_OP = 0.28; + + // Hit flash: when a scorer (feedBack#254) confirms a note, the two wires + // bracketing its fret (f-1 and f) flash bright. Emissive is boosted as well + // as albedo — a MeshStandard fret with no envMap barely brightens from + // albedo alone, so without the emissive lift the "flash" reads as a shrug. + const FRET_WIRE_HIT_HEX = 0xFFFFFF; // blown out to white at full flash + const FRET_WIRE_HIT_EMISSIVE = 0xFFE9B0; // hot warm-white glow + const FRET_WIRE_HIT_OP = 1.0; + // Emissive multiplier at full flash (baseline is 1). Pushing emissive past + // 1.0 is what actually makes the wire read as a light source rather than a + // brightly-lit object — the color alone saturates and stops there. + const FRET_WIRE_HIT_INTENSITY = 4.2; + // Seconds for a flash to fall to ~1/e once the provider stops reporting. + // The provider already fades its own `alpha` on a struck note; this tail + // just keeps the hand-off from popping, and smooths the frame-to-frame + // jitter of a held sustain (whose alpha tracks live input level). + const FRET_WIRE_HIT_DECAY = 0.32; + const S_BASE = 3 * K; const S_GAP = 4 * K; @@ -4030,6 +4058,10 @@ let _drawRecentByString = null; /** Snapshotted in update() — drawNote() is a sibling of update(), not nested in its closure. */ let _drawChordTemplates = null; + /** Ditto — drawNote() needs the anchors to resolve the lane's outer + * wires for an open note's hit flash (an open note has no fret of its + * own; its slab spans the lane, so the lane edges are what bracket it). */ + let _drawAnchors = null; /** Teaching marks sd/ch overlay pref (§6.2.2), mirrored from the 2D * highway's `teachingMarksVisible` bundle flag. */ let _drawTeachingMarks = false; @@ -4744,6 +4776,21 @@ const _scrStringSustain = new Array(MAX_RENDER_STRINGS).fill(false); const _scrStringAnticipation = new Array(MAX_RENDER_STRINGS).fill(0); const _scrFretHeat = new Array(NFRETS + 1).fill(0); + // Fret-wire hit flash. _fwHitIn is per-frame (cleared with the rest of + // the frame state, written by drawNote when a provider confirms a note); + // _fwHitGlow persists across frames so the flash can decay smoothly + // rather than snapping off the frame the provider goes quiet. + const _fwHitIn = new Float32Array(NFRETS + 1); + const _fwHitGlow = new Float32Array(NFRETS + 1); + // Per-frame chord accumulator. A chord flashes only the OUTERMOST wires + // of its shape, but drawNote() sees one chord note at a time and can't + // know the span — so hits accumulate here keyed by chord, and the flash + // pass (which runs after every draw loop) resolves min/max into wires. + // Typically 0-2 entries: only chords with a confirmed hit land here. + const _fwChordAcc = new Map(); + let _fwHitPrevTime = -Infinity; // chart time of the last decay step + let _fwHitColor = null; // T.Color scratch (built in initScene) + let _fwHitEmissive = null; const _scrStrGlow = new Array(MAX_RENDER_STRINGS).fill(0.5); const _scrAccentFillBoost = new Array(MAX_RENDER_STRINGS).fill(0); const _scrNextNoteByString = new Array(MAX_RENDER_STRINGS).fill(null); @@ -6883,6 +6930,10 @@ transparent: true, opacity: 1.0, depthWrite: false, })); _laneTargetColor = new T.Color(0x4488ff); + _fwHitColor = new T.Color(FRET_WIRE_HIT_HEX); + _fwHitEmissive = new T.Color(FRET_WIRE_HIT_EMISSIVE); + _fwHitGlow.fill(0); + _fwHitPrevTime = -Infinity; mSus = activePalette.map(c => new T.MeshLambertMaterial({ color: c, transparent: true, opacity: 0.35, })); @@ -8971,9 +9022,11 @@ // reads as brass. depthTest:false: string BoxGeometry (MeshStandard, // depthWrite:true) writes depth at Z=+STR_THICK/2; wires near Z=0 // would fail the depth test at string pixels despite higher layer. - // Colors are updated each frame by the fretWireMats loop in update(): - // default → gray 0x666688, opacity 0.4 - // in-anchor→ gold 0xD8A636, opacity 0.8 (same as FRET_LABEL_GOLD_HEX) + // Colors are updated each frame by the fretWireMats loop in update(), + // which drives every wire to one of two tiers: FRET_WIRE_IDLE_* by + // default, FRET_WIRE_ACTIVE_* inside the anchor lane. The material is + // created at the idle tier so frame 0 (before update() first runs) + // already matches. const yTop = Math.max(sY(0), sY(nStr - 1)); const yBottom = Math.min(sY(0), sY(nStr - 1)); const wireH = (yTop + S_GAP * 0.3) - (yBottom - S_GAP * 0.3); @@ -8995,12 +9048,12 @@ for (let f = 0; f <= NFRETS; f++) { const x = xFret(f); const mat = new T.MeshStandardMaterial({ - color: 0x666688, metalness: FRET_METALNESS, roughness: FRET_ROUGHNESS, + color: FRET_WIRE_IDLE_HEX, metalness: FRET_METALNESS, roughness: FRET_ROUGHNESS, emissive: FRET_EMISSIVE, // depthWrite:false (matches other transparent overlays here): // a transparent fret must not write depth or it can occlude // later-drawn transparent elements despite depthTest:false. - transparent: true, opacity: 0.4, depthTest: false, depthWrite: false, + transparent: true, opacity: FRET_WIRE_IDLE_OP, depthTest: false, depthWrite: false, }); const fw = new T.Mesh(fretTubeGeo, mat); fw.position.set(x, wireMidY, 0); @@ -10641,12 +10694,17 @@ const _m = fretWireMats[_f]; if (!_m) continue; if (_fwMin >= 0 && _f >= _fwMin && _f <= _fwMax) { - _m.color.setHex(0xD8A636); - _m.opacity = 0.8; + _m.color.setHex(FRET_WIRE_ACTIVE_HEX); + _m.opacity = FRET_WIRE_ACTIVE_OP; } else { - _m.color.setHex(0x666688); - _m.opacity = 0.4; + _m.color.setHex(FRET_WIRE_IDLE_HEX); + _m.opacity = FRET_WIRE_IDLE_OP; } + // Baseline emissive every frame: the hit-flash pass below + // lerps these toward FRET_WIRE_HIT_* in place, so they must + // be re-seeded or a flash would never fade back out. + _m.emissive.setHex(FRET_EMISSIVE); + _m.emissiveIntensity = 1; } } @@ -10679,6 +10737,8 @@ _scrStringSustain.fill(false, 0, nStr); _scrStringAnticipation.fill(0, 0, nStr); _scrFretHeat.fill(0); // always NFRETS+1, cheap flat fill + _fwHitIn.fill(0); // this frame's confirmed-hit frets + _fwChordAcc.clear(); _scrStrGlow.fill(0.5, 0, nStr); _scrAccentFillBoost.fill(0, 0, nStr); const noteState = { @@ -10793,6 +10853,7 @@ _drawNextByString = nextNoteByString; _drawChordTemplates = bundle.chordTemplates ?? null; + _drawAnchors = anchors ?? null; _drawTeachingMarks = !!bundle.teachingMarksVisible; // Default on: only an explicit false (older bundles omit the flag) hides fg. _showFingerHints = bundle.fingerHintsVisible !== false; @@ -12581,6 +12642,57 @@ ? arpeggioLaneDividerXYScaleMatchFrameRim(arpLaneRimAccentMul) : 1; + // ── Fret-wire hit flash (apply) ─────────────────────────────── + // Runs here, after the note + chord draw loops, so it sees this + // frame's verdicts (_fwHitIn) rather than the previous frame's — + // the base tier loop that seeds color/opacity/emissive sits far + // above, before any note has been drawn. + // + // _fwHitGlow decays exponentially in CHART time, so the tail is + // frame-rate independent and honours playback speed. Seeking + // backward resets it — otherwise a flash from a hit we jumped away + // from would linger on the wire. + if (fretWireMats.length && _fwHitColor) { + // Resolve accumulated chord hits: a chord flashes ONLY the wire + // behind its lowest fret and the wire at its highest, so the + // shape reads as one bracketed block rather than a picket fence + // of every wire in between. Open strings within the chord light + // the lane edges (they have no fret to bracket). + for (const _fwE of _fwChordAcc.values()) { + if (_fwE.maxF >= _fwE.minF && _fwE.a > 0) { + const _w0 = Math.max(0, _fwE.minF - 1); + const _w1 = Math.min(NFRETS, _fwE.maxF); + if (_fwE.a > _fwHitIn[_w0]) _fwHitIn[_w0] = _fwE.a; + if (_fwE.a > _fwHitIn[_w1]) _fwHitIn[_w1] = _fwE.a; + } + if (_fwE.openA > 0) { + const _fwB = anchorLaneBoundsAt(_drawAnchors, _fwE.t); + if (_fwB) { + if (_fwE.openA > _fwHitIn[_fwB.dMin]) _fwHitIn[_fwB.dMin] = _fwE.openA; + if (_fwE.openA > _fwHitIn[_fwB.dMax]) _fwHitIn[_fwB.dMax] = _fwE.openA; + } + } + } + + const _fwDt = now - _fwHitPrevTime; + if (!(_fwDt >= 0) || _fwDt > 1) _fwHitGlow.fill(0); // first frame, seek, or long stall + const _fwDecay = (_fwDt > 0 && _fwDt <= 1) + ? Math.exp(-_fwDt / FRET_WIRE_HIT_DECAY) + : 0; + _fwHitPrevTime = now; + for (let _f = 0; _f <= NFRETS; _f++) { + const _g = Math.max(_fwHitIn[_f], _fwHitGlow[_f] * _fwDecay); + _fwHitGlow[_f] = _g; + if (_g < 0.004) continue; // below perceptible — skip the lerps + const _m = fretWireMats[_f]; + if (!_m) continue; + _m.color.lerp(_fwHitColor, _g); + _m.emissive.lerp(_fwHitEmissive, _g); + _m.emissiveIntensity = 1 + (FRET_WIRE_HIT_INTENSITY - 1) * _g; + _m.opacity += (FRET_WIRE_HIT_OP - _m.opacity) * _g; + } + } + // ── Dynamic highway lane ────────────────────────────────────── // Chart tags drive the lane whenever they exist — do not // require nearby notes (activeFrets) or camera-driven activity. @@ -13763,6 +13875,59 @@ : (_ndState ? _ndGood : (hit || (n.f > 0 && inGhostWin))); + // ── Fret-wire hit flash ─────────────────────────────────────── + // A confirmed note lights the wires that bracket it: + // single, fretted (f > 0) → the wire behind it (f-1) and the wire + // it's pressed against (f). + // open (f = 0) → the OUTER wires of the anchor lane. An open + // string has no fret of its own, and its gem is drawn as a + // wide slab spanning the lane (see xBase / + // openNoteLaneBoxW), so the lane's edge wires are exactly + // what the slab sits between — the same relationship a + // fretted note has to its own two wires. + // chord → only the OUTERMOST wires of the whole shape, not every + // wire it spans. drawNote() is the chord loop's per-note + // call, so it can't see the span from here: chord hits + // accumulate into _fwChordAcc and the flash pass resolves + // min/max fret → outer wires once the loop has finished. + // Gated on _ndGood, not _showHit: only a scorer's verdict counts, + // never the proximity heuristic that merely means "near the line". + if (_ndGood) { + const _fwA = (_ndCsIsObj && _ndCs && typeof _ndCs.alpha === 'number') + ? Math.max(0, Math.min(1, _ndCs.alpha)) + : 1; + if (fromChord) { + // ch.id can be absent (pitfall #8) — fall back to the chord's + // time, which is what n.t already carries for a chord note. + const _fwK = (chordId !== undefined && chordId !== null) ? chordId : `t${n.t}`; + let _fwE = _fwChordAcc.get(_fwK); + if (!_fwE) { + _fwE = { minF: Infinity, maxF: -Infinity, a: 0, openA: 0, t: n.t }; + _fwChordAcc.set(_fwK, _fwE); + } + if (n.f > 0 && n.f <= NFRETS) { + if (_fwA > _fwE.a) _fwE.a = _fwA; + if (n.f < _fwE.minF) _fwE.minF = n.f; + if (n.f > _fwE.maxF) _fwE.maxF = n.f; + } else if (n.f === 0 && _fwA > _fwE.openA) { + _fwE.openA = _fwA; // open strings in the chord → lane edges + } + } else if (n.f > 0 && n.f <= NFRETS) { + if (_fwA > _fwHitIn[n.f - 1]) _fwHitIn[n.f - 1] = _fwA; + if (_fwA > _fwHitIn[n.f]) _fwHitIn[n.f] = _fwA; + } else if (n.f === 0) { + // Sampled at the note's own time, matching how the open + // slab's width is derived (openNoteLaneBoxW(n.t)) — so the + // flashed wires are the ones the slab is actually drawn + // between, even if the lane has since moved. + const _fwB = anchorLaneBoundsAt(_drawAnchors, n.t); + if (_fwB) { + if (_fwA > _fwHitIn[_fwB.dMin]) _fwHitIn[_fwB.dMin] = _fwA; + if (_fwA > _fwHitIn[_fwB.dMax]) _fwHitIn[_fwB.dMax] = _fwA; + } + } + } + if (!effSkipBody && !arpGhostOnlyMode && !_overLinger) { // ── Outline (slightly larger, bright emissive) ──────────── @@ -15235,7 +15400,12 @@ _drawNextByString = null; _drawRecentByString = null; _susVerdictLatch.clear(); _drawChordTemplates = null; + _drawAnchors = null; _laneTargetColor = null; + _fwHitColor = _fwHitEmissive = null; + _fwHitGlow.fill(0); + _fwChordAcc.clear(); + _fwHitPrevTime = -Infinity; _renderScale = 1; mBeatM = mBeatQ = null; pNote = pNoteEdge = pSus = pSusOutline = pSusRibbon = pSusRibbonOl = pLbl = pBeat = pSec = null; From 859a62e84a2b99ce5b47fba0bf41156a1730002d Mon Sep 17 00:00:00 2001 From: topkoa Date: Sat, 18 Jul 2026 20:05:30 -0400 Subject: [PATCH 02/11] feat(highway_3d): cap the fret-wire flash at one outer pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fast passages overlap their decay tails: consecutive notes on nearby frets left three, four, five wires glowing at once — the picket fence the chord rule was written to avoid, arriving through time instead of through a shape. The apply pass now decays every wire's glow state as before, but flashes only the outermost pair of the lit span (or the single wire when only one is above threshold). Interior wires keep decaying invisibly — the base tier loop re-seeds their materials each frame — so the bracket tightens naturally as the outer tails expire, and a hit inside the current span widens nothing. Net effect: at most two wires are ever lit, and everything currently glowing reads as one bracket, exactly like a chord. Signed-off-by: topkoa --- CHANGELOG.md | 6 ++++-- plugins/highway_3d/screen.js | 20 +++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a75d83e..e938f57e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,8 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 bracketing it light up. A fretted note lights the wire behind it and the wire it's pressed against; a chord lights only the outermost wires of its shape; an open string lights the anchor lane's edge wires (its gem is drawn as a slab spanning the - lane, so those are the wires it sits between). With no scorer attached, nothing - changes. + lane, so those are the wires it sits between). At most **two wires are ever lit at + once**: when overlapping decay tails (fast passages) would light a run of wires, the + flash collapses to the outermost pair of the lit span — one bracket, never a picket + fence. With no scorer attached, nothing changes. ### Changed - **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index be2e03b2..70387fcd 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -12680,10 +12680,28 @@ ? Math.exp(-_fwDt / FRET_WIRE_HIT_DECAY) : 0; _fwHitPrevTime = now; + // Decay EVERY wire's glow state, but flash only the OUTERMOST + // pair of lit wires. Fast passages overlap their decay tails, so + // without this a run of consecutive notes lights a picket fence + // of wires at once; collapsing to the outer pair keeps the whole + // lit span reading as ONE bracket — the same rule chords already + // follow, applied across everything currently glowing. Interior + // wires keep decaying invisibly (the base tier loop re-seeds + // their materials each frame), so the bracket tightens naturally + // as outer tails expire. + let _fwLo = -1, _fwHi = -1; for (let _f = 0; _f <= NFRETS; _f++) { const _g = Math.max(_fwHitIn[_f], _fwHitGlow[_f] * _fwDecay); _fwHitGlow[_f] = _g; - if (_g < 0.004) continue; // below perceptible — skip the lerps + if (_g < 0.004) continue; // below perceptible + if (_fwLo < 0) _fwLo = _f; + _fwHi = _f; + } + for (let _i = 0; _i < 2; _i++) { + const _f = _i === 0 ? _fwLo : _fwHi; + if (_f < 0) break; // nothing lit + if (_i === 1 && _f === _fwLo) break; // single wire lit + const _g = _fwHitGlow[_f]; const _m = fretWireMats[_f]; if (!_m) continue; _m.color.lerp(_fwHitColor, _g); From 49cda5ae8cfed4e6674e75ce2d04d7f40727d577 Mon Sep 17 00:00:00 2001 From: topkoa Date: Sat, 18 Jul 2026 20:20:42 -0400 Subject: [PATCH 03/11] feat(highway_3d): chord flash frames the lane, not the shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lit lane strip spans the anchor's width (minimum ~4 frets), which can run a fret past the chord's outermost fret. The chord flash bracketed the shape (wire behind its lowest fret, wire at its highest), so on those anchors the bracket sat one wire INSIDE the lit lane — reading as misaligned rather than as a frame around what's lit. Chord hits now light the anchor lane's edge wires: the exact wires the lane strip itself spans, and the same pair open strings already use, so every hit shape inside a lane produces the same bracket. The shape's own outer pair survives only as the fallback for charts with no anchors. Fretted and open intensities merge into one entry (they light the same two wires now), and an all-open chord on an anchor-less chart still degrades to no flash rather than a bad index. Signed-off-by: topkoa --- CHANGELOG.md | 1077 +++++++++++++++++----------------- plugins/highway_3d/screen.js | 41 +- 2 files changed, 563 insertions(+), 555 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e938f57e..8f7320a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,537 +1,540 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added -- **Gold tier (career passports)** — an earned badge turns **gold** when - Virtuoso verifies an improvised jam in the passport's style (the - `gold_improv` artifact relays with the drill snapshot; a genre inherits its - family's style, gained-only, and gold never substitutes for the badge bar - itself). Gold gets its own ceremony, stamp slam, foil chip, and gold ink on - the shelf cover, profile wall, and passport card; the bronze page's "Gold - rung coming" preview becomes a live invitation to jam it. -- **Gigs (the career verb, frontend)** — book a gig from any opened passport: - a gig poster proposes the setlist (re-roll for a different bill; save or - copy the poster as a PNG), "Play the gig" hands the set to the play queue - with the venue on stage, a floating strip tracks the set, and finishing it - logs dated entries with per-song accuracies in the passport book — with an - encore celebration (crowd eruption + confetti) when the whole set clears - the bar, and a summary poster to share. Quitting mid-set simply abandons - it: no log, no fail state. -- **Career on the Profile and Home pages** — the Profile gains a passport - wall (earned-badge covers per instrument, hours, gig count; absent until a - passport exists), injected through the same mount-point + rendered-event - seam the achievements plugin uses (now documented in docs/plugin-v3-ui.md). - The home page's plugin-count stat tile becomes a career trading card - (badges, hours, the closest stamp ask, foil shine) with the old stat as the - built-in fallback when career has no state. Earned passports gain **Save - card / Copy card** — a natively-drawn PNG passport card, downloadable or - copied straight to the clipboard for pasting outside the app (shared - `blob-io` helpers replace the download idiom previously duplicated in - settings-io and diagnostics-export). -- **Gigs (backend)** — career mode gains its verb: `POST - /api/plugins/career/gigs/propose` builds a playable setlist for an - instrument+genre (your qualifying songs plus a couple of stakes songs near - the bar; a young passport fills from unplayed genre songs — the first gig - is how stubs start; re-roll by calling again), naming the room your stars - can book. `POST /gigs` logs a **completed** set — per-song accuracies read - from the set's own freshly-recorded stats, an encore flag at the - data-driven bar (avg ≥ 75%) — into the career state; abandoned sets never - log (no fail state: the gig you finished is the gig you played). Passports - carry their gig log; instruments their gig count. -- **3D Highway: fret wires flash on a confirmed hit** — when a scorer (note_detect) - confirms a note through the `getNoteState` provider (feedBack#254), the fret wires - bracketing it light up. A fretted note lights the wire behind it and the wire it's - pressed against; a chord lights only the outermost wires of its shape; an open - string lights the anchor lane's edge wires (its gem is drawn as a slab spanning the - lane, so those are the wires it sits between). At most **two wires are ever lit at - once**: when overlapping decay tails (fast passages) would light a run of wires, the - flash collapses to the outermost pair of the lit span — one bracket, never a picket - fence. With no scorer attached, nothing changes. - -### Changed -- **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem - list (`[{id, url, default}]` + `full_mix_url`), the same list the highway's WS - `ready` sends. The stems plugin could only learn it from that WS message, which - arrives once the highway is already on screen — so it decoded and then copied the - whole song's PCM to its audio worklet with the player visible: over half a gigabyte - of memcpy in one frame for a 6-stem pack, a measured 698 ms freeze right as the - song-credits card appeared. With the list available at `song:loading` the plugin - does all of it before the highway is drawn. Built by calling `load_song` itself, so - it cannot drift from what the WS sends. Opt-in, so the library's metadata calls pay - nothing. -- **Folder library renders only the songs on screen** (#965) — a song list used to - render *every* song it held. On a flat 50,944-song library that was one `
` - with 50,938 children and ~1.3 **million** DOM nodes (~4.2 GB of renderer memory), - built even while another screen was showing. A document that size also punishes - unrelated code: any `document.querySelector` that misses has to walk the whole - tree — which is how the song-preview menu check ended up eating ~50% of the - renderer and dropping the app to 2.7 fps. Lists longer than 200 songs are now - windowed (25–31 rows in the DOM instead of 50,000); shorter lists are unchanged. -- **3D Highway: fret wires read as a focus cue** — the contrast between the active - anchor lane and the rest of the neck is widened (the lane's wires brighter, the rest - dimmer), and the wires themselves are slightly thicker. -- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a - top-level manifest key this repo invented (#583) that the feedpak spec never had. - The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0 - (feedpak-spec#53) reserves the id **`full`** for it, so that is where core reads it - from now. - - `full` is a mixdown, not a layer — it already contains every instrument — so - `load_song()` lifts it OUT of `LoadedSloppak.stems` onto `LoadedSloppak.full_mix`. - Nothing that sums stems or renders one fader per stem can see it, which is what - makes retaining it safe; leaving it in the list would double the whole song and - leave "guitar" audible with the guitar fader muted. That trap is exactly why the - packer invented the key instead of putting the mixdown where the format says it - goes — the bug was in the reader, and this fixes the reader. - - Consequences worth knowing: - - The highway WS `song_info` frame gains `full_mix_url` / `has_full_mix`. - `original_audio_url` / `has_original_audio` remain as **deprecated aliases** - (same values) for one release so a client built against the old frame keeps - working; they go with the fallback below (#945). - - `stems` on `song_info`, and `stem_ids` / `stem_count` in the library index, now - describe *instrument* stems only — a separated pack that retains its mixdown no - longer advertises a bogus "full" stem chip or an inflated stem count. - - Audio fingerprinting (`lib/enrichment.py`) now resolves the mixdown the same - way, which **widens** its coverage: it previously returned `None` for any pack - without the invented key, so fingerprinting silently did nothing for the - overwhelming majority of packs. - - Core still **reads** `original_audio:` as a deprecated fallback, because every - pack written before the spec caught up carries it and would otherwise lose its - pristine mix. `tools/migrate_full_mix_stem.py` rewrites those packs into the - spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem - at `default: off`, drops the key); the fallback and the aliases are removed once - they are migrated (#945). - -### Added -- **Genres fall back to MusicBrainz enrichment** — the effective genre now - resolves override → pack genre → the enrichment match's primary genre - (matched or user-pinned manual rows only). Converted packs rarely carry a `genres` manifest key, - which starved the library genre facet and career passports on real - libraries; with the fallback, every enriched song's genre is browsable and - passport-able immediately, and coverage grows as enrichment runs. -- **Badge ceremony in the venue** — earning a genre badge now stages a moment: - the crowd layer erupts (new public `v3VenueCrowd.celebrate()` — instant - ecstatic loop bypassing the stability/dwell hysteresis, plus a cheer stinger; - a no-op without a venue pack) and a full-screen overlay drops the bronze - stamp with a shine sweep and a confetti burst over whatever screen is active - (badges land right after `stats:recorded`, while the player is still up). - Click or wait ~4s to dismiss; `prefers-reduced-motion` gets the existing - chime + notification only. The stamp still slams into the passport book on - next open, unchanged. -- **Hours-per-genre odometer (career passports)** — the app now measures real - play time: the stats recorder accrues **wall-clock** seconds across - play/resume ↔ pause/stop/end spans (wall time, not song position — position - deltas double-count A-B loops and mis-read seeks; single spans clamp at 2h - against suspend/sleep inflation) and piggybacks them as `seconds` on the - `POST /api/stats` calls it already makes. New additive - `song_stats.seconds_total` column; a seconds-only POST banks time for - unscored plays that run to the song's natural end without touching the - resume position (and still counts as playing today for the streak). - Passports surface it honestly: "14.2 h in Blues" under the badge and on the - shelf cover — a true fact that only grows, never a target or a meter. -- **Career passport drills, curated** — Bronze in blues/rock/metal/funk/jazz - now also asks for that genre's signature Virtuoso drill (Blues Shuffle, - Power Chords & Backbeat, Gallop Picking, 16th Pocket, Shell Voicings — one - per genre, data-driven in `passports.json` with display labels). Drill - lists are per-instrument (`virtuoso_nodes: {instrument: [nodes]}`; a flat - list still means guitar), so a keys passport never demands a guitar drill. - A drill counts as cleared on the first real completion artifact — a - top-tier clean pass in one key (`keysCleared`), any depth rung, or - mastery — rather than only the maxed-speed depth flips. Genres without a - curated drill stay songs-only. -- **Career passport visuals pack** — earned covers and badge stamps become - trading cards (pointer-tracked tilt + light glint, hover-capable devices - only); the ghost stamp visibly "carves in" as qualifying songs land (a - conic ink fill, no numbers added); the Gold rung preview is a small foil - chip with a shimmer sweep, still honestly labeled coming. All theatrics - disabled under `prefers-reduced-motion`. -- **Career passports (backend)** — the badge-journey layer on top of career stars. - New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument - passport walls: genre badges computed on read from `song_stats` × the library's - effective genre — Bronze = N genre songs at K★, data-driven in - `plugins/career/passports.json`, default 5 songs at 2★ — plus qualifying-song - "ticket stubs", the library genre list, and drill status), `POST /passports/commit` - (instrument commitment), `POST /passports/open` (open a genre - passport), and `POST /drill-state` (intake for the relayed Virtuoso - `virtuoso.progress` snapshot, so drill requirements can gate badges - server-side). Badges are never stored; the only persisted state (commitments, - opened passports, drill snapshot) lives under `CONFIG_DIR/career/` and rides the - settings export/import bundle via `settings.server_files`. Instruments are - attributed via the existing progression arrangement→instrument mapping; - non-graded instruments (bass, drums) render shown-not-judged — repertoire - without a pass bar, never a false badge denial. -- **Career passports (UI)** — the Career screen gains a Passports tab beside - Venues: a physical per-instrument passport book (embossed leather cover, 3D - page-turn) with a wax-seal commitment ceremony (Stage 0), rubber-stamp badge - slam with ink bleed and deterministic per-genre jitter, qualifying songs as - collected ticket stubs, and unopened genres as an "Explore next" - travel-brochure rack (invitations, never greyed-out slots or completion - meters). Badge earns chime + notify immediately; the stamp slam plays when - the passport is next opened. Four small synthesized sound effects ship as - plugin assets. The career screen also relays the Virtuoso `virtuoso.progress` - localStorage snapshot to the drill-state intake on `virtuoso:progress` bus - events (debounced, plus a one-time bootstrap), closing the - fires-into-a-void seam without touching the virtuoso plugin. -- **CI gate: core must stay faithful to the feedpak spec (`feedpak-spec` job).** feedpak is published as - an open format with its own repo, normative spec, JSON Schemas, and reference validator — but nothing - stopped core from reading a manifest key the spec never defined, which is exactly what happened with - `original_audio` (#583 → #933). `tools/check_spec_conformance.py` now enforces four surface properties - in CI: (1) **key-coverage** — every manifest key core reads *or writes* is declared in the spec's - `manifest.schema.json`, found by walking the AST of `lib/sloppak.py`, `lib/enrichment.py`, and - `lib/songmeta.py`, `lib/gp2notation.py`, and `lib/routers/ws_highway.py` (writes are gated too — including - `setdefault()` — and reported separately: a key core writes lands in every pack we emit, so an undeclared - one seeds the ecosystem with non-spec data; a **readers-complete** guard fails the build if that module - list falls behind the codebase); (2) **allowlist-closed** — `feedpak-spec-exceptions.yml` never grows; - (3) **forward** — core's `load_song()` ingests every example pack the spec ships; - (4) **reverse** — every pack committed here passes the spec's own `tools/validate.py` (7/7 pass today). - The gate verifies against the spec repo's **HEAD** — the app must conform to the living spec, and the - flow is self-serve: a gated PR opens a FEP, the spec PR merges, re-running checks goes green. Nothing to - pin, nothing to bump. Each run logs the spec SHA it verified against so results are reproducible. - **There is no in-repo escape hatch, by design.** A blocked PR has exactly one route: land the key in the - spec via the [FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md), then - re-run the PR's checks — the gate verifies against the spec's HEAD, so it goes green once the key is real. `feedpak-spec-exceptions.yml` is a **closed - grandfather list** for keys that predate the gate, not a bypass: a fourth check (**allowlist-closed**) - diffs it against the base branch and fails any PR that *adds* an entry, so it may only shrink. - `original_audio` is grandfathered there against #933 so the gate lands green and starts blocking the - *next* instance immediately; the gate takes no position on how #933 resolves (the expected outcome is - removing the key, since the spec already carries the mixdown as a stem — not adopting it). Docs: - [docs/feedpak-spec-gate.md](docs/feedpak-spec-gate.md). - -### Removed -- **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the - `/v2` route, and the `FEEDBACK_UI` v2/legacy opt-out are deleted; `/` and `/v3` both serve - `static/v3/index.html`, which has been the default since 0.3.0. This is the first step of - the core-frontend ES-module migration (R3a): both shells load the same `static/app.js`, so - every subsequent step of that migration would otherwise have to be made, and verified, - twice. Removing the fallback now halves that surface before any of it is touched. - Incidentally fixes a latent bug in the old `index()` route — its guard read - `if getenv_compat("FEEDBACK_UI") or getenv_compat("FEEDBACK_UI") in ("v2", "legacy")`, - whose left operand is truthy for *any* non-empty value, so `FEEDBACK_UI=v3` actually served - the **v2** shell. `static/tailwind.min.css` is regenerated (the content globs scanned the - deleted file, so v2-only utility classes are now purged). Constitution amended to 1.3.0: - Principle II's frontend file list now names `static/v3/index.html`. - **Migration notes:** if you set `FEEDBACK_UI=v2` (or `=legacy`), or bookmarked `/v2`, there - is no longer a classic shell to fall back to — unset the variable and use `/`. The env var - itself is no longer read; the `SLOPSMITH_*`→`FEEDBACK_*` compat shim is unaffected. No - chart, settings, or plugin data changes, and no plugin API changes: v3 reuses the same - engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry). - -### Fixed -- **3D Highway: the lane stops at the hit line** (#991) — the highway lane, its - dividers, and the fret boundary extension lines ran `BEHIND` seconds *past* the - hit line toward the player. Nothing is ever drawn in that strip (notes and chord - frames clamp to `Math.min(0, dZ(dt))`), so it read as lane with no notes on it. - The floor geometry now ends at the hit line; its far edge is unchanged, still - `-AHEAD*TS` at the note horizon. -- **Career passports review polish** — the passport tabs and book overlay carry - proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`; - `role="dialog"` + `aria-modal` with focus moved to the close button on open - and restored on close), and a corrupt stored seen-badges value (e.g. a stray - `"null"`) can no longer throw on every passport refresh. -- **The packaged desktop app could not start (`ModuleNotFoundError: No module named - 'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded - list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`, - `static/`, `plugins/__init__.py`. The root-level `appstate.py` and `routers/` added in - R3 shipped correctly in Docker and passed every test, and were then silently dropped - from the packaged app, which died at startup. Both now live under **`lib/`** — the one - core directory the Dockerfile (`COPY lib/`), `docker-compose.yml`, and the desktop - bundler (`cp -r lib`) all copy wholesale, and that all three put on `sys.path` (on - Windows via the embeddable-Python `._pth`, where `PYTHONPATH` is ignored). This needs no - change in feedback-desktop and no new release to take effect. Placing them there is also - correct under Principle V: with the injection seam, `appstate.py` constructs nothing and - does no import-time IO, and a route module only builds an `APIRouter`. The - `Dockerfile` / `.dockerignore` / `docker-compose.yml` entries added for the root layout - are reverted. New `tests/test_packaging.py` walks `server.py`'s module-level imports and - fails if any first-party module resolves outside a directory the packagers copy, so the - next root-level module can't ship broken. - -### Added -- **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`. -- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `song` (upload/delete + the metadata write-back, user-meta, overrides, gap-fill, and per-song info routes; the scan/ingest helpers stay in `server.py` and are reached through new `appstate` seams — `kick_scan`, `invalidate_song_caches`, `stat_for_cache`, and a `scan_status()` getter — the `get_song_info` catch-all mounts after the art routes so it can't shadow them), and `library` + collections (the provider list/art/sync endpoints, the library query surface, and collection CRUD → `lib/routers/library.py`; the `LibraryProviderRegistry`/`LocalLibraryProvider`/`SmartCollectionProvider` classes + shared query/collection helpers move to `lib/library_registry.py`, and the registry instance + local provider ride the `appstate` seam — server.py still constructs the singleton and exposes `register_library_provider`/`unregister_library_provider` to plugins via `plugin_context` unchanged), and the `enrichment` route handlers (`/api/enrichment/*`: status, kick/cancel, per-song state, the Match-Review queue, and AcoustID identify → `lib/routers/enrichment.py`; the engine already lives in `lib/enrichment.py` and is reached as `enrichment.X`), and `media` (the file-serving routes — song audio `/audio/{f}`, the local-audio-path resolver `/api/audio-local-path`, and raw sloppak-member serving `/api/sloppak/{f}/file/{rel}` → `lib/routers/media.py`; the cache/static path seams were already in `appstate`), and `artist` (the artist page + external-links payload `/api/artist/{name}/page|links|links/refresh` → `lib/routers/artist.py`; MB link enrichment reached as `enrichment.X`), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along. -- **`routers/` — the first extracted route module (R3).** The five audio-effects mapping - endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a - `fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file - where they used to be defined** — FastAPI matches routes in registration order, so the - mount site preserves it. Verified: the full 143-route table (paths, methods, *and* - order) is byte-for-byte identical to `main`. Bodies are verbatim; the only edits are - the decorator receiver (`@app.get` → `@router.get`) and the singleton read - (`audio_effect_mappings` → `appstate.audio_effect_mappings`, a module attribute - resolved at call time). This proves the seam from #833 under a real consumer, including - the second slot. The `_demo_mode_guard` middleware still blocks all four moved write - routes with 403, and `Query(...)` validation still 422s — both checked against a running - server. `server.py`: **9,445 → 9,386 lines**. -- **`lib/enrichment.py` — the metadata-enrichment subsystem leaves `server.py` (R3, move-only).** MusicBrainz / Cover-Art-Archive / AcoustID transport, the match-scorer glue, and the background enrichment worker (~930 lines, 61 defs) move out as one cohesive unit. Bodies are verbatim; the only changes are seam reads — `meta_db`/`config_dir`/`sloppak_cache_dir`/`art_cache_dir` and the two shared art helpers (`song_pack_art_exists`, `art_override_paths`, which stay in `server.py` for the art/delete routes) are reached through `appstate` at call time, and the User-Agent VERSION lookup is corrected for the module's new depth. `server.py` drives the worker through the module (`import enrichment`; the routes + scan lifecycle call `enrichment.X`); tests that faked the network on `server` now patch the same names on `enrichment` (module attribute resolved at call time, so one `setattr` reaches both the routes and the worker's internal callers). Acyclic — `enrichment` imports no `server`. Route table byte-identical; full suite green. `server.py`: 6,917 → 5,988. -- **`appstate.py` — the router seam (R3).** Route modules moving out of `server.py` - need `meta_db` and friends but must not `import server`, or the import graph goes - circular the moment `server` imports them back. So `server.py` keeps *constructing* - its singletons and now **injects** them once — `appstate.configure(meta_db=…, - audio_effect_mappings=…)` — and a router reads them back as module attributes at call - time (`import appstate; appstate.meta_db.…`). This is the Python analogue of the - frontend refactor's injected `configureX({…})` seams and of the plugin - `setup(app, context)` contract: dependencies flow one way, `server → routers → - appstate`. Two properties are load-bearing and pinned by `tests/test_appstate.py`: - (1) `import appstate` constructs nothing and touches no disk, so the ~49 test fixtures - that `sys.modules.pop("server")` + re-import (to rebuild `meta_db` under a patched - `CONFIG_DIR`) keep working untouched — a singleton *owned* by `appstate` would survive - that pop and go stale; (2) reads must be late-bound (`appstate.meta_db`, never - `from appstate import meta_db`), since a `from` import freezes the binding and defeats - both a later `configure()` and `monkeypatch.setattr` — the same read-only-binding trap - as ES `import`. `configure()` rejects an unknown slot rather than silently creating a - global nothing reads, and the suite asserts `server` actually calls it (a seam whose - wiring can no-op undetected is worse than no seam). Lives at `lib/appstate.py`. - -### Changed -- **`AudioEffectsMappingDB` moved out of `server.py` into `lib/audio_effects_db.py` - (R3, move-only).** The core-owned song/tone → provider routing index follows - `MetadataDB` out of the host file, byte-identical apart from the same constructor - seam (`__init__` takes `config_dir`; `audio_effect_mappings = AudioEffectsMappingDB(CONFIG_DIR)`), - so the module does no IO at import. The singleton stays in `server.py`; no route, - no test and no `monkeypatch.setattr(server, …)` target moves. `server.py`: - **9,705 → 9,433 lines**. -- **`MetadataDB` moved out of `server.py` into `lib/metadata_db.py` (R3, move-only).** - The library metadata cache — the `MetadataDB` class (4,018 lines) plus the query - helpers it owns (keyset paging cursors, the tuning grouping key, smart-arrangement - naming, tag normalisation, the startup DB-restore swap) — now lives in its own flat - `lib/` module. `server.py` drops from **14,037 → 9,705 lines** and keeps the - `meta_db` singleton, so `server.meta_db` and `server.app` resolve exactly as before - and every route is untouched. The only non-verbatim change is the seam that lets the - class leave `server.py`: `MetadataDB.__init__` now takes `config_dir` explicitly - (`meta_db = MetadataDB(CONFIG_DIR)`) instead of reading the module-level `CONFIG_DIR`, - which also means `lib/metadata_db.py` performs no IO at import (Principle V). Logging - still goes through the `feedBack.server` logger, so existing log filters and `caplog` - assertions resolve to the same logger object. `tests/test_settings_export_library_db.py` - now imports `_apply_pending_db_restore` from `metadata_db` (the test moved with its - subject); no other test changed. Every moved block is byte-identical to its - `server.py` original. - -### Added -- **Plugins can ship an ES-module `src/` tree (module-migration rails, R0).** The host gains three things so a plugin can move off a single global-scope `screen.js` IIFE onto native ES modules with **no build step**: (1) a new sandboxed `GET /api/plugins/{id}/src/{path}` route that serves a plugin's `src/` source subtree, containment-checked by the same `safe_join` guard as `assets/` (traversal/absolute/NUL → 404); (2) the live-edit cache contract — `Cache-Control: no-cache` + a weak mtime/size `ETag` + `If-None-Match`→`304` — applied to `src/`, `screen.js`, and `assets/` (previously `screen.js` sent no cache headers and `assets/` emitted an ETag but never revalidated), so an edited module reloads on refresh while unchanged ones `304`; and (3) `scriptType`/`minHost` passthrough from `plugin.json` to `/api/plugins`, with the loader injecting a plugin that declares `"scriptType":"module"` as `