- TypeScript 91.9%
- CSS 3.8%
- JavaScript 2.6%
- Nix 0.8%
- HTML 0.4%
- Other 0.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
`@bjork` was two keys: `/` to ask for the caret, then `@`, then the name. But the sigils are not commands — `/` and `:` are, and they are precisely the keys that cancel themselves, because a command's glyph is not content. A sigil is the first character of the thing being typed, so those two now arm the search half the way `/` arms it and then do nothing: the keystroke that opened the field is the one carrying the `@` into it, exactly like every other character the reader types. An earlier version cancelled the key and wrote the character itself, which is a race the page cannot win. The cancel is issued while the keystroke's target is still the page, the caret moves into the field inside the same handler, and the browser is the one that knows whether that press had a character. When it decided it did, the box came up `@@` while the store held one `@` — a field whose text and whose needle had started to disagree about who was writing them. `/` has never had that problem, because `/` has no character to lose. `armSearch`'s `select()` turns out to be load-bearing rather than defensive here: the needle the box was holding — an accepted `amber` from ten minutes ago — comes up selected, and the arriving character lands on top of it. One question in, the previous one out, with no write from the key handler at all. That puts back the invariant the file already claims: the window writes the needle for `/` alone, and every other character of it arrives through the field's own `onInput`. The jsdom tests assert the arming and then do the browser's half, a keydown with no default action typing nothing; the behaviour itself was checked against Chrome over CDP, because both of the failures this replaces were invisible without a browser that actually types. `/` and `:` are byte-identical, and the two sigils typed with the caret already in the field stay characters — which is what keeps `bjork @` and `#1 hit` typeable. |
||
| docs/research | ||
| nix | ||
| public | ||
| scripts | ||
| src | ||
| .envrc | ||
| .gitignore | ||
| .oxfmtrc.json | ||
| AGENTS.md | ||
| file-routes.d.ts | ||
| flake.lock | ||
| flake.nix | ||
| oxlint.config.ts | ||
| package.json | ||
| PLAN-loop-detect.md | ||
| PLAN-loop-on-the-beat.md | ||
| PLAN-loop-points.md | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| RESEARCH-frontier-loop-points.md | ||
| RESEARCH-loops-to-the-beat.md | ||
| tsconfig.json | ||
| vite.config.ts | ||
| vitest-setup.ts | ||
hackermusic
A heavily keyboard-driven, highly optimized music player for Subsonic / Navidrome-based music servers. The UX is designed to be operated without a mouse: search, the list cursor, view switching, random playback and the transport are all on hotkeys (see Keys), and performance is a first-class goal: fast startup, snappy interactions, minimal memory/battery footprint, and efficient use of the Subsonic API (caching, prefetching, no wasted round-trips).
Honest caveat on "without a mouse": the mouse is still how you scrub the progress slider finely, and how you look at a cover. The slider deliberately takes no focus — it is out of the tab order and hands focus straight back — so the keyboard does not steer it; the number row is the keyboard's route to the same place, one tenth of the track per key. Clicking the artwork in the player bar blows it up to fill the window (Esc, a click anywhere, or the × puts it away); nothing else in the table below is anything but keyboard.
Built with SolidJS 2.0 (RC) — client-side rendered via Vite, with filesystem routing, TypeScript 7 (native compiler), and the oxc toolchain.
Stack
- solid-js
2.0.0-rc.x— Solid 2.0 release candidate (@solidjs/web,@solidjs/routerv2) - Vite 8 +
@solidjs/vite-plugin(turnkey client mode, diagnostics on) - filesystem-routing — file-based routes in
src/routes/ - TypeScript 7 — Go-native compiler,
tsc --noEmitfor typechecking - oxlint — linter (config in
oxlint.config.ts, with the Solid v2 ruleset) - oxfmt — formatter (config in
.oxfmtrc.json) - vitest +
@solidjs/testing-library— tests - valibot — runtime shape for the handful of values the app reads but did not write (~1.7 kB gz); see Boundaries
- colourthief — the OKLCH palette of the current cover, which the accent is chosen from (~7 kB gz, loaded with the first cover rather than at startup); see The accent
- pnpm — package manager
Development
Version control is jj (Jujutsu) backed by git
(colocated), with main as the bookmark. The main commit is the one you
work on top of:
jj new main # start a change off main
jj describe -m "..." # set the change description
jj bookmark set main -r @- # advance main after squashing/abandoning
jj log
This repo uses a Nix flake for its dev environment. With
direnv, just cd in (direnv allow the first time).
Without direnv, prefix commands with nix develop -c:
nix develop # or: direnv allow
pnpm install
pnpm dev # dev server on :3000, bound to the tailnet address
The dev server binds an address rather than every interface: it listens on the
100.64/10 address tailscale gave this machine, so another device in the tailnet can
open it — and a phone is the only way to know whether a keyboard app works
alongside a phone-sized screen — while the LAN never sees it. With no tailnet
address it falls back to the loopback default, and DEV_HOST answers the whole
question: DEV_HOST=127.0.0.1 pins it local-only, DEV_HOST=true takes every
interface when the device being debugged is on neither. Requests that name a Host
Vite was not told to expect are refused with a 403; tailscale addresses need no
permission because they are addresses, and names have to be named: .ts.net
(MagicDNS) and dev.hack.computa are listed in vite.config.ts, and
DEV_ALLOWED_HOSTS=a.computa,b.computa adds more — a leading dot on a value
allows that whole suffix.
The devshell provides the latest Node.js, pnpm, oxlint, oxfmt, git, and
jujutsu (jj). pnpm run scripts use the
project-local toolchain in node_modules, which is pinned in
package.json. Outside the devshell pnpm is not on PATH — run tools
straight out of node_modules/.bin there, e.g.
./node_modules/.bin/vitest --run.
Scripts
| Command | What it does |
|---|---|
pnpm dev |
Vite dev server on :3000, bound to the tailnet address |
pnpm build |
Production build to dist/client/ (plus the dist/server/ entry) |
pnpm preview |
Serve that build on http://localhost:4173 — run build first |
pnpm test |
vitest in watch mode |
pnpm test:run |
vitest, one pass, no watch (what CI wants) |
pnpm lint |
oxlint (lint:fix to apply fixes) |
pnpm fmt |
Format with oxfmt (fmt:check to verify without writing) |
pnpm typecheck |
tsc --noEmit (TypeScript 7) |
pnpm start is an alias of dev, kept for muscle memory.
With diagnostics: true in vite.config.ts, a connected dev page also
exposes GET /__solid/diagnostics and POST /__solid/diagnostics on the dev
server — the reactive cost tables and "why did this scope re-run" answers that
AGENTS.md tells you to capture instead of guessing.
Watching playback
A track that will not start is a failure of silence. Every stage of a load is a promise that
has not settled — the server's response, the response's body, the browser's decoder — and a
promise that has not settled throws nothing, so the console stays clean while the bar says
loading and the clock stays at zero. src/lib/player/trace.ts therefore says something at
each stage before awaiting it, so that the absence of the next line is the answer.
It is on in dev and silent under the tests, where a chatty probe sits on top of the failure someone is reading. The switch is in the console, which is where the question gets asked — usually on a phone, over the tailnet, while the track is still hanging:
hackermusic.playbackTrace(); // is it printing?
hackermusic.playbackTrace(true); // on, and remembered across the reload
hackermusic.playbackTrace(false); // off
What a load leaves behind, in order:
12:04:33.512 [player] s1 +0.0s · play (at=index 0/12 offset=0.00 bytes=not yet)
12:04:33.514 [player] s1 +0.0s · loading, from 0.00s (no bytes yet)
12:04:33.515 [player] s1 +0.0s · asking for the original, then flac, ogg, mp3 (container=alac try=1/4)
12:04:37.520 [player] s1 +4.0s · still waiting: the response has not arrived (4.0s after the request)
12:04:39.102 [player] s1 +5.6s · served 200 (type=audio/flac length=4182532)
12:04:39.330 [player] s1 +5.8s · decoded 187.5s of audio in 228ms (channels=2 rate=44100)
12:04:39.331 [player] · source armed on the audio clock (from=0.00 at=12.18 lead=0.08 stopAt=the end of the bytes)
12:04:39.332 [player] s1 +5.8s · playing (offset=0.00 of=187.50 context=running armedNext=s2)
Read it by finding the last line, because the line that should have followed it is the
verdict. still waiting: the response has not arrived and repeating is a server or a
network; the same sentence about the browser's decoder is a file the browser will not
finish decoding, which no amount of retrying fixes. A joining the load already in flight, asked for 41200ms ago says the bytes were never being fetched for this press at all — they
were being waited for from a prefetch that started three tracks ago, and it never came back.
Two lines name failures the state machine cannot express, since both leave it technically
correct. the AudioContext is still suspended — it is waiting for a gesture, not for this code is the track that says playing, has a source armed, and makes no sound: nothing is
wrong in here, the browser has not been given a tap. And a lead of -0.42 on the armed
source means the browser was asked to start a source in the past, which it does immediately —
that is a seam the main thread arrived too late to arm ahead of.
The waits are phrased in the present tense on purpose. Failed to fetch would be a claim about an event; the useful thing is which promise the code is sitting in front of right now, whether it has been sitting there long, and whether the sentence changed between one stall line and the next — which is how a slow download is told apart from a decode that has eaten the page. Off, the trace prints nothing and builds nothing: the detail of every line is a function the printer calls or does not, so no line reads a response, a buffer, or a store field behind your back. That is also why the tests exercise it with the switch explicitly thrown — a gate that has never been opened in anger is only assumed to work.
Where did the four seconds go?
The trace above answers what is this load waiting on, which is the question a hung track asks. A track that loads successfully but slowly asks a different one, and a list of lines cannot answer it: the stages of a load are not a list. The cover's palette is fetched while the stream is still arriving, the waveform fold runs between the decode and the first sample, and the loop detector's search sits on another thread for exactly as long as the main one draws the row. Read as lines, that is a timetable with the overlaps flattened away.
:timeaudit draws it instead. One press is one picture — every interval measured while that
track was being loaded, on one axis, in lanes, with the moments that have a time but no duration
marked where they happened: the press, the request leaving, the first sample reaching the ear. Two
such moments are often a millisecond apart, so their labels queue into rows beneath the chart while
their lines stay exactly where they were — the position is the measurement, the text is its caption:
:s1 · Second Scrape · play · 4.82s across 14 spans
0.00ms 4.80s 99% network ask the original (cameFrom=network)
0.06ms 0.44s 9% network │ server response (status=200 length=4182532)
0.50s 1.10s 23% network │ body (bytes=4182532)
0.50s 0.90s 19% accent accent from the cover (accent=#865b3a)
0.50s 0.04s 1% network │ cover pixels (swatches=24 size=300)
1.62s 0.23s 5% decode browser decoder (channels=2 rate=44100 seconds=187.5)
1.86s 0.31s 6% analysis loop & tempo detection (bpm=87.4 candidates=2)
2.10s 0.42s 9% waveform waveform fold (slices=48 reports=3)
hackermusic.timeAudit() prints the same tree as text, ("all") prints every recorded load that
way, and ("clear") forgets them; timeAudit(false) stops measuring, which costs the load path a
boolean and a call rather than the clock reads and the bookkeeping it saves. It records in the
production build as well as in dev, unlike the traces — the reader here is a keyboard shortcut on a
phone over the tailnet, and a diagnostic that only exists in the dev build cannot describe the build
in use.
What each colour is, and what a gap means, are both in the footer of the graph. Three things worth knowing before reading one:
- Every bar is an interval some function opened and closed around its own wait. Nothing is sampled or inferred, so a stretch with no bar in it is a stretch of the load that no code claimed — which is a finding rather than a missing sample.
- Bars sit beside each other when the work does. The accent's effect, the fold's pump and the
detector's search are all children of the track rather than of the fetch, so they get their own
lanes rather than being flattened under the codec. A dashed edge means the work happened on a
worker; the axis is wall-clock either way — and the beat model reports its phases back from that
thread, so the longest bar in a load arrives filled in: the runtime, the weights, the graph
optimisation, then one bar per window of the arithmetic, each where the thread said it happened —
and
resamplesays how much of the track it was given beside how long the track was, because the model is asked about the first three minutes rather than the whole of a five-minute one. A bar that endsoutcome=droppedis the page telling the thread that the listener moved on and the answer is no longer wanted — not a failure, and not the model's fault. The search itself appears twice on a track with a model waiting: once without the grid — which is why a loop can be on screen in two seconds rather than nine — and once snapped to it, withearly=<ms>on the detection's bar recording when the listener was first told. :timeaudit savewrites the whole session out as a file —hackermusic.timeAudit("export")does the same from the console, andjsonputs the text on the clipboard when the origin allows one. It is the records rather than a picture of them, with the machine they were measured on beside them (cores, whether there was a GPU to ask for, whether the page was isolated enough for threaded wasm), which is what to send when the question is being asked by somebody who was not in the room.- A thin bar can be hiding a hundred. Bars too narrow to draw are folded rather than squeezed, the header says how many, and the totals along the bottom still count their time — because a waveform fold is thirty thin bars, and the question is always whether that was the slow part.
The graph follows the track on the bar. ←/→ walk the loads recorded before it — usually what you
want, since the interrupted load is the one whose shape changed — z undoes a zoom, and
esc leaves.
What it cannot do is tell you about a load that has already left the buffer: sixteen are
kept, oldest
first out.
What an http origin costs
The export's device block answers this before a single bar is read: "secure": false is the whole
diagnosis of a load that is slower than the machine it ran on. Three APIs this app asks for are
gated on a secure context — caches, navigator.gpu and AudioDecoder — and a page opened at a
LAN or tailnet hostname over plain http has none of them, quietly and all at once:
- No disk tier. Every load's
write through to diskbar endsabandoned=no disk tier to write to, the bytes live only in memory, and a reload — or five tracks' worth of decoded-buffer eviction, which is a Tuesday for a record of long mixes — buys the same 30 MB download and the same 12-second decode again. The lineno browser cache tier here — Cache Storage needs a secure contextis the one thing the page prints about it, once. - No GPU for the beat model. ONNX is offered
webgpuand answersrefused=… WebGPU is not supported in current environment, so the tracker runs on wasm — and on one thread, becauseisolated: falsemeans noSharedArrayBuffereither. That report's model bars were 18 s to 34 s per mix on a twelve-core desktop, and the grid they produce is what the snapped loop is measured against. - No streaming decode.
AudioDecoderis the only chunk-at-a-time decoder in a browser. Without it the whole file goes todecodeAudioDatain one call and nothing — not the waveform, not the model's first three minutes, not the fingerprint — may begin until the last sample is out. That is the 11 seconds the decode bar is on, anddocs/research/streaming-decode.mdis the plan for the day it is worth paying for.
The fix is the origin rather than the code: serve it over https (see Serving it), or through
localhost/127.0.0.1, or tell Chrome to treat the development hostname as secure —
chrome://flags/#unsafely-treat-insecure-origin-as-secure with http://hm.mission written in the
box. The longest bars in that report are downstream of one certificate.
Why did it say not sure?
The loop detector has its own channel, on whenever the trace is:
[detect] s1 · fingerprinted (seconds=239.9 frames=5167 audible=3801 kept=74% frameSec=0.0460 binHz=10.77)
[detect] s1 · measured (frames=5167 audible=3801 kept=74% weightSpread=2.31 lags=3739 cells=9232456 periods=174–3913 best=0.993 tail=0.788 tau=0.930 floor=0.93 cleared=1 trim=true trimGate=0.8 trimGapSec=0.75 trimmed=1 body=30.6–127.3s smoothSec=0.5)
[detect] s1 · offered 1 candidate (periods=24.00s@19.1, lengths=23.71s, scores=0.993)
Two numbers decide it, and the line prints both. The search scores every period the track
can hold two copies of — every lag, every position, exactly, at a cost of a couple of hundred
milliseconds on a worker core — by comparing a region against the thing that is supposed to be
its repeat, over the whole region rather than a window inside it. best is the strongest such
agreement anywhere in the file; tau is the bar it had to clear, which is the larger of floor
(an absolute "this is a copy of something") and tail (this file's own ninety-eighth percentile
of lags, so that a track which resembles itself everywhere has to produce something better than
the best thing in it). A refusal names which half refused: below floor is no-repeat, above
floor and below tau is not-convincing.
What is being compared is a chroma vector per frame — the twelve pitch classes, tilted for the bass's natural loudness, de-weighted where this track over-uses a class, blurred over a quarter of a second — and the dot product of two such vectors. The blur is load-bearing: a twenty-four-second period is 516.29 hops at a 46 ms grid, so two "identical" copies are otherwise compared 13 ms out of true, which is enough to lose a real repeat to a track with nothing in it. Measured on the fixtures, with it: the four that repeat score 0.985–0.999 at their own period and are rank 0 of every lag tried; the two that do not top out at 0.858 and 0.880. Without it, the best true repeat scored 0.675 and the noise reached 0.806 — no threshold separates that, which is the whole reason the previous implementation could not have one.
Read lags and cells before arguing with a refusal. A search that scanned 3739 lags over nine
million frame-pairs looked hard and did not like what it found; that is a threshold to argue
with. A search that scanned thirty is not an opinion about the music — the mask ate the track
(kept), or the chroma never changes (tail at the ceiling with weightSpread near 1 is a drone)
— and lowering the bar would only make it recommend noise. The refusals that never reach a score
say which of those it was, in the line itself.
And because the thresholds were chosen against synthesised material — in which a repeated body is pasted byte for byte, and a real recording's repeat is the same bars played and mixed twice, with reverb tails, drum bleed and automation — the knobs move from the console, and the verdict follows. They persist, same terms as the trace switch, because finding a setting that suits a library is an experiment and an experiment wiped by a reload is one nobody runs:
hackermusic.detectTuning(); // {} — the constants
hackermusic.detectTuning({ floor: 0.9 }); // move the absolute line
hackermusic.detectTuning({ tailQuantile: 0.9 }); // ...and the comparative one
hackermusic.detectTuning({ trimGate: 0.95 }); // cut candidates back harder
hackermusic.detectTuning({ minPeriodSec: 6 }); // loops shorter than 8 s
hackermusic.detectTuning({ trim: false, body: false }); // the geometry, without the two refinements
hackermusic.detectTuning({ smoothSec: 0 }); // the chroma's temporal blur, off, to see what it was doing
hackermusic.detectTuning(null); // back to the constants
hackermusic.redetect(); // measure this track again under it
hackermusic.detectReport(); // every queued track, one line each
detectReport is the calibration the constants are waiting for: what each track scored and what
it was asked to clear, with the unmeasured ones named rather than omitted (and the call that
measures one printed next to them). It fills itself in over an ordinary listen, because the clock
measures the current track and the prefetch measures the next. A dozen lines of it from a real
library is the evidence the thresholds need; without it they are opinions about sine waves.
Two things sharpen the region beyond "one period starting at the best score", and both can be
turned off above rather than argued about. The trim cuts a candidate back to the stretch its
two copies go on agreeing over: a riff followed by a fill that never returns has a period longer
than its body, and offering the period loops the fill forever, which is the usual reason an offer
feels "somewhat long". Its gate is a per-frame agreement threshold — at the shipped 0.8 it will
not cut a drum fill that shares the riff's key and instrument, because blurred chroma calls those
0.9-agreement; trimGate: 0.95 does separate them on the fixture, and starts clipping performed
repeats, which is where the shipped number comes from. The body is a novelty curve (Foote's
checkerboard over the self-similarity matrix): candidates that sit in the intro or the outro are
dropped when any candidate sits in the body at all — and only then, because the curve is a prior
about where songs live while the correlation has actually looked at the region in question.
A loop that repeats is not one region. The first version of this search kept one position per
loop, which sounds right and is not: on a track that is literally a repeating beat for three
minutes it produced a list saying "the loop is at 0:02" to a listener sitting at 2:00, and the
regions in the middle were as true as the one at the top — the measurement said so, at 0.995 and
above, and the collapse threw them away. So each surviving period is now offered at up to four
places where it actually holds, taken greedily and never overlapping (each is at least a period
from the last, so the list walks the song instead of quoting one bar twice), and each carries the
agreement of its own region rather than the best in the family — which is what makes the first
press of c the best loop rather than the earliest one. Measured on a J Dilla instrumental whose
sixteen bars run from 1.4 s to 194 s: one period, 15.31 s, offered at 0:32, 0:47, 0:17 and 0:02,
ranked 0.9986 / 0.9970 / 0.9932 / 0.9793, the last one trimmed a half-second shorter because the
intro is not part of the body.
What remains unsolved is the phase inside a cycle. Every alignment that starts on the same bar
scores identically, so which bar the loop begins on is not something the correlation can be asked;
the candidates differ by whole cycles, not by an offset within one. If the seam you want is between
beats rather than at bar lines, m and M are the tool, and the loop you arm will be yours.
The bar before there is a track
Nothing loaded used to mean an empty stretch of panel: the whole footer lived behind when={song()}, so the
app's most common state had no transport in it — and, more to the point, the waveform's grow had no before
to grow from. The footer is always mounted now and only the parts that describe a track are gated, which
means the same row element is on screen while the app waits and when the track arrives: it grows into a
placeholder, and grows again into the track.
The placeholder is a waiting silhouette, not a small waveform: the same wavePath, the same knobs, ~a fifth
of the row's height, and 28 % of the row's ink. It has no played layer and no caret, because with no track
there is no position to state. And it means exactly one thing — nothing has been read yet — which is why a
track that folds out silent still draws a blank row: a measurement of nothing is a measurement, and letting
a silent track borrow the look of an unbuilt one is the same category of error as drawing bars past the fold.
That line moved once during this change, and the reason is worth keeping. The placeholder was first gated on "no bytes", which is right in the component and flickered in the app: bytes arrive a moment before the fold's first report, so the row blanked and came back on every load. Loading is not silence; the row now says so.
The row is the waveform
There is no hairline track any more. The transport row is a thousand bars mirrored about the axis that rule sat on — the part already heard in the accent, the part ahead in the row's faint ink, and a 1 px caret down the middle at the second it names. What used to be the playhead (an accent fill on a 2 px track with a caret riding it) is gone rather than hidden: one bar, one position, and the waveform answers it while also saying what the track is made of.
waveformTuning({ barsPerPx }) is the dial for how many bars — 2 asks for two thousand, 0 draws every
bucket the fold measured — and waveformTuning({ shape }) moves between the mirrored bars that ship, the
single envelope outline, and blocks, which stands the same reading on a floor. The row is aria-hidden
and takes no pointer events: the slider underneath is still what a drag reaches, and it hands the drawing the
same --pb-fill string it carries rather than letting the waveform compute its own answer to "how far along",
because two derivations of that number would agree almost always and "almost always" is the failure worth
designing out. Hover the row and the bars come forward — with no thumb left to thicken under the pointer, the
drawing has to say grabbable itself.
Two layers of one path, the second cut to the position with a clip-path, so a position tick moves one inset
instead of rebuilding a thousand bars four times a second; and the measurement rules are unchanged, since the
path stops at filled and a bar standing where nobody has listened yet is a claim about audio.
The bars grow into their final form when a measurement lands: one scaleY(0) → scaleY(1) on the row,
opening out from the axis the bars are mirrored about, over 220 ms with an outward-decelerating ease. It is
one animation on one element — the path is never rebuilt and no bar height is interpolated — which is the
whole reason it is affordable where the row's first version was not: the same idea once meant 144 animated
paths and a 252 ms task per arrival. It is keyed to the measurement, not to the path, because the fold
republishes slice by slice and a row that pulsed on every report is a row that never settles. prefers-reduced-motion
skips it, and the row just appears. transform-origin: 50% 100% is the one-line alternative that grows from
the floor instead.
Does it run at 120 fps?
Nothing in the app caps anything at 60. Frame rate is the display's, and the honest question is whether there
is headroom, which needs vsync switched off to measure — hence npm run perf:wave:playhead -- --unlock,
which reports the renderer's own ceiling rather than the panel's:
Measured twice, the row with the smoothed playhead produced ~1300 fps of frames with nothing else happening, and ~2300 fps with the grow animating at the same time — i.e. the renderer was producing a frame every 0.4–0.8 ms and charging the main thread ~0.13–0.2 ms for the privilege. A 60 fps reading on a 60 Hz laptop is the laptop, not the row.
So a 120 Hz panel is comfortably inside budget: ~120 frames/s × 0.2 ms ≈ 24 ms of main thread per second,
about 2.4 % of a core, with an order of magnitude of headroom to spare for everything else on the page. The same script's --block probe then separates who animates what — it starts the
animation, jams the main thread for 700 ms, screenshots through the jam, and counts the rAF callbacks that
fired inside it (a stuck thread fires zero):
- the grow (a
transform) → compositor. It keeps moving while the main thread is wedged, so it runs at the panel's rate whatever the page is doing. This is why the entrance is ascaleYand not a rewrite of a thousand bar heights. - the playhead (
clip-path+lefttransitions) → main thread. Correct at 120 Hz with ~10× headroom, but it depends on the main thread being free, so a 40 ms hitch is a visible hitch in the playhead. That is a real trade, not an oversight: the composited alternative is a wrapperscaleXwith a counter-scaled drawing inside, which would make the drawing's geometry depend on three transitions staying in lockstep — drift there shows as a squashed waveform, which is a worse bug than a stuttered one.
It moves smoothly. The position is sampled 4 times a second off the audio clock and interpolated between
samples by a linear CSS transition exactly one tick long, which is not a trick but arithmetic: playback
advances linearly, so interpolating each sample linearly across the interval before the next one arrives lands
on the truth at every sample boundary and between them, and a late sample retargets from wherever the display
has reached rather than drifting. The duration is the store's TICK_MS, handed to the row as --pb-tick —
a glide length the stylesheet invented would be a second answer to "how often does the clock speak". Deliberately
not a requestAnimationFrame loop: the clock is the audio element's, and a per-frame sampler would be a second
one writing styles 60–120 times a second to produce a number the transition already produces for free. Priced
(npm run perf:wave:playhead, which compares the row with the transition on and off, same path, same clip):
60 fps with zero dropped frames at both 1× and 6× CPU throttle, for ~20 ms/s of extra main-thread work —
about 2 % of a core, which is what "the transition is a main-thread animation, so say what it costs" means in
practice. It gets out of the way in three places. A discontinuity jumps instead of gliding: pressing 4 means be at forty
percent, not watch the caret travel there, so a change that was not playback is drawn with a zero-length
transition. Two things say so, and the second is a rule rather than a list. store.seek stamps its own cause
— so the digits, a click, a released drag and a :seek from the command line all get the same answer without
remembering to ask. And every write to the position goes through one function that stamps any step
backwards, because the audio clock does not run backwards: that single sentence covers the loop wrapping
back to A, a track ending and its successor starting at zero, previous, a restart inside a region, and the
ticker merely discovering a wrap it was not told about — which is the case a per-call-site stamp would
miss, because the write that shows it is the sample. What it cannot see is a jump forward that is not a seek,
and those go through seek. Dragging turns the glide off
outright, because a row following a finger should follow it now. And prefers-reduced-motion means don't
animate this, so the playhead snaps between samples as it used to.
Three things about it were measured rather than decided:
- Bars are
group × barRatiobuckets wide. A thinned bar stands for N buckets, and it used to be drawn one bucket wide: at 1000 marks over 4,800 buckets each bar was 0.55 buckets across — a fifth of a device pixel — and the row read as a comb of mist. - A bar stops at the measurement, not at the end of its group.
wavePath's default range was the track's extent, and every other rule in this file is written against its measurement — so a fold still running had its last bar standing on buckets nobody had read, by up to a group's width. A tenth of a percent of the row, invisible, and exactly the kind of lie that survives review; the row's own honesty bound caught it. The default is nowfilled, and a bar's width comes from how much of its group was actually measured. - Bars stand on their group's cell. Each one used to be plotted at whichever bucket inside the group had
peaked, so a thinned row had visibly uneven gaps: marks that look placed rather than measured. The
envelope still plots through the peak itself — an outline through uneven points is invisible, and a
transient drawn at the wrong second is a lost transient — so
Drawncarries two x's and the two shapes each take the one they mean. - The row lights up on hover, and it is the bars, not a line. A leftover rule lit the transparent 2 px track under the pointer, which painted a grey line straight across the waveform — the old playhead coming back for exactly one condition, and the one condition where the user is looking hardest at the row. The affordance is the bars coming forward instead, which is what a row with no thumb has to do.
- Stacking is a legibility decision. The played bars paint over the A–B region's tint, because 20 % gold
over gold-green bars is mud; the brackets paint over everything, because when the playhead passes the loop's
opening press, the left bracket used to disappear behind the waveform — the boundary vanishing exactly when
you are inside the span it bounds. The tint came down to 8 % for the same reason it exists at all: over a
waveform, a fill competes with the thing the row is there to show, and
--pb-region-tint: 0%is the brackets-only answer.
What the seek row is drawing
Not right now, on this branch. The row is off the bar: the bar is the hairline, the thumb and the A–B marks, and nothing draws the envelope under it. Everything below still describes the measurement and the drawing that were built and measured — and it stays written down because the measurement is live (
peaks()still answers,waveformTuningstill moves it) and whatever is drawn here next will be drawn from those numbers, by someone who will want to know what the last attempt cost.PlayerBar.test.tsxasserts the absence, so it stays a decision rather than an accident.
Once the bytes of a track are in the player's hands, the row under them stops being a hairline and
becomes the track's own envelope: a bar per fiftieth of a second, mirrored about the line the thumb
rides on, the part already heard in the accent and the rest in the row's faint ink. It is a
measurement rather than a decoration — the samples are the ones the player decoded to make sound,
folded by a pass that reads a quarter of them and takes the extremes of whichever channel says so —
so a place in the song can be read before it is aimed at, and the region c armed becomes two
stretches of drawing that visibly repeat, with the seam where the loop wraps standing in the shape
as well as in the tooltip.
An armed A–B region is drawn as a pair of brackets around a slight tint: ⟦ … ⟧, one at each press,
with 20 % of the region's own ink filled between them — the editing gold of a mark you made, the blue of one
the detector offered and a key accepted. The two sentences live on the same 20 px and they are different
sentences — how much has been heard is the slider's accent fill, which part keeps coming back is this —
so the region has to read as a boundary rather than as a second quantity, and a bracket is the clearest thing
in the alphabet that says "a span, placed here, by somebody". The brackets are three sides of a box rather
than a painted pattern, which is the reason their strokes are crisp: a border lands on a whole pixel, where a
gradient's colour stop can straddle two device pixels and come out as a soft 3 px line at precisely the place
that has to be exact. The proportions were set the same way: the arms are 4 px,
because 7 px read as a second mark rather than as a serif on the stroke, and the stroke is 1 px — the
weight the slider's own rules are drawn at — because a bracket on an empty row is not competing with
anything. The tint was set against the row's real height rather than a magnified one: 12 % is not a fill
at all — the span reads as empty air between two marks — and 34 % starts to dull the hairline it covers,
which is the number the thumb is riding on.
The row used to wear this span in its own bars instead, and the edges were then the region's only drawing —
which is the story behind the alternatives still on the knob. hackermusic.waveformTuning({ loopEdge })
moves between brackets and the four treatments designed when the boundary's job was to not be mistaken for
one more bar of a waveform: rects (a column of vertical rectangles, because the same rhythm at 3 px is
2 × 3 squares and photographs as dirt rather than as a mark), caps (one mark at each end), spine (those
caps joined by a faint line), solid (a plain 2 px column) and ticks (the squares). All of them stand on
the same box and wear the same tint, so the treatment is the only thing that moves. --pb-region-tint and
--pb-bracket-arm are the two properties the shipped rule reads, if you want to argue with them from
devtools.
It arrives rather than entering. A measurement lands and the bars are drawn at their measured heights in
the same frame: the path is built over the measured prefix, so the row's ink cannot be ahead of the
measurement and there is nothing left for a reveal to hide. That is a decision rather than an absence — the
row had an entrance, it was measured, and the measuring is what ended it. The first version cut the row into
48 lanes of buckets by position in the track and gave each lane an animated path, in each of the row's three
layers: 144 elements with 144 delays carrying 753 kB of path data. That is what "the entrance doesn't run at
120fps" turned out to be — the element count and the ink, not the easing. Two animations on the wrapper (a
clip-path reveal edge and a scaleY rise — on the wrapper because three layers wear one drawing, and an
entrance belonging to only one of them shows gold and accent bars at full height while the grey ones catch up)
cost 24.6 ms of main-thread rendering against the old row's 135 ms, and 1561 fps against 315, on a
1400 px row over a four-minute track (npm run perf:wave:entrance). Then the measurement that settled it:
take the two animations off that exact drawing and the row costs 14.7 ms — so the entrance was about
10 ms a frame-cycle of task and 4 ms of style recalculation, which is the clip-path being painted every
frame, and at 6× CPU throttle the two are within 1.2 ms of each other because the drawing is what the
throttle is charging for. Not free, then, and not expensive either: cheap, and buying nothing you can
measure. A row that costs a listener something in exchange for a motion that changes no reading is the
trade this file exists to refuse, so the drawing is what is left. scripts/wavebench/ still prices each part separately,
because "it was slow" is three claims in one sentence — too many elements, too much ink, or the wrong property
animated — and docs/research/player-waveform.md keeps the numbers, the compositor probe, and the two rules
the old design got right (the sweep belongs to the track rather than to the reports; a reveal must never fight
a growth) in case the row grows an entrance again.
What survives of the motion is the honesty rule: where the ink stops is bounded by the measurement, and it is carried by the path rather than by a clip over it, so there is one claim about it instead of two that can disagree. The row is also a tile rather than a pile — one path per layer over the measured prefix, so no bar is painted twice. (The version that drew the arriving slice on top of a settled path that already contained it is why chunks still popped for as long as they did: the bars were already at full height underneath the fade.)
And the fold is faster than the eye: a thousand buckets measure in single-digit milliseconds, so an
unrestricted reveal publishes 3% of a row and then, four milliseconds later, all of it — two slabs, which
reads as worse than the pop it replaced. So nothing shows until either 6% of the row is measured or 60 ms have
passed: a pass still running after that has something to show, and a fast one simply arrives. The store
publishes at 24 ms, and with nothing animating that cadence is the whole of what the fill looks like: each
publish rebuilds one path, the ink stops where the pass has got to, and the gap between those rebuilds is
row paint's dt — in the console rather than papered over by an easing curve.
The drawing's other dial is how much of the track fits in a pixel. barsPerPx (default 1) thins the drawn
points to the row's own width: the fold is 20 buckets a second, a 1400 px row has 1400 columns, and four
points in a column cannot be seen — they buy an aliased top edge and nothing else, at four times the
rasterization. curve (default 1.8) bends the level-to-height map, which is the difference between a row
with a silhouette and a slab: music lives between −12 and 0 dBFS, a straight decibel map puts that whole
range in the top quarter of the row, and the bend hands it back without lifting the floor — so quiet material
still reaches the hairline instead of being dropped, which is what a shallower floorDb would have done
instead.
To see the spacing for yourself — it is invisible otherwise, being numbers the code chose and forgot —
hackermusic.waveTrace() prints it: row report for what the store let the row see (filled, jump,
step per-mille of the row's own width, dt, dropped) and row paint for what the row drew (pct,
bars — points, kb of path data per layer, buckets, dt — how long since the row last
drew, fresh), with waveTrace("slices") for one line per slice of the pass. And if you want to watch
the fill happen on an ordinary three-minute track rather than a two-hour mix, that is what
waveformTuning({ sliceMs: 0 }) is for: it changes when the pass yields, never what it measures.
Whether the machine keeps up with any of it is not answerable from here — that is a fact about a
compositor, a CPU and a laptop on 40% battery — so it is asked of the machine instead. And because a stutter
that comes and goes is invisible to an instrument that has to be switched on before it happens, the row
keeps the last eight arrivals whether anyone asked: await hackermusic.waveStats() with no arguments
reports the one that just happened. Three doors, three questions:
await hackermusic.waveStats(); // the arrival that just happened
await hackermusic.waveStats({ refold: true }); // …or one measured on purpose, from its first frame
await hackermusic.waveStats({ watch: true }); // a verdict after every arrival; returns the off switch
Each prints a verdict in words over the numbers behind it, and the verdicts are written to separate the claims that "it stutters" bundles together:
- whether anything was animating at all. Each animation's clock is still read apart, by name: the row
once ran a transform and a clip side by side on one element, and they are not the same kind of object — a
transform the compositor owns advances while script is pegged and a
clip-pathdoes not. With no entrance on the row those columns read zero and the report says so in its first line rather than handing back a smooth nothing. The plumbing stays because it is the only way to answer "which half stalled" if an entrance comes back. - what the long frames were made of. Chrome's
long-animation-frameentries are the browser's own verdict on a frame over 50 ms: script, style/layout, render, and which script (invoker, plus the call site where the browser will say it).longtaskis the fallback where LoAF does not exist, and the report says which of the two it had. - what the row itself cost — every
wavePathbuild in ms, points and kB, set against the script time in those long frames. That is the line that decides whether to keep tuning the row or go and look at something else, and no frame gap carries it on its own. - what else was running. The load audit is already open on a track change, so the report intersects its spans with the entrance window: the accent off the cover, the fold's slices, the beat model warming up. A collision looks exactly like a slow row until you can see the other bar.
It also refuses to grade what it cannot: a hidden tab, or an arrival it never saw at all, each get said rather than scored — and a recording with nothing animating in it now leads by saying exactly that.
Two options are worth knowing. A page cannot read its own refresh rate, so the frame budgets are judged
against what you say the display does — waveStats({ hz: 120 }), remembered across reloads. This matters:
the budgets were 20ms and 50ms to begin with, which are 60Hz opinions, and on a 120Hz panel they would have
congratulated a row dropping every other refresh. Now a dropped frame is one interval and change, a stall
three, whatever the interval turns out to be — and if the page only got 60 out of a claimed 120, it says so.
Second, waveStats({ stress: true, refold: true }) blocks the main thread for a quarter second during the
row's arrival, which is how you find out from inside a page whether the thing you are watching is on the main
thread at all. With an entrance on the row that was a two-way test and it answered cleanly — the rise stayed
current through a 250 ms block while the reveal edge paid for it, which is the difference between a transform
and a clip, and the numbers are in docs/research/player-waveform.md. With none, the probe has no subject and
says so. waveRecorder() says what is being kept, whether LoAF was available, and how many frames the last
arrival held.
The recorder's cost, since it is always on: nothing until a row arrives. The observers register on the first arrival, the sampling loop runs only for it plus a short tail — deliberate, since a row that cannot keep up shows it at the end — and the history is eight recordings of about a hundred and twenty frames.
It appears when the bytes do and not before, which is the same gate the A–B marks stand on: a waveform is a reading of samples, so a track still on the wire gets the hairline, and a track whose samples say nothing gets it too. And it arrives as it is measured — the pass walks the track left to right in slices, hands over what it has each time, and the row fills in behind it. The long part of the wait is the download and the browser's own decoder, and neither can be hurried from here; the fold is the part that has something to say, so it says it as it goes rather than saving everything up for one pop at the end. A half-measured track is laid out against the length it will have, so the bars already drawn stand where they will finally stand, and an abandoned pass leaves nothing on screen — a fold that was thrown away is not a measurement.
It costs a few milliseconds of sliced main-thread time per track, folded once and cached past the
megabytes it came from, and the playhead moves a clip rather than redrawing a shape — the whole row is
three SVG layers of one path and one clip-path reading the same number the hairlines already read. The pass is deliberately not in a worker: getting 21 million samples out
of a decoded buffer costs more than folding them where they stand, and this browser does not put Web
Audio in workers at all (docs/research/player-waveform.md has the numbers, and the two things that
measurement settled wrong until the tests caught them).
hackermusic.peaks(); // what the fold said about the track playing: buckets, stride, channels, ms
hackermusic.waveformTuning(); // the row's knobs, as they now are
hackermusic.waveformTuning({ normalize: "track" }); // compare shapes instead of levels
hackermusic.waveformTuning({ floorDb: -30 }); // how far down the row a quiet passage still reaches
hackermusic.waveformTuning({ curve: 1 }); // the straight decibel map, before the bend that opens the row
hackermusic.waveformTuning({ barsPerPx: 2 }); // points per CSS pixel: detail asked for on a 2x display
hackermusic.waveformTuning({ loopEdge: "rects" }); // the region: brackets, rects, caps, spine, solid, ticks
hackermusic.waveformTuning({ sliceMs: 0 }); // yield every slice, to watch the row fill in
hackermusic.waveformTuning({ sweepMs: 2500 }); // how long an arrival is expected to take (the recorder's window)
hackermusic.waveTrace(true); // the fill's spacing: jumps, steps, slices mid-animation
hackermusic.waveTrace("slices"); // and one line per slice of the pass
hackermusic.waveformTuning(null); // back to the constants
hackermusic.refoldPeaks(); // measure this track again under the knobs as they are
hackermusic.waveStats(); // the arrival that just happened, reported
hackermusic.waveStats({ refold: true }); // …or one measured on purpose, from its first frame
hackermusic.waveStats({ watch: true }); // a verdict after every arrival until told to stop
hackermusic.waveStats({ hz: 120 }); // …judged against the panel you are actually on
hackermusic.waveStats({ stress: true, refold: true }); // …and whether the main thread was the one holding the row
hackermusic.waveRecorder(); // what the recorder is holding, and on what authority
The two readings are worth having side by side because the default is the honest one rather than the
flattering one: a quiet master draws a short row and a brickwalled one draws a solid one, which is
information about the file, same as the chip that says flac 24/96. When the question is instead
where the music turns over — where the body is, where the loop the detector found actually sits —
normalize: "track" takes the level out of it and leaves the shape.
Keys
Bound at the window level; nothing in the player bar is in the tab order, so the bar answers to the mouse and these keys answer to the app.
The bar's three names — the track, the record, the artist — are the exception worth
naming, since they are the one thing on it that talks back to the list: clicking one puts
that thing under the cursor and scrolls the list to it, which is the answer to "where is
this" without asking anybody to scan for a title they can already hear. The cursor is what
moves, not the transport: the keys carry on from wherever the click landed. A track the
query has hidden is reported on the command line rather than chased, because the query is
something the reader typed and Escape is the key that answers; and a name with no id behind
it — an untagged file, no artistId from the server — is drawn disabled, since a control
that cannot do its job is a lie about the interface.
What those three lines print is decided separately from what they are tagged, and the gap between
the two is why lib/naming.ts exists. A rip off a promotional box set is tagged with the production
team as its artist, the box set as its record — disc number and compiler still stapled to the name —
and the guests appended to the song: Partners For Life ft. Pharrell Williams & Jamie Foxx off The
Neptunes: The Other Side Of The Planet (Disc 12) [Presented by ESSNSE & Cory Townes], in a column
about fifty-two characters wide. So the bar prints Partners For Life / The Other Side Of The Planet
/ The Neptunes: a title loses its credits, which are people rather than song; a record loses the
artist's name repeated in front of it and the labels stapled to a pressing — but not Live at the Apollo or Dark Side of the Moon, because a bracket proves nothing and a keyword decides; and an
artist loses its credits the same way. Then a ceiling, which is not the CSS ellipsis: the three names
are content-sized flex items, so a two-hundred-character title claims width from the artist's name
from behind its own ellipsis, and a ceiling on the content is what bounds that argument. Anything that
would be tidied into the blank comes back untidied — a name the bar cannot print is not legible, it is
missing. And none of it reaches an identity: the tooltip, the list's search, the lyrics cache and
LRCLIB's questions all still see the tag the server sent, so the shorter the bar gets, the more it is
still pointing at the same file.
| Key | What it does |
|---|---|
/ |
Arm the command line as a search: the caret goes into the field above the transport, with the old query selected so the next key replaces it. Nothing on the line advertises the key at rest — see The command line |
: |
Arm the same field as a command: what is typed next is a verb for the player rather than a needle for the list. Enter runs it and hands the keyboard back, ↑/↓ walk what was run earlier, and :help lists the verbs |
Enter |
In the line, as a search: accept the filter — keeps the query, hands the keyboard to the list, and takes up the cursor on the top row; if the filter left exactly one track, play it. In command mode: run the verb, or keep the text on the line if nobody recognises it. Elsewhere: play what the cursor is on — a track, or the record / artist whose header it sits on |
Escape |
In the line, as a search: clear the needle (and leave the line once it is empty). In command mode: leave the line, and the half-typed command goes with it. Elsewhere: back to list view |
Backspace |
At an empty line: leave the line |
j / ↓ |
Move the cursor to the next row — tracks, album headers and artist headers all take the cursor. ↑/↓ go to a detected loop instead while one is armed and has somewhere to grow or shrink; j/k always move the cursor |
k / ↑ |
Move the cursor to the previous row (the arrows, when a detected loop is not standing on them) |
g |
Toggle list / grid |
Space |
Pause / resume — ignored while typing a query or on a focused button; with nothing loaded the browser keeps it, so it still scrolls the list |
n |
Next track |
p |
Previous track |
N / P |
Next / previous album: a whole record at once, landing on its first track; past the last one the queue runs out the way n runs it |
0 … 9 |
Seek to that tenth of the track: 0 restarts it, 5 is the midpoint, 9 is nine tenths in — of the track, never of the queue |
+ / - |
Up / down the volume by half a decile — 5% of the slider's travel a press, twenty to go end to end, and the level holds with nothing loaded because it is a fact about the room rather than about a track. = and _ answer as the unshifted spellings |
[ / ] |
The pitch down / up one semitone, without the tempo moving — twenty-four either way and stopped there. A setting of the deck rather than a move in the track, so it works with nothing loaded and needs no source re-armed. See The key, and the lock |
< / > |
The deck's speed down / up ten percent, half as fast to twice as fast and stopped there — a setting of the deck rather than a move in the track, so it works with nothing loaded. The practice-tape keys: > a record until it is slow enough to play along with. See How fast the deck runs |
{ / } |
Snap the speed to the three that have names — half, normal, double — in the direction pressed. The shifted form of the two pitch keys, because the pair that moves the whole record sits on the pair that moves its pitch. See How fast the deck runs |
\ / | |
Take the lock or let it go — hold the pitch while the speed moves — and | puts the whole pitch state back to where the record was written. Beside the brackets for the same reason the brackets are there: this is the pitch's corner of the keyboard |
l |
Cycle the loop modes: off → album → artist → single. One command with the repeat button in the bar, and taken with nothing loaded only when the bar is on screen to show it |
m |
A–B the track: press where the repeat should start, press again where it should end — and the playhead goes to A with it, so what you just marked is what you hear. Press again to stop, which leaves the clock where it is |
c |
A–B the loop the player found that is nearest where you are standing: the candidate whose region the playhead is inside — or, standing outside all of them, the nearest one — armed through the same door as a hand mark, and the clock taken there to hear it. Two candidates the playhead is inside of at once settle on the tighter one; the longer is one press of ↑ away |
C |
Walk the A–B regions the player found, one press each, along the track rather than by score, and wrapping at the end — a tour of the song so the two things being compared are the two you just heard. Continues from wherever c left off; the list itself is chosen so every sixth of the file has something in it |
↑ / ↓ |
On a detected loop: trade it for the next candidate up or down that still covers the region being heard — a longer loop, or a tighter one, each with evidence under it rather than a boundary stretched. Silent on a region you marked yourself, and on a detected one with nothing left of that shape to offer, in which case the arrows are the list's again |
← / → |
Jump the A–B loop to the next place — one sixth of the track, the same sixths the candidate list is chosen on — forward or back, taking the best loop in that place and the clock there with it. Places with nothing in them are stepped over rather than stopped at, and when every loop the track has already stands where you are the key is left where it was |
s |
Share the track under the cursor (or the playing one): a link on the server, copied to the clipboard, dead in a day |
f |
Favorite the track you can hear — the one the player is on, not the row the cursor rests on. The heart appears at once and is dimmed until the server has agreed; press again to take it back. With nothing playing the key says so and asks the server for nothing |
a |
Favorite the record under the cursor — the album header it rests on, or the record the pointed-at track belongs to. One request carries the record's own id, so sleeve and header both wear it; a record whose every track is favorited already wears the heart hollow, which this key fills in |
r |
Random track from the visible list, dropped in somewhere mid-track |
R |
Random track, played straight from its first second |
Alt+r |
Random album: the queue becomes that record and starts from its top |
r rolls inside whatever the query has narrowed the list to, and never replays
the track already playing.
m repeats a stretch of the track that is playing, and it is one key because it is
one gesture: the first press marks where the music is now, the second marks where it
should turn around, the third is done with it. Until the second press there is a hairline
across the seek row and nothing else — half a region is not a region, and the bar that
shows it says so. The boundary is handed to the audio thread when it is set, so the turn
happens at the second that was marked rather than whenever a timer next looks; the seconds
counted against the index's length, on the other hand, would be a boundary the file itself
might not have. A region belongs to one track, so another row takes it away, and while one
is on the queue waits — the next track arrives when the region is cleared, or when n
says now. l is the other question, the one the repeat button answers: how far the queue
runs before it stops.
c is m with the guessing done for you. Most of the music in a library is built out of
blocks that come back — a chorus, a four-bar body, a sixty-second map — and the seam where
a block starts is a place in the file, not a number worth typing twice, so the player looks
for it: what each frame is made of, in the twelve pitch classes, and then every period the
track can hold two copies of, each one scored by putting a region against the stretch that
follows it and asking whether they are the same music. It runs when the bytes arrive — which,
since the next track is already prefetched, usually means the candidates for the track you are
about to hear are already in by the time you wonder. The search is a few hundred milliseconds of
pure calculation and is kept off the main thread for that reason; the audio never leaves this
thread, because what crosses to the worker is twelve numbers per frame, not the samples.
Nothing it finds is armed by itself. An arrangement of four seconds that happens to be the strongest pair in the file is not necessarily what the listener meant, so the answer is a readout — a candidate held up, muted gold, at there's a loop at 0:20, just not where you are — and the press that arms it is the listener's. Two readings have to stay different: there's a loop, just not where you are and nothing in this track repeats are seconds of music apart in what they mean, and one chip that said "no loop" for both would make the feature look broken on exactly the music (game soundtracks with a one-shot head) where it works best. The same goes for the third refusal, something repeated, but not convincingly enough to bet a boundary on: it is the one where pressing the key again can change the answer, so it is the one that has to be sayable without being mistaken for the other.
What the detector measured is a claim about a file, so it goes through the same door as a
mark: clamped to where the bytes really end, refused if the region is too short to hear,
written to the session against the track it was measured on, and forgotten with the track.
m outranks it — marking your own region retires the chip — because the region you meant
is the region there is. The bar keeps the two straight: a region you marked is drawn in the
editing gold, and one the detector offered and a key accepted is drawn in blue, because the two are
the same drawing, bars and edges alike, making two different claims — one is what you did to the track, the other is
what the app believes about it — and the second is the one ↑ and ↓ have something to say about.
f marks the track you can hear, not the row the cursor happens to rest on. The cursor is a
reading position, and after a j walk, an n, or a track that ended by itself it is somewhere
else while the ear has not moved — so the press means the same thing whether or not the list is
the pane with focus, and the toolbar line names the track it took, because the row it marked is
not the row that was pointed at.
a is the record's half of the pair, and it reads the cursor rather than the player because a
record is a thing you look at rather than a thing you hear: an album is never playing, one of its
tracks is. Point it at a header and it marks that record; point it at a track and it marks the
record that track belongs to, which is the same one Enter on that group would queue. The server
takes a record's id directly — star resolves it to a row of the same annotation table a track's
id goes to — so the press is one request and not one per track: the record is favorited, not its
tracks individually, which is what lets the header's heart and a track's heart be two claims that
each come back off on their own. F keeps a favorited record's tracks, and the gallery wears the
same heart on the sleeve, because that is the other place a record is looked at.
A record whose every track is favorited wears a heart of its own too, drawn hollow: the
reader favorited that record one track at a time, and a header denying it while its whole section
glowed would be the glyph contradicting the rows underneath it. Hollow and not solid because they
are two different facts — one is a row in the server's annotation table, the other is a reading of
the tracks listed under the header — and this app does not print a server fact it does not have.
a on a hollow heart writes the record's own star and the glyph fills in; pressed again it takes
that star back, the heart stays hollow, and the line says so, because the tracks' hearts are
f's to take back one at a time, which is how they were written.
What neither key writes is an artist. star would take the id and Navidrome's own UI stars
artists, but a heart no row here can wear or take off is not a favorite, so getStarred's artist
list is dropped on the way in and a refuses an artist header. With no cursor, or on a record
whose tags carry no album id, it says so and asks the server for nothing: inventing the thing you
meant would favorite a stranger's record.
It also writes to the server and not to this browser. A favorite is the account's record of what it likes — the same list the phone shows, the same one a Subsonic client edits — so there is no local copy of it to keep in step: one request at boot asks the server what it has starred, the heart says what that answer says, and a press sends the server the change and paints the heart without waiting for the round trip to come back. Waiting would be the slower option twice over: the box is on somebody's LAN, and a key that answers two hundred milliseconds late is a key that gets pressed twice. When the server refuses — a read-only account, annotations switched off — the row goes back and the toolbar says why, in the server's sentence rather than a category, because "not authorized" and "could not reach that host" want different things from the reader. That line is also where the press is announced, rather than only drawn: the heart itself is CSS, and CSS has no accessibility tree to speak through.
The query itself outlives a reload: the box comes back holding it, with the list already narrowed to it, no keystroke needed. It comes back to the server it was typed against and to no other — ids and titles are that server's collection, and a needle kept across a server switch would filter the new library down to "no matches" with nothing on screen admitting the filter had anything to do with it. Escape clears it in the same permanent way: what is stored is cleared with it, so the next visit is not served back a search nobody cancelled.
While the artwork is blown up it owns the keyboard: Escape and Enter close it,
and g, /, j and the rest do not reach the list behind it — one key should
not close the cover and change the view. Space, the number row, l, m,
c/C, +/- and [/] are the exceptions, still the transport with the cover up: the art is on screen
because the music is playing, and pausing, skipping ahead, deciding how far the queue runs,
deciding which part of it to repeat, or turning the whole thing down are what you do while
looking at it — and the readout for the last three is on the bar underneath the picture.
That last part is why the art carries the paused mark: the bar has no play/pause button
(space is the transport), the clocks that would otherwise say it sit two rows away, and "the
number has stopped moving" is the least legible way to say a track is stopped on purpose.
Two bars over a scrim, drawn rather than glyphed so no platform gets to make it an emoji,
once on the tile and once on the blown-up cover — because when the cover is up the bar that
also wears it is behind a backdrop, and space still pauses behind it. It marks paused and
nothing else: loading is already told by the harvest's green crawl along the bar's border,
error by the refusal it belongs to, and a mark through either would spend the one glyph
that means stopped on purpose on a silence nobody chose.
How fast the deck runs
< and > move the whole record ten percent of its own speed a press, from half as fast to twice
as fast: ×0.8 is a four-minute record in five, and the pitch goes with it. That coupling is not a
limitation somebody settled for: AudioBufferSourceNode has no preservesPitch (the property
belongs to HTMLMediaElement, and the W3C thread declining to add it here is from 2012 and still
current), so on this graph rate is pitch with nothing to switch off, exactly as on a turntable
pushed to 78. It is also what most of the requests that arrive at all want: nightcore at 184 and
chopped & screwed at 70 both are the same record at a different speed. Moving the pitch without
the tempo is the other feature — a pitch shifter in an AudioWorklet, and it exists: see
The key, and the lock — and it is a separate control precisely because the
two are wanted by the same person on different days. Which is also the way to get 80 % of a record
in its own key: < twice and \.
The state is a whole percent, and so is the step — which matters more than it looks. A held key
is an accumulator, and an accumulator of binary fractions is how a control that is supposed to land
on 80 % lands on 79.99999999999999 % and prints it. { and } snap to the three speeds a person
can say out loud rather than doubling wherever the percent happened to be: at 80 % "an octave up"
that produced 160 % would offer a speed with no name and no way back.
What the speed does not touch is most of what the bar draws. Everything the app measures is
measured in seconds of the file: the waveform's x-axis, the A–B region, the beat grid, the seek
thumb. A rate changes only how fast wall time crosses those seconds, so a loop marked at concert
pitch still bounds the same four bars at ×2 — it simply takes twice as long to hear them — and
the clocks and the marks stay true without a single new branch. Two numbers are about wall time
rather than the file, and both move: the tempo the bar prints is the tempo being heard (92 becomes
184, and 92 at 80 % is 74, rounded once from the unrounded detection rather than twice from a
rounded one), and the glyph beside it says why the number no longer matches the sleeve. The pulse
is scaled in neither, because its interval is already measured in buffer seconds and its beats
double on their own — the readout and the flash agree by disagreeing about units.
Pressing > mid-phrase is a speed change rather than a skip: the running source is stopped at a
second on the audio clock and its follower armed for that same second, at the offset the old
source will have reached at the old rate — the same handover the loop clear uses, so there is no
hole in it and no repeat of eighty milliseconds of music. The successor armed ahead of the
boundary is abandoned and re-armed too, since it was armed for the end the old rate was going to
have. Both settings — this one and the level — outlive a reload, because both are facts about the
machine and not about the track.
The key, and the lock
[ and ] do the thing the browser cannot: move the pitch and leave the tempo where it was. One
semitone a press, two octaves either way, and the running source is not touched — no re-arm, no
handover, no seam moved, because the pitch happens downstream of everything that has a clock
attached to it.
The lock (\) is what makes the two controls independent rather than merely separate. A speed of
×2 drags the pitch up twelve semitones whether anybody asked or not — see the section above, there
is nothing on the source node to switch off — and locking says cancel that: the shifter is handed
minus what the rate just did, and what comes out is the record at twice its speed in the key it was
written in. It is exact at the octaves and fractional everywhere else, because the speed is: at 80 %
the speed has taken 3.9 semitones off, so the lock puts 3.9 back — 12·log₂(1.25), which is the number
the :lock line quotes rather than one the listener has to divide out at a prompt. | puts the
whole pitch state down: key, lock and speed at once, because it is one decision and the listener who
reaches for it has finished with the experiment.
How it is done, since "pitch without tempo" is not one algorithm but a decision about which
artefact to prefer. The shifter is an AudioWorklet on the master gain — one node for the whole
graph, so a gapless handover happens upstream of it and it never notices a seam — and it is a
WSOLA time-stretcher followed by a resampler: find the place in the incoming audio that agrees with
where the last frame left off, overlap-add it at the spacing that changes the duration, then read
the result back at the rate that changes the pitch by the amount asked for. The two stages cancel in
duration and leave the pitch moved. Transients survive it, which is the property WSOLA is bought
for: a kick comes out as a kick, on the beat it was on. What it does not have is a phase vocoder's
smoothness on a pad held for four bars — a stretched frame is a repeated frame, and on material with
no transient to lock onto, a repeated frame can be heard. That is the trade this makes rather than
the shimmer a phase vocoder adds to everything, and it is the reason there are three tunings.
:shift picks between them: crisp for drums and anything percussive, balanced (the default)
for a whole mix, smooth for pads, strings and voices. The three are window lengths — 23, 46 and
93 ms — and the longer the window the smoother the tone and the washier the rhythm, with the delay
the same order as the window. Which is the one cost that reaches the interface: the shifter reports
what the delay turned out to be and the bar moves its pulse by that number, so the flash stays
locked to what is heard rather than to where the audio thread happens to be. Nothing else is
compensated, deliberately — a seek thumb that showed the heard time would be a thumb that seeks to
the wrong second.
One graph, once you have used it: the shifter is wired in between the master and the speakers and
stays there, at +3 and at zero alike. It used to drop out at unison, on the sensible-sounding
argument that a node doing nothing should not be in the way, and moving between the two shapes was
what clicked — the node holds its output back by its window, so unplugging it snaps the audio
forward by 78 ms in a single sample, and the transition that skipped the middle of a word was the
one a listener makes most. Ratio 1 through the node is the same signal a window later, and a delay
is not a glitch: the bar already moves its pulse to meet it. So the only question left is when the
chain gets built, and the answer is the first time you press [ or ] — for everyone else the
module is never fetched, the node is never made, and nothing is delayed. :shift off puts the
pitch away and takes the chain out of the next session's graph; it leaves the running one alone,
because that is the click again.
And a browser that will not give the app an audio thread for it still plays, still varies speed, and simply has no pitch control. Key, lock, speed and tuning all outlive a reload with the level and the speed, on the same argument: they are facts about the machine.
The command line
One field above the transport takes both kinds of typing: / puts a needle in it for the
library, : puts a verb in it for the player. They are one component because they are one
<input> — two components negotiating a caret would be the bug, not the design — and they
are one line because the question they answer is the same question: what should this
music be doing.
It sits beside the transport rather than in the toolbar at the top of the screen, for the reason the transport does (App.tsx): what it controls belongs to the session, not to whichever view is up. The toolbar is the library's own readout — what the index holds, how far the crawl has got, the news of a key — and a field is not news. What the field cannot know about the list is registered instead of reached into: the mounted view tells the line how many tracks a needle leaves and what Enter does when the caret is handed back, so the line still works with no list mounted at all.
Nothing on the line advertises / at rest. An idle glyph is furniture that describes a key
rather than a state, and the caret plus the list above it already say the keyboard is
yours. The : prompt is the opposite case — it appears exactly when command mode is on,
which is when it is true, and it disappears the moment the field loses focus, because a
command is a press rather than a draft.
The wash that used to light the toolbar for a search moved with the field: green says the
rows are being narrowed, and it follows the needle rather than the caret, so an accepted
filter stays lit after Enter hands the keyboard away. Command mode takes a brighter ground
instead of the green, because :vol 40 narrows nothing and a green bar would say otherwise.
At the end of the line is one gutter with three claimants, ordered by recency: what the word being typed means ("the master gain", "jump to a place in the track", "no verb named vloume"), then what the last command did ("35%", "A–B 0:12–0:30", "repeat album"), then what the needle left ("9 tracks", "no matches"). The first two are why a command mode is worth having beside a list of hotkeys: you find out what a word means before you press Enter, and what a press did after you let go. Any other key takes the outcome away, the way it takes the share line — feedback that outlives the thing it reported on becomes furniture.
The verbs open the same doors the keys do, never a second path to the player:
| Verb | Does the same as |
|---|---|
:play |
resuming — with nothing loaded it refuses rather than picking a track |
:pause, :toggle |
Space |
:next, :prev |
n, p |
:seek 1:20 |
nothing on the keyboard — jump to a place, or :seek -20 to move there |
:vol 40 |
the slider; :vol +5, :vol up, :vol mute, and a bare :vol reports the level |
:speed 80 |
</>, with the number named — :speed up, :speed down, :speed off, a bare :speed reports. 80, 0.8 and 80% are one speed; :varispeed and :tempo are the same verb |
:octave up |
}, the snap: :octave down, :octave off, and a bare :octave snaps up, because there are three answers and no direction to lose |
:key +3 |
[/], with the semitones named — :key up, :key octave, :key off, a bare :key reports the key the record is being heard in; :pitch and :transpose are the same verb |
:lock |
\, and a bare verb flips it: :lock on, :lock off. The answer says what the lock is holding, which depends on the speed |
:shift smooth |
nothing on the keyboard — the tuning (crisp, balanced, smooth); :shift off is |, the whole pitch state put back |
:repeat album |
l, with the mode named; :loop 1:00-2:00 is m's A–B, given a span |
:queue 3 |
clicking a row in the queue — the queue is the visible list, so the filter decides what the third row is |
:timeaudit save |
nothing on the keyboard — the load flamegraph: where the time went loading a track. :timeaudit 2 is the second-newest load, :timeaudit save writes the whole session out as a file, :timeaudit off stops measuring |
:stop |
nothing else; it clears the bar the way closing the tab would, minus the tab |
:lyrics off |
nothing on the keyboard — whether the synced lyric is asked of LRCLIB at all. A bare :lyrics reports the setting and what the host said about the track on the bar: lyrics on · 42 synced lines · lrclib |
:lyricsaudit |
nothing on the keyboard — why the lyric was not found: every question the ladder asked about the track on the bar, and what each refused. :lyricsaudit 2 is the second-newest attempt, :lyricsaudit off stops writing them down, :lyricsaudit clear forgets the session |
:harvest |
nothing on the keyboard — what the last crawl fetched, every figure the server counted against it, and whether the delete ran. :harvest 2 is the crawl before it, :harvest rebuild drops the index and crawls again from cold |
:help |
prints the table the dispatcher runs, one line per verb, aliases and all |
A word nobody recognises is refused twice, once in that gutter while it is being typed and
once on Enter, and the second refusal leaves the text on the line with the caret in it: a
mistyped command should be finished, not retyped. So does a command with nothing to act on
— :seek with an empty queue, :next with nothing loaded. One refusal is worth naming:
:loop 1:00-2:00 before any audio has been decoded is refused rather than armed, because
the store drops a region with no bytes to measure and the bar would go on claiming a repeat
the audio does not perform.
↑/↓ walk what was typed earlier, and only in command mode — in the search half the
arrows belong to the caret, and on the bar they belong to the detected-loop candidates. The
needle survives a reload, because it is a question you may be mid-typing when the page goes
away, and it is stamped with the server it was typed against. The command survives nothing:
the last thing typed at a prompt is the last thing you want back.
Coming back
Reloading used to lose the transport: an empty bar, no queue, the track back at zero. The queue, which track it was on, the second that track had reached, the A–B region it was repeating, and the level the slider was left at are stored and put back — as one record rather than a key per field, because those four are a single moment and restoring them in five orders is five chances to bring back half of a moment.
It comes back still: the bar shows the track and the slider sits at the second it was left at, with nothing fetched and no audio graph built. Audio starts on a gesture and a reload is not one, so the space bar is what resumes — from the second on the slider, which it can also be dragged to first. The session is stamped with host and user, since the ids in it belong to the server that issued them: a queue from another server (or another account on this one) is left in storage rather than restored or cleared, because the user may go back. A track that has left the library takes the session with it — there is no nearest second to fall back to — and so does emptying the player, which is the listener saying they are done.
The position is written at the moments that mean something (a track chosen, paused,
the slider released, a region marked or cleared) and every five seconds while a track
plays, so a phone tab the OS closes without asking still comes back close; pagehide
and visibilitychange write the exact second, which is the case a reload is. The queue
is stored as ids, re-resolved against the index on the way back, and capped at a thousand
of them: the cap keeps the track and its position rather than the whole list, which is
the half worth having back. The level the slider was left at is its own record, because
it is a fact about the room rather than about what happens to be queued, and every server
gets the volume it was given.
The region comes back with the track it was marked on, and only with that track: the
two seconds are stored beside the id they were measured against, and a stored region
naming a different track is dropped rather than moved onto it. A loop that migrated
quietly onto the neighbour's timeline would be a wrong number on the bar with no way to
see how it got there, and the position next to it needs no such guard — it is only ever
read beside the track it belongs to. Restoring is silent like the rest of it: no fetch,
no boundary placed, because there is nothing to place one against until the bytes are
back. The marks, the chip and the key all wait for that, and so does the clamp that
brings a stored end down to where the file now actually ends.
Freshness
The library is harvested once into IndexedDB and kept. After that the app asks
the server one small question — getScanStatus — every minute, and re-harvests
only when the answer moves: the scan timestamp and the indexed count together,
so a file that lands inside the previous scan's own second still counts as new.
A scan in progress is waited out rather than crawled mid-flight, an unreachable
server is treated as no news rather than an empty library, a backgrounded tab
asks nothing at all (and picks the poll back up when it is visible again), and
an errored one retries on the next tick — so a server that was briefly down
comes back without a reload.
What a re-harvest does to the shelf while it runs is the other half of "kept". Each page of a crawl is published over the rows already on screen rather than in place of them, so a 2,500-track collection re-crawled after a scan stays at 2,500 tracks the whole way through and grows to 2,501 when the new file arrives. It used to spend that crawl as a 1,000-row one and then a 2,000-row one, because a page is rows fetched so far and the screen had been reading that as the library — and a server that gave out halfway left the fraction that had arrived standing in for all of it. Rows keep the places they had (the list is identity-keyed, so the one under the cursor is still there when the crawl re-fetches it), a crawl that dies halfway leaves the shelf exactly as it was, and the only thing that takes a row away is a crawl the server accounted for: the same verdict that earns the prune, applied to the screen and the disk together. A re-harvest of a library that did not move writes nothing at all.
The other half of freshness is knowing when a harvest was not believed, and the gutter
says it in the server's own numbers: harvest incomplete — 22,353 of 22,375 tracks, 22 marked missing · index unchanged. The clause is the diagnosis, because the same nine
words used to cover four failures with four different fixes — a proxy that ate
x-total-count, a scanner that died halfway, a login served part of the library, and an
empty answer from a server that still counts thirty thousand files. index unchanged is
the safe half: the rows on screen came from the disk, the crawl's rows are held, and the
one dangerous call — the prune that takes away rows the crawl never brought back — was
withheld. :harvest reads back the whole ledger the refusal was decided from (counted 22,375, listed 22,353 without missing is the server admitting it wrote files off; the
same count twice is it holding rows back from this login), including the crawls that were
believed, because "this server has never accounted for itself" and "it did, once, and
stopped" are the same line until you can see both. :harvest rebuild is the manual
answer to an index you no longer believe — it cannot lose anything, since every row is
one the server can serve again, which is exactly why the crawl was never allowed to
delete one without being told the count first.
s asks the server for a Navidrome share link and puts it on the clipboard, since
the next action after sharing is a paste in another window. It expires in a day —
Navidrome stores no expiry at all when the param is omitted, and a public page that
streams the file to whoever finds the link should not outlive being sent.
The two are asked for together rather than in sequence, and that is the difference
between a copy that works and one that usually does not: a clipboard write is allowed
only while the browser still counts the keypress as a user gesture (seconds in Chrome,
about one in Safari), and createShare is a round trip to a server that may be on the
other side of a tailnet. Writing after the answer arrives is writing after the
keypress has been forgotten. ClipboardItem is what unties it — its entry value may be
a promise, so the write is issued inside the keypress with the URL still in flight and
the browser resolves the content when the answer comes. Browsers that will not hold a
promised entry open get the old order, best-effort.
If the browser will not take the copy, the toolbar line says so in the browser's own
words — no clipboard API (needs https), NotAllowedError: Document is not focused,
... permission denied are three different refusals with three different fixes, and
printing copy failed for all three is how this became undiagnosable — and that line is
kept instead of being cleared by the next key, because it is the only copy of the link
there then is. One click on the URL selects the URL alone, so the paste that was going
to be the next action can still happen.
An http page gets the copy anyway, by the route that predates the Clipboard API: a
throwaway textarea, selected, copied, removed. The API is gated on a secure context
because it reads as well as writes — a clipboard holding a password is not something to
hand an attacker who can forge your origin — and that gate is sensible about reading and
collateral damage about writing a link this app minted a moment ago. execCommand("copy")
was never gated that way, so it is the third attempt rather than the first, and the one
sentence the app prints for it (no clipboard API (needs https)) is what you see when
there is no API and no execCommand either.
The link is not marked downloadable: that needs downloads enabled server-wide as well
as on the share, so it stays the guest account's job. A server running with sharing
switched off answers share failed: HTTP 501 from createShare — a setting on the
server, not a password to fix.
The cursor is the reader's position and the playing marker is the player's, kept
deliberately apart. j/k move the cursor and scroll only when it has actually
left the screen; a click picks the cursor up too, so Enter after a click replays
what was clicked. n and p walk the queue a track at a time, and Shift over either of
them takes a record at a time; those, and a buffer running out, all move the player and
leave the cursor where it was — a cursor that teleported every time a track ended would
make the list unusable to read. A track chosen by r/R/Alt+r is revealed, because
those jump to something you were not looking at; one chosen by click, or by the
clock, is not.
The cursor walks the section rows as well as the tracks, because an album and an
artist are things you can play: Enter on an album header queues that record from
its top and draws the outline a cover click would, and on an artist header queues
everything that artist has in what the filter left on screen. Same Enter, three
row kinds. Walking a stretch of songs costs one extra press per section crossed —
the price of being able to point at the sections.
Accepting a search — Enter on the command line — takes the cursor up as well when
nothing is picked, landing it on the top row of what the filter left so the keys
you have just been handed have somewhere to be; it plays nothing. An existing pick
is left where it is, and Enter with the line still open is not a licence to move
it. The one search that does start music is the one that leaves a single track:
you have already said which song you mean by typing something only it matches, so
that press plays it the way a click on the row would.
The accent
One colour in this theme is chosen by the record being heard: the accent — the caret, the
query text, the playing row's title, the fill of the seek bar. src/lib/theme/accent.ts asks
the current cover for a palette (colourthief's MMCQ over OKLCH-sampled pixels, fetched at 96px
of its own rather than reused from either <img> — a canvas drawn from a cross-origin image is
tainted and getImageData on it throws) and chooses from it. Vivid beats common, and
population enters to the fourth root, which is the difference between "the sleeve is brown, so
the app is brown" and the small red print in the corner being allowed to be the accent. A
monochrome sleeve has nothing to lend and the app keeps its own green; so does one whose only
colour is the gold the A–B region is drawn in.
What the record chooses is the hue and the chroma. The lightness stays the app's — 0.695, where the house green sits — because this token is ink as well as fill, drawn at 11px. Every hue parked there clears 6.7:1 against the app's surfaces; against the typing wash, which is 34% of the accent laid over its own ground, the wheel spans 4.34:1 to 5.15:1, so about half of it is lifted a few hundredths of a lightness until it clears 4.5:1 — WCAG's floor for body text, and not a number chosen for this theme, which is what makes it worth checking: an album cover is not to be trusted with deciding how bright the app's ink is. Chroma is trimmed to what sRGB can show rather than clipped — clipping moves the hue, and an ochre came back 17° round the wheel, orange gone to olive — and the whole change cross-fades over 400ms, still for anyone who asked for no moving news.
Two things it deliberately does not follow. The format chips stay fixed, because a hue means format only works while the hue is a constant: driven by the artwork, flac would wear the WAV's violet on one album and the AIFF's pink on the next. And it follows the current track and nothing else — not the grid, not a hover — because it is drawn on the transport, and a reader who takes it for a statement about what is playing is only allowed to be told the truth.
A cover that 404s, a server that will not hand one over, a palette with nothing in it: each leaves the last accent standing, since the last accent came from the record you were listening to and is still the true answer. The colour is kept between sessions, so the first paint is already it rather than the green arriving a few hundred milliseconds late. Every decision prints one dev-only line, because every way an accent can fail to change is a silence:
[accent] cover ALBUM-7 → #ff721e · took oklch(0.64 0.192 41) off a wall at
oklch(0.19 0.025 276) holding 3.7% of the cover · contrast 7.1:1 on the panel, 4.5:1 on its
own typing wash
The lyric
When the record has words and somebody has timed them, they appear in the bar: three lines of the synced lyric against the transport, read to its right edge, with the line being heard set in the bar's brightest ink and the two beside it kept dim enough to read but not to look at. They come from LRCLIB, a free and keyless lyrics database, asked by name from the listener's own browser — there is no proxy and no server of ours in the path, and no credential of the user's Subsonic host rides on the request, because a lyric host has no use for one and a URL that carried it would put a password's derivation in a third party's access log.
It is a window and not a block of text. The column is 44px tall — the art tile's edge to the pixel, which is what keeps the bar from growing the moment a lyric lands mid-track while you are looking at it — and the line being heard is held on its reading line in the middle, so the line you have just heard and the line coming up are both on screen. The edges fade rather than stopping, because a scrollbar there would cost the width the words need and would be a control in a bar that has decided it has no controls; the left edge fades too, since right-aligned text is clipped at the left and an ellipsis belongs at the end of a line, not where the words you have already heard are. The auto margin that holds the transport to the right edge of the bar moves to the column while a lyric is on screen and back to the transport when it is not, which is why the arrangement is written as sibling rules rather than as a class the bar sets: the two states are the DOM, and a flag would be a second answer to what is on screen. Every row is a seek: click a line and the deck starts where that line is stamped, which is the other thing anybody wants to do with a lyric.
Three rules keep a third-party look-up from misbehaving on a transport, and all three are asserted rather than trusted:
- one clock. The highlight reads the bar's own displayed position, the number the elapsed clock, the waveform's fill and the thumb are drawn from — so scrubbing drags the words with the thumb. A second reading of the playhead would be a second answer to "where in the track is this", and the two agreeing most of the time is worse than their disagreeing: words that drift out of step with the line above them are the one thing a column like this cannot do. For the same reason the shifter's latency is not compensated for here, as the beat pulse compensates it — the playhead does not wear that correction either.
- words are looked up by the track, never remembered about one. Answers are kept under the
recording's signature — artist, title, record, length, spelled the way the host spells it — and
the bar asks what is known about the track it is naming. So the previous song's lyric under a
new title is not a case the component has to notice and refuse; it is not in the derivation to
begin with. (The first version kept a single current state and compared its id against the
song's, and
@solidjs/diagnosticsnamed what that cost: every reader of the pair woke twice per track change, once against words that had just left.) An answer is kept whether or not anybody is still looking at the track it belongs to, so a lap through the queue and back costs no second request — and a track the host has never heard of is kept as nothing, so it is not asked again either. - the host is asked politely, because there is no API key to be rude in spite of. One request
on the wire at a time and 250ms apart, whatever the UI does in between; a 429 or a 503 puts the
host on the quiet period
Retry-Afterasked for, capped at a minute so that being switched off by a response body is not what obeying a header means; a 503 is retried once, a 429 is not retried at all. A question whose track has gone away is aborted rather than answered, and the framework's plain-text refusal of a malformed query is reported as what it is — the one failure here that means something this app has to fix.
The asking is a ladder of four questions, and each rung after the second gives something up, so the
order is by what an answer can still be believed on rather than by cleverness. /api/get takes the
whole signature — title, artist, record, length — and the 99 % of tracks stop there, on one request.
A 404 asks /api/search the same question in keywords with all the names still in it. If that is
empty too, the third rung strips the guests off the end of the title and asks without an artist at
all — but only when something actually came off, which is the rule that keeps the rung from being a
coin flip. It is the rung for the file whose tags name the production team and the box set instead of
the artist and the album the recording was released under: Partners For Life ft. Pharrell Williams &
Jamie Foxx off a promotional Neptunes compilation is Partners for Life by Diddy, from Press Play,
and LRCLIB has never heard of Disc 12. The fourth spends one of the credited names as the artist,
which is where ft. Jamie Foxx stops being noise and becomes the only name in the file the database
also uses.
Those two rungs ask about a track whose own tags have turned out to be unreliable, so the length is
what they have to be believed on: a candidate further than three seconds from the file's duration is
not this recording and is thrown out rather than ranked. That number came from this exact case. A
title search for Partners for Life returns the right recording at 270.3, 271.0, 271.8, 272.0 and
273.0 seconds under about fifteen spellings of the same credit — and, in the same list, Partners For
Life by Jeff Beal, from House of Cards, at 160. A preference between those two is a coin flip
waiting to be lost; a veto is not. And with no length to check an answer against the loose rungs are
not asked at all: a question that can be neither believed nor disbelieved is a question that should
not be asked. Credits inside brackets are always stripped, a credit at the end of a title only when it
is spelled feat/ft/featuring, and never a with — With or Without You is a title, and asking
about You is a wrong question rather than a looser one.
The ceiling is what an answer is believed on, and it is not what the loose rungs are asked for:
three seconds is a statement about how long a recording is, and nothing at all about whose it is. The
file that taught that distinction was Tesla / Card Game / glue70 — two Swedish artists LRCLIB has
never heard of, so the honest answer was none — and the question "any recording called Tesla at 148
seconds" answered with TESLA by Macky; Einár, at 147: same title, one second off, three seconds
inside the ceiling, somebody else's words in time to this track. Twenty songs in that database are
called Tesla. So the two loose rungs are earned by an edit, not by a miss: taking a credit off
the title is evidence that whoever tagged this file was writing a filename rather than a title, and
without that evidence a name alone is not an identity, and the bar would rather be silent than quote
the wrong song. :lyricsaudit says so when it happens — the title had no credit to take off —
because a track that got no lyric because the ladder held back should read as held back.
none and unreachable are kept apart the same way the loop chip keeps no loops apart from
didn't look: a track LRCLIB has no lyric for and a host that did not answer are different facts
for a listener to know, and only one of them is worth asking about again. Neither is said in the
bar — an absence is not a thing to reserve three lines for, and the absence of the column already
says it — so :lyrics is where you ask, and it answers with the setting and the verdict in one
line.
It is also where you turn the whole thing off, and that is a setting rather than a preference
menu's afterthought: this is the app's one request to a machine that is not the user's own server,
and it carries the titles, artists and records out of their library. :lyrics off means off now
rather than off after this track ends — the request is aborted and the words go with the press —
and :lyrics on asks about whatever is on the bar at once, rather than waiting for the queue to
move. The choice is persisted, in the same key-per-thing the volume and the octave use.
When the answer is wrong rather than merely absent, :lyricsaudit opens the ladder's account of
itself: one row per question the client asked about the track on the bar, with the query that went out
in LRCLIB's own field names, how many records came back, how many had timings, and — the column the
whole table exists for — what was refused. A rung that says nothing is a wall. The same rung saying
2 hits · 2 timed · 2 refused, with Jeff Beal — Partners For Life · 112s off · timed · refused for
its length listed under it, is the number that tells you whether the three-second ceiling is wrong or
the tags are. That is the difference between tuning a ladder and guessing at one, which is why the
refusals are printed in the host's units rather than summarized in the app's: a debugging table that
agrees with the code beside it instead of with the wire is a table that argues back.
It draws a table and not a chart because a ladder is sequential by construction — one request on the
wire, always, behind the same gate that keeps the host polite — so a timing bar would be four bars in
a row under a table that already says which ran first. ←/→ walk the attempts (the abandoned ones
included: a ladder the listener interrupted is still the record of what was asked for that song), the
store's own decisions print underneath as notes — remembered, the answer arrived after the listener
had moved on — and :lyricsaudit off stops writing them down without losing what was already seen.
Boundaries
What the app stores is read back through a schema — validated on the way in, and
only there. The write path stays a plain write-through, because it is the volume and
the scrub, and re-checking a value the app built a moment ago would tax frames to
guard against a mistake it did not make. The schemas catch what JSON.parse could
not: a key another extension wrote, a value an older build left behind, songCount
arriving as "3" — which never equals the length of the cache, so the library
re-crawles every boot with no symptom anyone could name. A rejected value falls back
to its default rather than throwing, and the junk is left where it is for the next
write to overwrite. The cached meta row is the one IndexedDB read that is checked,
because it is the one whose fields decide the warm boot; the song rows under it are
identity-keyed by the virtualizer and deliberately not parsed.
The Subsonic envelope is asserted before request dereferences the body, so a proxy
that answers /rest/ping with its own login page at a 200 is reported as a Subsonic
error naming the endpoint instead of as the JSON parser's TypeError. Everything
beside status is left unvalidated on purpose: servers omit fields, disagree about
which OpenSubsonic extensions they implement, and are allowed to explain their own
failures — a schema that demanded or defaulted those fields would substitute our
guess for the server's words.
Serving it
The build product is static — dist/client/, a prerendered index.html, and
fingerprinted bundles — and the app reaches the music server from the browser. So
nix/module.nix owns no process beyond nginx:
# /etc/nixos/configuration.nix, with this repo as a flake input
{ inputs, ... }: {
imports = [ inputs.hackermusic.nixosModules.default ];
services.hackermusic = {
enable = true;
serverName = "music.example.com";
forceSSL = true;
useACMEHost = "music.example.com";
};
}
enable alone is a deployment: package defaults to this flake's packages.web,
which is pnpm build in a sandbox.
Two cache rules, and the vhost hangs on them. Vite names every file under
/assets/ after its content (index-K5Lae7mC.js), so a byte changing produces a
different URL and a year of immutable is a statement about the filename rather
than a guess. index.html is the opposite — un-fingerprinted, and the thing that
names every fingerprint there is. Cache the shell and a deploy leaves someone
playing last week's bundle, and then a blank page, because last week's HTML asks
for asset names the new root does not have. So /assets/ is cached for a year,
/ is not cached at all, and a missing asset is a 404 rather than a fallback to
the shell — answering a request for a module with an HTML document arrives as a
syntax error in a .js file, far from the thing that caused it.
The proxy is what makes HTTPS work. services.hackermusic.proxy serves a music
server's prefixes — /rest, /api, /auth/login — under the app's own origin, at a
path you then type into the login dialog:
services.hackermusic.proxy = {
enable = true;
url = "http://navidrome.internal:4533";
pathPrefix = "/navidrome"; # → the user enters https://music.example.com/navidrome
};
Without it the browser goes from an HTTPS page straight to whatever the user typed,
and two things can go wrong: the server must send CORS headers, and if it is plain
HTTP the request is blocked outright — mixed content, no headers involved, not
fixable on the server. A LAN Navidrome behind an HTTP listener is exactly that
case. The prefix rather than the root is so the app keeps /, and so this vhost
does not claim /rest from anything else on the box that speaks Subsonic.
The rest is escape hatches: basicAuthFile to keep the box out of the internet's
guessing traffic, extraConfig for raw vhost directives, listen to pin the vhost
somewhere other than nginx's ports, gzip off. Ports 80/443 open in the firewall
with enable, unless listen says otherwise by hand.
nix build .#web --print-out-paths # the site, as a store path
nix build .#checks.x86_64-linux.nginx # nginx + a stub music server, in a VM
nix eval --impure -f ./nix/eval-check.nix --apply 'x: x.ok' # true: the directives reached nginx.conf
The VM test is the one that matters: it asks a running nginx for the shell, an
asset, a deep link and a ping through the proxy, and reads the headers back. The
eval check is the cheap one — it renders nginx.conf and looks for the directives,
which catches an option that has silently stopped producing anything.
When pnpm-lock.yaml changes the dependency hash is stale and the build says so:
run the failing nix build .#web, copy the got: sha256-… it prints into
pnpmDeps.hash in nix/package.nix, build again.
Updating Nix deps
nix flake update # bump nixpkgs (Node.js, pnpm, oxlint, oxfmt, jujutsu)