Finding un-split songs took five taps (cycle each stem pill to its
"lacks" state) — and was quietly wrong even then: the drawer offered
five of the canonical six stems, so a piano-only song lacked all five
listed and matched a hand-built "not split" filter despite being split.
The stems section gains a "Not split" toggle that sets stem_lacks to
every instrument stem in one tap (the same lacks-ALL query Stem
Splitter's missing-stems view runs, backend semantics unchanged), and
piano joins the pill row (already in the backend's allowed set).
(Rebuilt on current main after #1003/#810/#92e78be rewrote the drawer
region — the original branch conflicted whole-file.)
Signed-off-by: topkoa <topkoa@gmail.com>
* feat(highway_3d): fret wires flash on a confirmed hit
The fret wires were static scenery: gold inside the anchor lane, grey
outside, and nothing tied them to what the player was actually doing.
Give them a job. Widen the lane/neck contrast so the wires around the
active lane read as a focus cue, and flash the wires bracketing a note
when a scorer confirms it. A fretted note lights the wire behind it and
the wire it is pressed against; a chord lights only the outermost wires
of its shape, so it reads as one bracketed block rather than a picket
fence; an open string has no fret of its own and its gem is drawn as a
slab spanning the lane, so it lights the lane's edge wires instead.
Gated on the provider verdict, never the proximity heuristic -- the
latter only means "near the strike line", so it would flash on every
passing note whether or not it was played. With no scorer attached the
neck behaves exactly as before.
Emissive (and emissiveIntensity) carry the flash, not albedo: these are
MeshStandard materials in a scene with no envMap, so raising albedo
alone barely brightens them.
Every value is a named constant -- see FRET_WIRE_* -- because the look
is a taste call that wants tuning by eye, not a derivation.
Signed-off-by: Kris Anderson <topkoa@gmail.com>
* feat(highway_3d): cap the fret-wire flash at one outer pair
Fast passages overlap their decay tails: consecutive notes on nearby
frets left three, four, five wires glowing at once — the picket fence
the chord rule was written to avoid, arriving through time instead of
through a shape.
The apply pass now decays every wire's glow state as before, but flashes
only the outermost pair of the lit span (or the single wire when only
one is above threshold). Interior wires keep decaying invisibly — the
base tier loop re-seeds their materials each frame — so the bracket
tightens naturally as the outer tails expire, and a hit inside the
current span widens nothing.
Net effect: at most two wires are ever lit, and everything currently
glowing reads as one bracket, exactly like a chord.
Signed-off-by: topkoa <topkoa@gmail.com>
* feat(highway_3d): chord flash frames the lane, not the shape
The lit lane strip spans the anchor's width (minimum ~4 frets), which
can run a fret past the chord's outermost fret. The chord flash
bracketed the shape (wire behind its lowest fret, wire at its highest),
so on those anchors the bracket sat one wire INSIDE the lit lane —
reading as misaligned rather than as a frame around what's lit.
Chord hits now light the anchor lane's edge wires: the exact wires the
lane strip itself spans, and the same pair open strings already use, so
every hit shape inside a lane produces the same bracket. The shape's
own outer pair survives only as the fallback for charts with no
anchors. Fretted and open intensities merge into one entry (they light
the same two wires now), and an all-open chord on an anchor-less chart
still degrades to no flash rather than a bad index.
Signed-off-by: topkoa <topkoa@gmail.com>
* feat(highway_3d): gem rims flash string-coloured, wire-fashion
On a confirmed hit the gem's outline now flashes in the STRING'S OWN
colour with the same intensity treatment as the fret wires — the
FRET_WIRE_HIT_INTENSITY emissive ramp, faded by the provider's alpha —
instead of the fixed spring-green mHitBright rim. Just the rims: the
lateral face fill keeps its existing green, and the sustain trail is
untouched.
Mechanics mirror the wires' pattern. mRimFlash[s] is one material per
string (created with the other per-string materials, palette-retint
aware, fog-exempt, disposed in teardown); drawNote() assigns it as the
outline on a good verdict and records the verdict alpha into a
per-frame per-string max (_rimFlashIn); the flash pass applies the
intensity ramp once per string. Shared-per-string is the same
compromise mGlow already makes — two same-string gems flashing in
different phases share the brighter alpha.
No decay tail of our own, deliberately: the material is only assigned
while the provider confirms the note, and the provider's alpha already
fades. When it goes silent the outline reverts, so idle intensity never
shows.
Signed-off-by: topkoa <topkoa@gmail.com>
* feat(highway_3d): wire flash is a lightning strike, not a lingering glow
The flash was instant-on with a 0.32 s exponential tail, and a held
sustain kept re-feeding it — wires stayed lit for the whole note. The
requested feel is a shock: light hits the frets, they jolt, it's over.
The flash is now a one-shot pulse triggered on the input's rising edge:
a near-instant crack up (RISE 25 ms), a fast fall (FALL 160 ms) shaped
(1-u)^2 so it drops hard then eases out, with a 26 Hz flicker biting
into the fall (the electric shudder — the crack itself stays clean),
then hard zero. A held 'active' verdict keeps the input high
continuously, which by construction triggers nothing new: one strike
per hit, and the wires go dark while the note rings on. A re-strike
after the provider goes silent re-triggers cleanly.
Seeking backward or a long stall clears all pulse state, and a pulse
whose strike time lands ahead of the playhead after a seek is
discarded. The outer-pair bracket rule is unchanged — it now selects
across pulses instead of decay tails.
Knobs: FRET_WIRE_HIT_RISE / _FALL / _FLICKER_HZ / _FLICKER_DEPTH
(replacing FRET_WIRE_HIT_DECAY).
Signed-off-by: topkoa <topkoa@gmail.com>
* fix(highway_3d): one wire strike per judged hit, not per wire edge
The strike trigger was a rising edge on each WIRE's input, which merged
distinct hits: two consecutive correct notes on the same fret kept that
wire's input continuously high, so the second note produced no strike at
all. The wires must respond to what the player did — one strike per
judged hit-zone event.
The trigger is now per event identity, using the same seen-map pattern
as _sparkSeen: the first frame a note gets a good verdict its key
(string|fret|time — or the chord key for a strum, which strikes once as
a unit) lands in _fwStruck and requests a strike on its wires; the
event never fires again however long its verdict stays live. Because
every producer is gated, any nonzero input in the apply pass IS a fresh
strike, so it restarts a pulse already in flight — a rapid re-hit on
the same wire re-cracks instead of being swallowed.
Seeks clear the map (replayed notes strike again); it is size-bounded
like _sparkSeen. Envelope, flicker, and the outer-pair rule unchanged.
Signed-off-by: topkoa <topkoa@gmail.com>
* Revert the lightning-strike experiment — back to the decaying glow
Reverts d003532 and e05d90e. The wire flash returns to its original behaviour: instant-on at the provider's alpha with a smooth exponential fade (FRET_WIRE_HIT_DECAY 0.32 s), held sustains keep their wires lit while the note rings, and no flicker. The outer-pair bracket, lane-framed chords, and string-coloured gem rims are untouched.
Signed-off-by: topkoa <topkoa@gmail.com>
* test: wire-tier assertions follow the named constants
The two render-order tests pinned the old literal hexes (idle 0x666688). The tiers moved to named constants with a retuned idle (FRET_WIRE_IDLE_HEX 0x4A4A60); the tests now assert the code uses the constants AND pin the constants' values, so a future retune is a deliberate two-line change here rather than a silent one.
Signed-off-by: topkoa <topkoa@gmail.com>
* fix(highway_3d): clamp provider alpha in the rim-flash path (review)
The wire-flash path clamps the note-state provider's alpha to 0..1; the rim-flash accumulation used it raw, so a provider returning >1 would over-drive emissiveIntensity. Clamped to match.
Signed-off-by: topkoa <topkoa@gmail.com>
* test: add fret inlay dots (renderOrder 3) to the hierarchy header (review)
Signed-off-by: topkoa <topkoa@gmail.com>
* test: accurate depth-flag claims, anchored depth assertions (review)
Two review findings on the render-order test file, both correct:
The header claimed ALL 3D-highway materials use depthTest:false, making
renderOrder "the only" draw-order control — but the accent halo
materials set depthTest:true. Now says "nearly all", names the
exception, and calls renderOrder the primary control. A header someone
trusts mid-debug must not overclaim.
The fret-wire depthTest/depthWrite assertions matched anywhere in
screen.js, which is full of other depthTest:false materials — the test
would keep passing if the wire material dropped the flags. Both are now
anchored to the wire material literal via FRET_WIRE_IDLE_HEX (unique to
it), as two separate anchored matches so property order inside the
literal still isn't pinned.
Signed-off-by: topkoa <topkoa@gmail.com>
---------
Signed-off-by: Kris Anderson <topkoa@gmail.com>
Signed-off-by: topkoa <topkoa@gmail.com>
The lane maps chart time to z exactly as notes do, over the window
[now - BEHIND, now + AHEAD]. That puts its near edge at +TS*BEHIND — BEHIND
seconds PAST the hit line, toward the player. Nothing is ever drawn there:
drawNote and the chord frames both clamp to Math.min(0, dZ(dt)), so notes stop
dead at z = 0. The overhang was therefore lane surface with nothing on it.
Clamp the floor geometry's near edge to the hit line. The far edge is
deliberately untouched — it still lands at -AHEAD*TS, aligned with the note
horizon, which is why the span stays AHEAD+BEHIND in the sliced path and the
clamp is applied per slice (a slice entirely past the line collapses to zero
length and is skipped before the arpeggio probe, so it costs nothing).
All four floor sites move together — the sliced lane (which also feeds both
divider loops), the fallback lane, its dividers, and the fret boundary
extension lines. They shared the identical `+ TS * BEHIND` shift; fixing only
some would leave fret lines poking past a lane that now stops.
Closes#991
Signed-off-by: Kris Anderson <topkoa@gmail.com>
* fix: remote transcription posts to /transcribe, not /align (stem-splitter#17)
transcribe_vocals_remote() POSTed the vocal stem to /align. That endpoint is FORCED ALIGNMENT —
"here are the lyrics, tell me when each word is sung" — and its `text` field is required. We have
no lyrics; transcribing them is the entire point. So the server rejected every request with a 422
from FastAPI's validation layer, before its handler ever ran, and remote transcription has never
worked for anyone.
It now posts to /transcribe (added in feedBack-demucs-server#14), which takes only the audio.
`language` moves from the query string to the FORM BODY, where the server actually reads it
(Form("")). As a query param it was silently ignored, so an explicit hint did nothing and
Whisper's auto-detection quietly decided instead — loading the wrong wav2vec2 aligner. It
"worked", it was just wrong, which is the failure mode that hides for months.
Error bodies are no longer cut at 300 chars. The body IS the diagnosis: a 422's JSON names the
field it rejected, a 500's traceback answers on its LAST line. Both got decapitated — which is
part of why this stayed invisible for so long. The message explaining the bug was inside the part
that got cut.
Nothing caught any of this because every test of this module tested the MAPPER, fed a hand-written
dict. The mapper was always fine. The request was never exercised, and the request was the bug.
tests/test_lyrics_transcribe_remote.py now pins it: the endpoint, the form field, the multipart
upload, the bearer token, an instrumental returning no lyrics rather than an error, and a 404
saying the server is too old. Verified they FAIL against /align + params.
Signed-off-by: topkoa <topkoa@gmail.com>
* fix: make the error-body cap an actual bound; correct the docstring's endpoint
- _err_body() appended the truncation marker AFTER slicing to _MAX_ERR_BODY, so the result could
exceed the cap it exists to enforce (4014 chars for a 4000 bound). A cap that is only a
suggestion surprises exactly the callers who trust it — a log line, a job record persisted to
disk and re-read on every load. The marker now fits inside the bound.
It also stripped after measuring, so a short JSON body followed by kilobytes of trailing
whitespace got truncated: real content cut to make room for blanks. Strip first, then measure.
- The public docstring still advertised /align — the exact contract this PR exists to change, in
the one place a reader would look for it. It now says what the function does and why, and that
an older server answers 404.
Found by Copilot and CodeRabbit on #959.
Signed-off-by: topkoa <topkoa@gmail.com>
* fix: keep the exception line when truncating — the tail is the answer
_err_body() kept only the HEAD of an over-long body. On a traceback the last line is the
diagnosis, and the docstring said exactly that while the code threw it away: a 4000-char window
holding "Traceback (most recent call last)" and none of the exception is a window onto nothing.
Same mistake as the 300-char cap it replaced, one level up — cutting off precisely the part the
function exists to preserve.
Head AND tail now, both inside the bound: two thirds head (what was being attempted), one third
tail (what actually went wrong), with the marker between them. Verified the test FAILS against
head-only truncation.
Found by Copilot on #959.
Signed-off-by: topkoa <topkoa@gmail.com>
* fix: every failure out of transcribe_vocals_remote() is a RuntimeError; 404 says why
The docstring promised one failure mode — RuntimeError — and the caller (_maybe_transcribe_lyrics)
catches exactly that so one song's failed lyrics don't take down the batch around it. But a DNS
failure, a timeout, a reset connection or an unreadable stem escaped as requests.RequestException
or OSError, walked straight past that handler, and turned "this song's lyrics failed" into "the
whole batch died".
A 404 now explains itself. Bare "404" sends someone hunting for a typo in their server URL; the
real answer is that their server predates /transcribe, and we are the only ones in a position to
know that.
Found by Copilot on #959.
Signed-off-by: topkoa <topkoa@gmail.com>
---------
Signed-off-by: topkoa <topkoa@gmail.com>
The guitar/bass highway_3d renderer self-detects panel-canvas size changes
in its draw() loop and re-runs applySize() every frame, because the
splitscreen host overrides hw.resize and never calls renderer.resize().
The drum and keys highways lacked that fallback — they only re-framed when
the host explicitly called resize(w, h) — so their panels stayed framed for
the pre-fullscreen size while the guitar/bass panels adapted. Symptom: a
too-small, off-center highway in the drum/keys panels after maximizing a
split-screen session.
Port highway_3d's per-frame drift check into both draw() loops: re-apply on
backing-store change (canvas.width/height) AND on CSS-box drift
(clientWidth/clientHeight vs the last applied logical size, throttled to
every 10th frame). Track _lastHwW/_lastHwH + _appliedW/_appliedH per
instance and reset them in destroy() so a reused instance re-frames on the
next song.
plugins/drum_highway_3d -> 0.3.1, plugins/keys_highway_3d -> 0.1.1.
Tests: tests/js/drum_keys_highway_3d_resize_reframe.test.js.
Signed-off-by: Kris Anderson <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR #530 set accept=".feedpak,.sloppak" on the shared upload input, but a
later index.html edit reverted the attribute to ".sloppak" only. The
client-side extension filter (app.js) and the server upload endpoint both
accept .feedpak, so the only effect was the OS file-picker hiding .feedpak
files from the dialog. Restore the dual filter.
Signed-off-by: Kris Anderson <topkoa@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Add aspect-aware framing for ultra-wide highway panes
On a top/bottom 2-player split each 3D highway pane is full-width /
half-height (~32:9). The camera's vertical FOV was locked at a single
value, so at that aspect the horizontal cone ballooned past 130deg and
squeezed the fixed-width neck into a thin central sliver with large dead
margins on either side.
Add a "horizontal-FOV-hold" path: past a configurable start aspect the
effective vertical FOV is lowered so the horizontal cone stays roughly
constant, letting the neck fill a wide pane. At/under the start aspect it
is an exact no-op, so normal ~16:9 single-player and most 2x2 panes are
unchanged. Optional pose nudges (height / dolly / pitch / look-depth)
further flatten the view toward a low, immersive angle.
Everything is driven by a runtime bridge (window.__h3dAspectTune) with a
live tuner panel (Shift+A in the player) exposing every knob plus a live
aspect/FOV readout, localStorage persistence, and a Copy button. Toggling
the feature off restores the exact prior framing, so it doubles as an A/B
control. Shipped on by default for wide panes for testing feedback.
Source-pinned by tests/js/highway_3d_wide_fov.test.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
* fix(highway_3d): ship wide-pane framing default-OFF with a coherent config
Review fixes for the aspect-aware framing. The first cut shipped
_ASPECT_DEFAULTS = { enabled:true, baseVfov:30, blend:0, minVfovDeg:36 },
which contradicted the PR's own "default off → byte-for-byte prior behaviour"
claim:
- enabled:true made the tune active for everyone, and baseVfov:30 forced every
pane's vertical fov from 70° to 30° (normal single-player/2x2 panes included —
a drastic global zoom, not the advertised no-op).
- blend:0 collapsed the Hor+ math back to base, so the actual horizontal-FOV-
hold did nothing even on wide panes — the only net effect was the zoom.
- minVfovDeg:36 > baseVfov:30 was an inverted floor (clamped wide panes UP to
36° rather than flooring a real reduction).
New defaults: { enabled:false, baseVfov:BASE_VFOV(70), blend:1,
minVfovDeg:HORPLUS_MIN_VFOV(28) }. Now:
- OFF by default → camUpdate passes a null tune → effectiveVfov returns
BASE_VFOV → exact no-op on every pane (verified: 70° at 16:9 and 32:9).
- When a tester enables it (Shift+A), baseVfov==BASE_VFOV keeps normal/≤start
panes at 70° (still a no-op there) and blend:1 makes the hold actually engage
on genuinely wide panes (47.7° at 32:9, flooring toward 28° as aspect grows).
- minVfovDeg < baseVfov is a real floor.
Also bumps the localStorage key (h3d_aspect_tune → h3d_aspect_tune2) so a
machine that persisted the old broken default gets the corrected one, and adds
source-pin tests guarding default-off + the coherent base/blend/floor so this
can't silently regress to default-on again. The pose-nudge values are left as
the author's in-progress wide-pane look (dormant until enabled). 110/110 tests
pass; node --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
The persistent top-right "Up Next" pill showed the upcoming section name
and a countdown ("in 12.3s") but no at-a-glance sense of how far through
the current section the song is. Add a thin progress bar directly under
the existing text that fills as the current section elapses toward the
next, reaching full when the section flips.
The text row is wrapped unchanged in a flex row and the pill stacks the
bar beneath it; nothing else about the pill's content or styling changes.
Progress is computed in updateUpNext() as the fraction elapsed between the
previous section boundary (last section at/before now, else song start)
and the next section. The fill uses the same gradient as the section name
for visual cohesion.
Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Add "Edit region" + Loop-in-3D handoff between player and Song Editor
Wires the player half of the Editor ⇄ 3D Highway region round-trip
(editor half is in feedback-plugin-editor).
Highway → Editor:
- New "✎ Edit region" button in the loop controls (v2 and v3) opens the
Song Editor scrolled to the active A–B loop — or, when none is set, the
section under the playhead (or a short window around it).
- A "↩ Editor" button appears after a Loop-in-3D handoff to return to the
exact edit position you came from.
- Both are hidden unless the editor plugin is loaded (typeof
window.editSong) and gated by _updateEditRegionBtn.
Editor → Highway:
- A one-shot song:ready listener consumes window._pendingHighwayLoop set
by the editor's "Loop in 3D" button — after playSong()'s own clearLoop()
has run — arming setLoop(a,b) over the region and auto-starting playback.
Filename-guarded so a cancelled handoff can't arm a stale loop on an
unrelated song.
Reuses the existing A/B loop API; no new looping engine. Buttons added to
both static/index.html (v2) and static/v3/index.html (separate file —
v2 markup doesn't carry over), using already-scanned Tailwind classes.
New globals editRegionInEditor / returnToEditorFromHighway; helpers
_resolveEditRegion / _updateEditRegionBtn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
* fix(loop-in-3d): use canonical window.feedBack namespace (post-#537)
The new song:ready loop-applier landed on the legacy window.slopsmith
alias because the branch predated the slopsmith->feedBack rename (#537).
Normalize it to window.feedBack like the rest of core; the alias would
have worked but leaves the lone slopsmith reference in the file.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
* feat: save songs as .feedpak; discover and load both .feedpak and .sloppak
The open song format was renamed sloppak -> feedpak (public spec lives in
the feedback-feedpak-spec repo), but the server still wrote and recognized
only `.sloppak`. The two are byte-identical on disk.
Read both suffixes everywhere songs are discovered, uploaded, and loaded;
writing the new `.feedpak` suffix is handled in the editor plugin repo. Keep
the internal `format` tag `sloppak` so existing feature gates (stems, drums,
keys) are untouched, matching the "internal rename not landed yet" stance.
- lib/sloppak.py: add FEEDPAK_EXT / SLOPPAK_EXT / SONG_EXTS; is_sloppak()
now matches either suffix (covers all 7 callers).
- server.py: union scan glob over SONG_EXTS; widen loose-folder exclusion,
settings DLC count, upload gate (_ALLOWED_SONG_EXTS) and zip-magic check;
refresh user-facing messages to .feedpak.
- static: library format filter relabeled Sloppak -> Feedpak (value stays
sloppak, matches both); badge text SLOPPAK -> FEEDPAK in v2 + v3;
filename-suffix detection and upload drag-drop filter accept both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
* test: cover .feedpak/.sloppak dual-suffix support
Add tests/test_feedpak_extension.py pinning the four paths PR #553
widened so a refactor can't drop .sloppak back-compat or stop
accepting .feedpak:
- is_sloppak / SONG_EXTS suffix detection (file + dir form, case-insensitive)
- _background_scan discovery glob unions over both suffixes
- POST /api/songs/upload accepts both, rejects wrong suffix + non-zip
- save_settings DLC count includes both suffixes
19 tests, all passing; reuses the existing scan_module / TestClient /
isolate_logging fixtures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
The Arrangement Editor plugin was only reachable via the generic Plugins
gallery. Give it a dedicated sidebar entry (Library group, below Songs)
through the existing PROMOTED_PLUGINS mechanism in shell.js — a NAV entry,
a promoted slot anchored after "songs", and an edit icon.
renderPromotedNav already gates each promoted slot on the plugin being
present in /api/plugins, so the entry shows only when the editor is
installed. The displayed label comes from the plugin manifest's nav.label.
Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>