Compare commits

..
134 Commits
Author SHA1 Message Date
branderson 33d66c2af8 README: lead with requirements and Docker, refresh for the editor
The README described a CLI with a minimal web UI and told people to fix
charts in Yass. Rewrite it around how songtrace is used now: what it
does, its requirements up front (an NVIDIA GPU, tested on 12 GB, Docker
with the NVIDIA Container Toolkit, a Whisper server of your own, disk for
models and per-song files), a Docker quick start including writing songs
straight into a game's collection, then reviewing, editing and
rebuilding in the web UI. The command line moves near the end.

Regenerate the screenshot to show the note sheet: the screenshot script
now seeds the featured song with syllables, a chart, a pitch track and a
line break, and always scrolls to the review. The example config no
longer sets a real person as #CREATOR, and uses a reserved example host
for Whisper.
2026-10-15 17:06:19 -07:00
branderson c3660d267a webui: keep a far line break from stretching the sheet
Since breaks could be moved, the sheet widened its window to show the
breaks either side of a line. Before an instrumental passage the break
sits mid-gap, sometimes 15s away, so the line's notes were crushed into
a corner of the sheet. The window now grows at most 3s past the line to
show a break; one further off is pinned to the edge, labelled with an
arrow toward where it lies, and still drags inward.
2026-10-15 17:06:19 -07:00
branderson 2d7bde3d74 Merge pull request 'webui: a note sheet with a real pitch axis and beat grid' (#30) from editor into main
Reviewed-on: #30
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 16:52:44 -07:00
branderson 82147bf295 Merge remote-tracking branch 'origin/main' into editor-merge
# Conflicts:
#	tests/test_web.py
2026-10-15 16:36:27 -07:00
branderson b5e6cd326b Merge pull request 'webui: edit a song's notes on the sheet' (#31) from sheet-editing into editor
Reviewed-on: #31
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 16:22:40 -07:00
branderson 449f742d4b Merge pull request 'webui: mark songs with hand edits in the queue and REBUILD ALL FROM…' (#32) from edited-marks into main
Reviewed-on: #32
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 23:28:27 -07:00
branderson b781c6cb0c Merge pull request 'webui: a grouped keyboard shortcut panel for the sheet' (#33) from sheet-shortcuts into sheet-editing
Reviewed-on: #33
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 23:27:53 -07:00
branderson 3fbf39f9d6 Merge pull request 'webui: set a song's BPM and GAP from the sheet' (#34) from grid-edit into sheet-shortcuts
Reviewed-on: #34
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 23:17:45 -07:00
branderson 82cafa96bf Merge pull request 'webui: move line breaks on the sheet' (#35) from break-edit into grid-edit
Reviewed-on: #35
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 18:54:21 -07:00
branderson 30e4f8f202 webui: move line breaks on the sheet
Line breaks were always placed by songtrace, midway between lines and
recomputed around edited ones, so a break could not be put where a singer
breathes. The sheet now draws the breaks either side of a line as markers
that drag along the beat grid, kept between the lines they separate, and
double-click back to automatic. Break moves share the line's undo history
and SAVE LINE, which saves the notes only if they changed, so moving a
break never locks either line's notes.

Moved breaks are stored in edits.json keyed by the line each comes before,
follow their lines when the lyrics change, and win over the computed break
on every rebuild, clamped so a break never lands inside a line's notes.
The review lists each break with that line and whether it was moved, and
PUT /api/jobs/{id}/breaks takes {line: seconds | null}. merge now passes
untouched notes through as they are rather than rebuilding them, which
added float noise to their durations.
2026-10-13 16:24:17 -07:00
branderson bdfd7d8429 webui: set a song's BPM and GAP from the sheet
The beat grid came from the detected tempo (or the BPM override) and a GAP
pinned to the first note, so it did not always line up with the music,
and snapping did not land on its beats. A GRID bar on the sheet now sets
both, redrawing the grid live as values are tried, with x2 and /2 for a
tempo detected at double or half speed and ALIGN TO NOTE to move GAP the
least that puts the selected note on a beat. Note editing pauses while a
trial grid is unapplied, so notes never snap to a grid that is then
cancelled.

The grid is stored with the song's edits, so every rebuild keeps it, and
grid charts with it in place of its own choice; it records that choice in
tempo.json for RESET to return to. A GAP after the first note moves back
by whole beats, as notes on negative beats are rejected by players. PUT
and DELETE /api/jobs/{id}/grid set and reset it, rewriting the charts in
place.
2026-10-13 16:08:57 -07:00
branderson 2aeb16decb webui: a grouped keyboard shortcut panel for the sheet
The sheet's controls were listed in one run-on line under it, which was
hard to scan and grew with every feature. Replace it with a panel grouped
the way an editor's reference card is (select, move, length, notes, kind,
syllable, line) with keys drawn as key caps, opened by a KEYS button or ?,
closed by Esc, and remembered open or closed across visits.

The list is data (SHORTCUTS), and a test checks every letter key it names
is one the sheet's key handler handles, so the two cannot drift apart.
2026-10-12 23:13:45 -07:00
branderson 9a11db5794 webui: mark songs with hand edits in the queue and REBUILD ALL FROM…
A song's edits are kept through any rebuild, but nothing said which songs
had them. GET /api/jobs and GET /api/rebuild-all now report each song's
edited and set-aside lines, read from its edits file, on the job standing
for the song only, since the runs it replaced would claim the same edits.
Queue cards show them as a badge, and the rebuild dialog lists them per
song and says hand edits are kept.
2026-10-12 19:04:33 -07:00
branderson 381a499f2b webui: edit a song's notes on the sheet
Notes on the sheet can now be selected and edited: dragged in time and
pitch, resized from either end, nudged and stretched from the keyboard,
split, merged, added, deleted, given a syllable or a kind (golden,
freestyle, rap), with undo/redo. Everything snaps to the chart's beat grid
and to semitones, as the chart itself will. SAVE LINE stores the line
through the edits API, which rewrites the charts in place; REVERT goes back
to the generated notes; a line's edits set aside because its lyrics changed
are listed with a way to discard them. Unsaved changes are not lost to
paging, following playback, switching songs or closing the tab.

The operations live in static/edit-model.js as pure functions, unit-tested
with node --test. The sheet hit-tests by geometry rather than event
targets, since a render replaces the elements under the pointer.
2026-10-11 02:37:00 -07:00
branderson 793a78972d webui: a note sheet with a real pitch axis and beat grid
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 fa4a78f278 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 5e2afc1254 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 7a86726c5c 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 211e75c448 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 d4cdb4292d 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 15124e512f 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 2fbff3553c 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 8dd6ae5a17 Merge remote-tracking branch 'origin/main' into unheard-lines 2026-10-10 22:03:47 -07:00
branderson 4dff0dde4c 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 c0343edb64 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 4749a7810f 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 060669fda7 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 15f01c50eb 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 f3c43e0410 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 f5273b12e0 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 2eea510784 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 895c65bb4e 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 e3a1bb4809 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 c1368505c2 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 d484d6f1d2 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 6f5e273c6c 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 4272189bca 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 936641e13c 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 793817ce42 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 f3125370d3 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 6d297a8bfc 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 4af78fdd1d 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 cb485d3c6b 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 f187bfd6c0 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 22c7f6d03f 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 7c4359eb39 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 ba8bf9f4af 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 be67e01943 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 b5850042be 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 55e8297672 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 a7622c2d01 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 c489dd5742 docs: regenerate webui.png 2026-07-28 03:26:08 -07:00
branderson f1b967b487 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 e141cc5773 Fix DELETE slug-collision race and widen _discover_jobs error handling
- DELETE now checks under the shared lock whether another job with the
  same slug is queued/running before removing the job entry, so
  deleting a done parent can no longer rmtree a rebuild's active
  work/output directories.
- _discover_jobs now wraps the iterdir/stat/read_warnings calls in the
  same try/except as the JSON parse, so one corrupt or torn sidecar
  file (e.g. from a killed mid-write) no longer crashes the whole app
  at boot.
- Fixed test_delete_tolerates_a_work_dir_that_is_already_gone, which
  had become vacuous since _write_sidecar now eagerly creates the work
  dir; it now removes the dir before asserting DELETE tolerates its
  absence.
- Added a regression test covering the DELETE slug-collision race.
2026-07-28 03:06:09 -07:00
branderson 92948b2ea7 webui: add rebuild-from-scratch and delete actions 2026-07-28 02:52:43 -07:00
branderson c8c3d941ca web: persist song input as a sidecar and reload jobs on startup 2026-07-28 02:38:55 -07:00
branderson 7b8fce5b6e web: add delete endpoint 2026-07-28 02:34:09 -07:00
branderson cb10400a21 web: add force-rebuild-from-scratch endpoint 2026-07-28 02:31:23 -07:00
branderson 98e4f9532f pipeline: extract read_warnings for reuse by startup discovery 2026-07-28 02:28:27 -07:00
branderson 77bebcf9e1 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 986f26b8ed 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 d557ac6386 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 5023ea69d3 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 0599f6b665 docs: regenerate webui.png with the new lyrics review panel 2026-07-28 00:48:42 -07:00
branderson 2debfb171c 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 ac904cb09f screenshot: select the new Greensleeves fixture so it actually appears in the regenerated screenshot 2026-07-28 00:48:19 -07:00
branderson 8e89b08571 screenshot fixture: add songs with dropped lines, missing span, low-confidence notes 2026-07-28 00:48:08 -07:00
branderson 6a98ea0f21 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 903e83dbcc webui: replace flat warnings list with per-line SVG piano-roll review 2026-07-27 23:24:39 -07:00
branderson a4e0b45953 web: add GET /api/jobs/{id}/review for per-line note data 2026-07-27 23:18:26 -07:00
branderson ffd662fb38 notes: skip notes in unmatched spans, warn on dropped lines and missing lyrics 2026-07-27 23:13:39 -07:00
branderson 43f0554796 align: remove unused RefLyrics import 2026-07-27 23:11:30 -07:00
branderson ebb7fc4433 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
branderson cf1c7410ab transcribe: classify reference lines as matched/dropped, flag unmatched singing 2026-07-27 22:58:58 -07:00
branderson 5bc4855264 webui: move video source out of Advanced, give lyrics a tall column 2026-07-27 22:51:36 -07:00
branderson a650c2f7ec Move webui image to top 2026-07-27 19:59:46 -07:00
branderson 283da7e1a9 Fix yass link 2026-07-27 19:54:36 -07:00
branderson 292fa25d5e Fix Melody Mania link 2026-07-27 19:51:53 -07:00
branderson 0eaab6aeed Remove karaoke maker reference 2026-07-27 19:49:30 -07:00
branderson 7a48942f1c Merge pull request 'Rename project from karaoke-maker to songtrace' (#3) from songtrace-rename into main
Reviewed-on: branderson/karaoke-maker#3
2026-07-27 18:42:57 -07:00
branderson 6c15c3d8f1 docs: fix migration guidance for old console script and orphaned container 2026-07-27 18:36:42 -07:00
branderson d2b7986cb9 docs: rename to songtrace and add tagline 2026-07-27 18:26:50 -07:00
branderson 3f90f4857f refactor: restyle web UI wordmark as SONG / TRACE 2026-07-27 18:24:21 -07:00
branderson 5553614fdf refactor: rename config env vars and default config path to songtrace 2026-07-27 18:21:30 -07:00
branderson 8a87534c5d refactor: rename CLI command and distribution to songtrace 2026-07-27 18:18:11 -07:00
branderson 322f8eb98b refactor: move package from karaoke_maker to songtrace 2026-07-27 18:14:23 -07:00
branderson a3078bb2fc Convert to AGPLv3 2026-07-27 17:37:56 -07:00
branderson bd1b4aeb6e Add GPLv3 license and webui screenshot 2026-07-27 17:28:07 -07:00
branderson f2a97f4f10 Fix torchcodec issue 2026-07-27 10:05:59 -07:00
branderson fca9e874f3 Support whisper asr-webservice 2026-07-27 09:53:03 -07:00
branderson bd5593c3ed Merge pull request 'Rerun failed tracks and edit-and-rebuild existing ones' (#2) from rerun-rebuild into main
Reviewed-on: branderson/karaoke-maker#2
2026-07-27 09:19:56 -07:00
branderson f7b59e7e14 test: close coverage gaps found in final branch review
- add test_rebuild_inherits_the_parents_cover: covers the cover_path
  inheritance mechanism in the rebuild endpoint (Task 2's upload-retention
  design relied on it, but nothing exercised it)
- tighten test_lineage_marker_survives_in_the_accessibility_tree to assert
  the literal " rebuilt" screen-reader text, not just that rebuilt_from
  is referenced somewhere in paintCard
- normalize submit's youtube_url/genre/language the same way rebuild does,
  for symmetry, plus a pinning test
- pin acquire's stage outputs to [meta.json, audio.wav] in test_acquire.py,
  since web.py's _require_source hardcodes those same filenames
2026-07-27 02:55:56 -07:00
branderson 9a5ef49ab6 feat: rerun and edit-and-rebuild controls in the web ui 2026-07-27 02:30:21 -07:00
branderson 5bf63c7c60 feat: remove the output folder a rebuild superseded 2026-07-27 02:16:39 -07:00
branderson b997e7a471 fix: assert the rebuild log line can't be mistaken for a stage banner
Directly assert STAGE_LINE does not match the [rebuild] line, rather than
inferring it from job.stage, which ok_runner's later [acquire] running
line would overwrite either way.
2026-07-27 02:07:00 -07:00
branderson c016e3c09d feat: rebuild a song, redoing only the stages the edit invalidated 2026-07-27 01:59:36 -07:00
branderson 99c62822b4 feat: expose a job's inputs for prefilling the rebuild form 2026-07-27 01:53:54 -07:00
branderson fd0ea79a28 feat: rerun a finished job as a linked new job 2026-07-27 01:49:29 -07:00
branderson a646685c61 fix: wrap unlink in try/except to never raise from cleanup 2026-07-27 01:44:57 -07:00
branderson 96a5ad2e60 fix: keep uploads a rerun still needs 2026-07-27 01:40:40 -07:00
branderson 6536496a12 feat: map input changes to the pipeline stage they invalidate 2026-07-27 01:35:51 -07:00
branderson d65e55b137 Merge pull request 'Web UI: Spotify-style theming, first-class warnings, and live stage track' (#1) from webui-theming into main
Reviewed-on: branderson/karaoke-maker#1
2026-07-26 22:11:18 -07:00
branderson 3d36216769 fix: working clipboard on http, stale detail race, queued rail, idle label, empty-state copy
Final whole-branch review fix wave (5 items):
- copyPath: fall back to execCommand("copy") via an off-screen textarea,
  then to selecting the path text, since navigator.clipboard is undefined
  outside secure contexts (i.e. this app's normal http:// deployment)
- refreshDetail: snapshot selectedId before fetching and discard the
  response if selection changed while in flight, so a slow response can no
  longer permanently mis-pair the review panel with the wrong job now that
  polling stops when idle
- .st-queued: give the sidebar rail a colour that clears 3:1 against --panel
- now-bar: #now-label reflects running/idle state instead of always reading
  "NOW RUNNING"
- empty-state copy: distinguish a failed job from "nothing to review", fix
  the queue-empty messages to not ask the user to select something that
  doesn't exist, and normalise voice to lowercase/unpunctuated
2026-07-26 21:09:56 -07:00
branderson df5eff8c20 fix: pin the focus guards with mutation tests, keep #copy-path focusable, state not by colour alone 2026-07-26 20:27:23 -07:00
branderson 439f6f75b8 feat: live stage track, form polish, and idle-aware polling 2026-07-26 20:01:04 -07:00
branderson 9e01f0b204 feat: surface low-confidence warnings as first-class review rows 2026-07-26 19:45:03 -07:00
branderson cf48324f17 fix: strengthen xss guard, 3:1 field borders, clean disclosure names, centre main 2026-07-26 19:31:42 -07:00
branderson 8675d5594b feat: themed spotify-style shell for the web ui 2026-07-26 19:09:36 -07:00
branderson ca6006023d feat: vendor chakra petch display font and serve static assets 2026-07-26 18:50:41 -07:00
branderson 69810f584d feat: expose stage order and track cached stages in the web api 2026-07-26 18:44:46 -07:00
branderson 232f0354fc feat: record job timestamps in the web api 2026-07-26 18:41:02 -07:00
branderson d2ad3d1c90 fix: honest package-stage caching, safe cover fallback, unified language codes, upload cleanup
- package stage: cache off a package.done sentinel written only after every
  sub-step succeeds, instead of the txt/mp3 files that land first. A partial
  failure (e.g. ffprobe/PIL error after txt+mp3 are written) no longer looks
  cached, so a retry actually finishes the folder instead of being skipped.
- package stage: a corrupt/non-image cover file now degrades to the existing
  no-cover path (drop #COVER header) instead of raising UnidentifiedImageError.
- consolidate the three independent language-code tables (lyrics._LANG_MAP,
  notes._PYPHEN, whisper_client._ISO) into languages.LANGUAGES so a language
  known to one call site can't be silently mishandled by another (e.g.
  Japanese reaching Whisper correctly but syllabifying with English rules).
- web.py: remove a job's uploads/<job_id> dir once it reaches a terminal
  state; safe because acquire copies the upload into the per-song work dir
  and package reads cover_path directly, both inside the synchronous
  runner() call that precedes cleanup.
2026-07-25 21:59:45 -07:00
branderson ce57680a6a feat: docker packaging and readme
python:3.11-slim base relies on pip torch wheels bundling CUDA runtime libs,
with the host NVIDIA driver passed through via the nvidia container runtime
(compose GPU reservation); TORCH_HOME and XDG_CACHE_HOME point into the
mounted /data volume so Demucs/wav2vec2 weights survive container
recreation.

Docker was not installed in the implementation sandbox, so the image was
not built or run and remains untested. PyYAML was also unavailable, so
docker-compose.yml was not parse-checked programmatically — only hand
reviewed.
2026-07-25 21:38:24 -07:00
branderson 465d420b11 fix: harden web UI against XSS, stream uploads, and fix worker races 2026-07-25 21:24:43 -07:00
branderson 76075baf93 feat: minimal web ui with job queue 2026-07-25 21:15:34 -07:00
branderson 19d8ed6d7f fix: report bad lyrics file and content-dependent grid failures cleanly
- cli.py: guard --lyrics read against missing/unreadable/undecodable
  files, matching the file's existing error-reporting conventions
- stages/grid.py: route ValueError from scale_bpm/to_ultrastar through
  PipelineError with a user-facing message; use bpm_override is not None
  instead of truthiness so an explicit 0 override isn't silently ignored
2026-07-25 21:08:41 -07:00
branderson e093d8afd4 feat: cli make command and opt-in e2e test 2026-07-25 21:02:12 -07:00
branderson a349d697e4 feat: package stage producing final song folder 2026-07-25 20:56:34 -07:00
branderson 0f09e6702b fix: guard unusable bpm and empty notes, pin line-break tie ordering 2026-07-25 20:51:19 -07:00
branderson ba78587a54 feat: beat grid and ultrastar txt assembly 2026-07-25 20:42:08 -07:00
branderson b3a89b2f37 fix: guarantee non-overlapping notes with strictly increasing starts 2026-07-25 20:26:57 -07:00
branderson 7f1911642b feat: note generation from alignment + pitch 2026-07-25 20:08:55 -07:00
branderson bdde0af312 feat: torchcrepe pitch tracking stage 2026-07-25 20:00:24 -07:00
branderson f75686b736 fix: correct vocal section merging for out-of-order segments 2026-07-25 19:55:22 -07:00
branderson 19990d6883 feat: forced-alignment stage with lyrics sanity check 2026-07-25 19:46:32 -07:00
branderson c9d633eaf6 feat: whisper client and transcribe stage with section/similarity helpers 2026-07-25 19:38:51 -07:00
branderson ef1ac6e59c feat: demucs separation stage 2026-07-25 19:34:42 -07:00
branderson e5f1a17e0f fix: deterministic video/thumbnail classification in acquire stage 2026-07-25 19:31:34 -07:00
branderson d9dbfbfffa feat: acquire stage (yt-dlp / local file) and ffmpeg helpers 2026-07-25 19:26:48 -07:00
branderson 988970676a feat: pipeline framework with stage caching and force-from 2026-07-25 19:23:15 -07:00
branderson f3bd496461 fix: alignment_token whitespace consistency and typographic apostrophes 2026-07-25 19:21:07 -07:00
branderson ca42a6bad8 feat: reference lyrics normalization 2026-07-25 19:16:41 -07:00
branderson 2ad77775ef feat: config loading from toml 2026-07-25 19:14:15 -07:00
branderson d24d44582e feat: ultrastar txt parser/serializer with fixture round-trip 2026-07-25 19:09:49 -07:00
8 changed files with 330 additions and 253 deletions
+171 -227
View File
@@ -1,175 +1,194 @@
# songtrace
Turn a song and its lyrics into a playable UltraStar chart.
A CLI and minimal web UI that turns a song — title, artist, lyrics you supply,
and a YouTube URL or local video file — into a playable
[UltraStar](https://usdx.eu/format/)-format song folder. Also compatible with
[Melody Mania](https://melodymania.org/).
Self-hosted karaoke chart builder for [UltraStar](https://usdx.eu/format/) and
compatible games such as [Melody Mania](https://melodymania.org/). Give it a
song's video and lyrics and it charts the notes, timing and pitch, ready for
your collection. Fix anything in the built-in editor; your edits survive when
you rebuild your whole collection after a songtrace update.
![Web UI](docs/images/webui.png)
The pipeline:
## What it does
1. **acquire** — download audio/video via `yt-dlp` (or use a local video file)
2. **separate** — split vocals/instrumental with Demucs
3. **transcribe** — send the vocal stem to your own intranet Whisper server
(OpenAI-compatible, or [onerahmet/openai-whisper-asr-webservice](https://github.com/ahmetoner/whisper-asr-webservice)),
as a sanity check that the supplied lyrics match the audio and to locate
vocal sections
4. **align** — forced-align the *supplied* reference lyrics against the vocal
stem with wav2vec2 (torchaudio's MMS_FA) to get word timings
5. **pitch** — track pitch on the vocal stem with torchcrepe
6. **notes** — turn word timings + pitch into UltraStar notes
7. **grid** — quantise notes to a beat grid
8. **package** — write the final song folder (`.txt` + `.mp3` + video + cover)
You give it a title, artist, the lyrics, and a YouTube URL or a video file.
songtrace writes a song folder (`.txt` chart, `.mp3`, video and cover) into
your collection:
The shipped `.mp3` is the full original mix (vocals included) — the
separated stems are used for analysis only and are not included in the
output.
1. **acquire**: downloads the video with `yt-dlp`, or takes your file
2. **separate**: splits vocals from the backing track (Demucs)
3. **transcribe**: has your Whisper server transcribe the vocals, to check
the lyrics match the recording and to find where the singing is
4. **align**: places each word of *your* lyrics on the vocals (wav2vec2)
5. **pitch**: tracks the sung pitch (torchcrepe)
6. **notes**: turns word timings and pitch into notes
7. **grid**: snaps the notes to a beat grid
8. **package**: writes the song folder
**The output is a best-effort draft**, not a finished song. Every run ends by
printing the low-confidence regions it found (e.g.
`3 low-confidence notes near 0:42–0:47`) — open the result in an UltraStar
editor such as [Yass](https://www.yass-along.com/) or
[USDX](https://usdx.eu/) and touch those spots up before playing.
The result is a strong draft, not a finished chart. The web UI flags the notes
it is least sure of, plays the song back against the chart, and lets you fix
what it got wrong before you sing it.
## Reviewing a song
## Requirements
The web UI plays the finished song folder back against the review, so you can
check the timing without loading it into a game first. Pick a song in the
queue and the review panel gets the packaged video with a transport above the
lyrics; as it plays, the line being sung is highlighted, words light up as
they are reached, and a playhead sweeps each line against its notes. Click any
line or word to play from there.
- **An NVIDIA GPU with CUDA.** songtrace is developed on a 12 GB card.
Separating the vocals (Demucs) alone takes about 3.7 GB, and alignment holds
a whole stretch of singing in memory at once, so a long song whose vocals
never pause needs the most. Cards under 12 GB are untested. Each stage gives
the GPU back when it finishes (see [Sharing the GPU](#sharing-the-gpu)).
`device = "cpu"` in the config runs without a GPU, but it is untested, far
slower, and needs plenty of system RAM.
- **Docker** with the
[NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/),
so the container can see the GPU.
- **A Whisper server of your own.** songtrace does not include one. Either an
OpenAI-compatible API or
[whisper-asr-webservice](https://github.com/ahmetoner/whisper-asr-webservice)
works. If it shares songtrace's GPU, it needs its own share of the memory.
- **Disk space:** a few GB of model weights, downloaded on first use, plus a
working folder per song holding its source video and separated tracks,
typically several hundred MB.
Playback is modelled on the game: the `#MP3` is the audio and the clock, and
the `#VIDEO` is muted background slaved to it. It also uses the same seconds
the notes were generated from, so a word that lights up late in the browser is
late in the game too.
The packaged video has its audio track stripped for the same reason. It is the
same recording as the `#MP3`, so a player that does not mute the video plays
both at once, a few frames apart, which is heard as an echo. Songs built
before this change still have it — check with `ffprobe <song>.mp4` for an
audio stream, and rebuild with **Restart from** set to `package` to clear it
without re-downloading.
## Editing a song
Above the lyrics, the note sheet shows one line at a time on a pitch-by-beat
grid, with the pitch songtrace detected drawn over the notes: where a note is
right the curve runs through it, so a wrong one stands out. Fix it there and
**SAVE LINE**; the song folder's chart is rewritten on the spot.
Drag a note to move it and its ends to resize it; everything snaps to the
chart's beat grid and to semitones. Double-click empty space to add a note,
or a note to change its syllable. With the sheet focused: ←/→ move, ↑/↓
change pitch (Shift for an octave), Shift+←/→ change length, Tab selects the
next note, S splits, M merges with the next note, G/F/R make a note golden,
freestyle or rap, Delete removes, Ctrl+Z undoes, Ctrl+S saves and P plays
the line.
The ↵ markers either side of a line are its line breaks: drag one to move it
(it snaps to beats and stays between the lines it separates) and double-click
one to put it back where songtrace places it. Moving a break does not mark
either line's notes as edited, so they still follow a rebuild.
The **GRID** bar above the notes sets the chart's BPM and GAP. Changes redraw
the grid as you type and are charted on **APPLY**; ×2 and ÷2 fix a tempo
detected at double or half speed, and **ALIGN TO NOTE** moves GAP the least
that puts the selected note on a beat. Notes keep their timing in seconds and
re-snap to the new grid. **RESET** returns to the BPM songtrace chose (your
BPM override, or the detected tempo) with GAP at the first note.
Edits survive every rebuild, REBUILD ALL FROM… included: they are kept apart
from what the pipeline generates and laid back over it each time. **REVERT**
returns a line to the generated notes. Changing a line's lyrics sets its edit
aside rather than applying it to different words; the sheet lists those so
you can discard them.
## Configuration
Config is TOML. Resolution order:
1. `--config <path>` passed explicitly on the command line
2. the `SONGTRACE_CONFIG` environment variable
3. `~/.config/songtrace/config.toml`
Copy the example and fill in your Whisper server:
## Quick start (Docker)
```sh
cp config.example.toml config.toml
$EDITOR config.toml # set whisper_url at minimum
```
See `config.example.toml` for every available field (`whisper_url`,
`whisper_api_key`, `whisper_model`, `whisper_api`, `output_dir`, `work_dir`,
`creator`, `demucs_model`, `crepe_model`, `device`, `bind_host`, `port`,
`ytdlp_clients`, `ytdlp_cookies`, `ytdlp_impersonate`,
`ytdlp_pot_provider_url`, `ytdlp_version`).
`whisper_api` selects the wire format: `"openai"` (default) speaks the
OpenAI-compatible `POST {whisper_url}/audio/transcriptions` API; `"asr-webservice"`
speaks [onerahmet/openai-whisper-asr-webservice](https://github.com/ahmetoner/whisper-asr-webservice)'s
`POST {whisper_url}/asr` API instead (point `whisper_url` at that server's base
URL with no `/v1` suffix — `whisper_model`/`whisper_api_key` are unused for
this backend, since the model is fixed by how that server was started).
## Docker
```sh
cp config.example.toml config.toml
$EDITOR config.toml
$EDITOR config.toml # set whisper_url to your Whisper server
docker compose up -d
```
The web UI is then at `http://localhost:8000`. Downloaded model weights
(Demucs, wav2vec2, ...) are cached under `./data` on the host so they survive
container recreation.
Open `http://localhost:8000`, fill in the form and press **CREATE SONG**.
Run the CLI inside the container instead of the web UI:
Finished songs go to `output_dir` (`/data/output` in the container). To write
them straight into your game's song collection, mount that folder there in
`docker-compose.yml`:
```sh
docker compose run --rm songtrace songtrace make \
--title "Song Title" --artist "Artist Name" \
--lyrics /data/lyrics.txt --youtube https://youtu.be/...
```yaml
volumes:
- ./config.toml:/config/config.toml:ro
- ./data:/data
- /path/to/your/UltraStar/songs:/data/output
```
GPU passthrough requires the [NVIDIA Container
Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/)
installed on the host; `docker-compose.yml` requests one GPU via the
`nvidia` device driver.
Everything else lives under `./data` on the host (model weights, and each
song's working files), so it survives recreating the container.
## Reviewing and editing a song
Pick a song in the queue. The review panel plays the packaged song with its
video, highlights the line and words being sung, and shows the chart on a note
sheet above the lyrics. Playback works like the game: the `#MP3` is the clock
and the video follows it muted, so a word that lights up late here is late in
the game too.
The note sheet shows one line at a time on a pitch-by-beat grid, with the
pitch songtrace detected drawn over the notes. Where a note is right the curve
runs through it, so a wrong one stands out. Fix it there and **SAVE LINE**; the
song's chart is rewritten on the spot.
- **Notes:** drag a note to move it, drag its ends to resize it. Everything
snaps to the beat grid and to semitones. Double-click empty space to add a
note, or a note to change its syllable. With the sheet focused, the arrow
keys move notes, S splits, M merges, G/F/R make a note golden, freestyle or
rap, Ctrl+Z undoes and Ctrl+S saves. Press **?** for every shortcut.
- **Line breaks:** the ↵ markers either side of a line. Drag one to move it,
and double-click it to hand it back to songtrace.
- **Tempo:** the **GRID** bar sets the chart's BPM and GAP. It redraws as you
type and charts on **APPLY**. ×2 and ÷2 fix a tempo detected at double or
half speed, and **ALIGN TO NOTE** puts the selected note on a beat.
**RESET** goes back to songtrace's choice.
- **Undoing an edit:** **REVERT** returns a line to the generated notes.
Edits are kept apart from what the pipeline generates and laid back over it on
every rebuild, so they survive all of them, collection-wide ones included. If
you change a line's lyrics, its edit is set aside rather than applied to
different words, and the sheet lists those for you to discard. Songs with edits
are marked ✎ in the queue.
## Rebuilding
Select a song and its inputs come back up in the form; change them and press
**REBUILD SONG**. Only the stages your change affects are rerun: fixing a line
of lyrics realigns without downloading or separating again.
A songtrace update can improve a stage without any of your inputs changing.
**Restart from** on the rebuild form redoes a stage anyway, and
**REBUILD ALL FROM…**, under CREATE SONG, does it for your whole collection:
pick the stage the update improved, untick any songs to leave alone, and each
song is rebuilt from there. Your edits are kept.
Stages in order: `acquire`, `separate`, `transcribe`, `align`, `pitch`,
`notes`, `grid`, `package`. Rebuilding from `notes` onward needs no GPU.
## Configuration
`config.toml` is TOML; `config.example.toml` lists every setting. songtrace
reads, in order: `--config <path>`, the `SONGTRACE_CONFIG` environment variable
(set to `/config/config.toml` in the container), then
`~/.config/songtrace/config.toml`.
`whisper_api` chooses how to talk to Whisper: `"openai"` (the default) uses
`POST {whisper_url}/audio/transcriptions`, and `"asr-webservice"` uses
whisper-asr-webservice's `POST {whisper_url}/asr`. For the latter, give
`whisper_url` without a `/v1` suffix; `whisper_model` and `whisper_api_key`
are ignored, since that server's model is fixed when it starts.
`creator` sets the `#CREATOR` header in every chart; it defaults to
`songtrace`.
## Sharing the GPU
`songtrace serve` is long-lived but only wants the GPU in bursts, so it hands
it back after every stage rather than holding it for the life of the process.
Three stages load a model — `separate` (Demucs), `align` (wav2vec2) and
`pitch` (torchcrepe) — and each one's weights are dropped as soon as that
stage finishes, followed by `torch.cuda.empty_cache()` to return the
allocator's freed blocks to the driver instead of keeping them for reuse. This
matters most between `separate` and `align`: `transcribe` sits between them
waiting on your Whisper server, which can be minutes of a GPU nothing is
using.
Stages that gave memory back say so in the job log:
Three stages load a model: `separate` (Demucs), `align` (wav2vec2) and `pitch`
(torchcrepe). Each releases its model and returns the memory to the driver as
soon as it finishes, rather than holding it for the life of the server. This
matters most while `transcribe` waits on your Whisper server, which can take
minutes. The job log says how much each stage gave back:
```
[separate] released 3704 MiB of GPU memory
```
The cost is reloading each model once per job instead of once per process,
which is seconds against stages that run for minutes. What this does not
release is the CUDA context itself, a few hundred MB that belongs to the
process until it exits; freeing that would mean running each stage in a
subprocess. If you need every last MB back, stop the container between runs.
Reloading the models costs seconds per song. The CUDA context itself, a few
hundred MB, stays with the process; stop the container to free it.
## Bare-metal
## YouTube download failures
YouTube changes its defences every few weeks, so songtrace keeps `yt-dlp`
current by itself. Once a day, and again when a download fails, it checks PyPI
for a newer stable release, installs it under `/data/models/cache`, and tries
it before the copy it shipped with. That copy is never modified and is always
the fallback, so a broken release costs a retry, not the download. The job log
names the `yt-dlp` version behind each attempt. `ytdlp_version` pins a release,
or `"bundled"` turns the updates off.
If downloads still fail with `HTTP Error 403: Forbidden`:
1. **The network is rate-limited or flagged.** Datacenter and VPN ranges are
throttled hardest. Try another network before changing config.
2. **A PO token is required.** If the log says so, run
[bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider)
alongside songtrace, install its plugin into the image, and point
`ytdlp_pot_provider_url` at it.
3. **Other levers:** `ytdlp_clients` tries each listed YouTube player client
in turn. `ytdlp_cookies` takes a Netscape cookie jar for age-gated videos;
don't use one from an account you care about, as YouTube bans automated
access. `ytdlp_impersonate` presents a browser's TLS fingerprint (for
example `"chrome"`).
To check the JavaScript runtime `yt-dlp` needs for YouTube is present, run
`docker compose exec songtrace yt-dlp -v --simulate <url>`; the `JS runtimes:`
line must not say `none`. A failed download keeps its working folder, so
running the job again resumes it.
## Command line
The web UI is the main way to use songtrace, but every song can also be made
from the command line, in the container or on a host with the requirements
above and `ffmpeg`:
```sh
pip install -e .
songtrace make --help
docker compose run --rm songtrace songtrace make \
--title "Song Title" --artist "Artist Name" \
--lyrics /data/lyrics.txt --youtube https://youtu.be/...
```
```
@@ -179,99 +198,24 @@ songtrace make --title TITLE --artist ARTIST --lyrics LYRICS_FILE \
[--bpm BPM] [--force-from STAGE] [--config CONFIG]
```
Run the web UI instead with `songtrace serve [--config CONFIG]`.
`--force-from <stage>` redoes that stage and everything after it, reusing the
cached output of the stages before. On a bare-metal host, install with
`pip install -e .` and run the web UI with `songtrace serve`.
## Re-running a stage
Each stage caches its output under `work_dir`. If a later stage needs
another pass (e.g. you want to retry alignment with different lyrics, or
pitch tracking looks off), re-run with `--force-from <stage>` to redo that
stage and everything after it, reusing the cached output of everything
before it:
```sh
songtrace make --title "..." --artist "..." --lyrics lyrics.txt \
--youtube https://youtu.be/... --force-from align
```
Valid stages, in pipeline order: `acquire`, `separate`, `transcribe`,
`align`, `pitch`, `notes`, `grid`, `package`.
In the web UI this is what selecting a queue entry does: its title, artist,
lyrics and the rest come back up in the form, so you can adjust them and hit
REBUILD SONG. Only the stages your edit actually invalidates are rerun —
fixing a line of lyrics realigns without re-downloading or re-separating.
CREATE SONG, at the top of the queue, clears the page to start a fresh one.
That planning works off the fields you changed, which cannot see a change in
songtrace itself: after an upgrade, a rebuild of an older song finds every
stage cached and does nothing. **Restart from** on the rebuild form is the way
to redo a stage anyway — pick `package` to repackage without re-downloading.
It can only widen what is rerun, so an edit that invalidates an earlier stage
still wins.
**REBUILD ALL FROM…**, under CREATE SONG, does the same for every song at once:
pick the stage an update improved and each song is rebuilt from there with the
inputs it has now. Every song starts ticked; untick any already rebuilt by hand
(each shows when it was last built). Songs still in progress are skipped, as
are songs whose uploaded video is gone when the stage is `acquire`.
## YouTube download failures
`HTTP Error 403: Forbidden` from the acquire stage means YouTube served the
page but refused the media URL. In rough order of likelihood:
1. **yt-dlp is stale.** YouTube changes its defences every few weeks, so
songtrace keeps yt-dlp current itself. Once a day, and again whenever a
download fails outright, it checks PyPI for a newer stable release,
installs it under `$XDG_CACHE_HOME/songtrace/` (`/data/models/cache` in the
container), and tries it before the copy installed with songtrace. That
copy is never modified and is always the fallback, so a release that breaks
compatibility costs a retry rather than the download. The job log names
the yt-dlp version behind each attempt.
`ytdlp_version` overrides this: `"bundled"` uses only the installed copy,
and a release number such as `"2026.08.19"` pins that release instead of
the newest.
2. **Missing JS runtime.** yt-dlp needs `yt-dlp-ejs` plus a JavaScript engine
to solve YouTube's player challenges; without them it silently falls back to
formats that 403. The `[default,curl-cffi,deno]` extras in `pyproject.toml`
cover this, for both the installed copy and the ones fetched at runtime.
Check with `docker compose exec songtrace yt-dlp -v --simulate <url>` — the debug
header must show a `JS runtimes:` line that is not `none`.
3. **The IP is rate-limited or flagged.** Datacenter and VPN ranges get
throttled hardest. Try from another network before changing config.
4. **A PO token is required.** If the log says so explicitly, run
[bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider)
as a sidecar, install its plugin into the image, and point
`ytdlp_pot_provider_url` at it.
`ytdlp_clients` retries the download against each listed player client in
turn, so a client that YouTube has broken is skipped automatically.
`ytdlp_cookies` (a Netscape cookie jar, for age-gated videos) and
`ytdlp_impersonate` (a browser TLS fingerprint, e.g. `"chrome"`) are the other
two levers. Do not use a cookie jar from an account you care about — YouTube
bans for automated access.
Failed downloads leave the work dir intact, so re-running the job resumes
rather than starting over.
## Regenerating the web UI screenshot
`scripts/screenshot_webui.py` seeds the real app with a fixed set of jobs
covering every status and screenshots it to `docs/images/webui.png`. It uses
`ffmpeg` to give the selected song a playable folder so the shot includes the
player; without `ffmpeg` on `PATH` it still runs, just without it.
## Development
```sh
pip install -e '.[dev]'
playwright install chromium
python scripts/screenshot_webui.py
pytest
```
`pytest` also runs the web UI's JavaScript tests in `tests/js` when Node is
installed. Pull requests run the full suite in CI.
`scripts/screenshot_webui.py` regenerates `docs/images/webui.png` from a
seeded copy of the app (`playwright install chromium` first). With `ffmpeg` on
`PATH`, the shot includes the player.
## License
[AGPLv3](LICENSE)
+3 -3
View File
@@ -1,11 +1,11 @@
whisper_url = "http://whisper.intranet:9000/v1"
whisper_url = "http://whisper.example:9000/v1"
# whisper_api_key = ""
# whisper_model = "whisper-1"
# whisper_api = "openai" # or "asr-webservice" for onerahmet/openai-whisper-asr-webservice
# (set whisper_url without the /v1 suffix, e.g. "http://whisper.intranet:9000")
# (set whisper_url without the /v1 suffix, e.g. "http://whisper.example:9000")
output_dir = "/data/output"
work_dir = "/data/work"
creator = "brad"
# creator = "Your Name" # the #CREATOR header in every chart
# demucs_model = "htdemucs_ft"
# crepe_model = "full"
# device = "cuda"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 99 KiB

+30 -11
View File
@@ -15,11 +15,14 @@ import time
from pathlib import Path
import httpx
import numpy as np
import uvicorn
from playwright.sync_api import sync_playwright
from songtrace.config import Config
from songtrace.grid import to_ultrastar
from songtrace.pipeline import PipelineError, PipelineResult, song_slug
from songtrace.ultrastar import serialize_ultrastar
from songtrace.web import create_app
REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -128,13 +131,29 @@ def make_runner():
if "review" in extra:
work = cfg.work_dir / song_slug(inp)
work.mkdir(parents=True, exist_ok=True)
# one note per word in the seeded lines, so each sings its word
notes = [
{**n, "duration": n["end"] - n["start"], "text": "~",
"line_index": line["index"]}
for line in extra["review"]["lines"] for n in line["notes"]
{**n, "duration": n["end"] - n["start"],
"text": (" " if k else "") + w["text"], "line_index": line["index"]}
for line in extra["review"]["lines"]
for k, (n, w) in enumerate(zip(line["notes"], line["words"]))
]
sung = [line for line in extra["review"]["lines"] if line["notes"]]
breaks = [(a["notes"][-1]["end"] + b["notes"][0]["start"]) / 2
for a, b in zip(sung, sung[1:])]
(work / "notes_sec.json").write_text(json.dumps({
"notes": notes, "breaks": [], "warnings": extra["warnings"]}))
"notes": notes, "breaks": breaks, "warnings": extra["warnings"]}))
# a chart for the beat grid, and a sung pitch with a little
# vibrato for the curve over the notes
(work / "song.txt").write_text(serialize_ultrastar(to_ultrastar(
notes, breaks, 240.0, [("TITLE", inp.title)])), encoding="utf-8")
times = np.arange(0, 60, 0.01)
f0, conf = np.zeros_like(times), np.zeros_like(times)
for n in notes:
m = (times >= n["start"] + 0.02) & (times < n["start"] + n["duration"] - 0.02)
semis = n["pitch"] + 0.2 * np.sin(2 * np.pi * 5.5 * times[m])
f0[m], conf[m] = 440 * 2 ** ((semis - 9) / 12), 0.9
np.savez(work / "pitch.npz", times=times, f0=f0, conf=conf)
(work / "align_meta.json").write_text(json.dumps({
"dropped_lines": [line["index"] for line in extra["review"]["lines"]
if line["dropped"]],
@@ -230,13 +249,13 @@ def capture(base_url: str, with_player: bool) -> None:
# current word colouring rather than an untouched transport
page.evaluate("document.getElementById('player-media').currentTime = 11.6")
page.wait_for_timeout(400)
# scrolled to the review: the player pins to the top of #main and
# the tracked lines sit under it, which is the panel in use
page.evaluate("""() => {
const main = document.getElementById('main');
main.scrollTo(0, document.getElementById('review').offsetTop - 12);
}""")
page.wait_for_timeout(400)
page.wait_for_selector(".sheet-curve polyline")
# scrolled to the review, the panel in use, with the note sheet whole
page.evaluate("""() => {
const main = document.getElementById('main');
main.scrollTo(0, document.getElementById('review').offsetTop - 12);
}""")
page.wait_for_timeout(400)
OUT_IMAGE.parent.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(OUT_IMAGE))
browser.close()
+51 -3
View File
@@ -623,6 +623,11 @@ button { cursor: pointer; }
}
.lineage:hover { border-bottom-color: var(--aqua); }
.job-mark { grid-area: 1 / 2; justify-self: end; font-size: 0.8rem; color: var(--text-muted); }
.job-edits { grid-area: 2 / 2; justify-self: end; display: flex; gap: 6px; font-size: 0.72rem; }
.job-edits-kept { color: var(--aqua); }
.job-edits-stale { color: var(--warn); }
.rebuild-all-kept { color: var(--aqua); }
.rebuild-all-aside { color: var(--warn); }
#detail {
margin: 13px 0 0;
@@ -744,7 +749,8 @@ button { cursor: pointer; }
<form id="rebuild-all-form" method="dialog">
<h2 id="rebuild-all-heading">REBUILD ALL SONGS</h2>
<p>Reruns the songs ticked below from the chosen stage onwards with the
inputs they have now, for when a songtrace update improves that stage.</p>
inputs they have now, for when a songtrace update improves that stage.
Lines edited by hand are kept.</p>
<label class="field">
<span>Restart from</span>
<select name="force_from" required></select>
@@ -991,6 +997,18 @@ function rebuildAllRow(song) {
undefined, { dateStyle: "medium", timeStyle: "short" });
meta.textContent = busy ? "in progress, skipped"
: `${song.status === "failed" ? "failed" : song.rebuilt_from ? "rebuilt" : "built"} ${when || ""}`.trim();
if (song.edited_lines) {
const kept = document.createElement("span");
kept.className = "rebuild-all-kept";
kept.textContent = ` · ${song.edited_lines} edited line${song.edited_lines === 1 ? "" : "s"} kept`;
meta.append(kept);
}
if (song.stale_edits) {
const aside = document.createElement("span");
aside.className = "rebuild-all-aside";
aside.textContent = ` · ${song.stale_edits} set aside`;
meta.append(aside);
}
row.append(box, name, meta);
return row;
}
@@ -1066,7 +1084,10 @@ function jobCard(job) {
markLabel.className = "sr-only";
mark.append(markGlyph, markLabel);
card.append(title, artist, stateLine, time, mark);
const edits = document.createElement("span");
edits.className = "job-edits";
card.append(title, artist, stateLine, time, mark, edits);
card.addEventListener("click", () => selectJob(job.id));
paintCard(card, job);
return card;
@@ -1074,7 +1095,7 @@ function jobCard(job) {
function paintCard(card, job) {
const state = STATE[job.status] ?? { glyph: "·", cls: "st-queued" };
const [title, artist, stateLine, time, mark] = card.children;
const [title, artist, stateLine, time, mark, edits] = card.children;
card.className = `job ${state.cls}`;
if (job.id === selectedId) card.setAttribute("aria-current", "true");
else card.removeAttribute("aria-current");
@@ -1085,6 +1106,33 @@ function paintCard(card, job) {
time.textContent = elapsed(job);
mark.children[0].textContent = job.rebuilt_from ? "↻" : "";
mark.children[1].textContent = job.rebuilt_from ? " rebuilt" : "";
paintEdits(edits, job);
}
// hand edits a rebuild keeps, and any set aside because the lyrics changed
function paintEdits(node, job) {
const parts = [];
if (job.edited_lines) parts.push(["job-edits-kept", `✎ ${job.edited_lines}`]);
if (job.stale_edits) parts.push(["job-edits-stale", `⚠ ${job.stale_edits}`]);
const label = [
job.edited_lines ? `${job.edited_lines} edited line${job.edited_lines === 1 ? "" : "s"}` : "",
job.stale_edits ? `${job.stale_edits} set aside` : "",
].filter(Boolean).join(", ");
if (node.title === label) return;
node.title = label;
node.replaceChildren(...parts.map(([cls, text]) => {
const span = document.createElement("span");
span.className = cls;
span.setAttribute("aria-hidden", "true");
span.textContent = text;
return span;
}));
if (label) {
const sr = document.createElement("span");
sr.className = "sr-only";
sr.textContent = label;
node.append(sr);
}
}
// patched in place rather than replaced wholesale: detaching a card would blur
+21 -7
View File
@@ -10,6 +10,7 @@ const BLACK = new Set([1, 3, 6, 8, 10]);
const GUTTER = 46; // px of note names left of the grid
const PLOT_HEIGHT = 300; // px; rows divide this between the pitches shown
const CONTEXT = 0.75; // seconds shown either side of the line
const BREAK_REACH = 3; // seconds past the line the page grows to show a break
const EDGE = 6; // px from a note's end that grabs it for resizing
const KINDS = { "*": "golden", F: "free", R: "rap", G: "golden-rap" };
const KEYS_OPEN = "songtrace.sheet-keys";
@@ -298,11 +299,13 @@ export function createSheet(root, { seek, playLine, onSaved }) {
const span = ts.length ? { start: Math.min(...ts), end: Math.max(...ts) } : lineSpan(line);
const range = pitchRange(notes().map(n => n.pitch));
const want = { t0: span.start - CONTEXT, t1: span.end + CONTEXT, ...range };
// the breaks either side are part of the line's page, so they stay on it
// the breaks either side are part of the line's page, so it grows to
// show them, but not across a long instrumental: one further away is
// pinned to the edge instead (see shownBreakTime)
for (const slot of breakSlots()) {
const t = breakTime(slot);
want.t0 = Math.min(want.t0, t - 0.3);
want.t1 = Math.max(want.t1, t + 0.3);
want.t0 = Math.min(want.t0, Math.max(t - 0.3, span.start - BREAK_REACH));
want.t1 = Math.max(want.t1, Math.min(t + 0.3, span.end + BREAK_REACH));
}
layout = reset || !layout ? want : {
t0: Math.min(layout.t0, want.t0), t1: Math.max(layout.t1, want.t1),
@@ -413,6 +416,15 @@ export function createSheet(root, { seek, playLine, onSaved }) {
return Math.min(Math.max(t, slot.lo), Math.max(slot.hi, slot.lo));
}
// where a break is drawn and grabbed: on the page, pinned to its edge
// when it lies beyond it
function shownBreakTime(slot) {
const edge = (layout.t1 - layout.t0) * 0.003; // a few pixels in
const t = breakTime(slot);
const shown = Math.min(Math.max(t, layout.t0 + edge), layout.t1 - edge);
return { t: shown, pinned: shown !== t ? (t < shown ? "left" : "right") : null };
}
const undo = () => { if (hist?.canUndo) { hist.undo(); fit(false); render(); } };
const redo = () => { if (hist?.canRedo) { hist.redo(); fit(false); render(); } };
const one = () => (selected.size === 1 ? [...selected][0] : null);
@@ -617,14 +629,16 @@ export function createSheet(root, { seek, playLine, onSaved }) {
const marks = el("g", { class: "sheet-breaks" }, svg);
for (const slot of breakSlots()) {
const t = breakTime(slot);
const { t, pinned } = shownBreakTime(slot);
const value = hist.present[slot.which];
const byHand = value !== null &&
(value !== baseline[slot.which] || review.breaks?.find(b => b.line === slot.line)?.moved);
const cls = `sheet-break${byHand ? " sheet-break-moved" : ""}`;
el("line", { x1: x(t), x2: x(t), y1: 0, y2: PLOT_HEIGHT, class: cls }, marks);
el("text", { x: x(t) + 3, y: 11, class: `${cls} sheet-break-label` }, marks)
.textContent = "↵";
const label = el("text", { x: pinned === "right" ? x(t) - 3 : x(t) + 3, y: 11,
"text-anchor": pinned === "right" ? "end" : "start",
class: `${cls} sheet-break-label` }, marks);
label.textContent = pinned === "right" ? "↵ →" : pinned === "left" ? "← ↵" : "↵";
}
syllables.replaceChildren(...notes().map(n => {
@@ -708,7 +722,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
// by geometry, not the event's target: a render replaces the elements
// under the pointer, and the topmost note drawn wins
if (canEdit()) {
const brk = breakSlots().find(s => Math.abs(px - view.x(breakTime(s))) <= 5);
const brk = breakSlots().find(s => Math.abs(px - view.x(shownBreakTime(s).t)) <= 5);
if (brk) return { px, py, note: null, brk, mode: "break" };
}
const pitch = view.pitch(py);
+17 -2
View File
@@ -342,11 +342,21 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
position = {job_id: n for n, job_id in enumerate(order)}
return sorted(latest.values(), key=lambda j: position[j.id], reverse=True)
def _edit_counts(job: Job) -> dict:
"""How much of the song was edited by hand, from its edits file."""
path = cfg.work_dir / song_slug(job.inp) / edits.EDITS_FILE
try:
data = json.loads(path.read_text())
except (OSError, ValueError):
return {"edited_lines": 0, "stale_edits": 0}
return {"edited_lines": len(data.get("lines", {})),
"stale_edits": len(data.get("stale", []))}
@app.get("/api/rebuild-all")
def rebuild_all_candidates():
return [{"id": j.id, "title": j.title, "artist": j.artist,
"status": j.status, "rebuilt_from": j.rebuilt_from,
"finished_at": j.finished_at}
"finished_at": j.finished_at, **_edit_counts(j)}
for j in _current_songs()]
@app.post("/api/rebuild-all", status_code=201)
@@ -382,11 +392,16 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
@app.get("/api/jobs")
def list_jobs():
# edits belong to the song as it is now, so only the job standing for
# it carries them; the runs it replaced would claim them twice
counts = {j.id: _edit_counts(j) for j in _current_songs()}
none = {"edited_lines": 0, "stale_edits": 0}
with lock:
return [{"id": j.id, "title": j.title, "artist": j.artist,
"status": j.status, "stage": j.stage,
"created_at": j.created_at, "started_at": j.started_at,
"finished_at": j.finished_at, "rebuilt_from": j.rebuilt_from}
"finished_at": j.finished_at, "rebuilt_from": j.rebuilt_from,
**counts.get(j.id, none)}
for j in (jobs[i] for i in reversed(order))]
@app.get("/api/jobs/{job_id}")
+37
View File
@@ -2074,3 +2074,40 @@ def test_a_bad_break_is_refused(tmp_path):
job_id = make_charted_job(client)
for body in ({"0": 1.0}, {"9": 1.0}, {"1": -2}, {"1": "x"}):
assert client.put(f"/api/jobs/{job_id}/breaks", json=body).status_code == 400
def _listed(client, job_id):
return next(j for j in client.get("/api/jobs").json() if j["id"] == job_id)
def test_the_queue_reports_a_songs_edited_lines(tmp_path):
client, _ = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client)
assert _listed(client, job_id)["edited_lines"] == 0
client.put(f"/api/jobs/{job_id}/lines/1", json=EDIT)
assert _listed(client, job_id)["edited_lines"] == 1
assert _listed(client, job_id)["stale_edits"] == 0
def test_edit_counts_follow_the_song_to_its_latest_run(tmp_path):
client, _ = make_client(tmp_path, chart_runner)
first = make_charted_job(client)
client.put(f"/api/jobs/{first}/lines/1", json=EDIT)
rerun = client.post(f"/api/jobs/{first}/rerun").json()["id"]
wait_status(client, rerun, "done")
assert _listed(client, rerun)["edited_lines"] == 1
assert _listed(client, first)["edited_lines"] == 0
candidates = client.get("/api/rebuild-all").json()
assert [(c["id"], c["edited_lines"]) for c in candidates] == [(rerun, 1)]
def test_the_queue_reports_edits_set_aside(tmp_path):
client, _ = make_client(tmp_path, chart_runner)
first = make_charted_job(client)
client.put(f"/api/jobs/{first}/lines/1", json=EDIT)
second = make_charted_job(client, lyrics="one\nthree more")
# the next save looks at the edits against the new lyrics, as a rebuild does
client.put(f"/api/jobs/{second}/lines/0", json={"notes": [
{"start": 1.0, "duration": 0.5, "pitch": 0, "text": "one"}]})
listed = _listed(client, second)
assert (listed["edited_lines"], listed["stale_edits"]) == (1, 1)