Compare commits

..
53 Commits
Author SHA1 Message Date
branderson 7fa1f3912f webui: a note sheet with a real pitch axis and beat grid
tests / test (pull_request_target) Successful in 4m20s
The review drew each line's notes as thin bars in a 20px strip, with no
pitch axis or beat grid, so it was hard to tell where a note sat. Add a
sheet that shows one line at a time the way an UltraStar editor does: a
row per semitone, shaded like piano keys and named in a gutter, the
chart's own beat grid with bars every 4 and 16 beats, notes as blocks
styled by kind (golden, freestyle, rap) with low-confidence ones outlined,
neighbouring lines faded in for context, syllables and aligned words
beneath, and a playhead that follows the transport, paging with it.

The detected pitch curve is drawn over the notes, served by a new GET
/api/jobs/{id}/pitch in note semitones, so a note off the sung pitch
stands out. The sheet is its own ES module; its geometry is unit-tested
with node --test, run from pytest and skipped where Node is absent.
2026-10-11 01:19:12 -07:00
branderson 8e4ba91428 Merge pull request 'Keep manual edits to a song across rebuilds' (#29) from edit-layer into main
Reviewed-on: #29
Reviewed-by: branderson <brad@branderson.io>
2026-10-11 01:10:17 -07:00
branderson eb6abfe8b8 Keep manual edits to a song across rebuilds
The groundwork for an in-app editor. Edits live in edits.json in the work
dir, which no stage produces, so no rebuild removes them. An edit is a
whole lyric line as the user left it, in seconds, so a rebuild with a new
BPM re-snaps it. grid lays the edits over the freshly generated notes
(neighbouring lines give way to an edited one), writes the result to
notes_final.json with warnings brought up to date, and charts that. When
the lyrics change, edits follow their lines by text and order; an edit
whose line's words changed is set aside as stale and reported, not applied.

Notes gain a kind, so golden, freestyle and rap notes can be charted. The
review endpoint returns the merged notes with their syllables, kinds and
edit state, plus the chart's beat grid. PUT/DELETE
/api/jobs/{id}/lines/{n} save and revert a line, DELETE
/api/jobs/{id}/stale/{n} discards a stale edit; each rewrites the work and
packaged charts in place with the tempo already chosen, so a save
re-encodes nothing.
2026-10-11 01:04:37 -07:00
branderson b1339f04bf Merge pull request 'notes: judge low confidence against the song, and flag unsingable words' (#28) from relative-confidence into main
Reviewed-on: #28
Reviewed-by: branderson <brad@branderson.io>
2026-10-11 00:45:44 -07:00
branderson 1424404703 Merge pull request 'web: report a job finished only once everything finishing it is done' (#27) from job-done-last into main
Reviewed-on: #27
Reviewed-by: branderson <brad@branderson.io>
2026-10-11 00:22:02 -07:00
branderson 79f7d805fb notes: judge low confidence against the song, and flag unsingable words
A fixed 0.5 alignment-score cutoff flagged 73-99% of notes on sung
vocals, so the flag pointed nowhere. Scores still separate bad words from
good ones, but their scale differs from song to song: flag a word below a
quarter of the song's median score, or one shorter than 0.08s or longer
than 3s, which no singer produces and which a word stretched over
something else does. Flagged notes less than 2s apart share one warning.
2026-10-10 23:33:53 -07:00
branderson 61b9bca8aa web: report a job finished only once everything finishing it is done
The worker set status "done" before removing the job's upload and before
setting finished_at, so a client polling for done could see the upload
still there and finished_at null. Record the outcome and publish status,
error and finished_at together, under the lock, as the last step.
2026-10-10 23:29:37 -07:00
branderson c8d1d5ae9e Merge pull request 'align: keep lines Whisper missed when their singing went untranscribed' (#24) from unheard-lines into main
Reviewed-on: #24
Reviewed-by: branderson <brad@branderson.io>
2026-10-10 22:08:59 -07:00
branderson 0342909178 Merge remote-tracking branch 'origin/main' into unheard-lines 2026-10-10 22:03:47 -07:00
branderson e104c3f078 Merge pull request 'ci: upgrade pip in the venv before installing torch' (#26) from ci-pip into main
Reviewed-on: #26
Reviewed-by: branderson <brad@branderson.io>
2026-10-10 21:50:58 -07:00
branderson b1ae87d69c ci: upgrade pip in the venv before installing torch
Debian bookworm's python3-venv seeds pip 23.0.1, which rejects the
PyTorch CPU index's typing_extensions wheel because its metadata spells
the name with an underscore. It then falls back to the sdist, whose build
needs flit_core, which that index does not carry.
2026-10-10 21:38:30 -07:00
branderson b851145bdd Merge pull request 'align: let a star token absorb non-lyric audio between lines' (#25) from line-stars into unheard-lines
Reviewed-on: #25
Reviewed-by: branderson <brad@branderson.io>
2026-10-10 20:56:15 -07:00
branderson 2739a1e9e8 Merge pull request 'webui: add REBUILD ALL FROM… to rebuild every song from a stage' (#23) from rebuild-all into main
Reviewed-on: #23
Reviewed-by: branderson <brad@branderson.io>
2026-10-10 19:33:09 -07:00
branderson c39b7e5d06 Merge pull request 'ci: run the test suite on pull requests' (#22) from ci-tests into main
Reviewed-on: #22
Reviewed-by: branderson <brad@branderson.io>
2026-10-10 19:32:43 -07:00
branderson 267a172023 align: let a star token absorb non-lyric audio between lines
The aligner had to explain every frame of a section with the lyrics, so
background speech or laughter between lines pulled the next line's
letters onto it, which then came out as a burst of tiny notes before the
real line was rushed to catch up. MMS_FA has a star token that matches
any audio; put one before, between and after the lines of each section,
as ctc-forced-aligner does by default, and drop the stars' spans from the
result.
2026-10-10 19:21:58 -07:00
branderson 82e334955a align: keep lines Whisper missed when their singing went untranscribed
A line with nothing in the transcript was always dropped as not sung. When
Whisper skips a passage (one song lost a whole verse, 50s of clear singing
with no segments), that drops lines that were sung. Find singing in the
vocal stem that no segment covers; a run of unmatched lines with such
singing between its heard neighbours is kept, and that singing joins the
alignment sections so those lines have somewhere to go. Only singing that
kept a line is added, so an untranscribed ad-lib draws no lines into it.
2026-10-10 18:58:54 -07:00
branderson cd6a5859cc webui: let REBUILD ALL FROM… skip individual songs
The dialog now lists the songs it would rebuild, all ticked, each with when
it was last built and whether that was a rebuild, so songs already rebuilt
by hand can be unticked. GET /api/rebuild-all returns that list, so the
page no longer repeats the server's rule for which job stands for a song,
and POST takes the ticked job ids. A selected job that has since been
rebuilt is reported as skipped rather than rebuilt twice.
2026-10-10 18:16:02 -07:00
branderson 185b18f53b webui: add REBUILD ALL FROM… to rebuild every song from a stage
When an update improves a stage, existing songs only pick it up when each
is rebuilt from that stage. POST /api/rebuild-all queues an ordinary
rebuild with unchanged inputs for the current job of every song: jobs that
were themselves rebuilt are skipped, as are songs still in progress and
songs whose upload is gone when the stage is acquire. The sidebar button
opens a dialog to pick the stage and confirm the count.
2026-10-10 18:06:13 -07:00
branderson c20e3ded7a ci: run the test suite on pull requests
Runs on pull_request_target so the workflow always comes from main, on the
confined runner tier. Installs CPU-only torch before the package so pip
does not pull the CUDA build, then runs pytest. .dejima/allowed-domains
declares the hosts the job fetches from.
2026-10-09 18:21:46 -07:00
branderson b5187f2606 Merge pull request 'pitch: treat bleed in the vocal stem as silence' (#15) from pitch-silence into main
Reviewed-on: #15
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 18:15:21 -07:00
branderson a283eb4951 Merge pull request 'tests: replace copyrighted lyrics with placeholder text' (#14) from synthetic-lyrics into main
Reviewed-on: #14
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 18:15:01 -07:00
branderson c08b66d2f8 pitch: treat bleed in the vocal stem as silence
Separated stems keep faint bleed during instrumental passages, and crepe
finds a pitch in it with enough confidence to pass the voicing mask. A word
whose alignment ran into such a gap was then filled with notes for the
whole gap. Zero the confidence of frames more than 25 dB below the song's
90th-percentile A-weighted loudness, relative so it holds at any mastering
level.
2026-10-09 18:06:54 -07:00
branderson 57a9278189 tests: replace copyrighted lyrics with placeholder text
The align, transcribe, lyrics and notes tests quoted lines from published
songs, and the UltraStar parser fixtures were complete community charts of
commercial songs. Replace them with counting words and two hand-written
charts that keep what the parser tests rely on: CRLF line endings,
Latin-1 bytes, a decimal-comma BPM, freestyle/golden/rap notes, negative
pitch and a two-number line break.
2026-10-09 17:17:02 -07:00
branderson 8074497038 Merge pull request 'Keep matched words that fall inside a missing-lyrics span' (#13) from missing-notes into main
Reviewed-on: #13
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 16:54:17 -07:00
branderson 1fd2a5e932 notes: keep matched words that fall inside a missing-lyrics span
A missing span covers whole Whisper segments, so it often also holds lines
that were heard and matched. Skipping every aligned word starting inside one
dropped those lines' notes and left empty line breaks in the chart. Words
from lines never heard are already excluded via dropped_lines, so the span
now only drives the warning and the review marker.
2026-10-09 16:52:56 -07:00
branderson 05a3032636 Merge pull request 'Keep yt-dlp current at runtime, with the bundled copy as fallback' (#12) from ytdlp-runtime into main
Reviewed-on: #12
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 16:43:24 -07:00
branderson 57a21356f3 acquire: keep yt-dlp current at runtime, with the bundled copy as fallback
YouTube breaks old yt-dlp releases faster than songtrace is released.
songtrace now checks PyPI daily, and again after a download fails outright,
installs a newer stable release into its cache dir and tries it before the
bundled copy, which is never modified. yt-dlp runs in a subprocess through
its CLI, all from one module, so a cached release never mixes with the
imported one. ytdlp_version pins a release or turns updating off.
2026-10-09 14:36:29 -07:00
branderson 8058592a65 Merge pull request 'Release the GPU after every stage' (#11) from gpu-unload into main
Reviewed-on: #11
Reviewed-by: branderson <brad@branderson.io>
2026-10-06 13:44:44 -07:00
branderson a826f93720 Merge pull request 'webui: play a finished song against its review' (#10) from review-playback into main
Reviewed-on: #10
Reviewed-by: branderson <brad@branderson.io>
2026-10-06 13:44:23 -07:00
branderson 1747106292 Release the GPU after every stage
songtrace serve is long-lived but only wants the GPU in bursts, and until now
it kept everything it ever loaded. align cached wav2vec2 in a module dict that
was never cleared, torchcrepe caches its model on its own infer() function,
and even once the last reference to a model goes, PyTorch keeps the freed
blocks in its allocator rather than returning them to the driver. On a shared
box that is the whole GPU held against one idle process.

Stages now hand it back as soon as they are done with it. A stage that caches
a model registers how to drop it with @on_release, so models.py needs to know
nothing about the pipeline, and run_pipeline calls release() in a finally
after each stage — not once per job, because transcribe sits between separate
and align waiting on the Whisper server, which can be minutes of a GPU nothing
is using.

release() answers the bytes it handed back and the stage logs a line when that
is not zero, which is the only part of this visible from outside. It will not
initialise CUDA just to clean up after a run that never touched it.

The cost is reloading each model once per job rather than once per process,
seconds against stages that run for minutes. The CUDA context itself stays
until the process exits; releasing that would mean a subprocess per stage.
2026-09-29 00:01:20 -07:00
branderson 36de00c882 webui: clear the page for a new song, and let a rebuild redo a stage
Two problems found in review.

CREATE SONG emptied the form but left the previous song's review, player,
output path and queue selection in place, which reads as though the blank
form and that song belong together. It now clears the page down to a blank
sheet, and a `composing` flag stops the next poll selecting the newest song
back into it.

The reason it cleared so little is that clearDetail reached for an element id
that no longer exists, so it threw partway through and left everything after
the first two lines untouched. It now targets the review box and takes the
reason to show, since "no songs queued yet" is wrong when you have simply
deselected.

Separately, rebuild plans from the fields that changed, which cannot see a
change in songtrace itself: rebuilding a song packaged before the video's
audio was stripped found every stage cached and kept the echo. The rebuild
form gains "restart from", the CLI's --force-from, which combines with the
planner rather than replacing it — an explicit stage may widen what is rerun
but never narrow it below what the edit requires.

Also: a browser submits a textarea with CRLF, so every rebuild from the page
looked like a lyrics edit and redid the alignment. Lyrics are normalised to LF
on the way in. And [hidden] is only display:none, which .field's display:flex
was winning, so the new control showed in new-song mode despite the property
being set.
2026-09-25 23:52:28 -07:00
branderson c2152da0e4 webui: play a finished song against its review
Checking whether the notes line up meant packaging the song, finding it in
the game and starting it there. The review panel now plays the song folder
itself: a transport above the lyrics, the line being sung highlighted, words
lighting up as they are reached, and a playhead sweeping each line against
its notes. Clicking a line or a word plays from there.

Playback is modelled on the game rather than on whatever is convenient: #MP3
is the audio and the clock, #VIDEO is muted background slaved to it, and the
times are the same seconds the notes were generated from. A word that lights
up late here is late in the game.

package now strips the audio track from the video it writes. It is the same
recording as the #MP3, so a player that does not mute the video plays both a
few frames apart, which is heard as an echo. The compatible-video branch
remuxes with -c copy -an rather than copying the file, so it still does not
re-encode. The browser player mutes the video as well, which covers song
folders built before this.

A new /api/jobs/{id}/media/{audio,video,cover} serves what the song's own
#MP3/#VIDEO/#COVER headers name, refusing anything resolving outside the song
folder. Range requests come from FileResponse, so seeking works.

Also, per review: CREATE SONG moves out of the form and into the top of the
queue, where it is always available, and a successful submission keeps the
fields it sent instead of emptying them.
2026-09-25 23:33:23 -07:00
branderson cb3bec96f2 Merge pull request 'webui: bring a selected song's inputs back into the form' (#9) from webui-prefill into main
Reviewed-on: #9
Reviewed-by: branderson <brad@branderson.io>
2026-09-25 23:09:30 -07:00
branderson 188ec66d7c webui: bring a selected song's inputs back into the form
Selecting a queue entry filled the review panel but left the form empty, so
the only way to adjust a song was the EDIT & REBUILD button, and the only way
to see what a song was built from was to click it. Selection now loads the
job's inputs, which is what the form is for: pick a song, change a line of
lyrics, hit REBUILD SONG.

That makes the EDIT & REBUILD action redundant, so it goes. CANCEL becomes
NEW SONG INSTEAD, which is what it does now that the form is rarely empty.

The landing page deliberately keeps its blank form: the job auto-selected on
load fills the review panel only, so opening the page is still the way to
start a new song.

Three things the old button-driven flow never had to handle:

- Advanced is opened when the job has a genre, year, language, bpm or a kept
  cover. Those carry into a rebuild either way, so leaving them behind a
  closed summary meant rebuilding with values that were never on screen.
- Submit is disabled, with the reason shown, while the selected job is queued
  or running, since /rebuild answers 409 until it settles.
- Selecting a different entry asks before discarding edits, and deleting the
  job the form is holding drops it rather than leaving the form pointed at an
  id that would 404.
2026-09-25 22:48:43 -07:00
branderson 82ebc3c1d4 Merge pull request 'Fix YouTube 403s: install yt-dlp's YouTube extras and retry across player clients' (#8) from ytdlp-resilience into main
Reviewed-on: #8
Reviewed-by: branderson <brad@branderson.io>
2026-09-25 18:32:19 -07:00
branderson d5806f832e acquire: install yt-dlp's YouTube extras and retry across player clients
yt-dlp declares `dependencies = []` and puts everything in extras, so the
bare `yt-dlp` requirement installed it with no yt-dlp-ejs and no JavaScript
runtime. Without those it cannot solve YouTube's player challenges and falls
back to media URLs that YouTube rejects with HTTP 403, which is the failure
seen when adding a song. Require `yt-dlp[default,curl-cffi,deno]` instead;
the deno extra ships the JS runtime as a wheel, so the image needs no extra
apt packages.

Around that, make the stage survive the next time YouTube changes something:
the download is retried against each player client in `ytdlp_clients`
(default `["default", "tv"]`, the latter not needing a PO token) before
giving up, yt-dlp's retry counts are raised, and its own log lines are routed
into the job log so a PO-token demand can be told apart from a rate limit.
The final error names the running yt-dlp version, since a stale one is the
usual cause.

Cookies, TLS impersonation and a bgutil PO token provider URL are now config
fields, so an operator can work around a block without a code change.
`_classify_downloads` skips .part/.ytdl scratch files, which retries make far
more likely to be lying around.
2026-09-25 18:24:40 -07:00
branderson b111dbeb20 Merge pull request 'Add rebuild-from-scratch, delete, and cross-restart persistence for songs' (#5) from song-rebuild-persistence into main
Reviewed-on: #5
2026-07-28 03:25:48 -07:00
branderson fb44d0151e docs: regenerate webui.png 2026-07-28 03:26:08 -07:00
branderson dd8934a5cf Merge remote-tracking branch 'origin/main' into song-rebuild-persistence
# Conflicts:
#	src/songtrace/web.py
#	tests/test_web.py
2026-07-28 03:24:04 -07:00
branderson 619050e331 Merge pull request 'Webui: open video source, restructure lyrics form, and add a visual per-line review' (#4) from worktree-webui-lyrics-review into main
Reviewed-on: #4
2026-07-28 01:42:56 -07:00
branderson 8907844958 Fix note bars hidden under alternating word-lane tint
.review-lane spans the full timeline box height (top:0; bottom:0) to
group each word with its note visually, but as a positioned element it
painted after the unpositioned .review-roll SVG, so the lane's
background covered the note bars for every other word. Give .review-roll
position: relative so DOM order (lanes, then notes) governs paint order
instead.
2026-07-28 01:43:04 -07:00
branderson 91ad0fbd3a Use real Greensleeves melody in webui screenshot fixture
Replace arbitrary demo pitches with the actual melody transcribed from
Gilbert DeBenedetti's public-domain "Level 4, Flowing" arrangement (E
minor, 6/8), converted to UltraStar semitones, with one note per word
across both demo lines.
2026-07-28 01:35:44 -07:00
branderson 3a57b589f7 review: lay out lyrics words along a shared time axis with notes aligned beneath
The review panel used to show a full line of packed text with one
undifferentiated strip of notes below it, so it was impossible to tell
which notes belonged to which word. The forced-alignment stage already
produces per-word timestamps in alignment.json, but they were never
surfaced past the pipeline.

/api/jobs/{id}/review now includes each line's per-word timing, and the
frontend spaces words out by their real sung timing (karaoke-style)
instead of normal text flow, with the note strip sharing that same time
axis so each note lands directly under its word. Alternating lane
backgrounds make the word/note pairing unambiguous. Dropped lines and
jobs without alignment data keep the old plain-text rendering.
2026-07-28 01:22:48 -07:00
branderson 1345d0121e docs: regenerate webui.png with the new lyrics review panel 2026-07-28 00:48:42 -07:00
branderson b506de6147 transcribe: scope missing_spans to the unmatched audio itself, not the whole vocal section
Also fix a secondary ripple in build_notes(): a skipped row (inside a
missing span) no longer advances prev_line/prev_end, so it can't corrupt
line-initial spacing or break timing for its own line's later words.
2026-07-28 00:48:19 -07:00
branderson b3b9b9ee4b screenshot: select the new Greensleeves fixture so it actually appears in the regenerated screenshot 2026-07-28 00:48:19 -07:00
branderson c21a09ed93 screenshot fixture: add songs with dropped lines, missing span, low-confidence notes 2026-07-28 00:48:08 -07:00
branderson d357fc5b93 fix: clear lyrics-review cache on job status change
renderLyricsReview was caching its job ID only in the done path, allowing
stale data to persist when switching between done/running/failed jobs.
Now clears the cache in the failed and not-done branches so a previously-
viewed done job always re-fetches when re-selected.

Regression test: test_lyrics_review_cache_clears_on_job_status_change
verifies both branches clear the cache key before rendering placeholders.
2026-07-27 23:36:56 -07:00
branderson be7590efd1 webui: replace flat warnings list with per-line SVG piano-roll review 2026-07-27 23:24:39 -07:00
branderson bed7a135e9 web: add GET /api/jobs/{id}/review for per-line note data 2026-07-27 23:18:26 -07:00
branderson 781f7a326e notes: skip notes in unmatched spans, warn on dropped lines and missing lyrics 2026-07-27 23:13:39 -07:00
branderson 7eee4593c5 align: remove unused RefLyrics import 2026-07-27 23:11:30 -07:00
branderson 619804348e align: exclude never-sung lines from proportional distribution, record align_meta.json
- Added classify_lyrics_match import to wire lyric classification into align stage
- Changed _assign_lines_to_sections signature to accept line_indices list instead of ref
- Modified _run to classify lyrics match, filter dropped lines, and write align_meta.json
- Sort segments by start time before processing for correct matching of non-chronological transcripts
- Added tests for dropped line detection and missing span flagging
2026-07-27 23:06:02 -07:00

Diff Content Not Available