Compare commits

...
Author SHA1 Message Date
web-flow 584add1493 Add automated code review workflow 2026-07-07 03:40:16 +00:00
K. O. A.andGitHub cb72c5ab34 Merge pull request #802 from got-feedBack/fix/v3-library-filters-drawer
ship-ci / ci (push) Waiting to run
Fix v3 library Filters drawer crash on saved prefs
2026-07-06 19:51:38 -04:00
topkoa 92e78be62d Fix v3 library Filters drawer crash on saved prefs; rename Stems label
applySavedPrefs() rebuilt state.filters without the `genre` key, so with
saved prefs restored from localStorage state.filters.genre was undefined.
Clicking Filters ran renderDrawer(), which indexes f.genre.includes(g)
whenever the library has >=1 genre -> TypeError, renderDrawer aborts, and
openDrawer never removes translate-x-full. The drawer stayed off-screen so
the menu appeared dead. Only triggered for users with saved prefs AND a
non-empty genre list, matching the intermittent report.

Carry genre: [] alongside the other session-only facets (mastery, match),
mirroring the default and clear-all shapes which already include it.

Also rename the visible "Stems (sloppak)" drawer label to "Stems (feedpak)"
to match the public format name used elsewhere in the UI.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-06 19:50:34 -04:00
K. O. A.andGitHub 1840170e95 Merge pull request #799 from got-feedBack/chore/lyrics-source-transcribed
Accept spec lyrics_source values (authored, transcribed)
2026-07-06 17:31:11 -04:00
topkoaandClaude Opus 4.8 7cbf9824b1 Drop dead whisperx entry from allowed lyrics_source set
The whisperx->transcribed alias runs before the membership check, so the literal whisperx never reaches _ALLOWED_LYRICS_SOURCES (same reason sng is omitted). Remove the dead entry. Per CodeRabbit review on #799.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-06 17:30:26 -04:00
topkoaandClaude Opus 4.8 33146cc7f6 Accept spec lyrics_source values (authored, transcribed)
The feedpak spec (§7.1) defines the lyrics_source vocabulary as {authored, transcribed, user}, but the reader only accepted the legacy {xml, notechart, whisperx, user} set and silently downgraded anything else to "xml". A spec-compliant writer (e.g. the stem_splitter plugin, which emits transcribed for WhisperX-produced lyrics) therefore lost its provenance badge.

Widen the allowed set to the union of the spec vocabulary and the legacy values so both validate, and alias the legacy whisperx engine name to the spec transcribed so existing packs normalise to the spec badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-06 17:24:26 -04:00
69c8ad4e0c fix(settings): don't let a stale DLC path block saving the Demucs server address (#795)
ship-ci / ci (push) Waiting to run
The v3 Settings "Save" button posts dlc_dir together with demucs_server_url,
default_arrangement and av_offset_ms in one request. POST /api/settings
validated dlc_dir first and early-returned "DLC directory not found" before it
ever processed demucs_server_url, so on a machine whose DLC path doesn't resolve
(fresh install, unplugged/network drive, a path carried over from another
machine) setting the Demucs server address silently failed — reported in
got-feedBack/feedBack-demucs-server#3 (macOS 07-05 nightly).

- server: a non-resolving dlc_dir is now recorded as a warning and skipped
  rather than aborting the whole POST, so the co-submitted keys still persist.
  The bad path is surfaced via a new additive `warnings` field and folded into
  `message` so the settings status line still shows it.
- client (v3): the Demucs input now autosaves on blur/enter via a single-key
  persistSetting POST, like every other v3 setting, so it never depends on the
  coupled Save button.
- tests: cover the decoupling and the unchanged happy path.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:35:51 +02:00
a20dca21bb feat(keys_highway_3d): add note-colour palettes and selectable camera angles (#794)
ship-ci / ci (push) Waiting to run
* feat(keys_highway_3d): add note-colour palettes and selectable camera angles

Give the 3D keys highway player-facing view options and a tuned default
look, so the piano highway is readable out of the box and customisable
from the settings panel without touching code.

Note colours (settings -> Note colours, `keys3d_bg_palette`):
- Octaves (new default): each octave its own hue climbing the rainbow,
  darker sharps, so pitch height reads at a glance on any note range.
- Rainbow: the original per-pitch table (colours unchanged).
- Vivid / Pastel: per-pitch variants.
- Emerald / Ice: single-hue two-tone (uniform naturals, darker sharps).
The pick drives the notes, key glow, lane guides and hit flames, live.

Camera (settings -> Camera angle, `keys3d_bg_camera`):
- Classic (the original low rig) / Elevated / Overhead (new default).
- Height, distance and tilt fine-tune sliders nudge the base vantage the
  auto-pan/zoom follow-motion orbits; presets apply live.

The new defaults are opinionated for plug-and-play (octaves palette,
overhead camera, tilt -0.6); anyone who prefers the original look can
pick Rainbow + Classic. Settings changes are re-read on init() so they
apply on return from the settings screen, not only after a relaunch.

Scoring, hit-timing and MIDI handling are untouched -- these are purely
visual. Numeric FX keys clamp to declared ranges (FX_RANGES); the pure
colour/camera helpers are covered by unit tests (node --test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: LegionaryLeader <legionaryleader@gmail.com>

* fix(keys_highway_3d): make Classic camera reproduce the original rig + drop per-frame camera alloc

Review follow-ups on the palettes/camera feature:

- "Classic" preset now reproduces the historical rig exactly. The tuned
  plug-and-play downward aim (camTilt -0.6 x CAM_TILT_UNITS = -33) is baked
  into CAM_PRESETS.overhead.lookY, and camTilt now defaults to 0 (neutral).
  The default overhead look is byte-identical (effective lookY still -33),
  but "pick Classic for the original look" is now actually true instead of
  leaving a -33 down-tilt applied. settings.html tilt slider defaults to 0.

- _rig() writes into a hoisted reusable object instead of allocating a fresh
  {y,z,lookY,lookZ} literal every frame, honoring the module's documented
  "no per-frame allocations in draw()" discipline. Callers read it
  synchronously and never retain it, so one shared instance is safe.

Tests updated for the neutral camTilt default; adds an invariant test that
the default overhead framing is unchanged and Classic + neutral tilt == the
historical LOOK_Y. Full JS suite green (1069 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: LegionaryLeader <legionaryleader@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-06 10:34:09 +02:00
021ee55f2a Allow .feedpak files in library when uploading (#770)
Signed-off-by: Rob Sassack <rsassack25@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-06 10:05:27 +02:00
1f621e5fe5 perf(highway): draw the held-sustain glow without ctx.shadowBlur (#729)
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>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-06 09:49:06 +02:00
13 changed files with 970 additions and 70 deletions
+181
View File
@@ -0,0 +1,181 @@
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}`
});
}
+26 -14
View File
@@ -703,20 +703,32 @@ def load_song(
and isinstance(e.get("d"), (int, float))
]
if song.lyrics:
# 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"}
# 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"}
raw_source = manifest.get("lyrics_source")
if isinstance(raw_source, str):
raw_source = _LYRICS_SOURCE_ALIASES.get(raw_source, raw_source)
+2 -1
View File
@@ -3,10 +3,11 @@
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.
- 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.
- 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.1",
"version": "0.1.2",
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
"type": "visualization",
"bundled": true,
+320 -34
View File
@@ -83,6 +83,119 @@
// 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.
@@ -913,7 +1026,27 @@
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.
@@ -1090,6 +1223,64 @@
} 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 {
@@ -1104,10 +1295,10 @@
else if (raw === '0' || raw === 'false') fx[k] = false;
} else {
const n = parseFloat(raw);
// 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));
// 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);
}
}
} catch (_) { /* localStorage unavailable — use defaults */ }
@@ -1127,7 +1318,7 @@
} else {
v = Number(value);
if (!Number.isFinite(v)) return;
v = Math.min(1, Math.max(0, v)); // all numeric FX keys are 0-1
v = _fxClamp(key, v); // declared range, default 0-1
}
try {
localStorage.setItem(FX_LS_PREFIX + key, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));
@@ -1369,6 +1560,7 @@
// 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)
@@ -1432,8 +1624,20 @@
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(PITCH_CLASS_COLORS[((midi % 12) + 12) % 12]);
const base = new T.Color(_noteHex(midi));
const b = HAND_BRIGHTNESS[hand] != null ? HAND_BRIGHTNESS[hand] : 1.0;
base.multiplyScalar(b);
return base;
@@ -1461,14 +1665,15 @@
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 — 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;
// 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;
// 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)
@@ -1484,6 +1689,25 @@
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
@@ -1945,10 +2169,9 @@
_envRT = _makeStudioEnv(T, ren);
if (_envRT) scene.environment = _envRT.texture;
cam = new T.PerspectiveCamera(CAM_FOV, 1, 0.1, 2000 * K);
cam = new T.PerspectiveCamera(_camPreset.fov, 1, 0.1, 2000 * K);
_camX = 0; _camTargetX = 0; _camZoom = 1; _camTargetZoom = 1;
cam.position.set(0, CAM_Y, CAM_Z);
cam.lookAt(0, LOOK_Y, LOOK_Z);
{ const r = _rig(); cam.position.set(0, r.y * K, r.z * K); cam.lookAt(0, r.lookY * K, r.lookZ * K); }
ambLight = new T.AmbientLight(0xffffff, 0.75);
dirLight = new T.DirectionalLight(0xffffff, 1.1);
@@ -2091,13 +2314,16 @@
return geo;
}
// Glossy note material, cached per (pitch class, hand).
// 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).
function _noteMaterial(midi, hand) {
const handKey = HAND_BRIGHTNESS[hand] != null ? hand : 'rh';
const key = (((midi % 12) + 12) % 12) + '|' + handKey;
const col = noteColor(midi, hand);
const key = col.getHex();
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
@@ -2145,6 +2371,39 @@
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;
@@ -2181,13 +2440,15 @@
return _glowTex;
}
// 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);
// 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);
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;
@@ -2207,7 +2468,7 @@
ctx.fillStyle = falloff;
ctx.fillRect(0, 0, 64, 128);
tex = new T.CanvasTexture(cnv);
_flameTexCache.set(pc, tex);
_flameTexCache.set(c, tex);
return tex;
}
@@ -2294,7 +2555,7 @@
if (!entry) return;
const slot = _flamePool[_flameIdx];
_flameIdx = (_flameIdx + 1) % _flamePool.length;
slot.mat.map = _flameTexture(((midi % 12) + 12) % 12);
slot.mat.map = _flameTexture(midi);
slot.start = wallNow;
slot.baseY = entry.black ? BLACK_H + WHITE_H * 0.6 : WHITE_H;
slot.sprite.position.x = keyX(entry, _layoutInfo.whiteCount);
@@ -2382,6 +2643,7 @@
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;
@@ -2775,8 +3037,7 @@
}
_camX += (_camTargetX - _camX) * CAM_PAN_LERP;
_camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP;
cam.position.set(_camX, CAM_Y * _camZoom, CAM_Z * _camZoom);
cam.lookAt(_camX, LOOK_Y * _camZoom, LOOK_Z * _camZoom);
{ 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); }
for (const km of keyMeshes.values()) km.userData.glow = 0;
for (const { mesh, note, len, label } of noteMeshes) {
@@ -3217,9 +3478,12 @@
if (_isReady) teardown();
highwayCanvas = canvas;
fx = readFxSettings();
// Persisted string settings refresh here too — a theme saved
// while no instance was listening must not come up stale on a
// later init().
// 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;
_theme = readThemeSetting();
_bgStyle = readBgStyleSetting();
loadThree().then(() => {
@@ -3265,6 +3529,19 @@
_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);
@@ -3470,10 +3747,19 @@
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,
};
+92 -1
View File
@@ -12,6 +12,22 @@
<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)"
@@ -30,7 +46,59 @@
</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. Pitch-class note colours never change.
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.
</p>
<label for="keysh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
@@ -190,6 +258,24 @@
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)) {
@@ -200,6 +286,11 @@
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,3 +148,252 @@ 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');
});
+22 -2
View File
@@ -10765,6 +10765,9 @@ 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"]
@@ -10784,7 +10787,16 @@ def save_settings(data: dict):
if f.suffix.lower() in sloppak_mod.SONG_EXTS)
messages.append(f"DLC folder: {count} song files found")
else:
return {"error": f"DLC directory not found: {dlc_path}"}
# 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}")
# Both of these are consumed downstream as strings (e.g.
# demucs_server_url.rstrip('/')), so reject non-string shapes
@@ -11041,7 +11053,15 @@ 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"))
return {"message": ". ".join(messages) if messages else "Settings saved"}
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
# 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 accepted`);
failures.push(`${f.name}: only .feedpak or .sloppak accepted`);
}
}
if (files.length === 0) {
+24 -10
View File
@@ -2004,18 +2004,32 @@ function createHighway() {
const seedBase = (_frameIdx + n.s + ((n.t * 60) | 0)) | 0;
ctx.save();
ctx.fillStyle = col;
ctx.shadowColor = col;
ctx.shadowBlur = (8 + 6 * _shimmerNoise(seedBase)) * a; // shimmering glow
ctx.globalAlpha = (0.45 + 0.45 * a) * (0.78 + 0.22 * _shimmerNoise(seedBase + 17));
ctx.beginPath();
ctx.moveTo(x0 - sw0, y0);
ctx.lineTo(x0 + sw0, y0);
ctx.lineTo(x1 + sw1, y1);
ctx.lineTo(x1 - sw1, y1);
ctx.fill();
// Shimmering glow WITHOUT ctx.shadowBlur: blur cost scales with
// the blurred DEVICE-pixel area, and a held sustain's trail can
// span half the (DPR-scaled) canvas — profiling the "stutters
// while playing" report put this per-frame blur pass at the top
// exactly while a sustain is held. Three inflated low-alpha
// fills of the same quad read as the same soft glow at a flat,
// area-independent cost. The shimmer LUT still drives the
// per-frame size/brightness flicker (feedBack#254 intent).
const glowPx = (8 + 6 * _shimmerNoise(seedBase)) * a;
const baseA = (0.45 + 0.45 * a) * (0.78 + 0.22 * _shimmerNoise(seedBase + 17));
const fillTrail = (inflate) => {
ctx.beginPath();
ctx.moveTo(x0 - sw0 - inflate, y0);
ctx.lineTo(x0 + sw0 + inflate, y0);
ctx.lineTo(x1 + sw1 + inflate, y1);
ctx.lineTo(x1 - sw1 - inflate, y1);
ctx.fill();
};
ctx.globalAlpha = baseA * 0.22;
fillTrail(glowPx);
ctx.globalAlpha = baseA * 0.4;
fillTrail(glowPx * 0.45);
ctx.globalAlpha = baseA;
fillTrail(0);
// Crackling "current" — a jittery white core line down
// the trail, re-randomised each frame.
ctx.shadowBlur = 0;
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = a * (0.55 + 0.45 * _shimmerNoise(seedBase + 31));
ctx.strokeStyle = '#ffffff';
+6 -1
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=".sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
<input type="file" id="upload-songs-file" accept=".feedpak,.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,7 +641,12 @@
</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 are session-only facets (deliberately not
// mastery + match + genre are session-only facets (deliberately not
// persisted), but the restored object must still CARRY the keys —
// the filter drawer indexes f.mastery/f.match unconditionally, so
// dropping them here breaks the drawer for anyone with saved prefs.
// the filter drawer indexes f.mastery/f.match/f.genre 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: [],
mastery: [], match: [], genre: [],
};
}
}
@@ -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 (sloppak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) +
section('Stems (feedpak)', 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,6 +219,47 @@ 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.