Compare commits

..
Author SHA1 Message Date
byrongamatosandClaude Opus 4.8 d7d38005c5 docs: split the spliced Handedness/Colorblind CHANGELOG entries
A rebase pasted the Handedness bullet over the Colorblind preset entry's bold
lead, merging two unrelated Added entries into one run-on bullet. Restore them
as two separate bullets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 23:50:19 +02:00
ChrisBeWithYouandClaude Opus 4.8 33ff24bc51 feat(v3): add a handedness (left-handed) choice to the instrument selector + onboarding
Left-handed players could already mirror the highway, but only via a buried
Settings toggle they had to find AFTER setup -- so a lefty went through the tour,
the tuner and calibration all right-handed first (community callout).

Add a "Handedness: Right / Left" row to the v3 instrument badge popover, alongside
Instrument / Strings / Tuning (all player-orientation choices). It writes the same
lefty preference -- highway.setLefty when a live highway exists (flips it
immediately + persists), else the 'lefty' localStorage key the highway reads on
init -- and keeps the Settings "Left-handed" checkbox in sync. The first-run
tour's "Choose your instrument" step, which runs before the tuner/audio-
calibration steps, now calls it out so lefties flip it up front.

Frontend-only, additive. Full core JS suite green (938). Tests:
tests/js/badges_handedness.test.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UR2Cr7GEu3yMY7SrfxH6c1
2026-07-05 16:37:05 -05:00
17 changed files with 95 additions and 1230 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');
});
+4 -101
View File
@@ -1266,18 +1266,6 @@ class MetadataDB:
out.setdefault(fn, {})[field] = {"value": value, "locked": bool(locked)}
return out
def _romaji_display(self, filename: str, artist: str, title: str):
"""English-base display fallback. A blank-artist CDLC pack named
'Artist_Title_v1_p' has no readable name (artist blank; title = the raw
filename), and a match would fill it with the artist's NATIVE script
(kanji/kana). Surface the author's own romaji parsed from the filename
instead, so an English base reads 'Junko Yagami - BAY CITY'. Only kicks in
when the pack has no artist of its own a real pack artist is untouched."""
if (artist or "").strip():
return artist, title
d = _artist_title_from_filename(filename)
return (d["artist"], d["title"]) if d else (artist, title)
def pack_fields(self, filename: str) -> dict:
"""The stored (pack) values for the overridable catalog fields — the
Fix-metadata popup shows these behind each override as the 'revert to
@@ -1287,11 +1275,7 @@ class MetadataDB:
row = self.conn.execute(
"SELECT title, artist, album, year, genre FROM songs WHERE filename = ?",
(filename,)).fetchone()
vals = {k: ((row[i] or "") if row else "") for i, k in enumerate(keys)}
# Baseline the author's romaji (from the filename) for a blank-artist pack,
# so the Details tab's Pack reference matches what the grid shows.
vals["artist"], vals["title"] = self._romaji_display(filename, vals["artist"], vals["title"])
return vals
return {k: ((row[i] or "") if row else "") for i, k in enumerate(keys)}
# Effective genre = a per-song genre OVERRIDE (Fix-metadata popup) else the
# scanned pack genre. Applied at FILTER/FACET time (like the P4 artist alias)
@@ -4231,17 +4215,6 @@ class MetadataDB:
s["unmatched"] = s["filename"] in um
if amap:
s["artist"] = amap.get((s.get("artist") or "").lower(), s.get("artist"))
# English-base romaji fallback: a blank-artist CDLC pack shows nothing
# useful (artist blank; title = the raw filename). Surface the author's
# romaji from the "Artist_Title_v1_p" filename so the card reads
# "Junko Yagami — BAY CITY", never blank or native script. Display-only;
# a user override (below) still wins. Keyset-safe: stash the raw title
# for the cursor before replacing it.
if not (s.get("artist") or "").strip():
r_artist, r_title = self._romaji_display(s["filename"], s.get("artist"), s.get("title"))
if r_title != s.get("title") and "_sort_title" not in s:
s["_sort_title"] = s["title"]
s["artist"], s["title"] = r_artist, r_title
# Override wins over the pack AND the alias re-label — it's the user's
# explicit per-song choice. Only a non-empty override VALUE replaces a
# cell; a lock-only row (value None) leaves the displayed value alone.
@@ -4251,7 +4224,7 @@ class MetadataDB:
cell = ov.get(field)
val = cell.get("value") if cell else None
if val:
if field == "title" and "_sort_title" not in s:
if field == "title":
s["_sort_title"] = s["title"] # raw title, for the keyset cursor
s[field] = val
# Grouped rows carry the ⚑ N (chart_count) + the work_key from the
@@ -6547,36 +6520,6 @@ def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]:
return cands
def _mb_search_release_groups(query: str, limit: int = 8) -> list[dict]:
"""Text search /release-group for the Change-cover picker: albums matching a
free query, each mapped to its Cover Art Archive front thumb. One request;
tiles whose CAA art is missing self-hide client-side (front-250 404s). Lets a
cover be found even for a song with no metadata match (the city-pop pile)."""
q = (query or "").strip()
if not q:
return []
body = _mb_http_get("release-group", {"query": q, "limit": limit})
out: list[dict] = []
for rg in ((body or {}).get("release-groups") or []):
rid = rg.get("id")
if not rid:
continue
# artist-credit is a list of {name, joinphrase, artist} (joinphrase glues
# collaborations) — reconstruct the credited name.
artist = "".join(
(c.get("name", "") + c.get("joinphrase", "")) if isinstance(c, dict) else str(c)
for c in (rg.get("artist-credit") or [])
).strip()
title = rg.get("title") or ""
year = (rg.get("first-release-date") or "")[:4]
out.append({
"id": rid,
"label": " · ".join(x for x in (title, artist, year) if x) or title or "Cover",
"thumb_url": f"https://coverartarchive.org/release-group/{rid}/front-250",
})
return out
# ── AcoustID audio fingerprinting (content-based identification) ──────────────
# Optional path: requires the Chromaprint `fpcalc` binary AND an AcoustID API
# key ($ACOUSTID_API_KEY). Both absent ⇒ graceful no-op; the text matcher runs.
@@ -10765,9 +10708,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 +10727,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 +10984,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
@@ -12299,26 +12222,6 @@ async def get_song_art(filename: str, request: Request = None, source: str = "")
_ART_PICKER_MAX_CAA = 12
@app.get("/api/song/{filename:path}/art/cover-search")
def api_art_cover_search(filename: str, q: str = ""):
"""Search Cover Art Archive (via MusicBrainz release-groups) for album covers
powers the Change-cover picker's search box, so a cover can be found even
for a song with no metadata match (the unmatched city-pop pile, where
/art/candidates is empty). `q` defaults to the song's own artist + album/
title (romaji fallback applied). Read-only; the picker renders the thumbs and
applies a pick through the existing /art/url route."""
query = (q or "").strip()
if not query:
pack = meta_db.pack_fields(meta_db._canonical_song_filename(filename))
query = " ".join(x for x in (pack.get("artist"), pack.get("album") or pack.get("title")) if x).strip()
if not query:
return {"query": "", "covers": []}
try:
return {"query": query, "covers": _mb_search_release_groups(query, limit=8)}
except EnrichTransportError:
return {"query": query, "covers": [], "error": "unavailable"}
@app.get("/api/song/{filename:path}/art/candidates")
def get_song_art_candidates(filename: str):
"""Everything the cover picker can offer for one song, without fetching a
+11 -28
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) {
@@ -8100,26 +8100,6 @@ function _resolveEditRegion() {
return { a: Math.max(0, t - 4), b: t + 4 };
}
/* @pure:editor-pending-view:start */
function _buildEditorPendingViewPure(filename, arrangement, region, opts) {
const options = opts || {};
const view = {
filename,
arrangement: Number.isFinite(arrangement) && arrangement >= 0 ? arrangement : 0,
barSel: region ? { startTime: region.a, endTime: region.b } : null,
};
if (options.returnToHighway) view.returnToHighway = true;
if (typeof options.cursorTime === 'number') {
view.cursorTime = options.cursorTime;
} else if (region && typeof region.a === 'number') {
view.cursorTime = region.a;
}
if (typeof options.scrollX === 'number') view.scrollX = Math.max(0, options.scrollX);
if (typeof options.zoom === 'number' && options.zoom > 0) view.zoom = options.zoom;
return view;
}
/* @pure:editor-pending-view:end */
// Enable "Edit region" whenever the editor plugin is present and a song is
// loaded; show "↩ Editor" only while a return context is pending.
function _updateEditRegionBtn() {
@@ -8146,9 +8126,12 @@ function editRegionInEditor() {
arrangement = si.arrangement_index;
}
} catch (_) { /* default to 0 */ }
window._editorPendingView = _buildEditorPendingViewPure(currentFilename, arrangement, region, {
window._editorPendingView = {
filename: currentFilename,
arrangement,
barSel: { startTime: region.a, endTime: region.b },
returnToHighway: true,
});
};
window.editSong(currentFilename);
}
window.editRegionInEditor = editRegionInEditor;
@@ -8160,14 +8143,14 @@ function returnToEditorFromHighway() {
const ctx = window._highwayReturnCtx;
if (!ctx || typeof window.editSong !== 'function') return;
window._highwayReturnCtx = null;
const region = ctx.barSel
? { a: ctx.barSel.startTime, b: ctx.barSel.endTime }
: null;
window._editorPendingView = _buildEditorPendingViewPure(ctx.filename, ctx.arrangement, region, {
window._editorPendingView = {
filename: ctx.filename,
arrangement: ctx.arrangement,
scrollX: ctx.scrollX,
zoom: ctx.zoom,
cursorTime: ctx.cursorTime,
});
barSel: ctx.barSel,
};
window.editSong(ctx.filename);
}
window.returnToEditorFromHighway = returnToEditorFromHighway;
+10 -24
View File
@@ -2004,32 +2004,18 @@ function createHighway() {
const seedBase = (_frameIdx + n.s + ((n.t * 60) | 0)) | 0;
ctx.save();
ctx.fillStyle = col;
// 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);
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();
// 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';
+1 -56
View File
@@ -138,17 +138,6 @@
'<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + SKELETON_TILE + '</div>' +
'<div class="text-xs text-fb-textDim pt-2">Fetching covers… the source is rate-limited.</div>' +
'</div>' +
// Search Cover Art Archive — find an album cover even when the song has
// no match (the auto candidates above are empty then). Pre-filled from
// the song's artist + album/title; the source is rate-limited.
'<div class="space-y-2 pt-1">' +
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Search covers</div>' +
'<div class="flex gap-2">' +
'<input data-ip-search-input type="text" value="' + esc(_cur.query || '') + '" placeholder="artist album" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary">' +
'<button data-ip-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button>' +
'</div>' +
'<div data-ip-search-results class="flex flex-wrap gap-3"></div>' +
'</div>' +
'<div data-ip-status class="hidden text-xs text-fb-accent"></div>' +
'</div></div>' +
'<input type="file" accept="image/*" data-ip-file class="hidden">';
@@ -196,47 +185,9 @@
}
});
});
const searchInput = panel.querySelector('[data-ip-search-input]');
const runSearch = () => coverSearch(panel, (searchInput && searchInput.value) || '');
panel.querySelector('[data-ip-search-go]')?.addEventListener('click', runSearch);
searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); runSearch(); } });
panel.querySelector('[data-ip-close]')?.focus();
}
// Search Cover Art Archive (via the song-scoped cover-search endpoint) and
// render the album covers as pickable tiles — the same apply('url') path as
// the auto candidates. Covers with no CAA art self-hide (img onerror).
async function coverSearch(panel, query) {
const out = panel.querySelector('[data-ip-search-results]');
const fn = _cur && _cur.filename;
if (!out || !fn) return;
out.innerHTML = '<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + '</div>';
let body = null;
try {
const r = await fetch('/api/song/' + enc(fn) + '/art/cover-search?q=' + enc(String(query).trim()));
if (r.ok) body = await r.json();
} catch (_) { /* falls through to the empty state */ }
if (!_cur || _cur.filename !== fn) return; // closed / changed song while searching
const covers = (body && body.covers) || [];
if (!covers.length) {
out.innerHTML = '<div class="text-xs text-fb-textDim">' +
((body && body.error) ? 'Cover search is unavailable right now.' : 'No covers found — try a different search.') +
'</div>';
return;
}
out.innerHTML = covers.map((c, i) =>
tileHtml('data-ip-cover="' + i + '"', imgFace(c.thumb_url), c.label || 'Cover')).join('');
out.querySelectorAll('[data-ip-cover]').forEach((btn) => {
const img = btn.querySelector('img');
if (img) img.onerror = () => btn.classList.add('hidden'); // no CAA art for this album → hide
btn.addEventListener('click', () => {
if (_busy) return;
const c = covers[Number(btn.getAttribute('data-ip-cover'))];
if (c) apply('url', c.thumb_url);
});
});
}
// The one candidates fetch, cancelled if the modal closes first. Failure
// (offline, demo mode, aborted) is silent: the skeletons just clear and
// the instant tiles remain — never an error wall.
@@ -330,13 +281,7 @@
const filename = opts && opts.filename;
if (!filename) return;
_lastFocus = document.activeElement;
const title = (opts && opts.title) || filename;
const artist = (opts && opts.artist) || '';
const album = (opts && opts.album) || '';
// Pre-fill the cover search: "artist album" when the album is known, else
// just the artist, else the title — the server default backs it up.
const query = [artist, album].filter(Boolean).join(' ').trim() || title;
_cur = { filename: filename, title: title, query: query };
_cur = { filename: filename, title: (opts && opts.title) || filename };
_busy = false;
const m = ensureModal();
const panel = document.getElementById('v3-imgpick-panel');
+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>
+4 -11
View File
@@ -511,17 +511,10 @@
// then land on Details pre-filled for review.
async function useTheseValues(song, cand) {
if (!cand) return;
// Smart adopt for an English base: KEEP the readable name + title the card
// already shows (the author's romaji, e.g. "Junko Yagami / BAY CITY") — the
// match is often native script (kanji/kana). Take only what the pack lacks
// — album / year / genre — from the match; the pin below still brings the
// correct art + identity. The user can still edit any field.
song._pendingDetails = {
artist: String(song.artist || cand.artist || ''),
title: String(song.title || cand.title || ''),
album: String(cand.album || song.album || ''),
year: String(cand.year || song.year || ''),
genre: String((Array.isArray(cand.genres) && cand.genres[0]) || cand.genre || ''),
title: String(cand.title || ''), artist: String(cand.artist || ''),
album: String(cand.album || ''), year: String(cand.year || ''),
genre: (Array.isArray(cand.genres) && cand.genres[0]) ? String(cand.genres[0]) : String(cand.genre || ''),
};
try {
await post('/api/enrichment/review/' + enc(song.filename) + '/pick', { candidate: cand });
@@ -696,7 +689,7 @@
'</div>';
body.querySelector('[data-cover-open]')?.addEventListener('click', () => {
if (window.__fbOpenImagePicker) {
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
}
});
}
+13 -27
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: [],
};
}
}
@@ -515,22 +515,15 @@
done: ['bg-fb-good/90 text-black', '✓ Updated', ''],
nochange: ['bg-black/60 text-fb-textDim', '— No match', ''],
// Resting indicator: subtle, so a mostly-unmatched library isn't a
// wall of loud badges. Clickable — a one-click handoff into the
// Fix-metadata popup for this song (see the [data-meta-fix] wiring).
nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'Click to fix the metadata by hand'],
// wall of loud badges; points at the manual fix.
nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'No metadata match found — right-click to fix it by hand'],
};
const conf = M[st] || M.queued;
const fixable = st === 'nomatch'; // resting badge → opens Fix-metadata
// top-10 clears the tuning chip (top-2) in both normal and select mode;
// z-20 sits it above the art. Batch states are non-interactive; the
// resting "no match" badge is the handoff into the popup.
const cls = 'v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight ' +
(fixable ? 'pointer-events-auto cursor-pointer hover:bg-fb-primary hover:text-white transition-colors' : 'pointer-events-none');
return '<span class="' + cls + '"' +
(fixable ? ' data-meta-fix="1"' : '') +
(conf[2] ? ' title="' + conf[2] + '"' : '') +
'>' + conf[1] + '</span>';
// z-20 sits it above the art. Non-interactive so it never eats a click.
return '<span class="v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight pointer-events-none"' +
(conf[2] ? ' title="' + conf[2] + '"' : '') + '>' + conf[1] + '</span>';
}
// After a song is scored, the badge for that card is stale until the next
@@ -1007,7 +1000,7 @@
// the group's work_key/chart_count and pre-ticks the shown chart.)
if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; }
if (id === '__cover') {
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename, artist: playTarget.artist, album: playTarget.album });
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename });
return;
}
if (id === '__refreshmeta') {
@@ -1474,13 +1467,6 @@
e.stopPropagation();
openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song);
});
// "No match" badge → straight into the Fix-metadata popup for this
// song (the batch → fix handoff). stopPropagation so it doesn't also
// trigger the card's play. Follows the displayed chart, like the menu.
el.querySelector('[data-meta-fix]')?.addEventListener('click', (e) => {
e.stopPropagation();
if (window.__fbFixMatch) window.__fbFixMatch(playTarget);
});
// Artist line → the artist page (PR-B). In select mode the grid's
// capture-phase toggle intercepts first, so selection still wins.
el.querySelector('[data-v3-artist]')?.addEventListener('click', (e) => {
@@ -2821,7 +2807,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('')) +
@@ -3126,7 +3112,7 @@
// when image-picker.js isn't loaded.
artWrap.addEventListener('click', () => {
if (window.__fbOpenImagePicker) {
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
} else {
artFile.click();
}
-47
View File
@@ -1,47 +0,0 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
const m = src.match(/\/\* @pure:editor-pending-view:start \*\/[\s\S]*?\/\* @pure:editor-pending-view:end \*\//);
if (!m) throw new Error('pending-view helper block not found');
const api = new Function('"use strict";' + m[0] + '\nreturn { _buildEditorPendingViewPure };')();
test('edit-region handoff defaults cursor to region start and marks return path', () => {
const out = api._buildEditorPendingViewPure('song.sloppak', 2, { a: 12.5, b: 20 }, { returnToHighway: true });
assert.deepStrictEqual(out, {
filename: 'song.sloppak',
arrangement: 2,
barSel: { startTime: 12.5, endTime: 20 },
returnToHighway: true,
cursorTime: 12.5,
});
});
test('return-trip handoff preserves explicit viewport state', () => {
const out = api._buildEditorPendingViewPure('song.sloppak', 1, { a: 8, b: 14 }, {
scrollX: -4,
zoom: 160,
cursorTime: 9.25,
});
assert.deepStrictEqual(out, {
filename: 'song.sloppak',
arrangement: 1,
barSel: { startTime: 8, endTime: 14 },
cursorTime: 9.25,
scrollX: 0,
zoom: 160,
});
});
test('missing region still produces a stable pending view shell', () => {
const out = api._buildEditorPendingViewPure('song.sloppak', -1, null, {});
assert.deepStrictEqual(out, {
filename: 'song.sloppak',
arrangement: 0,
barSel: null,
});
});
-18
View File
@@ -233,24 +233,6 @@ def test_lock_only_genre_does_not_change_facet(server):
assert server.meta_db._effective_genre_expr() == "genre"
def test_romaji_fallback_for_blank_artist_pack(server):
fn = "CDLC/0 - City Pop/Junko-Yagami_BAY-CITY_v1_p.feedpak"
_put(server, fn, title="Junko-Yagami_BAY-CITY_v1_p", artist="") # scanner fell back to the filename
s = {x["filename"]: x for x in server.meta_db.query_page()[0]}[fn]
# the grid shows the author's romaji, not blank / the raw filename / kanji
assert s["artist"] == "Junko Yagami"
assert s["title"] == "BAY CITY"
# the Details baseline (pack_fields) matches, so the popup agrees with the grid
pack = server.meta_db.pack_fields(fn)
assert pack["artist"] == "Junko Yagami" and pack["title"] == "BAY CITY"
def test_romaji_fallback_left_alone_when_pack_has_artist(server):
_put(server, "a.archive", title="Real Title", artist="Real Artist")
s = {x["filename"]: x for x in server.meta_db.query_page()[0]}["a.archive"]
assert s["artist"] == "Real Artist" and s["title"] == "Real Title"
def test_title_keyset_paging_is_complete_with_overrides(client, server):
# Raw titles A/B/C → title-sort order is A, B, C on the RAW column.
_put(server, "b.archive", title="B")
-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.