mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 18:48:33 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
584add1493 | ||
|
|
cb72c5ab34 | ||
|
|
92e78be62d | ||
|
|
1840170e95 | ||
|
|
7cbf9824b1 | ||
|
|
33146cc7f6 | ||
|
|
69c8ad4e0c | ||
|
|
a20dca21bb | ||
|
|
021ee55f2a | ||
|
|
1f621e5fe5 |
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 0–1).
|
||||
- **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,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,
|
||||
|
||||
@@ -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 (C1–B1) 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 0–1 slider range. The camera fine-tune knobs
|
||||
// are multipliers/offsets centred on 1 (or 0), so they need headroom and a
|
||||
// floor a 0–1 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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
@@ -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('')) +
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user