Compare commits

..
Author SHA1 Message Date
Byron GamatosandGitHub 62df3aec2e Merge branch 'main' into perf/highway-sustain-glow-no-shadowblur 2026-07-06 09:45:05 +02:00
Jafz2001andClaude Fable 5 326001d618 perf(highway): draw the held-sustain glow without ctx.shadowBlur
shadowBlur cost scales with the blurred device-pixel area. The lit-sustain
trail can span half the canvas, the canvas is DPR-scaled (4x pixels on a
2x Mac), and the blur ran on every frame exactly while a sustain is HELD
— i.e. at the moment the player most notices a hitch. Sustain-heavy songs
(e.g. fingerpicked acoustic charts) hit this constantly.

Replace the blur with three inflated low-alpha fills of the same trail
quad: reads as the same soft shimmering glow (the shimmer LUT still
drives per-frame flicker, feedBack#254 intent preserved) at a flat,
area-independent cost. Also drops the now-dead shadowBlur reset in the
crackle pass; no shadowBlur uses remain in highway.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:00:49 -04:00
12 changed files with 60 additions and 946 deletions
-181
View File
@@ -1,181 +0,0 @@
name: OpenCodeReview PR Review
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
on:
issue_comment:
types: [created]
jobs:
code-review:
permissions:
contents: read
issues: write
pull-requests: write
if: |
github.event_name == 'pull_request' ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request &&
(startsWith(github.event.comment.body, '/open-code-review') ||
startsWith(github.event.comment.body, '@open-code-review')))
runs-on: [self-hosted, linux, x64]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install OpenCodeReview
run: npm install -g @alibaba-group/open-code-review
- name: Configure OCR
run: |
ocr config set llm.url "http://coordinator:8080/v1"
ocr config set llm.auth_token "${{ secrets.OCR_COORDINATOR_SECRET }}"
ocr config set llm.model "${{ secrets.OCR_LLM_MODEL || 'qwen3-30b-a3b' }}"
ocr config set llm.use_anthropic false
ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}'
ocr config set language English
- name: Run OpenCodeReview
id: review
run: |
ocr review \
--from "origin/${{ github.base_ref }}" \
--to "origin/${{ github.head_ref }}" \
--format json \
--audience agent \
> /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true
- name: Post review comments to PR
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const path = '/tmp/ocr-result.json';
// Read OCR output
let result;
let raw = '';
try {
raw = fs.readFileSync(path, 'utf8').trim();
if (!raw) {
throw new Error('OCR output file is empty');
}
// OCR may prefix a status line before JSON; try whole file first, then skip first line
try {
result = JSON.parse(raw);
} catch {
const jsonContent = raw.substring(raw.indexOf('\n') + 1);
result = JSON.parse(jsonContent);
}
} catch (e) {
console.log('Failed to parse OCR output:', e.message);
console.log('Raw OCR output (first 2000 chars):', raw.substring(0, 2000));
const stderr = fs.readFileSync('/tmp/ocr-stderr.log', 'utf8').trim();
if (stderr) {
console.log('OCR stderr:', stderr.substring(0, 2000));
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `⚠️ **OpenCodeReview** encountered an error:\n\`\`\`\n${stderr}\n\`\`\``
});
} else if (raw) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `⚠️ **OpenCodeReview** encountered an error. Raw output:\n\`\`\`\n${raw.substring(0, 2000)}\n\`\`\``
});
}
return;
}
const comments = result.comments || [];
if (comments.length === 0) {
const message = result.message || 'No comments generated. Looks good to me.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `✅ **OpenCodeReview**: ${message}`
});
return;
}
// Prepare PR review with inline comments
const prNumber = context.issue.number;
const commitSha = context.payload.pull_request.head.sha;
const reviewComments = [];
const commentsWithoutLine = [];
for (const comment of comments) {
const body = comment.content || '';
const hasValidLine = (comment.start_line >= 1) || (comment.end_line >= 1);
if (!hasValidLine) {
commentsWithoutLine.push(comment);
continue;
}
const reviewComment = { path: comment.path, body };
if (comment.start_line >= 1 && comment.end_line >= 1 && comment.start_line !== comment.end_line) {
reviewComment.start_line = comment.start_line;
reviewComment.line = comment.end_line;
reviewComment.start_side = 'RIGHT';
reviewComment.side = 'RIGHT';
} else if (comment.end_line >= 1) {
reviewComment.line = comment.end_line;
reviewComment.side = 'RIGHT';
} else if (comment.start_line >= 1) {
reviewComment.line = comment.start_line;
reviewComment.side = 'RIGHT';
}
reviewComments.push(reviewComment);
}
// Build summary
const totalCount = comments.length;
const inlineCount = reviewComments.length;
const summaryCount = commentsWithoutLine.length;
let summaryBody = `🔍 **OpenCodeReview** found **${totalCount}** issue(s) in this PR.`;
summaryBody += `\n- ✅ ${inlineCount} posted as inline comment(s)`;
summaryBody += `\n- 📝 ${summaryCount} posted in summary`;
// Add non-inline comments to summary
for (const comment of commentsWithoutLine) {
summaryBody += `\n\n---\n\n### 📄 \`${comment.path}\`\n\n${comment.content || ''}`;
}
try {
await github.rest.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
commit_id: commitSha,
body: summaryBody,
event: 'COMMENT',
comments: reviewComments
});
} catch (e) {
console.log('Failed to post review:', e.message);
// Fallback: post summary comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: summaryBody + `\n\n⚠️ Could not post inline comments: ${e.message}`
});
}
+14 -26
View File
@@ -703,32 +703,20 @@ def load_song(
and isinstance(e.get("d"), (int, float))
]
if song.lyrics:
# Provenance. The feedpak spec (§7.1) vocabulary is
# {authored, transcribed, user}; older manifests + the
# in-tree readers also use the source-format names
# (xml/notechart) and the WhisperX engine name
# (whisperx). Accept the union so both spec-compliant
# writers (e.g. the stem_splitter plugin emitting
# `transcribed`) and legacy packs validate. Validate
# against the closed enum so a hand-edited (or otherwise
# malformed) manifest can't propagate a YAML dict / list /
# arbitrary string into the highway WS `lyrics.source`
# field and out to plugin badges. Anything outside the
# enum (or the wrong type) falls back to "xml" — the
# back-compat default — instead of being stringified and
# trusted.
# Post-alias values only: `whisperx` is normalised to
# `transcribed` before the membership check below, so (like
# `sng`) it is intentionally absent from this set.
_ALLOWED_LYRICS_SOURCES = {
"xml", "notechart", "user",
"authored", "transcribed",
}
# Legacy aliases: older manifests labelled note-chart-derived
# lyrics with the source format's name, and the WhisperX
# fallback with the engine name — normalise both to the
# spec vocabulary the badges now expect.
_LYRICS_SOURCE_ALIASES = {"sng": "notechart", "whisperx": "transcribed"}
# Provenance — populated by the converter (xml/notechart),
# the WhisperX fallback (whisperx), or hand-edits
# (user). Validate against the closed enum so a
# hand-edited (or otherwise malformed) manifest can't
# propagate a YAML dict / list / arbitrary string
# into the highway WS `lyrics.source` field and out
# to plugin badges. Anything outside the enum (or
# the wrong type) falls back to "xml" — the spec's
# back-compat default — instead of being stringified
# and trusted.
_ALLOWED_LYRICS_SOURCES = {"xml", "notechart", "whisperx", "user"}
# Legacy alias: older manifests labelled note-chart-derived
# lyrics with the source format's name; normalise it.
_LYRICS_SOURCE_ALIASES = {"sng": "notechart"}
raw_source = manifest.get("lyrics_source")
if isinstance(raw_source, str):
raw_source = _LYRICS_SOURCE_ALIASES.get(raw_source, raw_source)
+1 -2
View File
@@ -3,11 +3,10 @@
RS+-style falling-note 3D piano highway for [Slopsmith](https://github.com/got-feedback/feedback), fed by the **Sloppak Notation Format** (sloppak-spec §5.3) — part of the piano/keys first-class epic (slopsmith#828, plugin workstream slopsmith#824).
- Consumes the `notation_info` / `notation_measures` highway-WS stream over a private per-instance socket and flattens measure → staff → voice → beat → note into `{midi, t, durSec, hand}` (durations derived from written `dur`/`dot`/`tu` at the running tempo; ties extend; overlap-clamped).
- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colours** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue. Selectable **note-colour palettes** (settings → Note colours, `keys3d_bg_palette`, default the per-octave scheme): a per-octave rainbow (each octave its own hue, darker sharps), the original per-pitch "Rainbow" table, vivid/pastel per-pitch variants, and single-hue two-tone palettes (uniform naturals, darker sharps) for players who want "black key coming" to read at a glance; notes, key glow, lane guides and hit flames all follow the pick live.
- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colours** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue.
- Full RS+ visual treatment: key **letter glyphs** printed on the active-range key tops (cached CanvasTextures), **bevelled gem-style note blocks** (ExtrudeGeometry, geometry/material caches keyed by size and pitch-class×hand), **floating bar numbers** scrolling with the notes, **active-range lane dimming** so the playable span pops, and a **glowing pulsing hit-line** (layered additive gradient planes — no postprocessing).
- Performance discipline: no per-frame allocations or DOM queries in `draw()`. Chart-scoped resources — note geometries/materials, bar-number and glow textures — are cached and disposed on chart teardown; the key-letter glyph `CanvasTexture`s live in a shared module-level cache that survives teardown and is reused across instances.
- Auto-selected for arrangements with notation via `matchesArrangement(songInfo.has_notation)`; capability-native `visualization` provider declaration.
- **Camera settings**: camera-rig presets (`keys3d_bg_camera` — classic low rig / elevated / overhead; default overhead, applied live, adaptive pan-zoom preserved) with base-rig fine-tune sliders for height, distance and tilt (`keys3d_bg_camHeight` / `camDist` / `camTilt`) that nudge the vantage point the follow-motion orbits. Numeric FX keys clamp to per-key declared ranges (`FX_RANGES`, default 01).
- **Web MIDI input scoring**: module-level MIDI singleton (one access per tab, focused-instance routing) with device auto-connect by saved id+name, loopback blocklist, channel filter, transpose and CC64 sustain (`keys3d_` localStorage prefix; `window.keysH3d*` settings API). Hit detection matches played MIDI against the flattened chart notes within ±0.10 s with per-note dedupe and a missed-note sweep (only while a device is connected — never retroactive across a mid-song connect).
- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class colour, ~400 ms).
- **End-of-run stats**: POSTs `/api/stats` `{filename, arrangement, score, accuracy}` exactly once per run with the same formula as the guitar notedetect path (`accuracy = hits / max(1, hits+misses)`, `score = round(hits·100·accuracy)`), then notifies the progression core when present.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "keys_highway_3d",
"name": "Keys Highway 3D",
"version": "0.1.2",
"version": "0.1.1",
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
"type": "visualization",
"bundled": true,
+34 -320
View File
@@ -83,119 +83,6 @@
// pitch class.
const HAND_BRIGHTNESS = { rh: 1.0, lh: 0.72 };
// Selectable note-colour palettes. Index = midi % 12, same contract as
// PITCH_CLASS_COLORS — which stays byte-identical as the 'classic'
// entry, so anyone who never touches the setting sees the stock look.
// Two palette families:
// per-pitch — every pitch class gets its own hue (classic/vivid/pastel)
// two-tone — naturals share one hue, sharps a darker shade of it, so
// a dark gem always telegraphs "black key coming"
// (emerald/ice)
const NOTE_PALETTES = {
classic: PITCH_CLASS_COLORS,
emerald: [
0x3fe25f, // C — bright green (naturals)
0x17863a, // C# — dark green (sharps)
0x3fe25f, // D
0x17863a, // D#
0x3fe25f, // E
0x3fe25f, // F
0x17863a, // F#
0x3fe25f, // G
0x17863a, // G#
0x3fe25f, // A
0x17863a, // A#
0x3fe25f, // B
],
vivid: [
0xff2020, // C
0xd45a10, // C#
0xffe000, // D
0xb8c010, // D#
0x1e6aff, // E
0x9fd0e8, // F
0x2fae7e, // F#
0x20e050, // G
0xa89a20, // G#
0xff8a00, // A
0xd05888, // A#
0xd040ff, // B
],
pastel: [
0xff9a9a, // C
0xd0a078, // C#
0xffe9a0, // D
0xcfd08a, // D#
0x9ec0ff, // E
0xc8dde8, // F
0x9ecfba, // F#
0x9fe8b0, // G
0xcfc79a, // G#
0xffc890, // A
0xd8a8ba, // A#
0xe0b0ff, // B
],
ice: [
0x58c8ff, // C — bright ice blue (naturals)
0x2a6a9a, // C# — deep blue (sharps)
0x58c8ff, // D
0x2a6a9a, // D#
0x58c8ff, // E
0x58c8ff, // F
0x2a6a9a, // F#
0x58c8ff, // G
0x2a6a9a, // G#
0x58c8ff, // A
0x2a6a9a, // A#
0x58c8ff, // B
],
};
// Octave-based colour scheme ('octaves'): every octave gets a distinct
// hue that steps like a rainbow (clear, uniform sections — NOT a smooth
// blend — so each octave is uniquely identifiable, but neighbouring
// octaves stay close so the change isn't jarring). Loops if a song runs
// past the table. Within an octave, sharps/flats take a darker shade of
// the same hue — the same two-tone idea as 'emerald'. Octave index =
// floor(midi/12) - 1 (so C1..B1 = octave 1). The three keys below C1
// (A0/A#0/B0 = octave 0) and anything lower get a distinct cool slate so
// the very bottom of the board reads apart from the red start.
const OCTAVE_HUES = [
0xe23a3a, // oct 1 (C1B1) red
0xe2803a, // oct 2 orange
0xe0c73a, // oct 3 yellow
0x5fc23a, // oct 4 green
0x3ac2a0, // oct 5 teal
0x3a86e2, // oct 6 blue
0x6a4ae2, // oct 7 indigo
0xc23ae2, // oct 8 (C8) magenta
];
const OCTAVE_SUBC1_HUE = 0x8090a0; // A0/A#0/B0 and below — cool slate
const OCTAVE_SHARP_DARKEN = 0.5; // sharps render at 50% of the octave hue
function _darkenHex(hex, f) {
const r = Math.round(((hex >> 16) & 0xff) * f);
const g = Math.round(((hex >> 8) & 0xff) * f);
const b = Math.round((hex & 0xff) * f);
return (r << 16) | (g << 8) | b;
}
function _isBlackPc(midi) {
return [1, 3, 6, 8, 10].indexOf(((midi % 12) + 12) % 12) !== -1;
}
// Colour (24-bit int) for a midi note under the octave scheme: hue by
// octave, darker for sharps. Pure (no THREE) so it is unit-testable.
function octaveNoteColor(midi) {
const oct = Math.floor(midi / 12) - 1; // C1..B1 => 1
let hex = (oct <= 0)
? OCTAVE_SUBC1_HUE
: OCTAVE_HUES[(oct - 1) % OCTAVE_HUES.length];
if (_isBlackPc(midi)) hex = _darkenHex(hex, OCTAVE_SHARP_DARKEN);
return hex;
}
// Every valid palette id: the 12-entry pitch-class tables PLUS the
// procedural 'octaves' scheme (which is not a 12-array, so it lives
// outside NOTE_PALETTES and is validated through this list).
const PALETTE_IDS = [...Object.keys(NOTE_PALETTES), 'octaves'];
// Note block cross-section height and bevel (world units). The bevel
// turns the flat slabs into glossy gem-like blocks that catch the light
// on their edges — the RS+ reference look.
@@ -1026,27 +913,7 @@
scoreFx: true, // 2D overlay: +N pops, combo rings, streak-break wash
bgIntensity: 0.5, // background-ambience density/strength
bgReactive: true, // background reacts to the audio analyser
// Camera base-rig fine-tune. These shift the BASE vantage point the
// auto-pan/zoom follow-motion is built on (they multiply/offset the
// active CAM_PRESET before the per-frame pan + dolly), so the camera
// still tracks the notes — just from a nudged height/distance/tilt.
camHeight: 1.0, // ×preset camera height (higher = more overhead)
camDist: 1.0, // ×preset camera distance (larger = further back)
camTilt: 0.0, // aim offset up(+)/down(); 0 = neutral — the tuned overhead aim lives in CAM_PRESETS.overhead, so this fine-tune only nudges from a preset (and Classic + tilt 0 == the historical rig)
};
// Numeric FX keys clamp to a declared [min, max]; keys absent from this
// table keep the historical 01 slider range. The camera fine-tune knobs
// are multipliers/offsets centred on 1 (or 0), so they need headroom and a
// floor a 01 range couldn't express.
const FX_RANGES = {
camHeight: [0.4, 2.2],
camDist: [0.4, 2.2],
camTilt: [-1.0, 1.0],
};
function _fxClamp(key, n) {
const r = FX_RANGES[key] || [0, 1];
return Math.min(r[1], Math.max(r[0], n));
}
const FX_LS_PREFIX = 'keys3d_bg_';
// Theme id lives OUTSIDE FX_DEFAULTS (string, not bool/number) — its own
// localStorage key + validation against BG_THEMES.
@@ -1223,64 +1090,6 @@
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
};
// Note-colour palette id — string-valued like the theme, so it gets its
// own validated key + setter rather than an FX_DEFAULTS slot.
const FX_LS_PALETTE = 'keys3d_bg_palette';
function readPaletteSetting() {
try {
const id = localStorage.getItem(FX_LS_PALETTE);
if (id && PALETTE_IDS.indexOf(id) !== -1) return id;
} catch (_) {}
// Default: the octave scheme (each octave its own colour, darker
// sharps) — the plug-and-play piano look. Emerald/classic/etc. remain
// selectable.
return 'octaves';
}
window.keys3dSetPalette = function (id) {
if (PALETTE_IDS.indexOf(id) === -1) return;
try { localStorage.setItem(FX_LS_PALETTE, id); } catch (_) {}
try {
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { palette: id } }));
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
};
// Camera-rig presets. 'classic' is the original low, near-telephoto rig
// (numerically identical to the historical constants, so 'classic' with the
// neutral camTilt default reproduces the exact stock framing). y/z/lookY/lookZ
// are in pre-K world units — the instance multiplies by K at the use sites,
// same as the old constants did. Zoom scales position AND look-at every
// frame, so all presets inherit the adaptive dolly behaviour unchanged.
// Each preset carries its OWN tuned aim in lookY: 'overhead' bakes in the
// plug-and-play downward tilt (the 0.6 × CAM_TILT_UNITS = 33 that used to
// ship as the camTilt default) so the default look is unchanged while
// camTilt now defaults to 0 (a neutral nudge from whatever preset is picked).
const CAM_PRESETS = {
classic: { fov: 40, y: 46, z: 112, lookY: 8, lookZ: -165 },
elevated: { fov: 44, y: 78, z: 118, lookY: 4, lookZ: -150 },
overhead: { fov: 48, y: 118, z: 74, lookY: -33, lookZ: -115 },
};
// Camera preset id — string-valued like the theme, so it gets its own
// validated key + setter rather than an FX_DEFAULTS slot.
const FX_LS_CAMERA = 'keys3d_bg_camera';
function readCameraSetting() {
try {
const id = localStorage.getItem(FX_LS_CAMERA);
if (id && CAM_PRESETS[id]) return id;
} catch (_) {}
// Default: the overhead reading rig — its lookY already carries the
// tuned downward aim, so with the neutral camTilt default it gives the
// plug-and-play piano view out of the box. Others selectable ('classic'
// + neutral tilt = the exact historical rig).
return 'overhead';
}
window.keys3dSetCamera = function (id) {
if (!CAM_PRESETS[id]) return;
try { localStorage.setItem(FX_LS_CAMERA, id); } catch (_) {}
try {
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { camera: id } }));
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
};
function readFxSettings() {
const fx = Object.assign({}, FX_DEFAULTS);
try {
@@ -1295,10 +1104,10 @@
else if (raw === '0' || raw === 'false') fx[k] = false;
} else {
const n = parseFloat(raw);
// Numeric FX keys clamp to their declared range
// (default 0-1) so a corrupt/foreign write can't
// overdrive opacities or the geometry multipliers.
if (Number.isFinite(n)) fx[k] = _fxClamp(k, n);
// All numeric FX keys are 0-1 sliders — clamp so a
// corrupt/foreign write can't overdrive opacities
// or the camera pulse.
if (Number.isFinite(n)) fx[k] = Math.min(1, Math.max(0, n));
}
}
} catch (_) { /* localStorage unavailable — use defaults */ }
@@ -1318,7 +1127,7 @@
} else {
v = Number(value);
if (!Number.isFinite(v)) return;
v = _fxClamp(key, v); // declared range, default 0-1
v = Math.min(1, Math.max(0, v)); // all numeric FX keys are 0-1
}
try {
localStorage.setItem(FX_LS_PREFIX + key, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));
@@ -1560,7 +1369,6 @@
// Theme/material handles (built by buildScene/buildKeyboardAndHighway;
// _applyTheme / _applyCinematic / the glow slider retune them live).
let _theme = readThemeSetting();
let _palette = readPaletteSetting();
let ambLight = null, dirLight = null;
let _floorMat = null;
const _railMats = []; // lane-edge rail materials (theme laneDim)
@@ -1624,20 +1432,8 @@
return [1, 3, 6, 8, 10].includes(((midi % 12) + 12) % 12);
}
function _paletteColor(pc) {
return (NOTE_PALETTES[_palette] || PITCH_CLASS_COLORS)[pc];
}
// Base colour (24-bit int, no hand dimming) for a midi note under the
// active palette — the octave scheme is procedural, every other
// palette is a 12-entry pitch-class table.
function _noteHex(midi) {
if (_palette === 'octaves') return octaveNoteColor(midi);
return _paletteColor(((midi % 12) + 12) % 12);
}
function noteColor(midi, hand) {
const base = new T.Color(_noteHex(midi));
const base = new T.Color(PITCH_CLASS_COLORS[((midi % 12) + 12) % 12]);
const b = HAND_BRIGHTNESS[hand] != null ? HAND_BRIGHTNESS[hand] : 1.0;
base.multiplyScalar(b);
return base;
@@ -1665,15 +1461,14 @@
const BLACK_W = 6.4 * K, BLACK_L = 28 * K, BLACK_H = 6.5 * K;
const HIGHWAY_LEN = 1150 * K; // longer runway → ~8.8s of lookahead visible
// Camera — the default 'classic' preset is a low, near-telephoto rig
// (RS+-style): a narrow FOV from low and back gives a deep receding
// runway and frames ~2 octaves instead of cramming the whole note
// range full-width. The x position pans to follow the active notes
// (see updateScene), so wide pieces stay zoomed in on the played hand
// rather than shrinking every key. The rig numbers now come from the
// user-selectable CAM_PRESETS table; switching applies live because
// position/lookAt are re-derived every frame.
let _camPreset = CAM_PRESETS[readCameraSetting()] || CAM_PRESETS.classic;
// Camera — low, near-telephoto rig (RS+-style): a narrow FOV from low
// and back gives a deep receding runway and frames ~2 octaves instead
// of cramming the whole note range full-width. The x position pans to
// follow the active notes (see updateScene), so wide pieces stay zoomed
// in on the played hand rather than shrinking every key.
const CAM_FOV = 40;
const CAM_Y = 46 * K, CAM_Z = 112 * K;
const LOOK_Y = 8 * K, LOOK_Z = -165 * K;
// Pan-follow: a slow ease toward a wide, gently-weighted centroid so the
// camera glides with the melody instead of darting as notes enter/leave.
const CAM_PAN_LERP = 0.022; // per-frame ease (~1s glide @60fps)
@@ -1689,25 +1484,6 @@
const CAM_ZOOM_BASE_KEYS = 11; // white keys framed at zoom = 1
const CAM_ZOOM_MIN = 0.9, CAM_ZOOM_MAX = 4.8;
const CAM_ZOOM_LERP = 0.025; // smooth zoom ease
// Full-swing of the camTilt aim offset (pre-K units) at slider ±1.
const CAM_TILT_UNITS = 55;
// Base rig with the live fine-tune knobs applied — height/distance
// multiply the preset, tilt offsets the aim height. Returns the
// effective {y, z, lookY, lookZ} in pre-K units; the caller scales by
// K and the auto-zoom. Keeps the pan/dolly follow-motion intact —
// these only move the vantage point it orbits around. Writes into a
// reusable object (returned live) so the per-frame camera update stays
// allocation-free — the callers read it synchronously and never retain
// it, so a single shared instance is safe.
const _rigOut = { y: 0, z: 0, lookY: 0, lookZ: 0 };
function _rig() {
_rigOut.y = _camPreset.y * fx.camHeight;
_rigOut.z = _camPreset.z * fx.camDist;
_rigOut.lookY = _camPreset.lookY + fx.camTilt * CAM_TILT_UNITS;
_rigOut.lookZ = _camPreset.lookZ;
return _rigOut;
}
// Per-key approach glow: a key lights in its pitch-class colour ONLY while a
// note is heading for it, ramping up the closer that note gets to the hit-line.
const KEY_GLOW_AHEAD = 2.0; // seconds before the hit-line a key starts to light
@@ -2169,9 +1945,10 @@
_envRT = _makeStudioEnv(T, ren);
if (_envRT) scene.environment = _envRT.texture;
cam = new T.PerspectiveCamera(_camPreset.fov, 1, 0.1, 2000 * K);
cam = new T.PerspectiveCamera(CAM_FOV, 1, 0.1, 2000 * K);
_camX = 0; _camTargetX = 0; _camZoom = 1; _camTargetZoom = 1;
{ const r = _rig(); cam.position.set(0, r.y * K, r.z * K); cam.lookAt(0, r.lookY * K, r.lookZ * K); }
cam.position.set(0, CAM_Y, CAM_Z);
cam.lookAt(0, LOOK_Y, LOOK_Z);
ambLight = new T.AmbientLight(0xffffff, 0.75);
dirLight = new T.DirectionalLight(0xffffff, 1.1);
@@ -2314,16 +2091,13 @@
return geo;
}
// Glossy note material, cached per resolved colour. Keying by the
// final colour int (hand brightness already baked in by noteColor)
// works for every palette — including 'octaves', where two notes of
// the same pitch class in different octaves are DIFFERENT colours and
// must not share a material (a pitch-class key would collide them).
// Glossy note material, cached per (pitch class, hand).
function _noteMaterial(midi, hand) {
const col = noteColor(midi, hand);
const key = col.getHex();
const handKey = HAND_BRIGHTNESS[hand] != null ? hand : 'rh';
const key = (((midi % 12) + 12) % 12) + '|' + handKey;
let mat = _noteMatCache.get(key);
if (mat) return mat;
const col = noteColor(midi, hand);
// MeshPhysicalMaterial with a clearcoat: lacquered glass-gem
// look — a sharp coat highlight over a colored body, lit by the
// studio env map. This is the "not plastic" ask: the old matte
@@ -2371,39 +2145,6 @@
for (const m of _laneGuideMats) m.opacity = lop;
}
// Live palette switch: recolour everything already built — cached
// note materials (future clones), per-note clones, key emissives
// (incl. the wrong-flash restore state), lane guides — and drop the
// pitch-class flame textures so the next spawn bakes the new hues.
// Same no-rebuild approach as _applyVibrancy.
function _applyPalette() {
// The base-material cache is keyed by resolved colour, so old
// entries are simply stale under a new palette — drop them and let
// the next build re-cache. The live per-note clones below are
// retinted directly from each note's midi (palette-correct).
for (const m of _noteMatCache.values()) m.dispose();
_noteMatCache.clear();
for (const nm of noteMeshes) {
if (!nm.mesh || !nm.mesh.material) continue;
const col = noteColor(nm.note.midi, nm.note.hand);
nm.mesh.material.color.copy(col);
nm.mesh.material.emissive.copy(col);
}
for (const [midi, km] of keyMeshes) {
const col = noteColor(midi, 'rh');
km.material.emissive.copy(col);
km.userData.origEmissive = col.getHex();
}
for (const m of _laneGuideMats) {
if (m.userData.midi != null) m.color.copy(noteColor(m.userData.midi, 'rh'));
}
_clearFlameTextures();
// Re-arm the pool so no slot keeps rendering a disposed texture
// (a flame mid-flight briefly re-tints — next spawn sets its
// true pitch texture).
for (const s of _flamePool) s.mat.map = _flameTexture(0);
}
function _barNumberTexture(idx) {
let tex = _barTexCache.get(idx);
if (tex) return tex;
@@ -2440,15 +2181,13 @@
return _glowTex;
}
// Vertical flame texture for hit flares / held-key halos: white-hot
// base fading up into the note's colour, with a horizontal falloff.
// Cached per resolved colour (bounded 12 for pitch-class palettes,
// up to ~one-per-octave for 'octaves'), so a flare always matches the
// struck note's colour whatever the palette.
function _flameTexture(midi) {
const c = _noteHex(midi);
let tex = _flameTexCache.get(c);
// Vertical flame texture for hit flares: white-hot base fading up
// into the pitch-class colour, with a horizontal falloff. Cached per
// pitch class (bounded, 12 entries).
function _flameTexture(pc) {
let tex = _flameTexCache.get(pc);
if (tex) return tex;
const c = PITCH_CLASS_COLORS[pc];
const r = (c >> 16) & 0xff, g = (c >> 8) & 0xff, b = c & 0xff;
const cnv = document.createElement('canvas');
cnv.width = 64;
@@ -2468,7 +2207,7 @@
ctx.fillStyle = falloff;
ctx.fillRect(0, 0, 64, 128);
tex = new T.CanvasTexture(cnv);
_flameTexCache.set(c, tex);
_flameTexCache.set(pc, tex);
return tex;
}
@@ -2555,7 +2294,7 @@
if (!entry) return;
const slot = _flamePool[_flameIdx];
_flameIdx = (_flameIdx + 1) % _flamePool.length;
slot.mat.map = _flameTexture(midi);
slot.mat.map = _flameTexture(((midi % 12) + 12) % 12);
slot.start = wallNow;
slot.baseY = entry.black ? BLACK_H + WHITE_H * 0.6 : WHITE_H;
slot.sprite.position.x = keyX(entry, _layoutInfo.whiteCount);
@@ -2643,7 +2382,6 @@
color: noteColor(midi, 'rh'), transparent: true,
opacity: _laneGuideOpacity(), depthWrite: false,
});
gmat.userData.midi = midi; // palette retint needs the lane's pitch
_laneGuideMats.push(gmat);
const strip = new T.Mesh(new T.PlaneGeometry(WHITE_W * 0.84, guideLen), gmat);
strip.rotation.x = -Math.PI / 2;
@@ -3037,7 +2775,8 @@
}
_camX += (_camTargetX - _camX) * CAM_PAN_LERP;
_camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP;
{ const r = _rig(); cam.position.set(_camX, r.y * K * _camZoom, r.z * K * _camZoom); cam.lookAt(_camX, r.lookY * K * _camZoom, r.lookZ * K * _camZoom); }
cam.position.set(_camX, CAM_Y * _camZoom, CAM_Z * _camZoom);
cam.lookAt(_camX, LOOK_Y * _camZoom, LOOK_Z * _camZoom);
for (const km of keyMeshes.values()) km.userData.glow = 0;
for (const { mesh, note, len, label } of noteMeshes) {
@@ -3478,12 +3217,9 @@
if (_isReady) teardown();
highwayCanvas = canvas;
fx = readFxSettings();
// Persisted string settings refresh here too — a palette,
// camera, theme or background style saved while no instance was
// listening (e.g. changed on the Settings screen, where the live
// viz is torn down) must not come up stale on a later init().
_palette = readPaletteSetting();
_camPreset = CAM_PRESETS[readCameraSetting()] || CAM_PRESETS.classic;
// Persisted string settings refresh here too — a theme saved
// while no instance was listening must not come up stale on a
// later init().
_theme = readThemeSetting();
_bgStyle = readBgStyleSetting();
loadThree().then(() => {
@@ -3529,19 +3265,6 @@
_bgStyle = d.bgStyle;
_bgMountStyle();
}
if (d && d.palette && PALETTE_IDS.indexOf(d.palette) !== -1) {
_palette = d.palette;
_applyPalette();
}
if (d && d.camera && CAM_PRESETS[d.camera]) {
_camPreset = CAM_PRESETS[d.camera];
// Position/lookAt re-derive next frame; only the
// projection needs an explicit poke.
if (cam) {
cam.fov = _camPreset.fov;
cam.updateProjectionMatrix();
}
}
};
window.addEventListener('keys3d:settings', _fxThemeHandler);
window.addEventListener('keys3d:settings', _fxHandler);
@@ -3747,19 +3470,10 @@
readFxSettings,
readThemeSetting,
readBgStyleSetting,
readPaletteSetting,
readCameraSetting,
_bgThemeColors,
BG_THEMES,
BG_STYLE_IDS,
NOTE_PALETTES,
PITCH_CLASS_COLORS,
PALETTE_IDS,
OCTAVE_HUES,
octaveNoteColor,
CAM_PRESETS,
FX_DEFAULTS,
FX_RANGES,
_classifyTiming,
};
+1 -92
View File
@@ -12,22 +12,6 @@
<div class="mt-3">
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
<label for="keysh3d-fx-palette" class="text-xs font-medium text-gray-400 mb-1 block">Note colours</label>
<select id="keysh3d-fx-palette"
onchange="window.keys3dSetPalette && window.keys3dSetPalette(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="octaves" selected>Octaves (colour per octave, darker sharps)</option>
<option value="emerald">Emerald (green, darker sharps)</option>
<option value="ice">Ice (blue, darker sharps)</option>
<option value="classic">Rainbow (per-pitch)</option>
<option value="vivid">Vivid (per-pitch, punchier)</option>
<option value="pastel">Pastel (per-pitch, soft)</option>
</select>
<p class="text-xs text-gray-500 mt-1 mb-3">
Choose the colour scheme for the falling notes, key glow, lane
guides and hit flames. Each option is described in its own label.
</p>
<label for="keysh3d-fx-theme" class="text-xs font-medium text-gray-400 mb-1 block">Scene theme</label>
<select id="keysh3d-fx-theme"
onchange="window.keys3dSetTheme && window.keys3dSetTheme(this.value)"
@@ -46,59 +30,7 @@
</select>
<p class="text-xs text-gray-500 mt-1 mb-3">
Background gradient, floor and lane rails — the same theme names
as the guitar highway. Note colours come from the
"Note colours" palette above.
</p>
<label for="keysh3d-fx-camera" class="text-xs font-medium text-gray-400 mb-1 block">Camera angle</label>
<select id="keysh3d-fx-camera"
onchange="window.keys3dSetCamera && window.keys3dSetCamera(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="classic">Classic (low, deep runway)</option>
<option value="elevated">Elevated (higher, more board)</option>
<option value="overhead" selected>Overhead (top-down reading view)</option>
</select>
<p class="text-xs text-gray-500 mt-1 mb-3">
Where the camera sits. Classic is the original low rig; Elevated
lifts it for a fuller view of the keybed; Overhead looks down the
lanes for a sheet-reading feel. Applies live, keeps the
auto-pan/zoom that follows your hands.
</p>
<label for="keysh3d-fx-camheight" class="text-xs font-medium text-gray-400 mb-1 block">
Camera height <span id="keysh3d-fx-camheight-val" class="text-gray-500 font-mono">1.00</span>
</label>
<input type="range" id="keysh3d-fx-camheight"
min="0.4" max="2.2" step="0.02" value="1"
oninput="window.keys3dSetFx && window.keys3dSetFx('camHeight', this.value); document.getElementById('keysh3d-fx-camheight-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
Raise or lower the camera around the angle above (higher = more
top-down). Fine-tunes the base view; the follow-motion stays.
</p>
<label for="keysh3d-fx-camdist" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Camera distance <span id="keysh3d-fx-camdist-val" class="text-gray-500 font-mono">1.00</span>
</label>
<input type="range" id="keysh3d-fx-camdist"
min="0.4" max="2.2" step="0.02" value="1"
oninput="window.keys3dSetFx && window.keys3dSetFx('camDist', this.value); document.getElementById('keysh3d-fx-camdist-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
Pull the camera back or push it in (larger = further away, smaller
= closer).
</p>
<label for="keysh3d-fx-camtilt" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Camera tilt <span id="keysh3d-fx-camtilt-val" class="text-gray-500 font-mono">0.00</span>
</label>
<input type="range" id="keysh3d-fx-camtilt"
min="-1" max="1" step="0.02" value="0"
oninput="window.keys3dSetFx && window.keys3dSetFx('camTilt', this.value); document.getElementById('keysh3d-fx-camtilt-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1 mb-3">
Tilt the view up (+) or down () without moving the camera —
aims higher up the runway or down toward the keys. 0 = neutral.
as the guitar highway. Pitch-class note colours never change.
</p>
<label for="keysh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
@@ -258,24 +190,6 @@
hydrateFxRange('vibrancy', 'keysh3d-fx-vibrancy', 'keysh3d-fx-vibrancy-val');
hydrateFxRange('glow', 'keysh3d-fx-glow', 'keysh3d-fx-glow-val');
hydrateFxRange('bgIntensity', 'keysh3d-fx-bgintensity', 'keysh3d-fx-bgintensity-val');
// Camera fine-tune sliders live outside 0-1 — clamp to the
// control's own min/max (mirrors screen.js FX_RANGES).
const hydrateFxRangeIn = (key, elId, valId) => {
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
if (!Number.isFinite(n)) return;
const el = document.getElementById(elId);
const v = Math.min(parseFloat(el.max), Math.max(parseFloat(el.min), n));
el.value = String(v);
document.getElementById(valId).textContent = v.toFixed(2);
};
hydrateFxRangeIn('camHeight', 'keysh3d-fx-camheight', 'keysh3d-fx-camheight-val');
hydrateFxRangeIn('camDist', 'keysh3d-fx-camdist', 'keysh3d-fx-camdist-val');
hydrateFxRangeIn('camTilt', 'keysh3d-fx-camtilt', 'keysh3d-fx-camtilt-val');
const storedCamera = localStorage.getItem('keys3d_bg_camera');
const cameraSel = document.getElementById('keysh3d-fx-camera');
if (storedCamera && Array.from(cameraSel.options).some(o => o.value === storedCamera)) {
cameraSel.value = storedCamera;
}
const storedStyle = localStorage.getItem('keys3d_bg_style');
const styleSel = document.getElementById('keysh3d-fx-bgstyle');
if (storedStyle && Array.from(styleSel.options).some(o => o.value === storedStyle)) {
@@ -286,11 +200,6 @@
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
themeSel.value = storedTheme;
}
const storedPalette = localStorage.getItem('keys3d_bg_palette');
const paletteSel = document.getElementById('keysh3d-fx-palette');
if (storedPalette && Array.from(paletteSel.options).some(o => o.value === storedPalette)) {
paletteSel.value = storedPalette;
}
} catch (e) {
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
}
@@ -148,252 +148,3 @@ test('FX defaults: ambience + score FX ship enabled', () => {
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
assert.equal(FX_DEFAULTS.bgReactive, true);
});
/* ── Note-colour palettes (feat/keys3d-note-palettes) ────────────────── */
test('note palettes: 12 entries each, classic IS the stock table', () => {
const { NOTE_PALETTES, PITCH_CLASS_COLORS } =
load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual(Object.keys(NOTE_PALETTES),
['classic', 'emerald', 'vivid', 'pastel', 'ice']);
for (const [id, colors] of Object.entries(NOTE_PALETTES)) {
assert.equal(colors.length, 12, id + ' has one colour per pitch class');
for (const c of colors) {
assert.ok(Number.isInteger(c) && c >= 0 && c <= 0xffffff,
id + ' colours are 24-bit ints');
}
}
// 'classic' preserves the shipped look byte-identically — it is the
// same array, not a copy that could drift.
assert.equal(NOTE_PALETTES.classic, PITCH_CLASS_COLORS);
assert.equal(PITCH_CLASS_COLORS[0], 0xff3030); // C stays red in classic
});
test('note palettes: two-tone tables use darker sharps than naturals', () => {
const { NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
const luma = (c) =>
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
for (const id of ['emerald', 'ice']) {
const p = NOTE_PALETTES[id];
for (const sharp of [1, 3, 6, 8, 10]) {
assert.ok(luma(p[sharp]) < luma(p[0]),
id + ' sharp pc ' + sharp + ' darker than naturals');
}
}
});
test('readPaletteSetting: octaves default, validated overrides only', () => {
// No localStorage in the vm → the plug-and-play default.
const bare = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(bare.readPaletteSetting(), 'octaves');
// An explicit non-default value (classic) overrides.
const store = { keys3d_bg_palette: 'classic' };
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
});
const { readPaletteSetting } = win.slopsmithViz_keys_highway_3d.__test;
assert.equal(readPaletteSetting(), 'classic');
// Corrupt/foreign value → the default rather than an undefined scheme.
store.keys3d_bg_palette = 'banana';
assert.equal(readPaletteSetting(), 'octaves');
});
test('keys3dSetPalette: persists + dispatches valid ids, ignores unknown', () => {
const store = {};
const events = [];
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: (ev) => { events.push(ev); return true; },
CustomEvent: class CustomEvent {
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
},
});
win.keys3dSetPalette('emerald');
assert.equal(store.keys3d_bg_palette, 'emerald');
assert.equal(events.length, 1);
assert.equal(events[0].type, 'keys3d:settings');
assert.equal(events[0].detail.palette, 'emerald');
// Unknown id: no write, no event.
win.keys3dSetPalette('banana');
assert.equal(store.keys3d_bg_palette, 'emerald');
assert.equal(events.length, 1);
// 'octaves' (procedural, not a 12-array) is a valid selectable id.
win.keys3dSetPalette('octaves');
assert.equal(store.keys3d_bg_palette, 'octaves');
assert.equal(events.length, 2);
});
test('PALETTE_IDS: the array palettes plus the procedural octaves scheme', () => {
const { PALETTE_IDS, NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual([...PALETTE_IDS],
[...Object.keys(NOTE_PALETTES), 'octaves']);
assert.ok(PALETTE_IDS.indexOf('octaves') !== -1);
assert.ok(!('octaves' in NOTE_PALETTES)); // it is NOT a 12-entry table
});
test('octaveNoteColor: hue steps per octave, loops, sharps darker, sub-C1 distinct', () => {
const { octaveNoteColor, OCTAVE_HUES } = load().slopsmithViz_keys_highway_3d.__test;
const luma = (c) =>
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
// C1 (midi 24) = first hue; C2 (36) = second; C8 (108) = 8th (index 7).
assert.equal(octaveNoteColor(24), OCTAVE_HUES[0]); // C1 red
assert.equal(octaveNoteColor(35), OCTAVE_HUES[0]); // B1 still octave 1
assert.equal(octaveNoteColor(36), OCTAVE_HUES[1]); // C2 orange
assert.equal(octaveNoteColor(60), OCTAVE_HUES[3]); // C4 (middle C)
assert.equal(octaveNoteColor(108), OCTAVE_HUES[7]); // C8 last hue
// Naturals across one octave (C1..B1 whites) all share the octave hue.
for (const nat of [24, 26, 28, 29, 31, 33, 35]) {
assert.equal(octaveNoteColor(nat), OCTAVE_HUES[0], 'natural ' + nat);
}
// Sharps in an octave are a DARKER shade of that same hue.
for (const sharp of [25, 27, 30, 32, 34]) { // C#1..A#1
assert.ok(luma(octaveNoteColor(sharp)) < luma(OCTAVE_HUES[0]),
'sharp ' + sharp + ' darker than the octave natural');
}
// The three keys below C1 (A0/A#0/B0) share a distinct sub-C1 colour,
// different from the red octave-1 start.
assert.equal(octaveNoteColor(21), octaveNoteColor(23)); // A0 == B0 hue
assert.notEqual(octaveNoteColor(21), OCTAVE_HUES[0]);
// Loop: an octave past the table wraps (safety for out-of-88 midi).
assert.equal(octaveNoteColor(24 + 12 * OCTAVE_HUES.length), OCTAVE_HUES[0]);
});
/* ── Camera presets + fine-tune (feat/keys3d-camera) ─────────────────── */
test('FX defaults: camera height/distance/tilt all neutral (preset carries the tuned aim)', () => {
const { FX_DEFAULTS, FX_RANGES } = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(FX_DEFAULTS.camHeight, 1.0);
assert.equal(FX_DEFAULTS.camDist, 1.0);
// Tilt ships NEUTRAL (0): the tuned plug-and-play aim now lives in
// CAM_PRESETS.overhead.lookY, so the fine-tune only nudges from a preset
// and 'classic' + this default reproduces the exact historical rig.
assert.equal(FX_DEFAULTS.camTilt, 0.0);
assert.ok(FX_DEFAULTS.camTilt >= FX_RANGES.camTilt[0] && FX_DEFAULTS.camTilt <= FX_RANGES.camTilt[1]);
// Height/distance bracket 1 (can go lower AND higher); tilt spans 0.
assert.ok(FX_RANGES.camHeight[0] < 1 && 1 < FX_RANGES.camHeight[1]);
assert.ok(FX_RANGES.camDist[0] < 1 && 1 < FX_RANGES.camDist[1]);
assert.ok(FX_RANGES.camTilt[0] < 0 && 0 < FX_RANGES.camTilt[1]);
});
test('camTilt: negative values survive the clamp (down-tilt must be reachable)', () => {
const store = {};
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: () => true,
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
});
const { FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
win.keys3dSetFx('camTilt', -0.5);
assert.equal(store.keys3d_bg_camTilt, '-0.5'); // NOT crushed to 0 by a 0-1 clamp
win.keys3dSetFx('camTilt', -99);
assert.equal(parseFloat(store.keys3d_bg_camTilt), FX_RANGES.camTilt[0]);
});
test('FX ranges: reader + setter clamp to the declared range, not 0-1', () => {
const store = { keys3d_bg_camHeight: '5', keys3d_bg_camDist: '0.01' };
const events = [];
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: (ev) => { events.push(ev); return true; },
CustomEvent: class CustomEvent {
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
},
});
const { readFxSettings, FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
// Reader: corrupt/out-of-range writes clamp to the declared bounds.
assert.equal(readFxSettings().camHeight, FX_RANGES.camHeight[1]);
assert.equal(readFxSettings().camDist, FX_RANGES.camDist[0]);
// Setter: same clamp on the way in; a value above 1 must survive
// (the historical 0-1 clamp would have crushed 1.3 to 1).
win.keys3dSetFx('camHeight', 1.3);
assert.equal(store.keys3d_bg_camHeight, '1.3');
win.keys3dSetFx('camDist', 99);
assert.equal(parseFloat(store.keys3d_bg_camDist), FX_RANGES.camDist[1]);
// Un-ranged keys keep the historical 0-1 clamp.
win.keys3dSetFx('vibrancy', 2);
assert.equal(store.keys3d_bg_vibrancy, '1');
});
test('scrollZ: distance-to-hitline scales linearly with the speed argument', () => {
const { scrollZ } = load().slopsmithViz_keys_highway_3d.__test;
const hitZ = 0;
const d1 = scrollZ(2, 0, hitZ, 130) - hitZ; // 2s ahead at stock speed
const d2 = scrollZ(2, 0, hitZ, 260) - hitZ; // same note at 2x speed
assert.equal(d2, d1 * 2);
// At the hit moment the note is at the hit-line regardless of speed.
assert.equal(scrollZ(5, 5, hitZ, 130), hitZ);
assert.equal(scrollZ(5, 5, hitZ, 260), hitZ);
});
test('camera presets: classic preserves the stock rig, overhead is the default', () => {
const { CAM_PRESETS, readCameraSetting } = load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual(Object.keys(CAM_PRESETS), ['classic', 'elevated', 'overhead']);
// 'classic' preserves the historical constants (pre-K units) even though
// it is no longer the default — anyone who picks it gets the old rig back
// EXACTLY, because camTilt now defaults to 0 (neutral): effective aim =
// classic.lookY + 0*CAM_TILT_UNITS = 8, the historical LOOK_Y.
assert.deepEqual({ ...CAM_PRESETS.classic },
{ fov: 40, y: 46, z: 112, lookY: 8, lookZ: -165 });
for (const [id, p] of Object.entries(CAM_PRESETS)) {
for (const f of ['fov', 'y', 'z', 'lookY', 'lookZ']) {
assert.ok(Number.isFinite(p[f]), id + '.' + f + ' is a number');
}
assert.ok(p.y > 0 && p.z > 0, id + ' sits above and behind the keys');
}
assert.equal(readCameraSetting(), 'overhead'); // no localStorage in the vm → tuned default
});
test('camera default look is unchanged: overhead bakes the old tuned tilt, camTilt is neutral', () => {
const { CAM_PRESETS, FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
const CAM_TILT_UNITS = 55; // full-swing of the camTilt offset at ±1 (screen.js)
// The shipped default look = overhead preset + the default camTilt. Before,
// that was lookY 0 + (0.6 × 55) = 33; the tuned aim now lives in the
// preset (lookY 33) with a neutral camTilt (0), so the effective aim — and
// thus the out-of-the-box framing — is byte-identical.
const effOverhead = CAM_PRESETS.overhead.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
assert.equal(effOverhead, -33);
// 'classic' + the neutral default reproduces the historical LOOK_Y (8) —
// the "pick Classic for the original look" promise, now actually true.
const effClassic = CAM_PRESETS.classic.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
assert.equal(effClassic, 8);
});
test('keys3dSetCamera: persists + dispatches valid ids, ignores unknown', () => {
const store = {};
const events = [];
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: (ev) => { events.push(ev); return true; },
CustomEvent: class CustomEvent {
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
},
});
win.keys3dSetCamera('overhead');
assert.equal(store.keys3d_bg_camera, 'overhead');
assert.equal(events.length, 1);
assert.equal(events[0].type, 'keys3d:settings');
assert.equal(events[0].detail.camera, 'overhead');
win.keys3dSetCamera('helicopter');
assert.equal(store.keys3d_bg_camera, 'overhead');
assert.equal(events.length, 1);
const { readCameraSetting } = win.slopsmithViz_keys_highway_3d.__test;
assert.equal(readCameraSetting(), 'overhead');
store.keys3d_bg_camera = 'garbage';
assert.equal(readCameraSetting(), 'overhead');
});
+2 -22
View File
@@ -10765,9 +10765,6 @@ def save_settings(data: dict):
config_file = CONFIG_DIR / "config.json"
updates: dict = {}
messages: list[str] = []
# Named dlc_warnings (not `warnings`) so it can't shadow the module-level
# `import warnings` used elsewhere in this file.
dlc_warnings: list[str] = []
if "dlc_dir" in data:
dlc_path = data["dlc_dir"]
@@ -10787,16 +10784,7 @@ def save_settings(data: dict):
if f.suffix.lower() in sloppak_mod.SONG_EXTS)
messages.append(f"DLC folder: {count} song files found")
else:
# A non-resolving DLC path (a stale value, an unplugged
# external/network drive, or a path carried over from another
# machine) must NOT abort the whole POST. saveSettings() bundles
# dlc_dir together with demucs_server_url / default_arrangement /
# av_offset_ms in a single request, so an early `return` here
# silently dropped every co-submitted key — this is the "can't
# set the Demucs server address" report (feedBack-demucs-server
# #3). Record it as a warning, skip persisting dlc_dir, and keep
# validating the rest so the other settings still save.
dlc_warnings.append(f"DLC directory not found: {dlc_path}")
return {"error": f"DLC directory not found: {dlc_path}"}
# Both of these are consumed downstream as strings (e.g.
# demucs_server_url.rstrip('/')), so reject non-string shapes
@@ -11053,15 +11041,7 @@ def save_settings(data: dict):
return {"error": str(exc)}
cfg = settings_with_instrument_profiles(cfg)
_atomic_write_file(config_file, json.dumps(cfg, indent=2).encode("utf-8"))
resp = {"message": ". ".join(messages) if messages else "Settings saved"}
if dlc_warnings:
# `warnings` is an additive response field (existing clients read
# `message || error`); fold the text into `message` too so the current
# settings status line still surfaces the bad DLC path even though the
# rest of the save succeeded.
resp["warnings"] = dlc_warnings
resp["message"] = resp["message"] + "" + "; ".join(dlc_warnings)
return resp
return {"message": ". ".join(messages) if messages else "Settings saved"}
# Keys a client "Reset {category}" action may clear. Resetting removes the key
+1 -1
View File
@@ -4379,7 +4379,7 @@ async function uploadSongs(fileList) {
if (lower.endsWith('.feedpak') || lower.endsWith('.sloppak')) {
files.push(f);
} else {
failures.push(`${f.name}: only .feedpak or .sloppak accepted`);
failures.push(`${f.name}: only .feedpak accepted`);
}
}
if (files.length === 0) {
+1 -6
View File
@@ -194,7 +194,7 @@
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
level so it stays reachable regardless of which screen is active. -->
<input type="file" id="upload-songs-file" accept=".feedpak,.sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
<input type="file" id="upload-songs-file" accept=".sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
<!-- ══ HOME (Hero + Library) — reused as the v3 "Songs" screen ════════ -->
<div id="home" class="screen">
@@ -641,12 +641,7 @@
</div>
</div>
<div class="fb-srow-control">
<!-- Autosave on blur/enter via a single-key POST (like every other v3
setting), so setting the address never depends on the shared Save
button — whose bundled dlc_dir could otherwise block it. Save button
kept for discoverability. -->
<input type="text" id="demucs-server-url" placeholder="http://192.168.1.100:7865"
onchange="persistSetting('demucs_server_url', this.value.trim())"
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
</div>
+5 -5
View File
@@ -166,15 +166,15 @@
const f = saved.filters;
if (f && typeof f === 'object') {
const arr = (x) => (Array.isArray(x) ? x.slice() : []);
// mastery + match + genre are session-only facets (deliberately not
// mastery + match are session-only facets (deliberately not
// persisted), but the restored object must still CARRY the keys —
// the filter drawer indexes f.mastery/f.match/f.genre unconditionally,
// so dropping them here breaks the drawer for anyone with saved prefs.
// the filter drawer indexes f.mastery/f.match unconditionally, so
// dropping them here breaks the drawer for anyone with saved prefs.
state.filters = {
arr_has: arr(f.arr_has), arr_lacks: arr(f.arr_lacks),
stem_has: arr(f.stem_has), stem_lacks: arr(f.stem_lacks),
lyrics: f.lyrics || '', tunings: arr(f.tunings),
mastery: [], match: [], genre: [],
mastery: [], match: [],
};
}
}
@@ -2821,7 +2821,7 @@
'<div class="flex items-center justify-between"><h3 class="text-lg font-semibold text-fb-text">Filters</h3>' +
'<button data-drawer-close class="text-fb-textDim hover:text-fb-text">✕</button></div>' +
section('Arrangements', ARRANGEMENTS.map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) +
section('Stems (feedpak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) +
section('Stems (sloppak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) +
section('Lyrics', ['', '1', '0'].map((v) => '<button data-lyrics="' + v + '" class="px-2 py-1 rounded-md text-xs border ' + (f.lyrics === v ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + (v === '' ? 'Any' : v === '1' ? 'Has lyrics' : 'No lyrics') + '</button>').join('')) +
// Progress (mastery bands) — multi-select; server filters via song_stats.
section('Progress', [['mastered', 'Mastered'], ['in_progress', 'In progress'], ['not_started', 'Not started']].map((it) => '<button data-mastery="' + it[0] + '" class="px-2 py-1 rounded-md text-xs border ' + (f.mastery.includes(it[0]) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + it[1] + '</button>').join('')) +
-41
View File
@@ -219,47 +219,6 @@ def test_dlc_dir_empty_string_clears(client, tmp_path):
assert _read_cfg(tmp_path)["dlc_dir"] == ""
def test_unresolvable_dlc_dir_does_not_block_other_keys(client, tmp_path):
# Regression (feedBack-demucs-server#3): the v3 "Save" button next to the
# Demucs field bundles dlc_dir with demucs_server_url in one POST. A DLC
# path that doesn't resolve on THIS machine (stale value / unplugged drive)
# must not abort the whole request — the co-submitted demucs_server_url has
# to persist, and the bad path is surfaced as a warning rather than a hard
# error that drops every other key.
missing = str(tmp_path / "does-not-exist")
r = client.post("/api/settings", json={
"dlc_dir": missing,
"demucs_server_url": "http://demucs.example:7865",
"default_arrangement": "Lead",
})
assert r.status_code == 200
body = r.json()
# No hard error; the bad path is reported as a warning.
assert "error" not in body
assert any("does-not-exist" in w for w in body.get("warnings", []))
assert "does-not-exist" in body["message"]
cfg = _read_cfg(tmp_path)
# The valid keys persisted...
assert cfg["demucs_server_url"] == "http://demucs.example:7865"
assert cfg["default_arrangement"] == "Lead"
# ...and the unresolvable path was NOT written.
assert cfg.get("dlc_dir", "") != missing
def test_valid_dlc_dir_still_reports_song_count(client, tmp_path):
# The happy path is unchanged: a resolvable DLC dir persists and the
# response message still carries the "N song files found" summary (no
# warnings key when nothing went wrong).
dlc = tmp_path / "dlc"
dlc.mkdir()
r = client.post("/api/settings", json={"dlc_dir": str(dlc)})
assert r.status_code == 200
body = r.json()
assert "warnings" not in body
assert "song files found" in body["message"]
assert _read_cfg(tmp_path)["dlc_dir"] == str(dlc)
@pytest.mark.parametrize("key", ["default_arrangement", "demucs_server_url"])
def test_string_key_null_is_noop(client, tmp_path, key):
# Match the dlc_dir contract: null preserves the on-disk value.