Lyrics, versioning, and the song page's fact line. - Lyrics are suggested at submission and never imposed: the worker makes one LRCLIB lookup, and Hae sanoitukset re-queries with whatever title and artist are typed. Neither overwrites what the submitter wrote. They live on the submission, travel to the song at publish, and stay editable after the song locks — the lock freezes what a song claims to be, and nobody reviewed the lyrics - The review strip reads and writes side by side: lyrics left, review right. Synced LRC highlights the playing line and seeks on click; plain text scrolls continuously with a nudge knob. Following can be turned off - CalVer YYYY.MM.DD-N, injected from the git tag with -ldflags, shown in the footer, the startup log and /healthz - The song page's metadata became four labelled cells instead of one flat run of five different kinds of fact Fixes: publishing wiped lyrics the worker had just fetched (a request that omitted a field cleared it), lyric auto-scroll landed in the wrong place, and the fader shifted the deck sideways at score 100.
208 lines
11 KiB
Markdown
208 lines
11 KiB
Markdown
# Later
|
||
|
||
Ideas with their reasoning, kept so the thinking isn't redone from scratch. **None of this is v1
|
||
work**, and none of it should shape v1 code beyond what the API contract already allows.
|
||
|
||
---
|
||
|
||
## Email: password reset and announcements
|
||
|
||
Wanted, blocked on not having a reliable SMTP server. Members already have addresses in their profile
|
||
(decisions 31), so the data is there when the transport is.
|
||
|
||
Two things to remember when it happens:
|
||
|
||
- **v1 cannot verify addresses.** Some of them will be typos, and a password reset to a typo'd
|
||
address is a locked-out member. The admin already resets passwords, so that is the fallback — but
|
||
the reset flow should not assume the address works.
|
||
- Announcements need an opt-out, or they become the reason someone stops reading them.
|
||
|
||
---
|
||
|
||
## Lyrics with scaled autoscroll
|
||
|
||
Fetch lyrics and scroll them in time with the audio. Designed and decided (decisions 45), not built.
|
||
|
||
**Coverage, measured 2026-07-31** rather than assumed. An earlier version of this page guessed
|
||
LRCLIB would miss nearly all Finnish music. It does not:
|
||
|
||
| Search | Results | With `syncedLyrics` |
|
||
|---|---|---|
|
||
| Nightwish | 20 | 20 |
|
||
| Eppu Normaali | 20 | 13 |
|
||
| CMX | 15 | 12 |
|
||
| Popeda | 20 | 8 |
|
||
|
||
**LRCLIB** (`lrclib.net`) needs no API key. `/api/get` matches on artist, track and duration within
|
||
±2 s and returns `syncedLyrics` — real LRC with `[mm:ss.xx]` per line — alongside `plainLyrics`;
|
||
`/api/search?q=` is the looser fallback. Go's side is `net/http` and `encoding/json`, so the
|
||
dependency budget survives, and it is treated exactly like ffmpeg and yt-dlp: a timeout, allowed to
|
||
fail, never blocking anything.
|
||
|
||
**Where it happens: at submission, as a suggestion.**
|
||
|
||
- The worker attempts one automatic lookup after conversion, using whatever metadata exists.
|
||
- The waiting page has a **Hae sanoitukset** button that re-queries with whatever is currently typed
|
||
in the title and artist fields. That is the answer for messy tags — `Sentenced Noose` from a
|
||
YouTube upload will not match until the submitter fixes it, and the automatic attempt would
|
||
otherwise just look broken.
|
||
- Neither ever overwrites text the submitter has typed. They can accept the suggestion, edit it, or
|
||
leave the field empty.
|
||
|
||
**Storage:** one nullable `lyrics text` column on both `submissions` and `songs`, copied across at
|
||
publish. LRC or plain is told apart by whether the first line starts with `[`, so no second column
|
||
and no flag. **Nothing has launched, so this goes into `001_init.sql` rather than a migration 002.**
|
||
|
||
**Lyrics stay editable after the song locks** — the lock exists so the thing people reviewed stops
|
||
changing, and nobody reviewed the lyrics. It also means someone can paste them for an old song a
|
||
year later, which is when this feature is most useful.
|
||
|
||
**Playback:**
|
||
|
||
- **Synced hit** → highlight the current line properly, driven by the transport's `timeupdate`.
|
||
- **Plain hit or manual paste** → distribute lines evenly across `duration_seconds` and scroll the
|
||
block *continuously without highlighting a line*. Highlighting makes every second of drift read as
|
||
a bug, and drift is guaranteed — intros and outros alone break a uniform mapping.
|
||
- The Web Animations API does the whole thing including seeking: build the scroll animation with
|
||
`duration_seconds`, `pause()` it, and bind `play`/`pause`/`seeked` on the audio element. No timers,
|
||
no drift accumulation.
|
||
- **Leave a nudge knob** — a ±10 s offset slider, remembered per song in `localStorage`. Uniform
|
||
distribution models a song no real song obeys, and one drag while listening beats any heuristic.
|
||
|
||
**Still open:** where the panel lives on the song page. That page's job is now *listen and write*,
|
||
and a scrolling lyrics panel competes with the review textarea for both space and attention — a
|
||
collapsed panel under the player is the starting guess, not a decision.
|
||
|
||
Copyright posture is the same as the YouTube note: private app, ten people, written down
|
||
deliberately.
|
||
|
||
---
|
||
|
||
## AI features (Gemma-class local model)
|
||
|
||
The constraint that shapes every idea: **E2B-class multimodal models are speech models.** The audio
|
||
encoder targets ASR and spoken-audio QA. Music is out of distribution — genre calls are near
|
||
coin-flips, "describe this track" produces beige copy, and singing over instrumentation is a worst
|
||
case for ASR. Encoders also work in ~30 s windows, so a four-minute song is a chunk loop, and on CPU
|
||
beside Postgres and ffmpeg that is minutes per submission.
|
||
|
||
So the ideas that use the model to be *correct* are the weak ones, and the idea that uses it to be
|
||
*entertaining* is the strong one:
|
||
|
||
- **A bot reviewer** — a member account that listens to each published song and posts a score and a
|
||
few sentences in character. The model's weaknesses become the feature; nobody files a bug because
|
||
the robot was wrong about a friend's doom metal demo. It is a `users` row and a `reviews` row, no
|
||
new domain concepts, and the reveal rule already makes its take something you unlock by reviewing —
|
||
which quietly rewards the behaviour the whole app exists for. Needs: a sharp persona, a
|
||
~3-sentence cap, exclusion from the reviewer leaderboards and from the ≥3-review threshold.
|
||
- Weak: genre suggestion (buys one click, and a confident wrong default is worse than an empty
|
||
dropdown), auto-drafted introductions (same objection as YouTube descriptions), "is this actually
|
||
music" screening (ffmpeg already proves it is audio, and the admin can delete).
|
||
- If **duplicate detection** is ever the real want, that is Chromaprint/AcoustID, not an LLM.
|
||
- Architecture, when it happens: an Ollama or llama.cpp sidecar called over HTTP from the worker with
|
||
a timeout — treated exactly like ffmpeg and yt-dlp, an external process allowed to fail. Go's side
|
||
is `net/http` and `encoding/json`, so the dependency budget survives. Never block publish on
|
||
inference.
|
||
|
||
---
|
||
|
||
## Native client
|
||
|
||
Deferred; the shape depends entirely on what it is *for*.
|
||
|
||
- **Launcher icon and media keys** → a PWA. `manifest.json` plus ~10 lines of
|
||
`navigator.mediaSession` gets media-key control, and on Linux the browser exposes it over MPRIS, so
|
||
the song appears in the GNOME/KDE media widget with artwork. Zero packaging.
|
||
- **Playback surviving navigation** → a webview shell, and this is *not* free even then: a webview is
|
||
one browser context, so in-app navigation tears down `<audio>` exactly like a tab does. The fixes
|
||
are an outer shell page with the app in a same-origin `<iframe>`, fetch-based navigation, or audio
|
||
played by the native layer with the webview as UI only.
|
||
- **Offline listening** → the only motivation nothing else covers, and a genuinely different project:
|
||
download endpoint, local cache management, sync.
|
||
- **Not Electron.** It bundles Chromium and Node — ~150 MB on disk, 200–400 MB idle RAM, and Node
|
||
back in the build, which is the exact dependency surface the rewrite exists to escape. The
|
||
system-webview equivalents are ~5–10 MB: **Wails** (Go, same language as the server) is the pick,
|
||
with Tauri as the Rust alternative.
|
||
- Packaging inverts with that choice: a plain Go binary → **AppImage** (one `appimagetool` step, one
|
||
file to hand out). A webview shell links WebKitGTK, which AppImage bundles badly and the GNOME
|
||
**Flatpak** runtime provides for free.
|
||
- Worth settling first: the usage pattern this app implies is opening a song, listening, and
|
||
reviewing it right there — one page, playback never interrupted. If that is the real behaviour, the
|
||
entire rationale reduces to wanting a launcher icon, and that is a `.desktop` file with
|
||
`chromium --app=…`.
|
||
|
||
---
|
||
|
||
## Custom audio player
|
||
|
||
Native `<audio controls>` is plain, but it is also correct — keyboard-operable, screen-reader
|
||
labelled, media-key aware. Replacing it is cheap and purely client-side: drop the `controls`
|
||
attribute, keep the same element, and drive it through `play()`, `pause()`, `currentTime`,
|
||
`duration`, and `timeupdate`. Playback, buffering, seeking and Range requests keep working untouched,
|
||
and the server changes not at all.
|
||
|
||
Two things keep it cheap and both are already in v1: the player lives in one template partial, and
|
||
`duration_seconds` is on the song row. Skip precomputed waveform peaks — if a SoundCloud-style
|
||
waveform is ever wanted, backfilling a couple hundred `.ogg` files is a ten-line script.
|
||
|
||
When building it, re-earn what the native controls gave away for free: keyboard operation, visible
|
||
focus states, and `aria-label`s on the buttons. That is the part custom players routinely drop, and
|
||
it is not optional.
|
||
|
||
---
|
||
|
||
## Stats not built
|
||
|
||
Wanted at some point, deliberately out of v1: Top 10 by genre, average given versus received per
|
||
member, a reviewer agreement matrix, genre distribution, activity over time. The agreement matrix in
|
||
particular is an O(n²) query and a UI nobody has asked for twice.
|
||
|
||
Also here: pruning the count-based leaderboards once the queue has drained and they are all ties
|
||
(decisions 35).
|
||
|
||
---
|
||
|
||
## Review form as a mixer channel
|
||
|
||
Idea for the UI polish pass, not now: put the score slider and the review textarea **on one row**,
|
||
with the slider **vertical** like a channel fader on a mixing desk. The score stops being a form
|
||
field and becomes the instrument the app is actually about, and the two things you do at once —
|
||
decide a number, write why — stop being stacked a screenful apart.
|
||
|
||
Notes for whoever builds it:
|
||
|
||
- A vertical `<input type="range">` is native now: `writing-mode: vertical-lr; direction: rtl` gives
|
||
bottom-to-top travel with no JS and no custom widget, so keyboard support and the value stay free.
|
||
- Keep the live `<output>` — on a fader it wants to sit at the top of the track, reading like a
|
||
channel's gain display.
|
||
- The row needs a mobile answer: below ~640px, either keep the fader and shrink the textarea beside
|
||
it, or fall back to the current stacked layout. A short vertical fader is worse than a horizontal
|
||
one, so measure before choosing.
|
||
- Tick marks along the track (1 / 25 / 50 / 75 / 100) replace today's `.scorescale` row, and are
|
||
what make it read as equipment rather than decoration.
|
||
|
||
---
|
||
|
||
## The JSON API
|
||
|
||
Designed and specified in [spec.md §8](./spec.md) — object shapes, endpoints, error codes,
|
||
pagination — and deliberately not implemented, because nothing calls it (decision 43).
|
||
|
||
When something does:
|
||
|
||
- Build only the endpoints that consumer needs, as `jsonOf(...)` adapters over the same data
|
||
functions the pages already use, so the domain rules cannot diverge between the surfaces.
|
||
- Add the golden-file tests at the same time, one per object shape. They are what makes a renamed
|
||
field a test failure rather than a silent break in a client you cannot update.
|
||
- CORS is a one-line middleware, added the day the consumer is on a different origin. Not before.
|
||
- The most likely first consumer is a native client (see above), and the endpoints it needs are
|
||
login, the queue, a song with its reviews, and posting a review — four routes, not twenty-one.
|
||
|
||
---
|
||
|
||
## Filters on the browse list
|
||
|
||
`/songs` is newest-first with no filters. Once there are a couple of hundred songs, "which ones
|
||
haven't I reviewed" and "show me the metal" become real questions — one `WHERE` each, additive to the
|
||
API contract as query parameters. Worth doing when the scrolling annoys someone, not before.
|