Compare commits

..
Author SHA1 Message Date
branderson 5877e35bfa Merge pull request 'webui: a grouped keyboard shortcut panel for the sheet' (#33) from sheet-shortcuts into sheet-editing
tests / test (pull_request_target) Successful in 4m28s
Reviewed-on: #33
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 23:27:53 -07:00
branderson 1843fd13b6 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 592dd46b47 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 103ff9361d 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 357715bd7d 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 d27d20b00e 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 d92135e8f2 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 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 25a8a228fe 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 34f878713a webui: add rebuild-from-scratch and delete actions 2026-07-28 02:52:43 -07:00
branderson a87d8e182d web: persist song input as a sidecar and reload jobs on startup 2026-07-28 02:38:55 -07:00
branderson db528fbe8d web: add delete endpoint 2026-07-28 02:34:09 -07:00
branderson d46c87b3f1 web: add force-rebuild-from-scratch endpoint 2026-07-28 02:31:23 -07:00
branderson e6db616290 pipeline: extract read_warnings for reuse by startup discovery 2026-07-28 02:28:27 -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
branderson ae0be499ae transcribe: classify reference lines as matched/dropped, flag unmatched singing 2026-07-27 22:58:58 -07:00
branderson 09624efb61 webui: move video source out of Advanced, give lyrics a tall column 2026-07-27 22:51:36 -07:00
branderson f40df98973 Move webui image to top 2026-07-27 19:59:46 -07:00
branderson 7d7055bad4 Fix yass link 2026-07-27 19:54:36 -07:00
branderson d35c18a2cc Fix Melody Mania link 2026-07-27 19:51:53 -07:00
branderson 200c9347d5 Remove karaoke maker reference 2026-07-27 19:49:30 -07:00
branderson 1633bc9645 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 0687c47c59 docs: fix migration guidance for old console script and orphaned container 2026-07-27 18:36:42 -07:00
branderson f6a0aef045 docs: rename to songtrace and add tagline 2026-07-27 18:26:50 -07:00
branderson a5ee6837f4 refactor: restyle web UI wordmark as SONG / TRACE 2026-07-27 18:24:21 -07:00
branderson e0c7eb9464 refactor: rename config env vars and default config path to songtrace 2026-07-27 18:21:30 -07:00
branderson be4883660a refactor: rename CLI command and distribution to songtrace 2026-07-27 18:18:11 -07:00
branderson e6fa6afe87 refactor: move package from karaoke_maker to songtrace 2026-07-27 18:14:23 -07:00
branderson c02c307377 Convert to AGPLv3 2026-07-27 17:37:56 -07:00
branderson a38461deec Add GPLv3 license and webui screenshot 2026-07-27 17:28:07 -07:00
branderson eed2d51128 Fix torchcodec issue 2026-07-27 10:05:59 -07:00
branderson 735513d048 Support whisper asr-webservice 2026-07-27 09:53:03 -07:00
branderson 128d4670ef 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 1ce3f85425 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 bf141fbdf4 feat: rerun and edit-and-rebuild controls in the web ui 2026-07-27 02:30:21 -07:00
branderson 944aabd50c feat: remove the output folder a rebuild superseded 2026-07-27 02:16:39 -07:00
branderson 34219f9167 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 c0ab6f0da5 feat: rebuild a song, redoing only the stages the edit invalidated 2026-07-27 01:59:36 -07:00
branderson e033330834 feat: expose a job's inputs for prefilling the rebuild form 2026-07-27 01:53:54 -07:00
branderson d8064b2665 feat: rerun a finished job as a linked new job 2026-07-27 01:49:29 -07:00
branderson 24b202ebe6 fix: wrap unlink in try/except to never raise from cleanup 2026-07-27 01:44:57 -07:00
branderson eadb225fef fix: keep uploads a rerun still needs 2026-07-27 01:40:40 -07:00
branderson eed25da2c3 feat: map input changes to the pipeline stage they invalidate 2026-07-27 01:35:51 -07:00
branderson dfd57a69cb 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 b1ab765121 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 28d07cc520 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 395f92e1db feat: live stage track, form polish, and idle-aware polling 2026-07-26 20:01:04 -07:00
branderson dba985d539 feat: surface low-confidence warnings as first-class review rows 2026-07-26 19:45:03 -07:00
branderson d593720ca4 fix: strengthen xss guard, 3:1 field borders, clean disclosure names, centre main 2026-07-26 19:31:42 -07:00
branderson f867a7b4d0 feat: themed spotify-style shell for the web ui 2026-07-26 19:09:36 -07:00
branderson 519bc4b2db feat: vendor chakra petch display font and serve static assets 2026-07-26 18:50:41 -07:00
branderson 819e09a733 feat: expose stage order and track cached stages in the web api 2026-07-26 18:44:46 -07:00
branderson ae4e2e0c87 feat: record job timestamps in the web api 2026-07-26 18:41:02 -07:00
branderson 062db175e6 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 abeb524b35 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 90a78464c5 fix: harden web UI against XSS, stream uploads, and fix worker races 2026-07-25 21:24:43 -07:00
branderson b534f24bb5 feat: minimal web ui with job queue 2026-07-25 21:15:34 -07:00
branderson 22d3594d0f 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 c1a010785b feat: cli make command and opt-in e2e test 2026-07-25 21:02:12 -07:00
branderson ef41c0ef29 feat: package stage producing final song folder 2026-07-25 20:56:34 -07:00
branderson 19037477e0 fix: guard unusable bpm and empty notes, pin line-break tie ordering 2026-07-25 20:51:19 -07:00
branderson e7738be810 feat: beat grid and ultrastar txt assembly 2026-07-25 20:42:08 -07:00
branderson 222c5c3ff3 fix: guarantee non-overlapping notes with strictly increasing starts 2026-07-25 20:26:57 -07:00
branderson b21536c4e2 feat: note generation from alignment + pitch 2026-07-25 20:08:55 -07:00
branderson f3253bd921 feat: torchcrepe pitch tracking stage 2026-07-25 20:00:24 -07:00
branderson 3d7e7e3f12 fix: correct vocal section merging for out-of-order segments 2026-07-25 19:55:22 -07:00
branderson 45f6887f93 feat: forced-alignment stage with lyrics sanity check 2026-07-25 19:46:32 -07:00
branderson 699ff3314c feat: whisper client and transcribe stage with section/similarity helpers 2026-07-25 19:38:51 -07:00
branderson 312c9d794a feat: demucs separation stage 2026-07-25 19:34:42 -07:00
branderson 504bc34889 fix: deterministic video/thumbnail classification in acquire stage 2026-07-25 19:31:34 -07:00
branderson 19a4f6e1ee feat: acquire stage (yt-dlp / local file) and ffmpeg helpers 2026-07-25 19:26:48 -07:00
branderson 5a9e342a17 feat: pipeline framework with stage caching and force-from 2026-07-25 19:23:15 -07:00
branderson 419704d534 fix: alignment_token whitespace consistency and typographic apostrophes 2026-07-25 19:21:07 -07:00
branderson c00c859fd4 feat: reference lyrics normalization 2026-07-25 19:16:41 -07:00
branderson 3fc665f3f5 feat: config loading from toml 2026-07-25 19:14:15 -07:00
branderson 67f26f047e feat: ultrastar txt parser/serializer with fixture round-trip 2026-07-25 19:09:49 -07:00
44 changed files with 5087 additions and 190 deletions
+6
View File
@@ -0,0 +1,6 @@
deb.debian.org
security.debian.org
pypi.org
files.pythonhosted.org
download.pytorch.org
download-r2.pytorch.org
+86
View File
@@ -0,0 +1,86 @@
name: tests
# `pull_request_target` reads this file from the base branch, so a pull
# request cannot change what runs or where (including `runs-on:`). The run's
# ref is therefore the base branch, and the checkout has to name the head.
on:
pull_request_target:
jobs:
test:
# No Docker socket, and egress limited to .dejima/allowed-domains on main.
runs-on: dejima-confined
# Node for actions/checkout; Debian bookworm's python3 is the 3.11 the
# Dockerfile ships.
container:
image: node:22-bookworm
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# Inline rather than a script in the repository: the files come from the
# branch under test, so a script would be missing from older branches.
- name: Check the hosts this job fetches from are reachable
run: |
set -u
bad=""
for host in deb.debian.org security.debian.org pypi.org \
files.pythonhosted.org download.pytorch.org \
download-r2.pytorch.org; do
resolved=""
for attempt in 1 2 3 4; do
getent hosts "$host" >/dev/null 2>&1 && { resolved=yes; break; }
sleep 2
done
if [ -z "$resolved" ]; then
echo "no $host - does not resolve"; bad="$bad $host(dns)"; continue
fi
# Any HTTP answer proves the connection; curl reports 000 for none.
code="$(curl -sS -o /dev/null --max-time 20 --retry 2 -w '%{http_code}' "https://$host/" 2>/dev/null || true)"
case "${code:-000}" in
000) echo "no $host - resolves, but nothing answered over https"
bad="$bad $host(net)" ;;
*) echo "ok $host (http $code)" ;;
esac
done
[ -z "$bad" ] && exit 0
{
echo
echo "Unreachable:$bad"
echo
echo "The confined tier reaches only what .dejima/allowed-domains on"
echo "main declares, rendered onto the runner separately from the"
echo "merge. On the runner:"
echo " sudo dejima-ci-allowlist show - is the name listed?"
echo " sudo dejima-ci-allowlist check - is a change pending?"
echo " sudo dejima-ci-allowlist render - apply it now"
echo "A (dns) failure is the resolver; a (net) one is the packet"
echo "filter, which sudo dejima-ci-firewall reapplies."
echo
echo "download.pytorch.org redirects its wheels to"
echo "download-r2.pytorch.org, so both have to be allowed."
} >&2
exit 1
- name: Install Python
run: |
apt-get update
apt-get install -y --no-install-recommends python3 python3-venv
python3 -m venv /opt/venv
# bookworm's pip 23.0 rejects the PyTorch index's typing_extensions
# wheel over its name's spelling, then cannot build it from source
/opt/venv/bin/pip install --retries 5 --upgrade pip
echo /opt/venv/bin >> "$GITHUB_PATH"
# The CPU build first: torch from PyPI is the CUDA build, several GB of
# libraries no test uses. The install below then finds torch satisfied.
- name: Install CPU-only torch
run: pip install --retries 5 torch torchaudio --index-url https://download.pytorch.org/whl/cpu
- name: Install songtrace
run: pip install --retries 5 -e '.[dev]'
- run: pytest
+146 -2
View File
@@ -34,6 +34,60 @@ printing the low-confidence regions it found (e.g.
editor such as [Yass](https://www.yass-along.com/) or
[USDX](https://usdx.eu/) and touch those spots up before playing.
## Reviewing a song
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.
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:
@@ -51,7 +105,9 @@ $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`).
`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"`
@@ -85,6 +141,30 @@ 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.
## 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:
```
[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.
## Bare-metal
```sh
@@ -117,10 +197,74 @@ songtrace make --title "..." --artist "..." --lyrics lyrics.txt \
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`:
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.
```sh
pip install -e '.[dev]'
+10
View File
@@ -11,3 +11,13 @@ creator = "brad"
# device = "cuda"
bind_host = "0.0.0.0"
# port = 8000
# YouTube downloads. Each player client is tried in order until one works;
# "default" means whatever yt-dlp currently picks for itself.
# ytdlp_clients = ["default", "tv"]
# ytdlp_cookies = "/config/youtube-cookies.txt" # Netscape cookie jar
# ytdlp_impersonate = "chrome" # TLS fingerprint to present
# ytdlp_pot_provider_url = "http://bgutil-provider:4416"
# Newer yt-dlp releases are fetched from PyPI at runtime and tried before the
# bundled one; "bundled" turns that off, a release number pins one.
# ytdlp_version = "auto"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 84 KiB

+6 -1
View File
@@ -5,7 +5,12 @@ description = "Generate UltraStar/Melody Mania karaoke songs from a video and re
requires-python = ">=3.11"
license = "AGPL-3.0-or-later"
dependencies = [
"yt-dlp",
# yt-dlp declares no runtime deps of its own; the extras are what make
# YouTube work. "default" pulls yt-dlp-ejs (JS challenge solving) and the
# requests/urllib3 handlers, "deno" ships the JS runtime that executes it,
# and "curl-cffi" provides the --impersonate targets. Without them YouTube
# extraction degrades to HTTP 403 on the media URLs.
"yt-dlp[default,curl-cffi,deno]",
"demucs",
"torch",
"torchaudio",
+51 -7
View File
@@ -7,6 +7,8 @@ One-time setup: pip install -e '.[dev]' && playwright install chromium
Run: python scripts/screenshot_webui.py
"""
import json
import shutil
import subprocess
import tempfile
import threading
import time
@@ -110,6 +112,7 @@ SONGS = [
("Vampire in the Corner", "Magdalena Bay", "queued", {}),
]
SELECTED = "Greensleeves"
REVIEW_HEADING = "REVIEW"
running_event = threading.Event()
@@ -157,14 +160,42 @@ def make_runner():
return runner
def start_server(tmp_path: Path) -> str:
def start_server(tmp_path: Path) -> tuple[str, Config]:
cfg = Config(whisper_url="http://localhost:9000/v1", output_dir=tmp_path / "out",
work_dir=tmp_path / "work", bind_host="127.0.0.1", port=8811)
app = create_app(cfg, runner=make_runner())
server = uvicorn.Server(uvicorn.Config(app, host=cfg.bind_host, port=cfg.port,
log_level="warning"))
threading.Thread(target=server.run, daemon=True).start()
return f"http://{cfg.bind_host}:{cfg.port}"
return f"http://{cfg.bind_host}:{cfg.port}", cfg
def install_media(cfg: Config) -> bool:
"""Give the selected song a playable folder, so the shot shows the player.
A flat fill rather than a test pattern: the point is the transport and the
lyric tracking underneath it, not the picture. Skipped without ffmpeg.
"""
if shutil.which("ffmpeg") is None:
print("ffmpeg not found; screenshotting without the player")
return False
artist, title = next((a, t) for t, a, _, _ in SONGS if t == SELECTED)
slug = f"{artist} - {title}"
out = cfg.output_dir / slug
out.mkdir(parents=True, exist_ok=True)
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi",
"-i", "color=c=0x1A1430:s=640x360:d=60", "-c:v", "libx264",
"-pix_fmt", "yuv420p", "-preset", "ultrafast", "-an",
str(out / f"{slug}.mp4")], check=True)
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi",
"-i", "anullsrc=r=44100:cl=stereo:d=60", "-b:a", "64k",
str(out / f"{slug}.mp3")], check=True)
(out / f"{slug}.txt").write_text(
f"#TITLE:{title}\n#ARTIST:{artist}\n#MP3:{slug}.mp3\n#VIDEO:{slug}.mp4\n"
f"#BPM:240\n#GAP:10000\n: 0 4 16 Alas,\nE\n", encoding="utf-8")
return True
def seed_jobs(base_url: str) -> None:
@@ -187,13 +218,25 @@ def seed_jobs(base_url: str) -> None:
time.sleep(0.05)
def capture(base_url: str) -> None:
def capture(base_url: str, with_player: bool) -> None:
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1280, "height": 900})
page = browser.new_page(viewport={"width": 1280, "height": 1000})
page.goto(base_url, wait_until="networkidle")
page.get_by_text(SELECTED, exact=True).click()
page.wait_for_timeout(300)
page.wait_for_timeout(500)
if with_player:
# paused mid-line, so the shot carries the playhead and the sung /
# 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)
OUT_IMAGE.parent.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(OUT_IMAGE))
browser.close()
@@ -201,10 +244,11 @@ def capture(base_url: str) -> None:
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
base_url = start_server(Path(tmp))
base_url, cfg = start_server(Path(tmp))
time.sleep(1)
seed_jobs(base_url)
capture(base_url)
with_player = install_media(cfg)
capture(base_url, with_player)
print(f"wrote {OUT_IMAGE}")
+11 -2
View File
@@ -1,6 +1,6 @@
import os
import tomllib
from dataclasses import dataclass, fields
from dataclasses import dataclass, field, fields
from pathlib import Path
@@ -18,9 +18,18 @@ class Config:
device: str = "cuda"
bind_host: str = "127.0.0.1"
port: int = 8000
# YouTube player clients to try, in order, until one yields a download.
# "default" means yt-dlp's own choice; see its extractor-args docs.
ytdlp_clients: list[str] = field(default_factory=lambda: ["default", "tv"])
ytdlp_cookies: Path | None = None
ytdlp_impersonate: str | None = None
ytdlp_pot_provider_url: str | None = None
# "auto" keeps yt-dlp current from PyPI, "bundled" uses only the copy
# installed with songtrace, and a release such as "2026.08.19" pins that.
ytdlp_version: str = "auto"
_PATH_FIELDS = {"output_dir", "work_dir"}
_PATH_FIELDS = {"output_dir", "work_dir", "ytdlp_cookies"}
def load_config(path: Path | None = None) -> Config:
+233
View File
@@ -0,0 +1,233 @@
"""Manual edits to a song, kept apart from what the pipeline generates.
Edits live in work/<slug>/edits.json, which no stage produces, so no rebuild
removes them; grid lays them over the freshly generated notes every time. An
edit is a whole lyric line as the user left it, timed in seconds so that a
rebuild with a different BPM re-snaps it rather than breaking it.
"""
import difflib
import json
import math
import time
from pathlib import Path
from .notes import MIN_NOTE_SEC, TimedNote, low_confidence_warnings
EDITS_FILE = "edits.json"
FINAL_FILE = "notes_final.json"
KINDS = {":", "*", "F", "R", "G"}
def _empty(lyrics: list[list[str]]) -> dict:
# grid: the BPM and GAP set in the editor, or None for the pipeline's own;
# breaks: line breaks moved by hand, keyed by the line each comes before
return {"lyrics": lyrics, "lines": {}, "stale": [], "grid": None, "breaks": {}}
def load(work: Path, lyrics: list[list[str]]) -> dict:
"""The song's edits, keyed to `lyrics`.
Edits are stored with the lyrics they were made against. When those
differ, each edited line is matched to its line in the new lyrics by
text and order; an edit whose line's words changed is set aside as stale
rather than applied to words it was not made for.
"""
path = work / EDITS_FILE
if not path.exists():
return _empty(lyrics)
data = json.loads(path.read_text())
if data["lyrics"] != lyrics:
data = _remap(data, lyrics)
save(work, data)
return data
def save(work: Path, data: dict) -> None:
path = work / EDITS_FILE
if not (data["lines"] or data["stale"] or data.get("grid") or data.get("breaks")):
path.unlink(missing_ok=True)
return
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data))
tmp.replace(path)
def _remap(data: dict, lyrics: list[list[str]]) -> dict:
old = [tuple(line) for line in data["lyrics"]]
new = [tuple(line) for line in lyrics]
moved = {}
for tag, i1, i2, j1, _ in difflib.SequenceMatcher(
a=old, b=new, autojunk=False).get_opcodes():
if tag == "equal":
moved.update({i1 + k: j1 + k for k in range(i2 - i1)})
lines, stale = {}, list(data["stale"])
for key, edit in data["lines"].items():
to = moved.get(int(key))
if to is None:
stale.append(edit)
else:
lines[str(to)] = {**edit, "line_index": to}
# a moved break goes with the line it comes before; with that line gone,
# there is nothing left for it to separate
breaks = {str(moved[int(k)]): t for k, t in data.get("breaks", {}).items()
if int(k) in moved}
return {"lyrics": lyrics, "lines": lines, "stale": stale, "grid": data.get("grid"),
"breaks": breaks}
def validate_notes(notes: list[dict]) -> list[dict]:
"""The notes of a line edit, checked and reduced to what is stored."""
clean = []
for n in notes:
try:
note = {"start": float(n["start"]), "duration": float(n["duration"]),
"pitch": int(n["pitch"]), "text": str(n["text"]),
"kind": str(n.get("kind", ":"))}
except (KeyError, TypeError, ValueError):
raise ValueError(f"malformed note {n!r}")
if note["start"] < 0 or note["duration"] <= 0:
raise ValueError(f"a note needs start >= 0 and duration > 0: {n!r}")
if note["kind"] not in KINDS:
raise ValueError(f"unknown note kind {note['kind']!r}")
if not note["text"].strip():
raise ValueError("a note needs text")
clean.append(note)
return sorted(clean, key=lambda n: n["start"])
def set_line(data: dict, index: int, notes: list[dict]) -> None:
data["lines"][str(index)] = {
"line_index": index, "words": data["lyrics"][index],
"notes": validate_notes(notes), "saved_at": time.time()}
def clear_line(data: dict, index: int) -> bool:
return data["lines"].pop(str(index), None) is not None
def set_breaks(data: dict, changes: dict) -> None:
"""Move line breaks, each keyed by the line it comes before, to a time in
seconds; None puts one back to where the notes alone would place it."""
breaks = dict(data.get("breaks", {}))
for key, t in changes.items():
try:
line = int(key)
except (TypeError, ValueError):
raise ValueError(f"a break is keyed by the line it comes before, not {key!r}")
if not 0 < line < len(data["lyrics"]):
raise ValueError(f"no line {line} for a break to come before")
if t is None:
breaks.pop(str(line), None)
continue
try:
t = float(t)
except (TypeError, ValueError):
raise ValueError(f"a break needs a time in seconds, not {t!r}")
if not (math.isfinite(t) and t >= 0):
raise ValueError(f"a break needs a time of 0 or more, not {t}")
breaks[str(line)] = t
data["breaks"] = breaks
def set_grid(data: dict, bpm, gap_ms) -> None:
"""Set the chart's BPM (UltraStar's, a quarter of a beat per beat) and GAP."""
try:
bpm, gap_ms = float(bpm), float(gap_ms)
except (TypeError, ValueError):
raise ValueError("bpm and gap_ms must be numbers")
if not (math.isfinite(bpm) and 0 < bpm <= 2000):
raise ValueError(f"bpm must be above 0 and at most 2000, not {bpm}")
if not (math.isfinite(gap_ms) and gap_ms >= 0):
raise ValueError(f"gap_ms must be 0 or more, not {gap_ms}")
data["grid"] = {"bpm": bpm, "gap_ms": gap_ms}
def clear_grid(data: dict) -> bool:
had = bool(data.get("grid"))
data["grid"] = None
return had
def merge(notes: list[dict], breaks: list[float], data: dict
) -> tuple[list[dict], list[float]]:
"""Generated notes and breaks with every edited line laid over them.
An edited line replaces the generated notes of that line, and the user's
timing wins: generated notes of earlier lines are cut back to end where it
starts, and those of later lines to start where it ends. Line breaks next
to an edited line move to the middle of the gap it leaves; a break moved
by hand stays where it was put, kept between the lines it separates.
"""
edited = {int(k): e["notes"] for k, e in data["lines"].items()}
moved = {int(k): t for k, t in data.get("breaks", {}).items()}
if not edited and not moved:
return notes, breaks
spans = {li: (ns[0]["start"], ns[-1]["start"] + ns[-1]["duration"])
for li, ns in edited.items() if ns}
kept = []
for n in notes:
li = n["line_index"]
if li in edited:
continue
start, end = n["start"], n["start"] + n["duration"]
for eli, (a, b) in spans.items():
if li < eli:
end = min(end, a)
elif li > eli:
start = max(start, b)
if (start, end) == (n["start"], n["start"] + n["duration"]):
kept.append(n)
elif end - start >= MIN_NOTE_SEC:
kept.append({**n, "start": start, "duration": end - start})
for li, ns in edited.items():
kept += [{**n, "line_index": li, "low_confidence": False} for n in ns]
kept.sort(key=lambda n: (n["start"], n["line_index"]))
def line_after(t: float) -> int | None:
return next((n["line_index"] for n in notes if n["start"] >= t), None)
first = {}
last = {}
for n in kept:
first.setdefault(n["line_index"], n)
last[n["line_index"]] = n
order = sorted(first, key=lambda li: first[li]["start"])
out_breaks = []
for prev, nxt in zip(order, order[1:]):
end = last[prev]["start"] + last[prev]["duration"]
if nxt in moved:
# where it was put, but never inside either line's notes
out_breaks.append(min(max(moved[nxt], end), max(first[nxt]["start"], end)))
continue
if prev not in edited and nxt not in edited:
# the generated break between these two lines, untouched
gen = [t for t in breaks if line_after(t) == nxt]
if gen:
out_breaks.append(gen[0])
continue
out_breaks.append((end + first[nxt]["start"]) / 2)
return kept, out_breaks
def finalize(work: Path, lyrics: list[list[str]]) -> dict:
"""The song as it is to be charted: generated notes with the edits laid
over them, warnings brought up to date, written to notes_final.json."""
generated = json.loads((work / "notes_sec.json").read_text())
data = load(work, lyrics)
notes, breaks = merge(generated["notes"], generated["breaks"], data)
warnings = generated.get("warnings", [])
if data["lines"]:
# low-confidence warnings must describe the merged notes, so a line
# the user fixed stops being reported; the others are about lyrics
warnings = (low_confidence_warnings([TimedNote(**n) for n in notes])
+ [w for w in warnings if "low-confidence" not in w])
if data["stale"]:
n = len(data["stale"])
warnings.append(f"{n} edited line{' no longer matches' if n == 1 else 's no longer match'}"
" the lyrics; set aside in the editor")
final = {"notes": notes, "breaks": breaks, "warnings": warnings,
"edited_lines": sorted(int(k) for k in data["lines"]),
"stale": data["stale"], "grid": data.get("grid"),
"moved_breaks": sorted(int(k) for k in data.get("breaks", {}))}
(work / FINAL_FILE).write_text(json.dumps(final))
return final
+35 -5
View File
@@ -3,7 +3,11 @@ from pathlib import Path
import numpy as np
from .ultrastar import LineBreak, Note, UltraStarSong, format_bpm
from .ultrastar import (LineBreak, Note, UltraStarSong, format_bpm, get_header,
parse_bpm, parse_ultrastar, serialize_ultrastar)
TEMPO_FILE = "tempo.json"
def detect_bpm(instrumental_path: Path) -> float:
@@ -25,11 +29,20 @@ def scale_bpm(bpm: float) -> float:
def to_ultrastar(notes: list[dict], breaks: list[float], file_bpm: float,
headers: list[tuple[str, str]]) -> UltraStarSong:
headers: list[tuple[str, str]],
gap_ms: float | None = None) -> UltraStarSong:
"""Snap notes to the beat grid. GAP defaults to the first note's start; a
GAP set later than that would put notes on negative beats, which players
reject, so it moves back by whole beats, keeping the grid where it was."""
if not notes:
raise ValueError("no notes to place on the beat grid")
gap_ms = round(notes[0]["start"] * 1000)
spb = 60 / (4 * file_bpm)
first_ms = notes[0]["start"] * 1000
if gap_ms is None:
gap_ms = first_ms
elif gap_ms > first_ms:
gap_ms -= math.ceil((gap_ms - first_ms) / (spb * 1000)) * spb * 1000
gap_ms = max(0, round(gap_ms))
def beat(t: float) -> int:
return round((t - gap_ms / 1000) / spb)
@@ -49,10 +62,27 @@ def to_ultrastar(notes: list[dict], breaks: list[float], file_bpm: float,
else:
b = max(beat(t), cursor)
length = max(1, beat(t + n["duration"]) - beat(t))
body.append(Note(kind=":", beat=b, length=length, pitch=n["pitch"],
text=n["text"]))
body.append(Note(kind=n.get("kind", ":"), beat=b, length=length,
pitch=n["pitch"], text=n["text"]))
cursor = b + length
out_headers = list(headers)
out_headers.append(("BPM", format_bpm(file_bpm)))
out_headers.append(("GAP", str(gap_ms)))
return UltraStarSong(headers=out_headers, body=body)
def rechart(txt_path: Path, notes: list[dict], breaks: list[float],
bpm: float | None = None, gap_ms: float | None = None) -> None:
"""Replace a chart's notes, keeping its headers and, unless given new
ones, its BPM.
For an edit saved from the editor: the packaged chart's headers may differ
from the work copy's (a re-encoded #VIDEO, a dropped #COVER), so each
keeps its own.
"""
song = parse_ultrastar(txt_path.read_text(encoding="utf-8"))
if bpm is None:
bpm = parse_bpm(get_header(song, "BPM"))
headers = [(k, v) for k, v in song.headers if k.upper() not in ("BPM", "GAP")]
txt_path.write_text(serialize_ultrastar(to_ultrastar(notes, breaks, bpm, headers, gap_ms)),
encoding="utf-8")
+47
View File
@@ -0,0 +1,47 @@
"""Handing the GPU back between stages.
The GPU this runs on is shared, and `songtrace serve` outlives every job it
runs, so a stage that caches its weights holds VRAM until the process exits —
including while a later stage is waiting on the Whisper server, which can be
minutes of a GPU nothing is using. Two things have to be undone: the caches
themselves, and PyTorch's allocator, which keeps freed blocks for reuse rather
than returning them to the driver.
A stage that caches a model registers how to drop it with @on_release, so this
module needs to know nothing about the pipeline. Registration happens at import
time, which run_pipeline has already done by the time it calls release().
"""
import gc
import sys
from typing import Callable
_callbacks: list[Callable[[], None]] = []
def on_release(fn: Callable[[], None]) -> Callable[[], None]:
"""Register a callback that drops a cached model."""
_callbacks.append(fn)
return fn
def release() -> int:
"""Drop every cached model and return its VRAM to the driver.
Answers the bytes handed back, which is the only part of this a caller can
show someone. Safe whenever: a stage that never loaded anything has nothing
to drop, and the allocator is left alone unless this process actually
initialised CUDA.
"""
for callback in _callbacks:
callback()
# weights reach the allocator's free list only once the last reference
# goes, and a module graph is cyclic enough to outlive refcounting
gc.collect()
torch = sys.modules.get("torch")
if torch is None or not torch.cuda.is_available():
return 0
if not torch.cuda.is_initialized():
return 0
before = torch.cuda.memory_reserved()
torch.cuda.empty_cache()
return max(before - torch.cuda.memory_reserved(), 0)
+34 -22
View File
@@ -7,7 +7,17 @@ import pyphen
from .languages import LANGUAGES
CONF_MASK = 0.5 # pitch frames below this are unvoiced
LOW_SCORE = 0.5 # word alignment score below this -> low_confidence
# A word is low-confidence when it scores below this share of the song's
# median alignment score: the scale differs a lot from song to song, so no
# fixed cutoff holds across them.
LOW_SCORE_OF_MEDIAN = 0.25
# ...or when no one sings a word that fast, or it was stretched over more than
# it can be holding.
MIN_WORD_SEC = 0.08
MAX_WORD_SEC = 3.0
# Flagged notes closer than this share one warning, so the list reads as
# places to look rather than one line per note.
WARN_GAP_SEC = 2.0
SPLIT_SEMITONES = 0.8
MIN_SEG_SEC = 0.05
MIN_NOTE_SEC = 0.05 # alignment may hand us zero-length words
@@ -24,6 +34,7 @@ class TimedNote:
text: str # display text incl. leading space / "~"
low_confidence: bool
line_index: int
kind: str = ":" # UltraStar note type; generated notes are all normal
def hz_to_midi_float(hz: float) -> float:
@@ -84,19 +95,12 @@ def build_notes(rows: list[dict], times, f0, conf, language: str | None,
) -> tuple[list[TimedNote], list[float], list[str]]:
notes: list[TimedNote] = []
breaks: list[float] = []
prev_line, prev_end, last_seen_line = None, 0.0, None
floor = LOW_SCORE_OF_MEDIAN * float(np.median([r["score"] for r in rows])) if rows else 0.0
prev_line, prev_end = None, 0.0
for row in rows:
# last_seen_line tracks every row (skipped or not) so a run of
# skipped rows can't trigger a duplicate break on the next row of
# the same still-unseen line; prev_line/prev_end only advance on
# rows that actually emit notes, so a skipped row can't corrupt
# line_initial or break timing for its own line's later words.
if last_seen_line is not None and row["line_index"] > last_seen_line:
if prev_line is not None and row["line_index"] > prev_line:
breaks.append((prev_end + row["start"]) / 2)
last_seen_line = row["line_index"]
line_initial = row["line_index"] != prev_line
if any(a <= row["start"] < b for a, b in missing_spans):
continue
prev_line, prev_end = row["line_index"], row["end"]
segs = segment_span(times, f0, conf, row["start"], row["end"])
if not segs:
@@ -105,7 +109,8 @@ def build_notes(rows: list[dict], times, f0, conf, language: str | None,
_text(row["word"], 0, line_initial), True,
row["line_index"]))
continue
low = row["score"] < LOW_SCORE
low = (row["score"] < floor
or not MIN_WORD_SEC <= row["end"] - row["start"] <= MAX_WORD_SEC)
sylls = syllabify(row["word"], language)
if len(segs) < len(sylls):
segs = _subdivide(segs, len(sylls))
@@ -166,18 +171,25 @@ def _fmt(t: float) -> str:
return f"{int(t // 60)}:{int(t % 60):02d}"
def low_confidence_warnings(notes: list[TimedNote]) -> list[str]:
runs: list[list[TimedNote]] = []
for n in notes:
if not n.low_confidence:
continue
last = runs[-1][-1] if runs else None
if last is not None and n.start - (last.start + last.duration) < WARN_GAP_SEC:
runs[-1].append(n)
else:
runs.append([n])
return [
f"{len(run)} low-confidence note{'' if len(run) == 1 else 's'} "
f"near {_fmt(run[0].start)}–{_fmt(run[-1].start + run[-1].duration)}"
for run in runs]
def _warnings(notes: list[TimedNote], dropped_lines=(), missing_spans=(),
ref_lines=()) -> list[str]:
warnings, run = [], []
for n in list(notes) + [None]:
if n is not None and n.low_confidence:
run.append(n)
elif run:
first, last = run[0], run[-1]
warnings.append(
f"{len(run)} low-confidence note{'' if len(run) == 1 else 's'} "
f"near {_fmt(first.start)}–{_fmt(last.start + last.duration)}")
run = []
warnings = low_confidence_warnings(notes)
for group in _consecutive_runs(dropped_lines):
n = len(group)
text = " ".join(ref_lines[group[0]]) if group[0] < len(ref_lines) else ""
+15 -2
View File
@@ -3,6 +3,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from . import models
from .config import Config
class PipelineError(Exception):
@@ -46,7 +47,11 @@ def song_slug(inp: SongInput) -> str:
return name.replace("/", "").replace("\0", "")
def read_warnings(work: Path) -> list[str]:
notes_file = work / "notes_sec.json"
# grid's copy accounts for the user's edits; notes' is all a song built
# before edits existed has
notes_file = work / "notes_final.json"
if not notes_file.exists():
notes_file = work / "notes_sec.json"
if not notes_file.exists():
return []
return json.loads(notes_file.read_text()).get("warnings", [])
@@ -74,6 +79,14 @@ def run_pipeline(inp: SongInput, cfg: Config, log=print,
log(f"[{stage.name}] cached, skipping")
continue
log(f"[{stage.name}] running")
stage.run(ctx)
# per stage rather than per job: each model is used by one stage, so
# holding it past that only keeps VRAM from whatever else wants it
try:
stage.run(ctx)
finally:
# logged only when it actually gave something back, so the line is
# evidence rather than noise on a box where this matters
if freed := models.release():
log(f"[{stage.name}] released {freed / 2**20:.0f} MiB of GPU memory")
return PipelineResult(output_dir=cfg.output_dir / song_slug(inp),
warnings=read_warnings(work))
+11
View File
@@ -38,6 +38,17 @@ def plan(old: SongInput, new: SongInput) -> str | None:
return min((FIELD_STAGE[f] for f in changed), key=STAGE_ORDER.index)
def earliest(*stages: str | None) -> str | None:
"""The earliest of the stages given, ignoring None ("nothing forced").
An operator's explicit choice is combined with plan()'s rather than
replacing it: the planner's stage is the latest that is still correct for
the edit, so a choice may only ever redo more.
"""
named = [s for s in stages if s]
return min(named, key=STAGE_ORDER.index) if named else None
def relocate_work(old: SongInput, new: SongInput, cfg: Config) -> bool:
"""Move the cached work dir when title/artist changed its slug.
+52 -13
View File
@@ -2,14 +2,19 @@ import json
import shutil
from pathlib import Path
from .. import ytdlp
from ..config import Config
from ..media import ffmpeg
from ..pipeline import Ctx, PipelineError, Stage
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"}
# yt-dlp's in-progress scratch files, which a failed attempt leaves behind.
PARTIAL_EXTENSIONS = {".part", ".ytdl", ".temp"}
def _classify_downloads(dest: Path) -> tuple[Path, Path | None]:
files = list(dest.glob("source_video.*"))
files = [p for p in dest.glob("source_video.*")
if p.suffix.lower() not in PARTIAL_EXTENSIONS]
thumbs = [p for p in files if p.suffix.lower() in IMAGE_EXTENSIONS]
videos = [p for p in files if p.suffix.lower() not in IMAGE_EXTENSIONS]
if not videos:
@@ -18,17 +23,50 @@ def _classify_downloads(dest: Path) -> tuple[Path, Path | None]:
return video, thumbs[0] if thumbs else None
def download_youtube(url: str, dest: Path) -> tuple[Path, Path | None]:
import yt_dlp
opts = {
"outtmpl": str(dest / "source_video.%(ext)s"),
"format": "bestvideo*+bestaudio/best",
"writethumbnail": True,
"noplaylist": True,
}
with yt_dlp.YoutubeDL(opts) as ydl:
ydl.extract_info(url, download=True)
return _classify_downloads(dest)
def _ydl_args(cfg: Config, dest: Path, client: str, url: str) -> list[str]:
args = [
"--output", str(dest / "source_video.%(ext)s"),
"--format", "bestvideo*+bestaudio/best",
"--write-thumbnail", "--no-playlist", "--no-progress",
"--retries", "10", "--fragment-retries", "10",
"--extractor-retries", "5",
]
if client != "default":
args += ["--extractor-args", f"youtube:player_client={client}"]
if cfg.ytdlp_pot_provider_url:
args += ["--extractor-args",
f"youtubepot-bgutilhttp:base_url={cfg.ytdlp_pot_provider_url}"]
if cfg.ytdlp_cookies:
args += ["--cookies", str(cfg.ytdlp_cookies)]
if cfg.ytdlp_impersonate:
args += ["--impersonate", cfg.ytdlp_impersonate]
return [*args, "--", url]
def download_youtube(url: str, dest: Path, cfg: Config,
log=print) -> tuple[Path, Path | None]:
clients = list(cfg.ytdlp_clients) or ["default"]
tried: list[str] = []
last: Exception | None = None
# If every copy fails, look for a release newer than the daily check found.
for refresh in (False, True):
for ydl in ytdlp.candidates(cfg, log, refresh):
if ydl.version in tried:
continue
tried.append(ydl.version)
for client in clients:
try:
ydl.run(_ydl_args(cfg, dest, client, url), log)
return _classify_downloads(dest)
except PipelineError as e:
last = e
log(f"yt-dlp {ydl.version} player_client={client} "
f"failed: {e}")
raise PipelineError(
f"yt-dlp {', '.join(tried)} could not download {url} after trying "
f"player_client={', '.join(clients)}. Last error: {last}. "
"See the yt-dlp options in config.toml (cookies, impersonation, "
"PO token provider).")
def _run(ctx: Ctx) -> None:
@@ -36,7 +74,8 @@ def _run(ctx: Ctx) -> None:
raise PipelineError("provide exactly one of youtube_url / video_path")
thumb = None
if ctx.inp.youtube_url:
video, thumb = download_youtube(ctx.inp.youtube_url, ctx.work)
video, thumb = download_youtube(ctx.inp.youtube_url, ctx.work,
ctx.cfg, ctx.log)
else:
video = ctx.work / f"source_video{ctx.inp.video_path.suffix}"
shutil.copyfile(ctx.inp.video_path, video)
+80 -9
View File
@@ -1,12 +1,27 @@
import json
from pathlib import Path
import numpy as np
import soundfile as sf
from ..lyrics import alignment_token, normalize_lyrics
from ..models import on_release
from ..pipeline import Ctx, PipelineError, Stage
from .transcribe import classify_lyrics_match, load_transcript, lyrics_similarity, vocal_sections
from ..whisper_client import TranscriptSegment
from .transcribe import (classify_lyrics_match, load_transcript, lyrics_similarity,
recovered_spans, vocal_sections)
SIMILARITY_THRESHOLD = 0.4
# MMS_FA's wildcard token: any audio the transcript does not cover
STAR = "*"
# Singing is audio within this much of the stem's loud level; separation
# bleed sits far below it.
SINGING_BELOW_LOUD_DB = 25.0
# Shorter stretches are breaths, ad-libs or Whisper's loose segment edges.
MIN_UNHEARD_SEC = 3.0
# a stage aligns section by section, so the model is cached across the calls
# within one run and dropped at the end of it
_model_cache = {}
@@ -17,6 +32,11 @@ def _get_model(device: str):
return _model_cache[device]
@on_release
def unload_model() -> None:
_model_cache.clear()
def force_align_section(vocals_path: Path, start: float, end: float,
tokens: list[str], device: str) -> list[tuple[float, float, float]]:
import torch
@@ -32,7 +52,7 @@ def force_align_section(vocals_path: Path, start: float, end: float,
waveform[:, lo:hi], sr, bundle.sample_rate)
# MMS_FA only models a-z and apostrophe; anything else becomes the star token.
vocab = bundle.get_dict()
tokens = ["".join(c if c in vocab else "*" for c in t) for t in tokens]
tokens = ["".join(c if c in vocab else STAR for c in t) for t in tokens]
with torch.inference_mode():
emission, _ = _get_model(device)(waveform.to(device))
spans = bundle.get_aligner()(emission[0], bundle.get_tokenizer()(tokens))
@@ -42,6 +62,39 @@ def force_align_section(vocals_path: Path, start: float, end: float,
sum(t.score for t in s) / len(s)) for s in spans]
def untranscribed_singing(vocals_path: Path, segments,
frame: float = 0.05) -> list[tuple[float, float]]:
"""Stretches of the vocal stem with singing in them but no transcript."""
data, sr = sf.read(str(vocals_path), dtype="float32", always_2d=True)
hop = int(sr * frame)
n = len(data) // hop
if n == 0:
return []
rms = np.sqrt((data[:n * hop].mean(1).reshape(n, hop) ** 2).mean(1))
db = 20 * np.log10(rms + 1e-10)
sung = db >= np.quantile(db, 0.9) - SINGING_BELOW_LOUD_DB
times = np.arange(n) * frame
for s in segments:
sung[(times >= min(s.start, s.end)) & (times < max(s.start, s.end))] = False
spans, start = [], None
for t, on in zip(times, sung):
if on and start is None:
start = float(t)
elif not on and start is not None:
spans.append((start, float(t)))
start = None
if start is not None:
spans.append((start, float(times[-1] + frame)))
# a breath or a quiet syllable must not split one unsung passage in two
merged: list[list[float]] = []
for a, b in spans:
if merged and a - merged[-1][1] < 1.0:
merged[-1][1] = b
else:
merged.append([a, b])
return [(a, b) for a, b in merged if b - a >= MIN_UNHEARD_SEC]
def _assign_lines_to_sections(line_indices: list[int], sections) -> list[list[int]]:
"""Split matched lyric line indices across vocal sections proportionally to section duration."""
total = sum(b - a for a, b in sections) or 1.0
@@ -84,9 +137,20 @@ def _enforce_monotonic(rows: list[dict]) -> None:
def _align_section(vocals: Path, sec: tuple[float, float], words: list[tuple[str, int]],
language: str | None, device: str) -> list[dict]:
tokens = [alignment_token(w, language) for w, _ in words]
alignable = [t for t in tokens if t]
aligned = iter(force_align_section(vocals, sec[0], sec[1], alignable, device)
if alignable else [])
# A star before, between and after lines lets speech, laughter or an
# ad-lib there go unexplained rather than claim the next line's letters.
seq, prev_line = [], None
for (_, line_index), token in zip(words, tokens):
if not token:
continue
if line_index != prev_line:
seq.append(STAR)
prev_line = line_index
seq.append(token)
if seq:
seq.append(STAR)
spans = force_align_section(vocals, sec[0], sec[1], seq, device) if seq else []
aligned = iter(span for span, token in zip(spans, seq) if token != STAR)
rows, blank = [], (None, None, 0.0)
for (word, line_index), token in zip(words, tokens):
start, end, score = next(aligned, blank) if token else blank
@@ -104,11 +168,18 @@ def _run(ctx: Ctx) -> None:
raise PipelineError(
f"lyrics don't match audio (similarity {sim:.2f}) — "
"wrong lyrics or wrong recording?")
# normalised so an inverted span can never collapse a section onto one instant
sections = [(min(a, b), max(a, b)) for a, b in vocal_sections(segments)]
dropped_lines, missing_spans = classify_lyrics_match(segments, ref)
matched = [i for i in range(len(ref.lines)) if i not in set(dropped_lines)]
vocals = ctx.work / "vocals.wav"
unheard = untranscribed_singing(vocals, segments)
dropped_lines, missing_spans = classify_lyrics_match(segments, ref,
unheard=unheard)
# lines Whisper missed are aligned against the singing it had no text for;
# only that singing, so an untranscribed ad-lib cannot draw lines into it
recovered = [TranscriptSegment(a, b, "")
for a, b in recovered_spans(segments, ref, unheard)]
# normalised so an inverted span can never collapse a section onto one instant
sections = [(min(a, b), max(a, b))
for a, b in vocal_sections(segments + recovered)]
matched = [i for i in range(len(ref.lines)) if i not in set(dropped_lines)]
rows = []
for sec, line_idxs in zip(sections, _assign_lines_to_sections(matched, sections)):
words = [(w, li) for li in line_idxs for w in ref.lines[li]]
+14 -4
View File
@@ -1,13 +1,16 @@
import json
from pathlib import Path
from .. import grid
from .. import edits, grid
from ..lyrics import normalize_lyrics
from ..pipeline import Ctx, PipelineError, Stage, song_slug
from ..ultrastar import serialize_ultrastar
def _run(ctx: Ctx) -> None:
data = json.loads((ctx.work / "notes_sec.json").read_text())
data = edits.finalize(ctx.work, normalize_lyrics(ctx.inp.lyrics_text).lines)
if data["edited_lines"]:
ctx.log(f"[grid] kept {len(data['edited_lines'])} edited line(s)")
meta = json.loads((ctx.work / "meta.json").read_text())
if ctx.inp.bpm_override is not None:
bpm = ctx.inp.bpm_override
@@ -16,6 +19,12 @@ def _run(ctx: Ctx) -> None:
bpm = grid.scale_bpm(grid.detect_bpm(ctx.work / "instrumental.wav"))
except ValueError:
raise PipelineError("could not detect a usable tempo - is the audio silent?")
# the pipeline's own tempo, which RESET in the editor returns to
(ctx.work / grid.TEMPO_FILE).write_text(json.dumps({"bpm": bpm}))
gap_ms = None
if data["grid"]:
bpm, gap_ms = data["grid"]["bpm"], data["grid"]["gap_ms"]
ctx.log("[grid] using the BPM and GAP set in the editor")
slug = song_slug(ctx.inp)
video_ext = Path(meta["video_file"]).suffix
headers = [("TITLE", ctx.inp.title), ("ARTIST", ctx.inp.artist)]
@@ -28,7 +37,7 @@ def _run(ctx: Ctx) -> None:
headers += [("CREATOR", ctx.cfg.creator), ("MP3", f"{slug}.mp3"),
("COVER", f"{slug}.jpg"), ("VIDEO", f"{slug}{video_ext}")]
try:
song = grid.to_ultrastar(data["notes"], data["breaks"], bpm, headers)
song = grid.to_ultrastar(data["notes"], data["breaks"], bpm, headers, gap_ms)
except ValueError:
raise PipelineError("no notes were generated - the lyrics may not match the audio")
(ctx.work / "song.txt").write_text(serialize_ultrastar(song), encoding="utf-8")
@@ -36,5 +45,6 @@ def _run(ctx: Ctx) -> None:
def grid_stage() -> Stage:
return Stage(name="grid",
outputs=lambda ctx: [ctx.work / "song.txt"],
outputs=lambda ctx: [ctx.work / "song.txt",
ctx.work / edits.FINAL_FILE],
run=_run)
+6 -2
View File
@@ -51,11 +51,15 @@ def _run(ctx: Ctx) -> None:
meta = json.loads((ctx.work / "meta.json").read_text())
src_video = ctx.work / meta["video_file"]
fmt, codec = ffprobe_codec(src_video)
# The video is background only: the song's audio is #MP3, and the two are
# the same recording. A player that does not mute the video plays both at
# once, which is heard as an echo, so the audio track is dropped here.
if (fmt, codec) in GOOD_VIDEO:
shutil.copyfile(src_video, out / f"{slug}{src_video.suffix}")
ffmpeg("-i", str(src_video), "-c", "copy", "-an",
str(out / f"{slug}{src_video.suffix}"))
else:
ffmpeg("-i", str(src_video), "-c:v", "libx264", "-crf", "20",
"-preset", "medium", "-c:a", "aac", str(out / f"{slug}.mp4"))
"-preset", "medium", "-an", str(out / f"{slug}.mp4"))
_set_header(txt, "VIDEO", f"{slug}.mp4")
cover_src = ctx.inp.cover_path
if cover_src is None and meta.get("thumbnail"):
+24
View File
@@ -1,9 +1,32 @@
import sys
from pathlib import Path
import numpy as np
from ..models import on_release
from ..pipeline import Ctx, Stage
# Separated vocal stems keep faint bleed that crepe still finds a pitch in, so
# frames this far below the song's loud vocal level count as silence. Relative
# rather than absolute, so it holds however loud the song was mastered.
SILENCE_BELOW_LOUD_DB = 25.0
@on_release
def unload_model() -> None:
"""torchcrepe caches its model as attributes on its own infer function."""
torchcrepe = sys.modules.get("torchcrepe")
if torchcrepe is None:
return
for attr in ("model", "capacity"):
if hasattr(torchcrepe.infer, attr):
delattr(torchcrepe.infer, attr)
def mute_silence(conf: np.ndarray, loudness: np.ndarray) -> None:
"""Zero the confidence of frames quiet enough to be bleed, in place."""
conf[loudness < np.quantile(loudness, 0.9) - SILENCE_BELOW_LOUD_DB] = 0.0
def track_pitch(vocals_path: Path, model: str, device: str):
import torchaudio
@@ -20,6 +43,7 @@ def track_pitch(vocals_path: Path, model: str, device: str):
return_periodicity=True, batch_size=512)
f0 = f0[0].cpu().numpy()
conf = periodicity[0].cpu().numpy()
mute_silence(conf, torchcrepe.loudness.a_weighted(waveform, 16000, 160)[0].cpu().numpy())
times = np.arange(len(f0)) * 0.01
return times, f0, conf
+62 -17
View File
@@ -39,29 +39,74 @@ def lyrics_similarity(segments, ref: RefLyrics) -> float:
return matcher.ratio()
def classify_lyrics_match(segments, ref: RefLyrics, pad: float = 1.0
def _line_times(segments, matcher, seg_of, line_of) -> dict[int, tuple[float, float]]:
"""For each reference line that was heard, the start of the first and the
end of the last transcript segment its words were heard in. Words in a
'replace' block are paired with heard words proportionally."""
times: dict[int, tuple[float, float]] = {}
for tag, ai1, ai2, bj1, bj2 in matcher.get_opcodes():
if tag not in ("equal", "replace"):
continue
for j in range(bj1, bj2):
seg = segments[seg_of[ai1 + (j - bj1) * (ai2 - ai1) // (bj2 - bj1)]]
a, b = times.get(line_of[j], (seg.start, seg.end))
times[line_of[j]] = (min(a, seg.start), max(b, seg.end))
return times
def _unheard_runs(segments, ref: RefLyrics, unheard
) -> tuple[list[list[int]], list[list[tuple[float, float]]]]:
"""Runs of reference lines with nothing heard for them, each with the
untranscribed singing between the heard lines either side of it."""
matcher, seg_of, line_of = match_lyrics(segments, ref)
times = _line_times(segments, matcher, seg_of, line_of)
alignable = set(line_of)
runs, spans, run, prev_end = [], [], [], 0.0
def close(next_start: float) -> None:
if run:
runs.append(list(run))
spans.append([(max(a, prev_end), min(b, next_start)) for a, b in unheard
if a < next_start and b > prev_end])
run.clear()
for li in range(len(ref.lines)):
if li in times:
close(times[li][0])
prev_end = times[li][1]
elif li in alignable:
run.append(li)
close(float("inf"))
return runs, spans
def recovered_spans(segments, ref: RefLyrics, unheard) -> list[tuple[float, float]]:
"""The untranscribed singing that stands in for lines Whisper missed, so
alignment can place them there."""
return _merge_spans([sp for spans in _unheard_runs(segments, ref, unheard)[1]
for sp in spans])
def classify_lyrics_match(segments, ref: RefLyrics, pad: float = 1.0, unheard=()
) -> tuple[list[int], list[tuple[float, float]]]:
"""Split reference lines into matched vs. dropped, and flag sung spans no
reference line explains.
dropped_lines: reference line indices covered only by 'insert' opcodes
(present in the lyrics, never heard). missing_spans: the padded, merged
bounds of the heard segments behind 'delete' opcodes (heard, but matching
no reference word) — scoped tightly to the unmatched audio itself, not
the whole vocal section it falls in, so matched lines sharing a section
with an ad-lib keep their notes.
"""
matcher, seg_of, line_of = match_lyrics(segments, ref)
covered = [False] * len(line_of)
for tag, _, _, bj1, bj2 in matcher.get_opcodes():
if tag in ("equal", "replace"):
for j in range(bj1, bj2):
covered[j] = True
by_line = {}
for j, li in enumerate(line_of):
by_line.setdefault(li, []).append(covered[j])
dropped_lines = sorted(li for li, cov in by_line.items() if not any(cov))
(present in the lyrics, never heard), unless `unheard` (sung spans the
transcript has no text for) has singing between the heard lines either
side of them: then Whisper missed them, not the singer.
missing_spans: the padded, merged bounds of the heard segments behind
'delete' opcodes (heard, but matching no reference word). A segment can
also hold matched lines, so a span marks where to look, not words to
discard.
"""
runs, run_spans = _unheard_runs(segments, ref, unheard)
dropped_lines = sorted(li for run, sp in zip(runs, run_spans) if not sp
for li in run)
matcher, seg_of, _ = match_lyrics(segments, ref)
spans = []
for tag, ai1, ai2, _, _ in matcher.get_opcodes():
if tag != "delete":
+158
View File
@@ -0,0 +1,158 @@
// Editing one line's notes. Every operation returns a new array and leaves
// its input alone, so undo is just keeping the old one. Times are seconds,
// snapped to the chart's beat grid as the chart itself will be. Notes carry
// a client-side `id` so a selection survives reordering; it is never saved.
const MIN_BEATS = 1;
const spbOf = (grid) => 60 / (4 * grid.bpm);
const gapOf = (grid) => grid.gap_ms / 1000;
const byStart = (a, b) => a.start - b.start;
let nextId = 1;
export const newId = () => nextId++;
export function beatOf(t, grid) {
return (t - gapOf(grid)) / spbOf(grid);
}
export function timeOf(beat, grid) {
return gapOf(grid) + beat * spbOf(grid);
}
export function snap(t, grid) {
return timeOf(Math.round(beatOf(t, grid)), grid);
}
// A note as the chart will have it: start on a beat, at least one beat long.
function onGrid(n, grid) {
const start = snap(n.start, grid);
const beats = Math.max(MIN_BEATS, Math.round((n.end - n.start) / spbOf(grid)));
return { ...n, start, end: start + beats * spbOf(grid) };
}
export function withIds(notes) {
return notes.map(n => ({ ...n, id: n.id ?? newId() })).sort(byStart);
}
export function move(notes, ids, beats, semitones, grid) {
const spb = spbOf(grid);
return notes.map(n => {
if (!ids.has(n.id)) return n;
const g = onGrid(n, grid);
const shift = Math.max(beats * spb, -g.start); // never before the song starts
return { ...g, start: g.start + shift, end: g.end + shift, pitch: n.pitch + semitones };
}).sort(byStart);
}
// Drag one edge of a note to time t; the other edge stays put.
export function resize(notes, id, edge, t, grid) {
const spb = spbOf(grid);
return notes.map(n => {
if (n.id !== id) return n;
const g = onGrid(n, grid);
if (edge === "end") return { ...g, end: Math.max(snap(t, grid), g.start + spb) };
return { ...g, start: Math.max(0, Math.min(snap(t, grid), g.end - spb)) };
}).sort(byStart);
}
// Longer or shorter by whole beats, keeping the start.
export function stretch(notes, ids, beats, grid) {
const spb = spbOf(grid);
return notes.map(n => {
if (!ids.has(n.id)) return n;
const g = onGrid(n, grid);
return { ...g, end: Math.max(g.end + beats * spb, g.start + spb) };
});
}
// In two at the beat nearest the middle; the second half holds the syllable.
export function split(notes, id, grid) {
const i = notes.findIndex(n => n.id === id);
if (i < 0) return { notes, ids: [] };
const g = onGrid(notes[i], grid);
const beats = Math.round((g.end - g.start) / spbOf(grid));
if (beats < 2) return { notes, ids: [] };
const mid = g.start + Math.floor(beats / 2) * spbOf(grid);
const a = { ...g, end: mid };
const b = { ...g, id: newId(), start: mid, text: "~", low_confidence: false };
return { notes: [...notes.slice(0, i), a, b, ...notes.slice(i + 1)], ids: [a.id, b.id] };
}
// Into one with the note after it; a held "~" adds no text.
export function merge(notes, id) {
const i = notes.findIndex(n => n.id === id);
if (i < 0 || i + 1 >= notes.length) return notes;
const a = notes[i];
const b = notes[i + 1];
const text = b.text.trim() === "~" ? a.text : a.text + b.text;
return [...notes.slice(0, i), { ...a, end: b.end, text }, ...notes.slice(i + 2)];
}
export function remove(notes, ids) {
return notes.filter(n => !ids.has(n.id));
}
export function insert(notes, t, pitch, grid) {
const start = Math.max(0, snap(t, grid));
const note = { id: newId(), start, end: start + 2 * spbOf(grid), pitch,
text: "~", kind: ":", low_confidence: false };
return { notes: [...notes, note].sort(byStart), id: note.id };
}
// Applying a kind every selected note already has turns it back to normal.
export function setKind(notes, ids, kind) {
const chosen = notes.filter(n => ids.has(n.id));
const to = chosen.length && chosen.every(n => n.kind === kind) ? ":" : kind;
return notes.map(n => (ids.has(n.id) ? { ...n, kind: to } : n));
}
// A syllable that starts a word keeps the space before it that marks it so.
export function setText(notes, id, text) {
const clean = text.trim();
if (!clean) return notes;
return notes.map(n => {
if (n.id !== id) return n;
const lead = n.text.startsWith(" ") && !text.startsWith(" ") ? " " : "";
return { ...n, text: lead + (text.startsWith(" ") ? " " + clean : clean) };
});
}
// What the server stores for a line.
export function toSaved(notes) {
return notes.map(n => ({ start: n.start, duration: n.end - n.start, pitch: n.pitch,
text: n.text, kind: n.kind ?? ":" }));
}
export function history(initial) {
const past = [];
const future = [];
let present = initial;
return {
get present() { return present; },
get canUndo() { return past.length > 0; },
get canRedo() { return future.length > 0; },
push(next) {
if (next === present) return;
past.push(present);
future.length = 0;
present = next;
},
undo() {
if (past.length) { future.push(present); present = past.pop(); }
return present;
},
redo() {
if (future.length) { past.push(present); present = future.pop(); }
return present;
},
};
}
// Where a dragged line break lands: on a beat, but kept between the end of
// the line before it and the start of the line it introduces.
export function placeBreak(t, lo, hi, grid) {
const snapped = snap(t, grid);
if (hi < lo) return lo;
return Math.min(Math.max(snapped, lo), hi);
}
+675 -25
View File
@@ -111,6 +111,62 @@ body {
}
#sidebar .eyebrow, #jobs .empty-note { padding: 0 18px; }
#create-song { display: block; width: calc(100% - 36px); margin: 0 18px 20px; padding: 9px 12px; }
#rebuild-all { display: block; margin: -8px 18px 20px; }
#rebuild-all-note { margin: -12px 18px 20px; }
#rebuild-all-dialog {
width: min(480px, calc(100vw - 32px));
padding: 26px 28px;
color: var(--text);
background: var(--panel);
border: 1px solid var(--border);
}
#rebuild-all-dialog::backdrop { background: rgb(10 10 15 / 0.75); }
#rebuild-all-dialog h2 {
margin: 0 0 14px;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.8rem;
letter-spacing: 0.22em;
color: var(--text-muted);
}
#rebuild-all-form { display: grid; gap: 16px; }
#rebuild-all-form p { margin: 0; font-size: 0.88rem; line-height: 1.5; }
#rebuild-all-count { color: var(--warn); }
#rebuild-all-songs { margin: 0; padding: 0; border: 0; min-width: 0; }
#rebuild-all-songs legend {
margin-bottom: 6px;
padding: 0;
font-family: var(--font-display);
font-weight: 600;
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-muted);
}
#rebuild-all-list {
max-height: 40vh;
overflow-y: auto;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.rebuild-all-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 1px 10px;
align-items: baseline;
padding: 7px 4px;
font-size: 0.88rem;
cursor: pointer;
}
.rebuild-all-row input { accent-color: var(--purple); }
.rebuild-all-row:has(input:disabled) { cursor: default; color: var(--text-muted); }
.rebuild-all-row .meta { grid-column: 2; font-size: 0.76rem; color: var(--text-muted); }
.rebuild-all-every { padding-top: 0; color: var(--text-muted); }
#rebuild-all-error { min-height: 1.2em; color: var(--fail); }
#rebuild-all-error:empty { display: none; }
#jobs { display: flex; flex-direction: column; gap: 1px; margin-top: 8px; }
@@ -199,6 +255,8 @@ body {
.song-form-lyrics textarea { height: 100%; flex: 1 1 auto; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.field { display: flex; flex-direction: column; gap: 6px; }
/* [hidden] is only display:none, which the rule above would otherwise win */
.field[hidden], .keep-note[hidden] { display: none; }
.field > span {
font-family: var(--font-display);
font-weight: 600;
@@ -307,6 +365,7 @@ button { cursor: pointer; }
transition: background 120ms ease;
}
.primary:hover { background: #BE7BF9; }
.primary:disabled { background: var(--control); color: var(--text-muted); cursor: not-allowed; }
#form-error { margin: 12px 0 0; min-height: 1.2em; font-size: 0.85rem; color: var(--fail); }
@@ -319,8 +378,187 @@ button { cursor: pointer; }
}
#review h3 span { color: var(--text-muted); font-weight: 400; }
/* sticks to the top of #main so the video stays in frame as the lyrics scroll */
#player { position: sticky; top: 0; z-index: 2; margin: 0 0 14px; padding: 6px 0 10px; background: var(--bg); }
#player-video {
display: block;
width: 100%;
max-height: 30vh;
background: #000;
border: 1px solid var(--border);
border-radius: 2px;
}
#player-video[hidden] { display: none; }
/* the native transport renders light otherwise, against everything around it */
#player-media { display: block; width: 100%; margin-top: 8px; color-scheme: dark; }
#sheet { margin: 14px 0; padding: 12px 14px 10px; background: var(--panel); border: 1px solid var(--border); }
#sheet[hidden] { display: none; }
.sheet-nav { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.sheet-title {
min-width: 9em;
text-align: center;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.72rem;
letter-spacing: 0.18em;
color: var(--text-muted);
}
.sheet-nav .action:disabled { opacity: 0.35; cursor: default; }
.sheet-badges { display: flex; gap: 8px; margin-left: auto; }
.sheet-badge {
padding: 2px 8px;
font-family: var(--font-display);
font-weight: 600;
font-size: 0.66rem;
letter-spacing: 0.1em;
text-transform: uppercase;
border: 1px solid currentColor;
border-radius: 2px;
}
.sheet-badge-edited { color: var(--aqua); }
.sheet-badge-low { color: var(--fail); }
.sheet-grid {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-bottom: 12px;
padding: 8px 10px;
border: 1px solid var(--border);
}
.sheet-grid[hidden] { display: none; }
.sheet-grid-head {
font-family: var(--font-display);
font-weight: 700;
font-size: 0.66rem;
letter-spacing: 0.16em;
color: var(--aqua);
}
.sheet-grid-field {
display: flex;
align-items: center;
gap: 6px;
margin-left: 6px;
font-family: var(--font-display);
font-weight: 600;
font-size: 0.66rem;
letter-spacing: 0.1em;
color: var(--text-muted);
}
.sheet-grid-field input { width: 6.5em; padding: 3px 2px; font: 0.85rem var(--font-mono); }
.sheet-grid .action:disabled { opacity: 0.35; cursor: default; }
.sheet-grid .sheet-save:not(:disabled) { color: var(--bg); background: var(--purple); border-color: var(--purple); }
.sheet-grid-source { flex: 1; min-width: 7em; font-size: 0.76rem; text-align: right; color: var(--text-muted); }
.sheet-grid-pending { color: var(--warn); }
.sheet-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-bottom: 12px; }
.sheet-tool-group { display: flex; gap: 6px; }
.sheet-tools .action:disabled { opacity: 0.35; cursor: default; }
.sheet-tools .sheet-save:not(:disabled) { color: var(--bg); background: var(--purple); border-color: var(--purple); }
.sheet-status { flex: 1; min-width: 6em; font-size: 0.78rem; color: var(--text-muted); text-align: right; }
.sheet-status-error { color: var(--fail); }
.sheet-badge-dirty { color: var(--warn); }
.sheet-plot { position: relative; cursor: crosshair; touch-action: none; }
.sheet-plot:focus-visible { outline: 2px solid var(--aqua); outline-offset: 2px; }
.sheet-note-selected { stroke: var(--text); stroke-width: 2; stroke-dasharray: none; }
.sheet-syllable-selected { color: var(--aqua); }
.sheet-text-input {
position: absolute;
top: -2px;
width: 7em;
padding: 1px 4px;
font-size: 0.8rem;
background: var(--panel-raised);
border: 1px solid var(--aqua);
}
.sheet-stale { margin-bottom: 12px; padding: 8px 10px; font-size: 0.82rem; border: 1px solid var(--warn); }
.sheet-stale[hidden] { display: none; }
.sheet-stale-head { color: var(--warn); margin-bottom: 6px; }
.sheet-stale-row { display: flex; align-items: center; gap: 10px; margin-top: 4px; }
.sheet-stale-text { flex: 1; color: var(--text-muted); }
.sheet-nav > .action[aria-expanded] { margin-left: 4px; }
.sheet-keys {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px 22px;
margin-bottom: 14px;
padding: 12px 14px;
background: var(--panel-raised);
border: 1px solid var(--border);
}
.sheet-keys[hidden] { display: none; }
.sheet-keys-head {
margin-bottom: 6px;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.66rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--aqua);
}
.sheet-keys dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; margin: 0; font-size: 0.78rem; }
.sheet-keys dt { white-space: nowrap; color: var(--text-muted); }
.sheet-keys dd { margin: 0; color: var(--text); }
.sheet-keys kbd {
display: inline-block;
min-width: 1.4em;
padding: 0 5px;
font: 0.72rem var(--font-mono);
text-align: center;
color: var(--text);
background: var(--panel);
border: 1px solid var(--control);
border-bottom-width: 2px;
border-radius: 3px;
}
.sheet-help[hidden] { display: none; }
.sheet-help { margin-top: 10px; font-size: 0.74rem; line-height: 1.5; color: var(--text-muted); }
.sheet-svg { display: block; }
.sheet-row { fill: var(--panel-raised); }
.sheet-row-black { fill: var(--bg); }
.sheet-octave { stroke: var(--control); stroke-width: 1; }
.sheet-key { fill: var(--text-muted); font: 10px var(--font-mono); text-anchor: end; dominant-baseline: middle; }
.sheet-key-c { fill: var(--text); font-weight: 600; }
.sheet-beat { stroke: var(--border); stroke-width: 1; opacity: 0.35; }
.sheet-beat-1 { stroke: var(--control); opacity: 0.55; }
.sheet-beat-2 { stroke: var(--text-muted); opacity: 0.8; }
.sheet-breaks { pointer-events: none; }
.sheet-break { stroke: var(--text-muted); stroke-width: 1.5; stroke-dasharray: 5 4; }
.sheet-break-moved { stroke: var(--aqua); stroke-dasharray: none; }
.sheet-break-label { fill: var(--text-muted); stroke: none; font: 12px var(--font-mono); }
.sheet-break-label.sheet-break-moved { fill: var(--aqua); stroke: none; }
.sheet-curve { pointer-events: none; }
.sheet-curve polyline { fill: none; stroke: var(--aqua); stroke-width: 1.5; opacity: 0.9; }
.sheet-note { fill: var(--purple); }
.sheet-note-golden { fill: #E8B931; }
.sheet-note-free { fill: none; stroke: var(--purple); stroke-width: 1.5; stroke-dasharray: 4 3; }
.sheet-note-rap { fill: var(--aqua); }
.sheet-note-golden-rap { fill: #E8B931; stroke: var(--aqua); stroke-width: 2; }
.sheet-note-low { stroke: var(--fail); stroke-width: 2; stroke-dasharray: 3 2; }
.sheet-note-context { opacity: 0.28; }
.sheet-playhead { position: absolute; top: 0; height: 300px; width: 2px; margin-left: -1px; background: var(--aqua); pointer-events: none; }
.sheet-playhead[hidden] { display: none; }
.sheet-syllables, .sheet-words { position: relative; overflow: hidden; }
.sheet-syllables { height: 22px; margin-top: 6px; overflow: visible; }
.sheet-syllable { position: absolute; top: 0; font-size: 0.8rem; white-space: pre; color: var(--text); cursor: text; }
.sheet-words { height: 24px; margin-top: 4px; }
.sheet-word {
position: absolute;
top: 0;
padding-top: 4px;
font-size: 0.78rem;
white-space: nowrap;
color: var(--text-muted);
border-top: 2px solid var(--border);
}
#lyrics-review { display: flex; flex-direction: column; gap: 10px; }
.review-line { padding: 8px 12px; background: var(--panel); border: 1px solid var(--border); }
.review-line-active { background: var(--panel-raised); border-color: var(--aqua); }
.review-word-sung { color: var(--text-muted); }
.review-word-active { color: var(--aqua); font-weight: 600; }
.review-playhead { position: absolute; top: 0; bottom: 0; width: 2px; margin-left: -1px; background: var(--aqua); }
.review-seek { cursor: pointer; }
.review-line-text { display: block; font-size: 0.9rem; }
.review-line-dropped { opacity: 0.55; }
.review-line-dropped .review-line-text { text-decoration: line-through; }
@@ -354,7 +592,7 @@ button { cursor: pointer; }
border: 1px solid var(--border);
}
.output-path code { flex: 1; overflow-wrap: anywhere; font-family: var(--font-mono); font-size: 0.82rem; }
#copy-path, .action, #cancel-rebuild {
#copy-path, .action {
flex: none;
padding: 5px 12px;
font-family: var(--font-display);
@@ -367,10 +605,11 @@ button { cursor: pointer; }
border: 1px solid var(--control);
border-radius: 2px;
}
#copy-path:hover, .action:hover, #cancel-rebuild:hover { border-color: var(--text-muted); }
#copy-path:hover, .action:hover { border-color: var(--text-muted); }
.form-actions { display: flex; align-items: center; gap: 14px; }
.keep-note { margin: -8px 0 0; font-size: 0.78rem; color: var(--aqua); }
#form-lock { margin: 0; font-size: 0.78rem; color: var(--text-muted); }
#review-actions { display: flex; gap: 10px; margin-top: 14px; }
#review-lineage { margin: -10px 0 14px; }
.lineage {
@@ -493,11 +732,40 @@ button { cursor: pointer; }
<div class="kana" aria-hidden="true">カラオケ</div>
</div>
<div>
<button type="button" id="create-song" class="primary">CREATE SONG</button>
<button type="button" id="rebuild-all" class="action">REBUILD ALL FROM…</button>
<p id="rebuild-all-note" class="empty-note" role="status" hidden></p>
<h2 class="eyebrow">QUEUE</h2>
<div id="jobs"></div>
</div>
</aside>
<dialog id="rebuild-all-dialog" aria-labelledby="rebuild-all-heading">
<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>
<label class="field">
<span>Restart from</span>
<select name="force_from" required></select>
</label>
<fieldset id="rebuild-all-songs">
<legend>Songs</legend>
<label class="rebuild-all-row rebuild-all-every">
<input type="checkbox" id="rebuild-all-toggle" checked>
<span>All songs</span>
</label>
<div id="rebuild-all-list"></div>
</fieldset>
<p id="rebuild-all-count"></p>
<p id="rebuild-all-error" role="alert"></p>
<div class="form-actions">
<button type="submit" class="primary">REBUILD SELECTED</button>
<button type="button" class="action" id="rebuild-all-cancel">CANCEL</button>
</div>
</form>
</dialog>
<main id="main">
<section id="new-song">
<h2>NEW SONG</h2>
@@ -511,6 +779,10 @@ button { cursor: pointer; }
<label class="field"><span>YouTube URL</span><input name="youtube_url"></label>
<label class="field"><span>Video file</span><input type="file" name="video"></label>
<p class="keep-note" data-keep="video" hidden></p>
<label class="field" id="restart-field" hidden>
<span>Restart from</span>
<select name="force_from"></select>
</label>
<details>
<summary>Advanced</summary>
<div class="stack">
@@ -531,7 +803,7 @@ button { cursor: pointer; }
</div>
<div class="form-actions">
<button type="submit" class="primary">CREATE SONG</button>
<button type="button" id="cancel-rebuild" hidden>CANCEL</button>
<p id="form-lock" hidden></p>
</div>
</form>
<p id="form-error" role="alert"></p>
@@ -542,6 +814,11 @@ button { cursor: pointer; }
<h3 id="review-title"></h3>
<p id="review-lineage" hidden></p>
<p id="review-error" class="review-error" hidden></p>
<div id="player" hidden>
<video id="player-video" muted playsinline preload="metadata"></video>
<audio id="player-media" controls preload="metadata"></audio>
</div>
<div id="sheet" hidden></div>
<div id="lyrics-review"><p class="empty-note">no songs queued yet — nothing to review</p></div>
<div id="output-path"></div>
<div id="review-actions"></div>
@@ -572,10 +849,15 @@ const nowTitle = document.getElementById("now-title");
const stageTrack = document.getElementById("stage-track");
const reviewActions = document.getElementById("review-actions");
const reviewLineage = document.getElementById("review-lineage");
const cancelRebuild = document.getElementById("cancel-rebuild");
const createSong = document.getElementById("create-song");
const formLock = document.getElementById("form-lock");
const formHeading = document.querySelector("#new-song h2");
const submitButton = form.querySelector("button[type=submit]");
let rebuildOf = null;
let formDirty = false;
// set by CREATE SONG: the page is a blank sheet on purpose, so the poll must
// not quietly select the newest song back into it
let composing = false;
let JOBS = [];
let selectedId = null;
let STAGES = [];
@@ -610,10 +892,16 @@ form.addEventListener("submit", async (e) => {
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
formError.textContent = body.detail || `error: ${resp.status}`;
return; // before the reset below, so a rejected submission is never retyped
return;
}
selectedId = (await resp.json()).id;
exitRebuild();
composing = false;
// the fields stay as submitted: the song just sent is the obvious starting
// point for the next one, and nothing has to be retyped to correct it. The
// queue's CREATE SONG button is what empties the form.
rebuildOf = null;
formDirty = false;
setFormMode(null, null);
refreshJobs();
});
@@ -638,7 +926,115 @@ for (const input of form.querySelectorAll('input[type="file"]')) {
});
}
cancelRebuild.addEventListener("click", exitRebuild);
// selecting another queue entry reloads the form, so track whether there is
// anything of the operator's in there worth warning about first
form.addEventListener("input", () => { formDirty = true; });
// Always available, and the form is usually holding a selected song by now, so
// this clears the whole page down to a blank sheet — fields, review, player and
// queue selection — and brings the form back into view from wherever playback
// scrolled the panel to. Leaving another song's review under an empty form
// reads as though the two belong together.
createSong.addEventListener("click", () => {
if (sheet?.isDirty() && !confirm("Discard unsaved changes to this line?")) return;
if (formDirty && !confirm("Discard your unsaved edits to this form?")) return;
composing = true;
selectedId = null;
for (const card of jobsDiv.querySelectorAll(".job")) card.removeAttribute("aria-current");
exitRebuild();
clearDetail("nothing selected");
document.getElementById("new-song").scrollIntoView({ block: "start", behavior: "smooth" });
form.elements.title.focus();
});
const rebuildAllButton = document.getElementById("rebuild-all");
const rebuildAllNote = document.getElementById("rebuild-all-note");
const rebuildAllDialog = document.getElementById("rebuild-all-dialog");
const rebuildAllForm = document.getElementById("rebuild-all-form");
const rebuildAllCount = document.getElementById("rebuild-all-count");
const rebuildAllError = document.getElementById("rebuild-all-error");
const rebuildAllList = document.getElementById("rebuild-all-list");
const rebuildAllToggle = document.getElementById("rebuild-all-toggle");
const rebuildAllSubmit = rebuildAllForm.querySelector("button[type=submit]");
function rebuildAllBoxes() {
return [...rebuildAllList.querySelectorAll("input:not(:disabled)")];
}
function updateRebuildAllCount() {
const boxes = rebuildAllBoxes();
const chosen = boxes.filter(b => b.checked).length;
rebuildAllToggle.checked = chosen === boxes.length && chosen > 0;
rebuildAllToggle.indeterminate = chosen > 0 && chosen < boxes.length;
rebuildAllCount.textContent = chosen === 1 ? "1 song will be rebuilt."
: `${chosen} songs will be rebuilt.`;
// with nothing ticked no ids are sent, which the API reads as every song
rebuildAllSubmit.disabled = chosen === 0;
}
function rebuildAllRow(song) {
const busy = song.status === "queued" || song.status === "running";
const row = document.createElement("label");
row.className = "rebuild-all-row";
const box = document.createElement("input");
box.type = "checkbox";
box.name = "ids";
box.value = song.id;
box.checked = !busy;
box.disabled = busy;
const name = document.createElement("span");
name.textContent = `${song.title} · ${song.artist}`;
const meta = document.createElement("span");
meta.className = "meta";
const when = song.finished_at && new Date(song.finished_at * 1000).toLocaleString(
undefined, { dateStyle: "medium", timeStyle: "short" });
meta.textContent = busy ? "in progress, skipped"
: `${song.status === "failed" ? "failed" : song.rebuilt_from ? "rebuilt" : "built"} ${when || ""}`.trim();
row.append(box, name, meta);
return row;
}
rebuildAllButton.addEventListener("click", async () => {
rebuildAllError.textContent = "";
rebuildAllForm.reset();
const resp = await fetch("/api/rebuild-all");
const songs = resp.ok ? await resp.json() : [];
rebuildAllList.replaceChildren(...songs.map(rebuildAllRow));
updateRebuildAllCount();
rebuildAllDialog.showModal();
});
rebuildAllList.addEventListener("change", updateRebuildAllCount);
rebuildAllToggle.addEventListener("change", () => {
for (const box of rebuildAllBoxes()) box.checked = rebuildAllToggle.checked;
updateRebuildAllCount();
});
document.getElementById("rebuild-all-cancel").addEventListener(
"click", () => rebuildAllDialog.close());
rebuildAllForm.addEventListener("submit", async (e) => {
e.preventDefault();
const stage = rebuildAllForm.elements.force_from.value;
const resp = await fetch("/api/rebuild-all", {
method: "POST", body: new FormData(rebuildAllForm) });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
rebuildAllError.textContent = body.detail || `error: ${resp.status}`;
return;
}
const { ids, skipped } = await resp.json();
rebuildAllDialog.close();
let note = `Queued ${ids.length} rebuild${ids.length === 1 ? "" : "s"} from ${stage}.`;
if (skipped.length) {
const reasons = [...new Set(skipped.map(s => s.reason))].join("; ");
note += ` Skipped ${skipped.length}: ${reasons}.`;
}
rebuildAllNote.textContent = note;
rebuildAllNote.hidden = false;
refreshJobs();
});
function jobCard(job) {
const card = document.createElement("button");
@@ -713,6 +1109,13 @@ function renderJobs(jobs) {
async function loadStages() {
const resp = await fetch("/api/stages");
STAGES = (await resp.json()).stages;
// a rebuild redoes only what the edit invalidated, which cannot see a change
// in songtrace itself — this is how you redo a stage anyway
const select = form.elements.force_from;
select.replaceChildren(new Option("only what changed", ""),
...STAGES.map(s => new Option(s, s)));
rebuildAllForm.elements.force_from.replaceChildren(
new Option("choose a stage", ""), ...STAGES.map(s => new Option(s, s)));
}
function renderStageTrack(job) {
@@ -772,20 +1175,30 @@ async function refreshJobs() {
const resp = await fetch("/api/jobs");
const jobs = await resp.json();
JOBS = jobs;
if (selectedId === null && jobs.length) selectedId = jobs[0].id;
// deliberately not via selectJob: the auto-selection on load fills the review
// panel but leaves the form blank, so landing on the page is still the way to
// start a new song. Clicking a queue entry is what loads its inputs.
if (!composing && selectedId === null && jobs.length) selectedId = jobs[0].id;
renderJobs(jobs);
renderNowBar(jobs);
if (selectedId) refreshDetail();
else clearDetail();
else clearDetail(composing ? "nothing selected" : undefined);
schedulePoll(jobs);
}
async function selectJob(id) {
if (id !== selectedId && sheet?.isDirty() &&
!confirm("Discard unsaved changes to this line?")) return;
// the form already holds this job; reloading it would drop edits in progress
const reload = id !== rebuildOf;
if (reload && formDirty && !confirm("Discard your unsaved edits to this form?")) return;
composing = false;
selectedId = id;
for (const card of jobsDiv.querySelectorAll(".job")) {
if (card.dataset.id === id) card.setAttribute("aria-current", "true");
else card.removeAttribute("aria-current");
}
if (reload) await loadInputs(id, JOBS.find(j => j.id === id)?.status);
refreshDetail();
}
@@ -857,6 +1270,10 @@ function lineTimeline(words, notes, minPitch, span) {
}
box.append(wordsRow);
if (notes.length) box.append(pianoRoll(notes, minPitch, span, t0, t1));
const playhead = document.createElement("div");
playhead.className = "review-playhead";
playhead.hidden = true;
box.append(playhead);
return box;
}
@@ -890,6 +1307,22 @@ function lineNode(line, minPitch, span) {
return row;
}
// What playback needs from a rendered line: the time range it was laid out
// over, the word labels along it, and the bar that sweeps across it.
function lineEntry(row, line) {
const laid = line.words.length ? line.words : line.notes;
if (!laid.length || line.dropped) return null;
const labels = [...row.querySelectorAll(".review-word")];
return {
row,
index: line.index,
t0: laid[0].start,
t1: Math.max(...laid.map(x => x.end)),
words: line.words.map((w, i) => ({ el: labels[i], start: w.start, end: w.end })),
playhead: row.querySelector(".review-playhead"),
};
}
function buildLyricsReview(data) {
const allNotes = data.lines.flatMap(l => l.notes);
const pitches = allNotes.map(n => n.pitch);
@@ -898,6 +1331,7 @@ function buildLyricsReview(data) {
const span = Math.max(1, maxPitch - minPitch);
const spans = [...data.missing_spans].sort((a, b) => a.start - b.start);
const nodes = [];
const entries = [];
let spanIdx = 0;
for (const line of data.lines) {
const lineStart = line.notes.length ? Math.min(...line.notes.map(n => n.start)) : null;
@@ -905,24 +1339,205 @@ function buildLyricsReview(data) {
nodes.push(missingSpanNode(spans[spanIdx]));
spanIdx++;
}
nodes.push(lineNode(line, minPitch, span));
const row = lineNode(line, minPitch, span);
const entry = lineEntry(row, line);
if (entry) entries.push(entry);
nodes.push(row);
}
while (spanIdx < spans.length) {
nodes.push(missingSpanNode(spans[spanIdx]));
spanIdx++;
}
return nodes;
return { nodes, entries };
}
// The song folder's own media, played against the same seconds the review was
// built from, so the highlighted line is the one you are hearing.
//
// Modelled on the game: #MP3 is the audio and the clock, the video is muted
// background slaved to it. Playing the video's own audio alongside the mp3 is
// the same recording twice, a few frames apart, which is heard as an echo.
const player = document.getElementById("player");
const media = document.getElementById("player-media");
const video = document.getElementById("player-video");
const DRIFT = 0.25; // seconds before the picture is worth re-seeking
// the note sheet lives in its own module, loaded before any review renders
let sheet = null;
let stopAt = null; // set by playing one line: where playback pauses again
const sheetReady = import("/static/sheet.js").then((m) => {
sheet = m.createSheet(document.getElementById("sheet"), {
seek: (t) => {
media.currentTime = t;
if (media.paused) syncPlayback();
},
playLine: (from, to) => {
if (player.hidden) return;
media.currentTime = from;
stopAt = to;
media.play().catch(() => {});
},
// a saved line changes the notes the lyric list draws
onSaved: (data) => {
const box = document.getElementById("lyrics-review");
const { nodes, entries } = buildLyricsReview(data);
box.replaceChildren(...nodes);
bindTimeline(player.hidden ? [] : entries);
if (!player.hidden) syncPlayback();
},
});
});
function slaveVideo(force) {
if (video.hidden || !video.getAttribute("src")) return;
if (force || Math.abs(video.currentTime - media.currentTime) > DRIFT) {
video.currentTime = media.currentTime;
}
}
for (const event of ["seeked", "ratechange"]) {
media.addEventListener(event, () => {
video.playbackRate = media.playbackRate;
slaveVideo(true);
});
}
media.addEventListener("pause", () => {
video.pause();
stopAt = null;
});
let timeline = [];
let activeEntry = null;
let syncFrame = null;
// the first line that has not finished yet: the one being sung, or the one
// coming up while the track is between lines
function entryAt(t) {
return timeline.find(e => t <= e.t1) ?? timeline[timeline.length - 1] ?? null;
}
function paintEntry(entry, t) {
for (const w of entry.words) {
w.el.classList.toggle("review-word-sung", t >= w.end);
w.el.classList.toggle("review-word-active", t >= w.start && t < w.end);
}
if (!entry.playhead) return;
const inside = t >= entry.t0 && t <= entry.t1;
entry.playhead.hidden = !inside;
if (inside) {
const frac = (t - entry.t0) / Math.max(entry.t1 - entry.t0, 0.001);
entry.playhead.style.left = `${frac * 100}%`;
}
}
function clearEntry(entry) {
entry.row.classList.remove("review-line-active");
if (entry.playhead) entry.playhead.hidden = true;
for (const w of entry.words) {
w.el.classList.remove("review-word-sung", "review-word-active");
}
}
function syncPlayback() {
const t = media.currentTime;
const entry = entryAt(t);
if (entry !== activeEntry) {
if (activeEntry) clearEntry(activeEntry);
activeEntry = entry;
if (entry) {
entry.row.classList.add("review-line-active");
sheet?.showLine(entry.index);
focusActive();
}
}
if (entry) paintEntry(entry, t);
sheet?.setTime(t);
}
// centred so there is context either side, like a scrolling lyric sheet; only
// while running, so it does not fight a reader who has scrolled away to look
// at something else
function focusActive() {
if (!activeEntry || media.paused) return;
activeEntry.row.scrollIntoView({ block: "center", behavior: "smooth" });
}
function pumpPlayback() {
if (stopAt !== null && media.currentTime >= stopAt) media.pause();
syncPlayback();
slaveVideo(false);
syncFrame = media.paused ? null : requestAnimationFrame(pumpPlayback);
}
// timeupdate fires about four times a second, too coarse for a playhead
media.addEventListener("play", () => {
if (syncFrame === null) syncFrame = requestAnimationFrame(pumpPlayback);
if (!video.hidden && video.getAttribute("src")) video.play().catch(() => {});
focusActive(); // the line may already be active from a seek while paused
});
for (const event of ["pause", "seeked", "loadedmetadata"]) {
media.addEventListener(event, syncPlayback);
}
function bindTimeline(entries) {
if (activeEntry) clearEntry(activeEntry);
activeEntry = null;
timeline = entries;
for (const entry of entries) {
entry.row.classList.add("review-seek");
entry.row.title = "click to play from here";
entry.row.addEventListener("click", (e) => {
const word = entry.words.find(w => w.el === e.target);
media.currentTime = word ? word.start : entry.t0;
if (media.paused) syncPlayback();
});
}
}
// Returns true when the folder gave us something to play. #MP3 is required —
// it is the audio the game uses, so it is the one the review has to track.
function setPlayerSource(job, available) {
const src = available?.audio ? `/api/jobs/${job.id}/media/audio` : "";
player.hidden = !src;
if (media.dataset.src === src) return !!src;
media.dataset.src = src;
if (src) media.src = src;
else { media.removeAttribute("src"); media.load(); }
video.hidden = !(src && available.video);
video.poster = available?.cover ? `/api/jobs/${job.id}/media/cover` : "";
if (!video.hidden) video.src = `/api/jobs/${job.id}/media/video`;
else { video.removeAttribute("src"); video.load(); }
return !!src;
}
function resetPlayer() {
sheet?.clear();
// the 3s poll reaches here for every running job; reloading a cleared
// element each time would be pure churn
if (player.hidden && !timeline.length) return;
bindTimeline([]);
player.hidden = true;
media.pause();
video.pause();
media.dataset.src = "";
for (const el of [media, video]) {
el.removeAttribute("src");
el.removeAttribute("poster");
el.load();
}
}
async function renderLyricsReview(job) {
const box = document.getElementById("lyrics-review");
if (job.status === "failed") {
delete box.dataset.jobId;
resetPlayer();
box.replaceChildren(emptyNote("job failed — nothing to review"));
return;
}
if (job.status !== "done") {
delete box.dataset.jobId;
resetPlayer();
box.replaceChildren(emptyNote("waiting for this song to finish…"));
return;
}
@@ -930,6 +1545,7 @@ async function renderLyricsReview(job) {
const resp = await fetch(`/api/jobs/${job.id}/review`);
if (job.id !== selectedId) return;
if (!resp.ok) {
resetPlayer();
box.replaceChildren(emptyNote("no review data available"));
return;
}
@@ -937,10 +1553,20 @@ async function renderLyricsReview(job) {
if (job.id !== selectedId) return;
box.dataset.jobId = job.id;
if (!data.lines.length) {
resetPlayer();
box.replaceChildren(emptyNote("no lyrics to review"));
return;
}
box.replaceChildren(...buildLyricsReview(data));
const { nodes, entries } = buildLyricsReview(data);
box.replaceChildren(...nodes);
await sheetReady;
if (job.id !== selectedId) return;
sheet.load(job.id, data);
// with nothing to play, a highlighted line and a click-to-seek cursor would
// both be promising something the song folder cannot deliver
const playable = setPlayerSource(job, data.media);
bindTimeline(playable ? entries : []);
if (playable) syncPlayback();
}
async function copyPath(path, button) {
@@ -1000,40 +1626,63 @@ function renderOutputPath(job) {
outputPathDiv.replaceChildren(wrap);
}
function clearDetail() {
function clearDetail(why = "no songs queued yet") {
reviewTitle.replaceChildren();
reviewError.hidden = true;
reviewError.textContent = "";
document.getElementById("warnings").replaceChildren(
emptyNote("no songs queued yet — nothing to review"));
resetPlayer();
// the review box, not an element that no longer exists: getting this wrong
// threw partway through and left the rest of the panel showing the old song
const box = document.getElementById("lyrics-review");
delete box.dataset.jobId;
box.replaceChildren(emptyNote(`${why} — nothing to review`));
outputPathDiv.replaceChildren();
reviewActions.replaceChildren();
delete reviewActions.dataset.jobId;
reviewLineage.replaceChildren();
reviewLineage.hidden = true;
delete reviewLineage.dataset.jobId;
detail.textContent = "no songs queued yet — nothing to log";
detail.textContent = `${why} — nothing to log`;
}
function setFormMode(inp) {
// a rebuild is refused with 409 until the job settles, so the form says so
// rather than letting the submission fail
function setSubmitLock(status) {
const locked = rebuildOf !== null && (status === "queued" || status === "running");
submitButton.disabled = locked;
formLock.textContent = locked ? "editable once this run finishes" : "";
formLock.hidden = !locked;
}
function setFormMode(inp, status) {
formHeading.textContent = rebuildOf ? "REBUILD" : "NEW SONG";
submitButton.textContent = rebuildOf ? "REBUILD SONG" : "CREATE SONG";
cancelRebuild.hidden = !rebuildOf;
setSubmitLock(status);
// only a rebuild has stages to restart from; a new song always runs them all
document.getElementById("restart-field").hidden = !rebuildOf;
if (!rebuildOf) form.elements.force_from.value = "";
for (const [field, key] of [["video", "video_filename"], ["cover", "cover_filename"]]) {
const note = form.querySelector(`[data-keep="${field}"]`);
const kept = rebuildOf && inp ? inp[key] : null;
note.textContent = kept ? `keeping ${kept} — attach a file to replace it` : "";
note.hidden = !kept;
}
// the advanced fields carry into a rebuild whether or not the section is open,
// so anything actually set must not sit hidden behind a closed summary
form.querySelector("details").open = !!inp?.cover_filename ||
["genre", "year", "language", "bpm"].some(n => form.elements[n].value);
}
function exitRebuild() {
rebuildOf = null;
form.reset();
setFormMode(null);
formDirty = false;
setFormMode(null, null);
}
async function startRebuild(id) {
// selecting a queue entry brings its inputs back up, so a song can be adjusted
// and rebuilt without being retyped
async function loadInputs(id, status) {
const resp = await fetch(`/api/jobs/${id}/input`);
if (!resp.ok) return;
const inp = await resp.json();
@@ -1044,9 +1693,9 @@ async function startRebuild(id) {
if (el && value !== null) el.value = value;
}
rebuildOf = id;
setFormMode(inp);
formDirty = false;
setFormMode(inp, status);
formError.textContent = "";
form.elements.title.focus();
}
async function rerunJob(id) {
@@ -1083,19 +1732,19 @@ async function deleteJob(id) {
return;
}
if (selectedId === id) selectedId = null;
// the form is holding a job that no longer exists; a rebuild would 404
if (rebuildOf === id) exitRebuild();
refreshJobs();
}
const ACTION_LABELS = {
rerun: "RERUN",
edit: "EDIT & REBUILD",
"rebuild-full": "REBUILD FROM SCRATCH",
delete: "DELETE",
};
const ACTION_HANDLERS = {
rerun: rerunJob,
edit: startRebuild,
"rebuild-full": rebuildFullJob,
delete: deleteJob,
};
@@ -1112,7 +1761,7 @@ function actionButton(action, jobId) {
function renderActions(job) {
// no controls mid-run: every endpoint answers 409 until the job settles
const doneActions = ["edit", "rebuild-full", "delete"];
const doneActions = ["rebuild-full", "delete"];
const wanted = job.status === "failed" ? ["rerun", ...doneActions]
: job.status === "done" ? doneActions : [];
const shown = [...reviewActions.children].map(b => b.dataset.action);
@@ -1149,6 +1798,7 @@ async function refreshDetail() {
reviewTitle.replaceChildren(`${job.title} `, artist);
reviewError.hidden = !job.error;
reviewError.textContent = job.error ?? "";
if (rebuildOf === job.id) setSubmitLock(job.status);
renderLyricsReview(job);
renderOutputPath(job);
renderLineage(job);
+951
View File
@@ -0,0 +1,951 @@
// The note sheet: one lyric line at a time on a pitch-by-beat grid, the way
// an UltraStar editor lays a song out, with the detected pitch drawn over
// the notes so a wrong one stands out. The line on show can be edited.
import * as edit from "./edit-model.js";
const SVG = "http://www.w3.org/2000/svg";
const NAMES = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"];
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 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";
// The sheet's controls, grouped as an editor's reference card lists them.
// "+" joins keys pressed together, " / " separates alternatives, and a
// lowercase word is something done with the mouse rather than a key.
export const SHORTCUTS = [
["Select", [
["click", "select a note"],
["Shift+click", "add to the selection"],
["Tab / Shift+Tab", "next, previous note"],
["Esc", "clear the selection"]]],
["Move", [
["drag", "move in time and pitch"],
["← / →", "a beat earlier, later"],
["↑ / ↓", "a semitone up, down"],
["Shift+↑ / Shift+↓", "an octave up, down"]]],
["Length", [
["drag an end", "resize"],
["Shift+← / Shift+→", "a beat shorter, longer"]]],
["Notes", [
["double-click", "add a note, on empty space"],
["S", "split in two"],
["M", "merge with the next"],
["Delete", "remove"]]],
["Kind", [
["G", "golden"],
["F", "freestyle"],
["R", "rap"],
["G / F / R", "again: back to normal"]]],
["Line break", [
["drag a ↵ marker", "move it, between the lines it separates"],
["double-click a ↵", "back to where songtrace puts it"]]],
["Syllable", [
["double-click", "edit, on a note"],
["Enter", "edit the selected note"]]],
["Line", [
["Ctrl+Z", "undo"],
["Ctrl+Shift+Z / Ctrl+Y", "redo"],
["Ctrl+S", "save"],
["P", "play"],
["Page Up / Page Down", "previous, next line"],
["?", "show or hide these keys"]]],
];
// "Shift+Tab / Esc" as key caps, with mouse actions left as words
export function comboParts(combo) {
return combo.split(" / ").map(alt => alt.split("+").map(part => ({
text: part, key: !/^[a-z]/.test(part),
})));
}
const wrap = (n, d) => ((n % d) + d) % d;
const mod12 = (p) => wrap(p, 12);
// UltraStar pitch 0 is C4 (MIDI 60)
export function noteName(pitch) {
return NAMES[mod12(pitch)] + (Math.floor((pitch + 60) / 12) - 1);
}
export function isBlackKey(pitch) {
return BLACK.has(mod12(pitch));
}
// The pitches to show: the line's notes with room around them, and never
// less than an octave and a bit, so rows stay a consistent size to aim at.
export function pitchRange(pitches, { pad = 3, min = 15 } = {}) {
if (!pitches.length) return { lo: -7, hi: 7 };
let lo = Math.min(...pitches) - pad;
let hi = Math.max(...pitches) + pad;
const short = min - (hi - lo + 1);
if (short > 0) {
lo -= Math.floor(short / 2);
hi += Math.ceil(short / 2);
}
return { lo, hi };
}
// UltraStar beats between t0 and t1 on the chart's grid. A beat is a quarter
// of the #BPM beat; weight 2 marks every 16th, 1 every 4th, as editors do.
export function beatLines(t0, t1, grid) {
const spb = 60 / (4 * grid.bpm);
const gap = grid.gap_ms / 1000;
const out = [];
for (let k = Math.ceil((t0 - gap) / spb); gap + k * spb <= t1; k++) {
const weight = wrap(k, 16) === 0 ? 2 : wrap(k, 4) === 0 ? 1 : 0;
out.push({ t: gap + k * spb, beat: k, weight });
}
return out;
}
// The seconds a line occupies, from its notes and the words it was aligned to.
export function lineSpan(line) {
const ts = [...line.notes.flatMap(n => [n.start, n.end]),
...line.words.flatMap(w => [w.start, w.end])];
return ts.length ? { start: Math.min(...ts), end: Math.max(...ts) } : null;
}
function el(name, attrs = {}, parent = null) {
const node = document.createElementNS(SVG, name);
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
if (parent) parent.append(node);
return node;
}
function div(cls, text = null) {
const node = document.createElement("div");
node.className = cls;
if (text !== null) node.textContent = text;
return node;
}
export function createSheet(root, { seek, playLine, onSaved }) {
const button = (label, title, onClick, cls = "action") => {
const b = document.createElement("button");
b.type = "button";
b.className = cls;
b.textContent = label;
b.title = title;
b.addEventListener("click", onClick);
return b;
};
const title = div("sheet-title");
const badges = div("sheet-badges");
const prev = button("◀ PREV", "previous line (Page Up)", () => page(-1));
const next = button("NEXT ▶", "next line (Page Down)", () => page(1));
const nav = div("sheet-nav");
const keysButton = button("KEYS ?", "keyboard shortcuts (?)", () => toggleKeys());
keysButton.setAttribute("aria-expanded", "false");
nav.append(prev, title, next, badges, keysButton);
const tools = {
undo: button("UNDO", "Ctrl+Z", () => undo()),
redo: button("REDO", "Ctrl+Shift+Z", () => redo()),
split: button("SPLIT", "split the selected note (S)", () => splitSelected()),
merge: button("MERGE", "merge with the next note (M)", () => mergeSelected()),
remove: button("DELETE", "delete the selected notes (Delete)", () => removeSelected()),
golden: button("GOLDEN", "golden note (G)", () => kind("*")),
free: button("FREESTYLE", "freestyle note (F)", () => kind("F")),
rap: button("RAP", "rap note (R)", () => kind("R")),
play: button("▶ LINE", "play this line (P)", () => playCurrent()),
discard: button("DISCARD", "drop unsaved changes to this line", () => discard()),
revert: button("REVERT", "go back to the generated notes for this line", () => revert()),
save: button("SAVE LINE", "Ctrl+S", () => save(), "action sheet-save"),
};
const toolbar = div("sheet-tools");
const group = (...items) => { const g = div("sheet-tool-group"); g.append(...items); return g; };
const status = div("sheet-status");
status.setAttribute("role", "status");
toolbar.append(group(tools.undo, tools.redo),
group(tools.split, tools.merge, tools.remove),
group(tools.golden, tools.free, tools.rap),
group(tools.play), status,
group(tools.discard, tools.revert, tools.save));
const plot = div("sheet-plot");
plot.tabIndex = 0;
plot.setAttribute("aria-label", "note sheet: select notes and edit them with the mouse or keyboard");
const svg = el("svg", { class: "sheet-svg" });
const playhead = div("sheet-playhead");
playhead.hidden = true;
plot.append(svg, playhead);
const syllables = div("sheet-syllables");
const words = div("sheet-words");
const help = div("sheet-help", "Press ? for keyboard shortcuts.");
const keys = div("sheet-keys");
keys.id = "sheet-keys";
keysButton.setAttribute("aria-controls", keys.id);
for (const [group, entries] of SHORTCUTS) {
const section = div("sheet-keys-group");
const heading = div("sheet-keys-head", group);
const list = document.createElement("dl");
for (const [combo, action] of entries) {
const dt = document.createElement("dt");
comboParts(combo).forEach((alt, i) => {
if (i) dt.append(" / ");
alt.forEach((part, j) => {
if (j) dt.append("+");
const node = document.createElement(part.key ? "kbd" : "span");
node.textContent = part.text;
dt.append(node);
});
});
const dd = document.createElement("dd");
dd.textContent = action;
list.append(dt, dd);
}
section.append(heading, list);
keys.append(section);
}
const stale = div("sheet-stale");
const gridBar = div("sheet-grid");
const field = (label, step, unit) => {
const wrap = document.createElement("label");
wrap.className = "sheet-grid-field";
const input = document.createElement("input");
input.type = "number";
input.step = step;
input.min = "0";
wrap.append(label, input);
if (unit) wrap.append(unit);
return { wrap, input };
};
const bpmField = field("BPM", "0.01");
const gapField = field("GAP", "1", "ms");
const gridSource = div("sheet-grid-source");
const gridTools = {
double: button("×2", "double the BPM", () => tryGrid({ bpm: grid().bpm * 2 })),
half: button("÷2", "halve the BPM", () => tryGrid({ bpm: grid().bpm / 2 })),
earlier: button("−10", "GAP 10ms earlier", () => tryGrid({ gap_ms: grid().gap_ms - 10 })),
later: button("+10", "GAP 10ms later", () => tryGrid({ gap_ms: grid().gap_ms + 10 })),
align: button("ALIGN TO NOTE", "move GAP the least that puts the selected note on a beat",
() => alignToNote()),
cancel: button("CANCEL", "drop the BPM and GAP being tried", () => { gridDraft = null; render(); }),
reset: button("RESET", "back to the BPM songtrace chose, with GAP at the first note",
() => resetGrid()),
apply: button("APPLY", "chart the song on this BPM and GAP", () => applyGrid(),
"action sheet-save"),
};
const gridHead = div("sheet-grid-head", "GRID");
gridBar.append(gridHead, bpmField.wrap, gridTools.double, gridTools.half,
gapField.wrap, gridTools.earlier, gridTools.later, gridTools.align,
gridSource, gridTools.cancel, gridTools.reset, gridTools.apply);
for (const [f, key] of [[bpmField, "bpm"], [gapField, "gap_ms"]]) {
f.input.addEventListener("input", () => {
const v = Number(f.input.value);
if (f.input.value !== "" && Number.isFinite(v) && v >= 0 && (key === "gap_ms" || v > 0)) {
tryGrid({ [key]: v }, f.input);
}
});
}
root.replaceChildren(nav, stale, gridBar, toolbar, keys, plot, syllables, words, help);
// stays as the person left it, across lines, songs and visits
function showKeys(open) {
keys.hidden = !open;
help.hidden = open;
keysButton.setAttribute("aria-expanded", String(open));
try { localStorage.setItem(KEYS_OPEN, open ? "1" : ""); } catch {}
}
function toggleKeys() {
showKeys(keys.hidden);
}
let keysOpen = false;
try { keysOpen = !!localStorage.getItem(KEYS_OPEN); } catch {}
showKeys(keysOpen);
let review = null;
let curve = null;
let index = null;
let jobId = null;
let view = null; // what the last render laid out: window and scale
let layout = null; // window and pitch range, kept steady while editing
let hist = null; // the current line's notes, with undo
let baseline = null; // what the line holds on the server
let preview = null; // notes mid-drag, not yet in the history
let breakPreview = null; // a break mid-drag: { which, t }
let selected = new Set();
let drag = null;
let saving = false;
const visible = () => review.lines.filter(l => !l.dropped && lineSpan(l));
// the line being edited: its notes and the breaks either side of it, as
// one state so undo covers both. A break is its time, undefined where there
// is none to move, or null when it is to go back to automatic.
const notes = () => preview ?? hist?.present.notes ?? [];
const dirty = () => !!hist && hist.present !== baseline;
let gridDraft = null; // BPM and GAP being tried out, not yet applied
// the grid being drawn and snapped to: the one being tried out, if any
const grid = () => gridDraft ?? review?.grid;
// notes snap to the grid, so they wait while a new one is being tried
const canEdit = () => !!review?.grid && !gridDraft && !saving;
function say(text, error = false) {
status.textContent = text;
status.classList.toggle("sheet-status-error", error);
}
// the window and pitch range that hold the line, grown (never shrunk while
// editing) so rows do not jump under the pointer
function fit(reset) {
const line = review.lines[index];
const ts = [...notes().flatMap(n => [n.start, n.end]),
...line.words.flatMap(w => [w.start, w.end])];
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
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);
}
layout = reset || !layout ? want : {
t0: Math.min(layout.t0, want.t0), t1: Math.max(layout.t1, want.t1),
lo: Math.min(layout.lo, want.lo), hi: Math.max(layout.hi, want.hi),
};
}
// edits whose line's lyrics have since changed: kept, not applied, until
// the person who made them decides
function paintStale() {
const items = review?.stale ?? [];
stale.hidden = !items.length;
stale.replaceChildren();
if (!items.length) return;
stale.append(div("sheet-stale-head",
`${items.length} edited line${items.length === 1 ? " was" : "s were"} set aside ` +
"because the lyrics changed:"));
items.forEach((item, n) => {
const row = div("sheet-stale-row");
row.append(div("sheet-stale-text", `"${item.text}"`),
button("DISCARD", "throw this edit away", () => discardStale(n)));
stale.append(row);
});
}
async function discardStale(n) {
const resp = await fetch(`/api/jobs/${jobId}/stale/${n}`, { method: "DELETE" });
if (!resp.ok) { say(`error ${resp.status}`, true); return; }
review = await resp.json();
paintStale();
onSaved(review);
}
function open(i) {
index = i;
const line = review.lines[i];
const lines = visible();
const at = lines.findIndex(l => l.index === i);
const breakBefore = (l) => review.breaks?.find(b => b.line === l?.index)?.time;
baseline = {
notes: edit.withIds(line.notes.map(n => ({ ...n, kind: n.kind ?? ":" }))),
before: at > 0 ? breakBefore(line) : undefined,
after: breakBefore(lines[at + 1]),
};
hist = edit.history(baseline);
breakPreview = null;
preview = null;
selected = new Set();
say("");
fit(true);
paintStale();
render();
}
// asked for by the person using the sheet, so it may cost them their edits
function goTo(i) {
if (!review || i === index) return;
if (dirty() && !confirm("Discard unsaved changes to this line?")) return;
open(i);
}
function page(step) {
const lines = visible();
const at = lines.findIndex(l => l.index === index);
const to = lines[Math.min(Math.max(at + step, 0), lines.length - 1)];
if (to) goTo(to.index);
}
function change(next, keep = selected) {
hist.push({ ...hist.present, notes: next });
selected = new Set([...keep].filter(id => next.some(n => n.id === id)));
fit(false);
render();
}
function changeBreak(which, t) {
if (hist.present[which] === t) return;
hist.push({ ...hist.present, [which]: t });
render();
}
// the breaks either side of the line, each with the gap it may move in:
// from the end of the line before it to the start of the line after
function breakSlots() {
const ns = notes();
if (!hist || !ns.length) return [];
const lines = visible();
const at = lines.findIndex(l => l.index === index);
const prev = lines[at - 1];
const next = lines[at + 1];
const slots = [];
if (prev?.notes.length && hist.present.before !== undefined) {
slots.push({ which: "before", line: index,
lo: Math.max(...prev.notes.map(n => n.end)), hi: ns[0].start });
}
if (next?.notes.length && hist.present.after !== undefined) {
slots.push({ which: "after", line: next.index,
lo: Math.max(...ns.map(n => n.end)), hi: Math.min(...next.notes.map(n => n.start)) });
}
return slots;
}
// where a break is drawn; one going back to automatic is shown mid-gap,
// roughly where it will land
function breakTime(slot) {
const v = breakPreview?.which === slot.which ? breakPreview.t : hist.present[slot.which];
const t = v === null ? (slot.lo + slot.hi) / 2 : v;
return Math.min(Math.max(t, slot.lo), Math.max(slot.hi, slot.lo));
}
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);
function splitSelected() {
if (one() === null || !grid()) return;
const out = edit.split(notes(), one(), grid());
if (out.notes !== notes()) change(out.notes, new Set([out.ids[0]]));
}
function mergeSelected() {
if (one() !== null) change(edit.merge(notes(), one()));
}
function removeSelected() {
if (selected.size) change(edit.remove(notes(), selected), new Set());
}
function kind(k) {
if (selected.size) change(edit.setKind(notes(), selected, k));
}
function playCurrent() {
if (!layout) return;
const ns = notes();
const start = ns.length ? ns[0].start : layout.t0 + CONTEXT;
const end = ns.length ? Math.max(...ns.map(n => n.end)) : layout.t1 - CONTEXT;
playLine(Math.max(0, start - 0.3), end + 0.2);
}
function discard() {
if (!dirty()) return;
open(index);
}
// a change saved to the server, which answers with the refreshed review
async function send(path, method, body, busy, done) {
saving = true;
render();
say(busy);
try {
const resp = await fetch(`/api/jobs/${jobId}/${path}`, {
method, headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
say(err.detail || `error ${resp.status}`, true);
return false;
}
const data = await resp.json();
const at = index;
review = data;
gridDraft = null;
open(at);
say(done);
onSaved(data);
return true;
} catch {
say("could not reach songtrace", true);
return false;
} finally {
saving = false;
render();
}
}
// only what changed: a moved break alone must not mark the line edited
async function save() {
if (!dirty() || saving) return;
const state = hist.present;
const lines = visible();
const next = lines[lines.findIndex(l => l.index === index) + 1];
const moved = {};
if (state.before !== baseline.before) moved[index] = state.before;
if (state.after !== baseline.after && next) moved[next.index] = state.after;
if (state.notes !== baseline.notes &&
!await send(`lines/${index}`, "PUT", { notes: edit.toSaved(state.notes) }, "saving…", "saved")) {
return;
}
if (Object.keys(moved).length) await send("breaks", "PUT", moved, "saving…", "saved");
}
function revert() {
if (!review.lines[index].edited || saving) return;
if (!confirm("Go back to the generated notes for this line? Your edit is lost.")) return;
send(`lines/${index}`, "DELETE", null, "reverting…", "reverted to the generated notes");
}
// redraws on the trial grid straight away; nothing is saved until APPLY
function tryGrid(change, typing = null) {
const next = { ...grid(), ...change };
next.gap_ms = Math.max(0, next.gap_ms);
const same = review.grid && next.bpm === review.grid.bpm && next.gap_ms === review.grid.gap_ms;
gridDraft = same ? null : next;
render(typing);
}
function alignToNote() {
const note = notes().find(n => selected.has(n.id));
if (!note) return;
const g = grid();
const spbMs = 60000 / (4 * g.bpm);
const atMs = note.start * 1000;
const offset = (atMs - g.gap_ms) - Math.round((atMs - g.gap_ms) / spbMs) * spbMs;
tryGrid({ gap_ms: Math.round((g.gap_ms + offset) * 100) / 100 });
}
async function applyGrid() {
if (!gridDraft || saving) return;
if (dirty()) { say("save or discard this line's changes first", true); return; }
await send("grid", "PUT", gridDraft, "charting on the new grid…", "grid applied");
}
async function resetGrid() {
if (!review.grid_edited || saving) return;
if (dirty()) { say("save or discard this line's changes first", true); return; }
await send("grid", "DELETE", null, "resetting the grid…",
"grid reset to the BPM songtrace chose");
}
function paintGrid(typing) {
const g = grid();
gridBar.hidden = !review.grid;
if (!g) return;
const round = (v, d) => String(Math.round(v * 10 ** d) / 10 ** d);
// the field being typed in keeps what was typed, half-finished or not
if (typing !== bpmField.input) bpmField.input.value = round(g.bpm, 2);
if (typing !== gapField.input) gapField.input.value = round(g.gap_ms, 2);
gridSource.textContent = gridDraft ? "not applied"
: review.grid_edited ? "set in the editor"
: review.auto_bpm ? "songtrace's choice" : "";
gridSource.classList.toggle("sheet-grid-pending", !!gridDraft);
gridTools.align.disabled = saving || selected.size !== 1;
gridTools.cancel.disabled = !gridDraft || saving;
gridTools.apply.disabled = !gridDraft || saving;
gridTools.reset.disabled = !review.grid_edited || !!gridDraft || saving;
for (const b of [gridTools.double, gridTools.half, gridTools.earlier, gridTools.later]) {
b.disabled = saving;
}
}
function render(typing = null) {
if (!review || index === null) return;
const line = review.lines[index];
const { t0, t1, lo, hi } = layout;
const width = Math.max(plot.clientWidth, 320);
const rowH = PLOT_HEIGHT / (hi - lo + 1);
const x = (t) => GUTTER + ((t - t0) / (t1 - t0)) * (width - GUTTER);
const y = (p) => (hi - p) * rowH; // top of the row for pitch p
view = { t0, t1, x, width, rowH, hi,
time: (px) => t0 + ((px - GUTTER) / (width - GUTTER)) * (t1 - t0),
pitch: (py) => hi - Math.floor(py / rowH) };
svg.setAttribute("viewBox", `0 0 ${width} ${PLOT_HEIGHT}`);
svg.setAttribute("width", width);
svg.setAttribute("height", PLOT_HEIGHT);
svg.replaceChildren();
const rows = el("g", { class: "sheet-rows" }, svg);
for (let p = lo; p <= hi; p++) {
el("rect", { x: GUTTER, y: y(p), width: width - GUTTER, height: rowH,
class: isBlackKey(p) ? "sheet-row sheet-row-black" : "sheet-row" }, rows);
if (mod12(p) === 0) {
el("line", { x1: GUTTER, x2: width, y1: y(p) + rowH, y2: y(p) + rowH,
class: "sheet-octave" }, rows);
}
// every C names its octave; the naturals between get a name when
// there is room for it
if (mod12(p) === 0 || (rowH >= 16 && !isBlackKey(p))) {
const label = el("text", { x: GUTTER - 6, y: y(p) + rowH / 2,
class: mod12(p) === 0 ? "sheet-key sheet-key-c" : "sheet-key" },
rows);
label.textContent = noteName(p);
}
}
if (grid()) {
const beats = el("g", { class: "sheet-beats" }, svg);
const spbPx = x(60 / (4 * grid().bpm)) - x(0);
for (const b of beatLines(t0, t1, grid())) {
if (b.weight === 0 && spbPx < 10) continue;
el("line", { x1: x(b.t), x2: x(b.t), y1: 0, y2: PLOT_HEIGHT,
class: `sheet-beat sheet-beat-${b.weight}` }, beats);
}
}
const layer = el("g", { class: "sheet-notes" }, svg);
const pad = Math.min(2, rowH * 0.12);
const drawNote = (n, current) => {
const cls = ["sheet-note", `sheet-note-${KINDS[n.kind] ?? "normal"}`];
if (!current) cls.push("sheet-note-context");
if (current && n.low_confidence) cls.push("sheet-note-low");
if (current && selected.has(n.id)) cls.push("sheet-note-selected");
const rect = el("rect", { x: x(n.start), y: y(n.pitch) + pad,
width: Math.max(x(n.end) - x(n.start), 2), height: rowH - 2 * pad,
rx: Math.min(4, rowH / 3), class: cls.join(" ") }, layer);
if (current) rect.dataset.id = n.id;
el("title", {}, rect).textContent = `${n.text.trim() || "~"} · ${noteName(n.pitch)}`;
};
for (const l of review.lines) {
if (l.dropped || l.index === index) continue;
for (const n of l.notes) if (n.end >= t0 && n.start <= t1) drawNote(n, false);
}
for (const n of notes()) drawNote(n, true);
const marks = el("g", { class: "sheet-breaks" }, svg);
for (const slot of breakSlots()) {
const t = breakTime(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 = "↵";
}
syllables.replaceChildren(...notes().map(n => {
const s = div("sheet-syllable" + (selected.has(n.id) ? " sheet-syllable-selected" : ""),
n.text.trim() || "~");
s.style.left = `${x(n.start)}px`;
s.dataset.id = n.id;
return s;
}));
// over the notes: the curve matters most where it agrees with one
if (curve) {
const g = el("g", { class: "sheet-curve" }, svg);
const from = Math.max(0, Math.floor((t0 - curve.start) / curve.step));
const to = Math.min(curve.pitch.length, Math.ceil((t1 - curve.start) / curve.step));
let pts = [];
const flush = () => {
if (pts.length > 1) el("polyline", { points: pts.join(" ") }, g);
pts = [];
};
for (let k = from; k < to; k++) {
const p = curve.pitch[k];
if (p === null || p < lo - 0.5 || p > hi + 0.5) { flush(); continue; }
pts.push(`${x(curve.start + k * curve.step).toFixed(1)},${(y(p) + rowH / 2).toFixed(1)}`);
}
flush();
}
words.replaceChildren(...line.words.map(w => {
const node = div("sheet-word", w.text);
node.style.left = `${x(w.start)}px`;
node.style.width = `${Math.max(x(w.end) - x(w.start), 2)}px`;
return node;
}));
const lines = visible();
const at = lines.findIndex(l => l.index === index);
title.textContent = `LINE ${at + 1} / ${lines.length}`;
prev.disabled = at <= 0;
next.disabled = at >= lines.length - 1;
const tags = [];
if (dirty()) tags.push(["unsaved", "sheet-badge-dirty"]);
if (line.edited) tags.push(["edited", "sheet-badge-edited"]);
if (notes().some(n => n.low_confidence)) tags.push(["low confidence", "sheet-badge-low"]);
badges.replaceChildren(...tags.map(([text, cls]) => div(`sheet-badge ${cls}`, text)));
paintTools();
paintGrid(typing);
}
function paintSelection() {
for (const node of root.querySelectorAll("[data-id]")) {
const on = selected.has(Number(node.dataset.id));
node.classList.toggle(node.tagName === "rect" ? "sheet-note-selected"
: "sheet-syllable-selected", on);
}
paintTools();
paintGrid();
}
function paintTools() {
const line = review.lines[index];
const editable = canEdit();
tools.undo.disabled = !editable || !hist.canUndo;
tools.redo.disabled = !editable || !hist.canRedo;
tools.split.disabled = tools.merge.disabled = !editable || one() === null;
for (const b of [tools.remove, tools.golden, tools.free, tools.rap]) {
b.disabled = !editable || !selected.size;
}
tools.discard.disabled = !dirty() || saving;
tools.save.disabled = !dirty() || saving;
tools.revert.disabled = !line.edited || saving;
}
// --- pointer -------------------------------------------------------------
function hit(e) {
const box = plot.getBoundingClientRect();
const px = e.clientX - box.left;
const py = e.clientY - box.top;
// 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);
if (brk) return { px, py, note: null, brk, mode: "break" };
}
const pitch = view.pitch(py);
const note = [...notes()].reverse().find(n => n.pitch === pitch &&
px >= view.x(n.start) - 1 && px <= view.x(n.end) + 1) ?? null;
let mode = null;
if (note) {
const left = view.x(note.start);
const right = view.x(note.end);
mode = right - left > 3 * EDGE && px >= right - EDGE ? "end"
: right - left > 3 * EDGE && px <= left + EDGE ? "start" : "move";
}
return { px, py, note, brk: null, mode };
}
plot.addEventListener("pointermove", (e) => {
if (!view) return;
if (!drag) {
const { note, brk, mode } = hit(e);
plot.style.cursor = brk ? "col-resize"
: !note || !canEdit() ? "crosshair"
: mode === "move" ? "grab" : "ew-resize";
return;
}
const box = plot.getBoundingClientRect();
const px = e.clientX - box.left;
const py = e.clientY - box.top;
if (Math.abs(px - drag.px) + Math.abs(py - drag.py) > 3) drag.moved = true;
if (drag.moved && drag.brk) {
const { which, lo, hi } = drag.brk;
breakPreview = { which, t: edit.placeBreak(view.time(px), lo, hi, grid()) };
render();
return;
}
if (!drag.moved || !drag.note) return;
if (drag.mode === "move") {
const spbPx = view.x(60 / (4 * grid().bpm)) - view.x(0);
const beats = Math.round((px - drag.px) / spbPx);
const semis = Math.round((drag.py - py) / view.rowH);
preview = edit.move(drag.base, selected, beats, semis, grid());
} else {
preview = edit.resize(drag.base, drag.note.id, drag.mode, view.time(px), grid());
}
render();
});
plot.addEventListener("pointerdown", (e) => {
if (!view || e.button !== 0) return;
// the browser's own focus handling would otherwise take focus off the
// sheet, and with it the keyboard
e.preventDefault();
plot.focus();
const { px, py, note, brk, mode } = hit(e);
if (px < GUTTER) return;
if (brk) plot.setPointerCapture(e.pointerId);
if (note && canEdit()) {
if (e.shiftKey) {
selected = new Set(selected);
if (selected.has(note.id)) selected.delete(note.id); else selected.add(note.id);
} else if (!selected.has(note.id)) {
selected = new Set([note.id]);
}
plot.setPointerCapture(e.pointerId);
plot.style.cursor = mode === "move" ? "grabbing" : "ew-resize";
paintSelection();
}
drag = { px, py, note, brk, mode, base: notes(), moved: false };
});
const endDrag = (e, cancelled) => {
if (!drag) return;
const d = drag;
drag = null;
if (breakPreview) {
const { which, t } = breakPreview;
breakPreview = null;
if (cancelled) render(); else changeBreak(which, t);
} else if (preview) {
const next = preview;
preview = null;
if (cancelled) render(); else change(next);
} else if (!d.moved && !d.note && !d.brk && !cancelled) {
selected = new Set();
render();
seek(view.time(d.px));
}
if (plot.hasPointerCapture?.(e.pointerId)) plot.releasePointerCapture(e.pointerId);
};
plot.addEventListener("pointerup", (e) => endDrag(e, false));
plot.addEventListener("pointercancel", (e) => endDrag(e, true));
plot.addEventListener("dblclick", (e) => {
if (!view || !canEdit()) return;
const { px, py, note, brk } = hit(e);
if (px < GUTTER) return;
if (brk) { changeBreak(brk.which, null); return; } // back to automatic
if (note) { editText(note.id); return; }
const out = edit.insert(notes(), view.time(px), view.pitch(py), grid());
change(out.notes, new Set([out.id]));
editText(out.id);
});
// --- syllable text -------------------------------------------------------
function editText(id) {
const note = notes().find(n => n.id === id);
if (!note) return;
const input = document.createElement("input");
input.className = "sheet-text-input";
input.value = note.text.trim();
input.style.left = `${view.x(note.start)}px`;
input.setAttribute("aria-label", "syllable");
syllables.append(input);
input.focus();
input.select();
let done = false;
const finish = (commit) => {
if (done) return;
done = true;
const value = input.value;
input.remove();
if (commit) {
const next = edit.setText(notes(), id, value);
if (next !== notes()) change(next);
}
plot.focus();
};
input.addEventListener("keydown", (e) => {
e.stopPropagation();
if (e.key === "Enter") finish(true);
else if (e.key === "Escape") finish(false);
});
input.addEventListener("blur", () => finish(true));
}
syllables.addEventListener("dblclick", (e) => {
const id = e.target.dataset?.id;
if (id) editText(Number(id));
});
// --- keyboard ------------------------------------------------------------
plot.addEventListener("keydown", (e) => {
if (!view) return;
const ctrl = e.ctrlKey || e.metaKey;
const key = e.key;
const handled = () => { e.preventDefault(); e.stopPropagation(); };
if (ctrl && key.toLowerCase() === "s") { handled(); save(); return; }
if (ctrl && key.toLowerCase() === "z") { handled(); e.shiftKey ? redo() : undo(); return; }
if (ctrl && key.toLowerCase() === "y") { handled(); redo(); return; }
if (key === "PageUp") { handled(); page(-1); return; }
if (key === "PageDown") { handled(); page(1); return; }
if (ctrl || e.altKey || !canEdit()) return;
const ns = notes();
if (key === "Tab") {
handled();
if (!ns.length) return;
const at = ns.findIndex(n => selected.has(n.id));
const to = at < 0 ? (e.shiftKey ? ns.length - 1 : 0)
: (at + (e.shiftKey ? -1 : 1) + ns.length) % ns.length;
selected = new Set([ns[to].id]);
render();
return;
}
if (key === "?") { handled(); toggleKeys(); return; }
if (key === "Escape") {
handled();
if (!keys.hidden) showKeys(false);
else { selected = new Set(); render(); }
return;
}
if (key.toLowerCase() === "p") { handled(); playCurrent(); return; }
if (!selected.size) return;
const arrows = { ArrowLeft: -1, ArrowRight: 1 };
if (key in arrows) {
handled();
change(e.shiftKey ? edit.stretch(ns, selected, arrows[key], grid())
: edit.move(ns, selected, arrows[key], 0, grid()));
} else if (key === "ArrowUp" || key === "ArrowDown") {
handled();
change(edit.move(ns, selected, 0, (key === "ArrowUp" ? 1 : -1) * (e.shiftKey ? 12 : 1), grid()));
} else if (key === "Delete" || key === "Backspace") {
handled(); removeSelected();
} else if (key === "Enter" && one() !== null) {
handled(); editText(one());
} else if (key.toLowerCase() === "s") {
handled(); splitSelected();
} else if (key.toLowerCase() === "m") {
handled(); mergeSelected();
} else if ({ g: "*", f: "F", r: "R" }[key.toLowerCase()]) {
handled(); kind({ g: "*", f: "F", r: "R" }[key.toLowerCase()]);
}
});
window.addEventListener("beforeunload", (e) => {
if (dirty()) e.preventDefault();
});
new ResizeObserver(() => render()).observe(plot);
return {
// keeps the line on show when the song is the same one, refreshed
load(id, data) {
const same = id === jobId;
review = data;
root.hidden = false;
if (!same) {
jobId = id;
curve = null;
gridDraft = null;
fetch(`/api/jobs/${id}/pitch`)
.then(r => (r.ok ? r.json() : null))
.then(c => { if (jobId === id) { curve = c; render(); } })
.catch(() => {});
}
const lines = visible();
if (!lines.length) { root.hidden = true; return; }
open(same && lines.some(l => l.index === index) ? index : lines[0].index);
},
clear() {
review = null;
jobId = null;
curve = null;
index = null;
view = layout = hist = baseline = preview = gridDraft = null;
root.hidden = true;
},
// playback following the song: never at the cost of unsaved edits
showLine(i) {
if (review && i !== index && !dirty()) open(i);
},
isDirty: dirty,
setTime(t) {
if (!view) return;
const inside = t >= view.t0 && t <= view.t1;
playhead.hidden = !inside;
if (inside) playhead.style.left = `${view.x(t)}px`;
},
};
}
+266 -21
View File
@@ -9,19 +9,25 @@ import uuid
from dataclasses import asdict, dataclass, field
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
import numpy as np
from fastapi import Body, FastAPI, File, Form, HTTPException, UploadFile
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from . import edits, grid
from . import rebuild as rebuild_policy
from .config import Config
from .lyrics import normalize_lyrics
from .notes import CONF_MASK
from .pipeline import (STAGE_ORDER, PipelineError, SongInput, read_warnings,
run_pipeline, song_slug)
from .ultrastar import get_header, parse_ultrastar
STATIC = Path(__file__).parent / "static"
STAGE_LINE = re.compile(r"^\[(\w+)\] (?:running|cached, skipping)")
# the song folder names its own media, exactly as the game reads it
MEDIA_HEADERS = {"audio": "MP3", "video": "VIDEO", "cover": "COVER"}
@dataclass
@@ -74,7 +80,7 @@ def _cleanup_uploads(cfg: Config, job_id: str, video_path: Path | None) -> None:
Covers are left behind: they are small, and a rebuild inherits cover_path, so
removing one would silently strip #COVER from the rebuilt song. A rebuild can
also inherit its parent's upload, so the directory is not always uploads/<job_id>.
Never raises — the caller has already marked the job done.
Never raises, so cleaning up cannot fail a job that otherwise succeeded.
"""
uploads = (cfg.work_dir / "uploads").resolve()
dirs = {uploads / job_id}
@@ -129,6 +135,7 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
jobs: dict[str, Job] = {}
order: list[str] = []
lock = threading.Lock()
edit_lock = threading.Lock()
todo: queue.Queue[str] = queue.Queue()
for job in _discover_jobs(cfg):
@@ -137,18 +144,18 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
def _prepare_rebuild(job: Job, log) -> str | None:
"""Relocate cached work and report the stage this rebuild restarts from."""
if job.force_stage:
log(f"[rebuild] restarting from {job.force_stage} (forced rebuild from scratch)")
return job.force_stage
if not job.rebuilt_from:
return None
return job.force_stage
with lock:
parent = jobs.get(job.rebuilt_from)
if parent is None:
return None
force_from = rebuild_policy.plan(parent.inp, job.inp)
return job.force_stage
planned = rebuild_policy.plan(parent.inp, job.inp)
changed = rebuild_policy.changed_fields(parent.inp, job.inp)
rebuild_policy.relocate_work(parent.inp, job.inp, cfg)
# a stage the operator asked for can only widen what plan() decided,
# which is how "redo the packaging" reaches work nothing invalidated
force_from = rebuild_policy.earliest(planned, job.force_stage)
log(f"[rebuild] restarting from {force_from or 'the first incomplete stage'}"
f" ({', '.join(changed) if changed else 'nothing'} changed)")
return force_from
@@ -190,6 +197,7 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
if m := STAGE_LINE.match(msg):
job.stage = m[1]
status, error = "done", None
try:
force_from = _prepare_rebuild(job, log)
_write_sidecar(job)
@@ -198,18 +206,15 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
job.output_dir = str(result.output_dir)
job.warnings = list(result.warnings)
_drop_superseded_output(job, log)
with lock:
job.status = "done"
# uploads survive a failure so the job stays rerunnable; acquire
# has already copied the video into the work dir by now.
_cleanup_uploads(cfg, job_id, job.inp.video_path)
except BaseException as e:
with lock:
job.status = "failed"
job.error = str(e)
finally:
with lock:
job.finished_at = time.time()
status, error = "failed", str(e)
# last, and all at once: a client that sees the job finished must
# find everything finishing it does already done
with lock:
job.status, job.error, job.finished_at = status, error, time.time()
threading.Thread(target=worker, daemon=True).start()
@@ -245,6 +250,7 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
cover: UploadFile | None = File(None)):
youtube_url, genre, language = (youtube_url or None, genre or None,
language or None)
lyrics = lyrics.replace("\r\n", "\n")
has_video = bool(video and video.filename)
if bool(youtube_url) == has_video:
raise HTTPException(400, "provide exactly one of youtube_url / video")
@@ -290,13 +296,20 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
lyrics: str = Form(...), youtube_url: str | None = Form(None),
genre: str | None = Form(None), year: int | None = Form(None),
language: str | None = Form(None), bpm: float | None = Form(None),
force_from: str | None = Form(None),
video: UploadFile | None = File(None),
cover: UploadFile | None = File(None)):
parent = _finished_job(job_id)
force_from = force_from or None
if force_from is not None and force_from not in STAGE_ORDER:
raise HTTPException(400, f"unknown stage {force_from!r}")
# a cleared field arrives as "" from some clients; "" and None must not
# look like different inputs to plan()
youtube_url, genre, language = (youtube_url or None, genre or None,
language or None)
# a browser submits a textarea with CRLF; without this every rebuild
# from the page looks like a lyrics edit and redoes the alignment
lyrics = lyrics.replace("\r\n", "\n")
has_video = bool(video and video.filename)
if youtube_url and has_video:
raise HTTPException(400, "provide exactly one of youtube_url / video")
@@ -314,8 +327,54 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
youtube_url=youtube_url, video_path=video_path,
genre=genre, year=year, language=language,
cover_path=cover_path, bpm_override=bpm)
_require_source(parent.inp, inp, rebuild_policy.plan(parent.inp, inp))
return _enqueue(new_id, inp, rebuilt_from=job_id)
_require_source(parent.inp, inp, rebuild_policy.earliest(
rebuild_policy.plan(parent.inp, inp), force_from))
return _enqueue(new_id, inp, rebuilt_from=job_id, force_stage=force_from)
def _current_songs() -> list[Job]:
"""The job that stands for each song now, newest first."""
with lock:
# a job that was rebuilt is history: its rebuild is the song now,
# possibly under another slug
rebuilt = {j.rebuilt_from for j in jobs.values()}
latest = {song_slug(j.inp): j for j in (jobs[i] for i in order)
if j.id not in rebuilt}
position = {job_id: n for n, job_id in enumerate(order)}
return sorted(latest.values(), key=lambda j: position[j.id], reverse=True)
@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}
for j in _current_songs()]
@app.post("/api/rebuild-all", status_code=201)
def rebuild_all(force_from: str = Form(...), ids: list[str] | None = Form(None)):
"""Rebuild every song, or the ones in `ids`, from `force_from` on, for
when songtrace itself improved that stage. Each is an ordinary rebuild
with unchanged inputs."""
if force_from not in STAGE_ORDER:
raise HTTPException(400, f"unknown stage {force_from!r}")
songs = _current_songs()
queued, skipped = [], []
if ids is not None:
current = {j.id for j in songs}
skipped = [{"id": i, "reason": "no longer the current job for its song"}
for i in ids if i not in current]
songs = [j for j in songs if j.id in set(ids)]
for job in songs:
if job.status in ("queued", "running"):
skipped.append({"id": job.id, "reason": "still in progress"})
continue
try:
_require_source(job.inp, job.inp, force_from)
except HTTPException as e:
skipped.append({"id": job.id, "reason": e.detail})
continue
queued.append(_enqueue(uuid.uuid4().hex, job.inp, rebuilt_from=job.id,
force_stage=force_from)["id"])
return {"ids": queued, "skipped": skipped}
@app.get("/api/stages")
def list_stages():
@@ -376,13 +435,44 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
shutil.rmtree(cfg.work_dir / slug, ignore_errors=True)
shutil.rmtree(cfg.output_dir / slug, ignore_errors=True)
def _resolve_media(job: Job, header: str) -> Path | None:
"""The file a song folder's #MP3/#VIDEO/#COVER header points at."""
slug = song_slug(job.inp)
out = (cfg.output_dir / slug).resolve()
txt = out / f"{slug}.txt"
if not txt.exists():
return None
name = get_header(parse_ultrastar(txt.read_text(encoding="utf-8")), header)
if not name:
return None
# a header names a file in the song folder and nothing else
path = (out / name).resolve()
if path.parent != out or not path.is_file():
return None
return path
@app.get("/api/jobs/{job_id}/media/{kind}")
def get_media(job_id: str, kind: str):
header = MEDIA_HEADERS.get(kind)
if header is None:
raise HTTPException(404)
job = _finished_job(job_id)
if job.status != "done":
raise HTTPException(404)
path = _resolve_media(job, header)
if path is None:
raise HTTPException(404)
return FileResponse(path)
@app.get("/api/jobs/{job_id}/review")
def get_review(job_id: str):
job = _finished_job(job_id)
if job.status != "done":
raise HTTPException(404)
work = cfg.work_dir / song_slug(job.inp)
notes_file = work / "notes_sec.json"
notes_file = work / edits.FINAL_FILE
if not notes_file.exists():
notes_file = work / "notes_sec.json"
if not notes_file.exists():
raise HTTPException(404)
notes_data = json.loads(notes_file.read_text())
@@ -402,11 +492,166 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
for n in notes_data["notes"]:
by_line.setdefault(n["line_index"], []).append({
"start": n["start"], "end": n["start"] + n["duration"],
"pitch": n["pitch"], "low_confidence": n["low_confidence"]})
"pitch": n["pitch"], "low_confidence": n["low_confidence"],
"text": n["text"], "kind": n.get("kind", ":")})
edited = set(notes_data.get("edited_lines", []))
lines = [{"index": i, "text": " ".join(words), "dropped": i in dropped,
"edited": i in edited,
"notes": by_line.get(i, []), "words": words_by_line.get(i, [])}
for i, words in enumerate(ref.lines)]
return {"lines": lines, "missing_spans": missing_spans}
stale = [{"text": " ".join(e["words"]), "notes": e["notes"]}
for e in notes_data.get("stale", [])]
media = {kind: _resolve_media(job, header) is not None
for kind, header in MEDIA_HEADERS.items()}
# each break with the line it comes before, the key a move is saved by
moved = set(notes_data.get("moved_breaks", []))
starts = sorted((n["start"], n["line_index"]) for n in notes_data["notes"]
if "line_index" in n)
breaks = []
for t in notes_data.get("breaks", []):
after = next((li for start, li in starts if start >= t), None)
if after is not None:
breaks.append({"line": after, "time": t, "moved": after in moved})
return {"lines": lines, "missing_spans": missing_spans, "media": media,
"stale": stale, "breaks": breaks, "grid": _chart_grid(work),
"grid_edited": bool(notes_data.get("grid")), "auto_bpm": _auto_bpm(work)}
@app.get("/api/jobs/{job_id}/pitch")
def get_pitch(job_id: str):
"""The detected vocal pitch, to draw behind the notes: UltraStar
semitones (0 = C4) every `step` seconds, null where nothing is sung."""
job = _finished_job(job_id)
path = cfg.work_dir / song_slug(job.inp) / "pitch.npz"
if not path.exists():
raise HTTPException(404)
data = np.load(path)
times, f0, conf = data["times"], data["f0"], data["conf"]
# every other 10ms frame is still finer than a note can be drawn
stride = 2
f0, conf = f0[::stride], conf[::stride]
voiced = (conf >= CONF_MASK) & (f0 > 0)
semis = 9 + 12 * np.log2(np.where(voiced, f0, 440.0) / 440.0)
hop = float(times[1] - times[0]) if len(times) > 1 else 0.01
return {"start": float(times[0]) if len(times) else 0.0, "step": hop * stride,
"pitch": [round(float(v), 2) if ok else None
for v, ok in zip(semis, voiced)]}
def _chart_grid(work: Path) -> dict | None:
"""The beat grid the chart was built on, for snapping edits to it."""
txt = work / "song.txt"
if not txt.exists():
return None
song = parse_ultrastar(txt.read_text(encoding="utf-8"))
bpm, gap = get_header(song, "BPM"), get_header(song, "GAP")
if bpm is None or gap is None:
return None
return {"bpm": float(bpm.replace(",", ".")), "gap_ms": float(gap.replace(",", "."))}
def _editable(job_id: str) -> Job:
job = _finished_job(job_id)
slug = song_slug(job.inp)
with lock:
# a build of this song would overwrite the chart being edited
if any(j.status in ("queued", "running") and song_slug(j.inp) == slug
for j in jobs.values()):
raise HTTPException(409, "this song is being rebuilt")
if job.status != "done":
raise HTTPException(409, "only a finished song can be edited")
return job
def _edit(job_id: str, change) -> dict:
"""Apply `change` to the song's edits, then rewrite its charts."""
job = _editable(job_id)
work = cfg.work_dir / song_slug(job.inp)
lyrics = normalize_lyrics(job.inp.lyrics_text).lines
with edit_lock:
data = edits.load(work, lyrics)
try:
change(data, lyrics)
except ValueError as e:
raise HTTPException(400, str(e))
edits.save(work, data)
final = edits.finalize(work, lyrics)
# the editor's grid, else the pipeline's own tempo with GAP at
# the first note, as the grid stage would chart it
chosen = final["grid"] or {"bpm": _auto_bpm(work), "gap_ms": None}
charts = [work / "song.txt"]
if job.output_dir:
charts.append(Path(job.output_dir) / f"{song_slug(job.inp)}.txt")
for txt in charts:
if txt.exists():
grid.rechart(txt, final["notes"], final["breaks"],
chosen["bpm"], chosen["gap_ms"])
with lock:
job.warnings = list(final["warnings"])
return get_review(job_id)
def _auto_bpm(work: Path) -> float | None:
"""The tempo the pipeline chose: recorded by grid, or for a song built
before that was, the one its chart already has."""
try:
return float(json.loads((work / grid.TEMPO_FILE).read_text())["bpm"])
except (OSError, ValueError, KeyError):
g = _chart_grid(work)
return g["bpm"] if g else None
@app.put("/api/jobs/{job_id}/breaks")
def save_breaks(job_id: str, body: dict = Body(...)):
"""Move line breaks: {line: seconds}, keyed by the line each comes
before; null puts one back where the notes alone would place it."""
return _edit(job_id, lambda data, lyrics: edits.set_breaks(data, body))
@app.put("/api/jobs/{job_id}/grid")
def save_grid(job_id: str, body: dict = Body(...)):
"""Set the chart's BPM and GAP, kept through rebuilds like any edit."""
job = _editable(job_id)
work = cfg.work_dir / song_slug(job.inp)
tempo = work / grid.TEMPO_FILE
# a song built before grid recorded its tempo: the chart's, taken now
# while it is still the pipeline's, is what RESET must return to
if not tempo.exists() and (bpm := _auto_bpm(work)) is not None:
tempo.write_text(json.dumps({"bpm": bpm}))
return _edit(job_id, lambda data, lyrics: edits.set_grid(
data, body.get("bpm"), body.get("gap_ms")))
@app.delete("/api/jobs/{job_id}/grid")
def reset_grid(job_id: str):
"""Go back to the BPM the pipeline chose and GAP at the first note."""
def change(data, lyrics):
if not edits.clear_grid(data):
raise HTTPException(404, "the grid was not set in the editor")
return _edit(job_id, change)
def _line(lyrics, index: int) -> int:
if not 0 <= index < len(lyrics):
raise HTTPException(404, f"no line {index}")
return index
@app.put("/api/jobs/{job_id}/lines/{index}")
def save_line(job_id: str, index: int, body: dict = Body(...)):
"""Save a line as edited: its full note list, in seconds."""
notes = body.get("notes")
if not isinstance(notes, list):
raise HTTPException(400, "body must be {\"notes\": [...]}")
return _edit(job_id, lambda data, lyrics: edits.set_line(
data, _line(lyrics, index), notes))
@app.delete("/api/jobs/{job_id}/lines/{index}")
def revert_line(job_id: str, index: int):
"""Drop a line's edit, so it follows what the pipeline generates."""
def change(data, lyrics):
if not edits.clear_line(data, _line(lyrics, index)):
raise HTTPException(404, f"line {index} has no edit")
return _edit(job_id, change)
@app.delete("/api/jobs/{job_id}/stale/{n}")
def discard_stale(job_id: str, n: int):
"""Throw away an edit whose line's lyrics have since changed."""
def change(data, lyrics):
if not 0 <= n < len(data["stale"]):
raise HTTPException(404, f"no stale edit {n}")
data["stale"].pop(n)
return _edit(job_id, change)
return app
+185
View File
@@ -0,0 +1,185 @@
"""The one place songtrace touches yt-dlp.
YouTube breaks old yt-dlp releases faster than songtrace is released, so
besides the copy installed with songtrace (the bundled one), newer stable
releases are fetched from PyPI into a cache dir at runtime and tried first,
with the bundled copy as the fallback. yt-dlp runs in a subprocess and is
driven through its command line, its most stable interface, so a cached
release never mixes with anything songtrace has imported.
"""
import importlib.metadata
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import time
from dataclasses import dataclass
from pathlib import Path
import httpx
from .config import Config
from .pipeline import PipelineError
# The extras are what make YouTube work; see pyproject.toml.
REQUIREMENT = "yt-dlp[default,curl-cffi,deno]"
CHECK_INTERVAL = 24 * 3600
_lock = threading.Lock()
class YtDlpError(PipelineError):
pass
def _key(version: str) -> tuple[int, ...]:
return tuple(int(p) for p in version.split("."))
def cache_dir() -> Path:
base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
# Installs hold compiled wheels, so they are only good for one Python.
return Path(base) / "songtrace" / f"yt-dlp-py{sys.version_info[0]}{sys.version_info[1]}"
@dataclass(frozen=True)
class YtDlp:
version: str
path: Path | None = None # None for the bundled copy
def _env(self) -> dict[str, str]:
env = dict(os.environ)
if self.path:
env["PYTHONPATH"] = os.pathsep.join(
filter(None, [str(self.path), env.get("PYTHONPATH")]))
env["PATH"] = os.pathsep.join(
[str(self.path / "bin"), env.get("PATH", "")])
return env
def run(self, args: list[str], log) -> None:
"""Runs yt-dlp with `args`, passing each line it prints to `log`."""
proc = subprocess.Popen(
[sys.executable, "-P", "-m", "yt_dlp", "--ignore-config", *args],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
errors="replace", env=self._env())
last = error = ""
for line in proc.stdout:
last = line.rstrip()
log(last)
if last.startswith("ERROR:"):
error = last
if proc.wait() == 0:
return
if proc.returncode == 2:
raise YtDlpError(f"yt-dlp {self.version} rejected songtrace's "
f"options, so it may be incompatible: {last}")
raise YtDlpError(error or f"yt-dlp exited with status {proc.returncode}")
def bundled() -> YtDlp:
return YtDlp(importlib.metadata.version("yt-dlp"))
def _latest_release() -> str:
r = httpx.get("https://pypi.org/pypi/yt-dlp/json", timeout=15,
follow_redirects=True)
r.raise_for_status()
return r.json()["info"]["version"]
def _pip_install(target: Path, version: str) -> None:
proc = subprocess.run(
[sys.executable, "-m", "pip", "install", "--quiet", "--no-input",
"--disable-pip-version-check", "--target", str(target),
f"{REQUIREMENT}=={version}"],
capture_output=True, text=True)
if proc.returncode:
raise YtDlpError(f"pip could not install yt-dlp {version}: "
f"{proc.stderr.strip()[-500:]}")
def _install(version: str, log) -> YtDlp:
"""Installs `version` into the cache unless it is already there.
The install is checked to run before it is moved into place, so a
directory in the cache is always a usable yt-dlp."""
dest = cache_dir() / version
if dest.is_dir():
return YtDlp(version, dest)
dest.parent.mkdir(parents=True, exist_ok=True)
log(f"installing yt-dlp {version}")
tmp = Path(tempfile.mkdtemp(dir=dest.parent, prefix=".tmp-"))
try:
_pip_install(tmp, version)
proc = subprocess.run(
[sys.executable, "-P", "-m", "yt_dlp", "--version"],
capture_output=True, text=True, env=YtDlp(version, tmp)._env())
got = proc.stdout.strip()
if proc.returncode or _key(got) != _key(version):
raise YtDlpError(f"yt-dlp {version} failed its check after install: "
f"{(got or proc.stderr.strip())[-500:]}")
try:
tmp.rename(dest)
except OSError:
if not dest.is_dir():
raise
finally:
shutil.rmtree(tmp, ignore_errors=True)
return YtDlp(version, dest)
def _cached() -> list[YtDlp]:
root = cache_dir()
if not root.is_dir():
return []
found = [YtDlp(p.name, p) for p in root.iterdir()
if p.is_dir() and re.fullmatch(r"\d+(\.\d+)*", p.name)]
return sorted(found, key=lambda y: _key(y.version))
def _newest(base: YtDlp, refresh: bool, log) -> YtDlp | None:
"""The newest cached release newer than `base`, checking PyPI for a newer
one first if that has not been done for a day or `refresh` is set."""
marker = cache_dir() / ".checked"
if (refresh or not marker.exists()
or time.time() - marker.stat().st_mtime > CHECK_INTERVAL):
# Recorded even on failure, so a broken release is not reinstalled
# for every job; a failed download refreshes regardless.
marker.parent.mkdir(parents=True, exist_ok=True)
marker.touch()
try:
latest = _latest_release()
if _key(latest) > _key(base.version):
_install(latest, log)
except Exception as e:
log(f"could not update yt-dlp: {e}")
newer = [y for y in _cached() if _key(y.version) > _key(base.version)]
for old in newer[:-1]:
shutil.rmtree(old.path, ignore_errors=True)
return newer[-1] if newer else None
def candidates(cfg: Config, log, refresh: bool = False) -> list[YtDlp]:
"""The yt-dlp copies to try, in order: the newest release (or the one
`cfg.ytdlp_version` pins), then the bundled copy."""
base = bundled()
want = cfg.ytdlp_version
if want == "bundled":
return [base]
with _lock:
if want == "auto":
first = _newest(base, refresh, log)
elif not re.fullmatch(r"\d+(\.\d+)*", want):
raise YtDlpError(f"ytdlp_version must be \"auto\", \"bundled\" or a "
f"release such as \"2026.08.19\", not {want!r}")
elif _key(want) == _key(base.version):
first = None
else:
try:
first = _install(want, log)
except Exception as e:
log(f"could not install the pinned yt-dlp {want}: {e}")
first = None
return [first, base] if first else [base]
+36
View File
@@ -0,0 +1,36 @@
#TITLE:Placeholder Chart
#ARTIST:Test Artist
#LANGUAGE:English
#YEAR:2000
#CREATOR:songtrace tests
#GENRE:Test
#COVER:Test Artist - Placeholder Chart [CO].jpg
#MP3:Test Artist - Placeholder Chart.mp3
#VIDEO:Test Artist - Placeholder Chart.mp4
#BPM:217,98
#GAP:4880
#MEDLEYSTARTBEAT:113
#MEDLEYENDBEAT:240
: 0 3 16 ah
: 5 3 20 ah
: 11 3 23 la
: 17 3 16 la
: 22 28 15 ah
: 51 2 16 doo
: 54 31 16 ~
- 95
F 113 3 18 la
F 117 3 21 la
F 121 3 21 la
* 125 3 21 doo
* 129 12 19 ~
- 150
: 155 4 12 Na
R 160 4 0 na
G 165 4 0 na
: 170 6 14 la
- 190
: 194 3 16 La
: 198 3 18 la
* 202 10 20 ah
E
+21
View File
@@ -0,0 +1,21 @@
#TITLE:Café Placeholder
#ARTIST:Tést Artist
#LANGUAGE:English
#GENRE:Test
#YEAR:2000
#MP3:Tést Artist - Café Placeholder.mp3
#CREATOR:songtrace tests
#BPM:256
#GAP:49673
: 0 4 2 Doo
: 6 4 4 doo
: 12 8 5 laé
- 28 30
: 32 4 0 Na
: 38 4 -2 na
* 44 12 -9 ah
- 64
F 70 4 3 La
: 76 6 -5 la
: 84 10 -9 ~
E
+129
View File
@@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as m from "../../src/songtrace/static/edit-model.js";
// 300 BPM: one UltraStar beat is 0.05s, starting at GAP = 1s
const GRID = { bpm: 300, gap_ms: 1000 };
const near = (a, b) => Math.abs(a - b) < 1e-9;
function line() {
return m.withIds([
{ start: 1.52, end: 1.71, pitch: 0, text: "one", kind: ":", low_confidence: false },
{ start: 1.8, end: 2.0, pitch: 2, text: " two", kind: ":", low_confidence: true },
]);
}
test("snapping lands on the beat grid from GAP", () => {
assert.ok(near(m.snap(1.52, GRID), 1.5));
assert.ok(near(m.snap(1.53, GRID), 1.55));
assert.ok(near(m.snap(0.97, GRID), 0.95)); // beats before GAP too
});
test("moving snaps to the grid, then shifts by beats and semitones", () => {
const notes = line();
const out = m.move(notes, new Set([notes[0].id]), 2, -3, GRID);
assert.ok(near(out[0].start, 1.6) && near(out[0].end, 1.8));
assert.equal(out[0].pitch, -3);
assert.equal(out[1], notes[1]); // untouched note is the same object
assert.equal(notes[0].start, 1.52); // input left alone
});
test("moving never goes before the start of the song", () => {
const notes = m.withIds([{ start: 0.1, end: 0.2, pitch: 0, text: "x", kind: ":" }]);
const out = m.move(notes, new Set([notes[0].id]), -100, 0, GRID);
assert.ok(near(out[0].start, 0));
});
test("resizing moves one edge and keeps at least a beat", () => {
const notes = line();
const id = notes[0].id;
assert.ok(near(m.resize(notes, id, "end", 1.9, GRID)[0].end, 1.9));
assert.ok(near(m.resize(notes, id, "end", 1.0, GRID)[0].end, 1.55));
const startMoved = m.resize(notes, id, "start", 1.4, GRID)[0];
assert.ok(near(startMoved.start, 1.4) && near(startMoved.end, 1.7));
});
test("stretching changes length by whole beats", () => {
const notes = line();
const out = m.stretch(notes, new Set([notes[1].id]), 2, GRID);
assert.ok(near(out[1].start, 1.8) && near(out[1].end, 2.1));
assert.ok(near(m.stretch(notes, new Set([notes[1].id]), -10, GRID)[1].end, 1.85));
});
test("splitting halves a note on a beat and holds the syllable", () => {
const notes = line();
const { notes: out, ids } = m.split(notes, notes[1].id, GRID);
assert.equal(out.length, 3);
assert.ok(near(out[1].end, 1.9) && near(out[2].start, 1.9));
assert.deepEqual([out[1].text, out[2].text], [" two", "~"]);
assert.equal(ids.length, 2);
// one beat cannot be split
const short = m.withIds([{ start: 1.0, end: 1.05, pitch: 0, text: "x", kind: ":" }]);
assert.equal(m.split(short, short[0].id, GRID).notes, short);
});
test("merging joins a note with the next, dropping a held ~", () => {
const notes = m.withIds([
{ start: 1.0, end: 1.2, pitch: 0, text: "sev", kind: ":" },
{ start: 1.2, end: 1.4, pitch: 0, text: "en", kind: ":" },
{ start: 1.4, end: 1.6, pitch: 0, text: "~", kind: ":" },
]);
const once = m.merge(notes, notes[0].id);
assert.deepEqual(once.map(n => n.text), ["seven", "~"]);
const twice = m.merge(once, once[0].id);
assert.deepEqual(twice.map(n => [n.text, n.end]), [["seven", 1.6]]);
assert.equal(m.merge(twice, twice[0].id), twice); // nothing after it
});
test("inserting adds a two-beat held note on the grid", () => {
const { notes, id } = m.insert(line(), 1.76, 4, GRID);
const added = notes.find(n => n.id === id);
assert.ok(near(added.start, 1.75) && near(added.end, 1.85));
assert.deepEqual([added.pitch, added.text, added.kind], [4, "~", ":"]);
assert.deepEqual(notes.map(n => n.text), ["one", "~", " two"]);
});
test("a kind toggles back to normal when every selected note has it", () => {
const notes = line();
const both = new Set(notes.map(n => n.id));
const golden = m.setKind(notes, both, "*");
assert.deepEqual(golden.map(n => n.kind), ["*", "*"]);
assert.deepEqual(m.setKind(golden, both, "*").map(n => n.kind), [":", ":"]);
const mixed = m.setKind(golden, new Set([notes[0].id]), "F");
assert.deepEqual(m.setKind(mixed, both, "F").map(n => n.kind), ["F", "F"]);
});
test("editing text keeps a word-initial space and refuses blank", () => {
const notes = line();
assert.equal(m.setText(notes, notes[1].id, "too")[1].text, " too");
assert.equal(m.setText(notes, notes[0].id, "won")[0].text, "won");
assert.equal(m.setText(notes, notes[0].id, " won")[0].text, " won");
assert.equal(m.setText(notes, notes[0].id, " "), notes);
});
test("saved notes carry duration and kind, not ids", () => {
const saved = m.toSaved(line());
assert.deepEqual(Object.keys(saved[0]).sort(), ["duration", "kind", "pitch", "start", "text"]);
assert.ok(near(saved[0].duration, 0.19));
});
test("history undoes and redoes, and a new change drops the redo", () => {
const h = m.history("a");
h.push("b");
h.push("c");
assert.equal(h.undo(), "b");
assert.equal(h.undo(), "a");
assert.equal(h.canUndo, false);
assert.equal(h.redo(), "b");
h.push("d");
assert.equal(h.canRedo, false);
assert.equal(h.present, "d");
});
test("a dragged break snaps to a beat but stays between its lines", () => {
assert.ok(near(m.placeBreak(1.52, 1.4, 1.8, GRID), 1.5));
assert.equal(m.placeBreak(1.0, 1.42, 1.8, GRID), 1.42); // not into the line before
assert.equal(m.placeBreak(2.5, 1.4, 1.78, GRID), 1.78); // nor into the line after
assert.equal(m.placeBreak(1.6, 1.9, 1.8, GRID), 1.9); // lines overlap: at the end of the first
});
+72
View File
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";
import { beatLines, isBlackKey, lineSpan, noteName, pitchRange }
from "../../src/songtrace/static/sheet.js";
test("note names follow UltraStar's C4 = 0", () => {
assert.equal(noteName(0), "C4");
assert.equal(noteName(9), "A4");
assert.equal(noteName(-1), "B3");
assert.equal(noteName(13), "C♯5");
assert.equal(noteName(-13), "B2");
});
test("black keys are the sharps, in any octave", () => {
assert.deepEqual([0, 1, 2, 3, 4, 5, 6].map(isBlackKey),
[false, true, false, true, false, false, true]);
assert.equal(isBlackKey(-11), true); // C#3
assert.equal(isBlackKey(-12), false); // C3
});
test("the pitch range pads the line's notes and keeps a minimum height", () => {
assert.deepEqual(pitchRange([0, 5]), { lo: -4, hi: 10 }); // 15 rows
assert.deepEqual(pitchRange([0, 20]), { lo: -3, hi: 23 }); // padded only
assert.deepEqual(pitchRange([]), { lo: -7, hi: 7 });
});
test("beat lines sit on the chart's grid with bar weights", () => {
// 300 BPM: an UltraStar beat is 60 / (4 * 300) = 0.05s, from GAP 1.0s
const lines = beatLines(1.0, 1.85, { bpm: 300, gap_ms: 1000 });
assert.equal(lines.length, 18);
assert.deepEqual(lines.slice(0, 5).map(b => b.weight), [2, 0, 0, 0, 1]);
assert.equal(lines[16].weight, 2);
assert.ok(Math.abs(lines[4].t - 1.2) < 1e-9);
});
test("beats before GAP keep their weights", () => {
const lines = beatLines(0.75, 1.0, { bpm: 300, gap_ms: 1000 });
assert.deepEqual(lines.map(b => [b.beat, b.weight]),
[[-5, 0], [-4, 1], [-3, 0], [-2, 0], [-1, 0], [0, 2]]);
});
test("a line spans its notes and words", () => {
assert.deepEqual(lineSpan({ notes: [{ start: 2, end: 3 }], words: [{ start: 1.5, end: 2.5 }] }),
{ start: 1.5, end: 3 });
assert.equal(lineSpan({ notes: [], words: [] }), null);
});
import { readFileSync } from "node:fs";
import { SHORTCUTS, comboParts } from "../../src/songtrace/static/sheet.js";
test("shortcut combos split into keys and mouse actions", () => {
assert.deepEqual(comboParts("Shift+click / Tab"), [
[{ text: "Shift", key: true }, { text: "click", key: false }],
[{ text: "Tab", key: true }],
]);
assert.deepEqual(comboParts("Page Up"), [[{ text: "Page Up", key: true }]]);
});
test("every letter key the shortcut list names is one the sheet handles", () => {
const source = readFileSync(new URL("../../src/songtrace/static/sheet.js", import.meta.url), "utf8");
const handler = source.slice(source.indexOf('plot.addEventListener("keydown"'));
const letters = SHORTCUTS.flatMap(([, entries]) => entries)
.flatMap(([combo]) => comboParts(combo).flat())
.filter(p => p.key && /^[A-Z]$/.test(p.text))
.map(p => p.text.toLowerCase());
assert.ok(letters.length >= 6);
for (const letter of new Set(letters)) {
assert.ok(handler.includes(`"${letter}"`) || handler.includes(`${letter}:`),
`the list names ${letter.toUpperCase()} but the sheet does not handle it`);
}
});
+118 -1
View File
@@ -24,7 +24,7 @@ def test_local_video(tmp_path, monkeypatch):
assert any("audio.wav" in str(a[-1]) for a in calls)
def test_youtube(tmp_path, monkeypatch):
def fake_dl(url, dest):
def fake_dl(url, dest, cfg, log):
video = dest / "source_video.webm"
video.write_bytes(b"v")
thumb = dest / "source_video.jpg"
@@ -83,3 +83,120 @@ def test_classify_picks_largest_video_when_multiple(tmp_path):
(tmp_path / "source_video.mp4").write_bytes(b"much larger contents here")
video, _ = acquire._classify_downloads(tmp_path)
assert video.name == "source_video.mp4"
def test_classify_ignores_partials_left_by_a_failed_attempt(tmp_path):
(tmp_path / "source_video.webm").write_bytes(b"v")
(tmp_path / "source_video.f137.mp4.part").write_bytes(b"a much bigger partial")
(tmp_path / "source_video.f251.webm.ytdl").write_bytes(b"resume state")
video, _ = acquire._classify_downloads(tmp_path)
assert video.name == "source_video.webm"
def test_ydl_args_default_client_adds_no_player_client(tmp_path):
cfg = Config(whisper_url="http://x/v1")
args = acquire._ydl_args(cfg, tmp_path, "default", "https://yt/x")
assert "--extractor-args" not in args
assert "--retries" in args and "--fragment-retries" in args
assert args[-2:] == ["--", "https://yt/x"]
def test_ydl_args_named_client_pot_provider_and_cookies(tmp_path):
cfg = Config(whisper_url="http://x/v1",
ytdlp_pot_provider_url="http://pot:4416",
ytdlp_cookies=tmp_path / "cookies.txt",
ytdlp_impersonate="chrome")
args = acquire._ydl_args(cfg, tmp_path, "tv", "https://yt/x")
joined = " ".join(args)
assert "--extractor-args youtube:player_client=tv" in joined
assert ("--extractor-args youtubepot-bgutilhttp:base_url=http://pot:4416"
in joined)
assert f"--cookies {tmp_path / 'cookies.txt'}" in joined
assert "--impersonate chrome" in joined
def test_ydl_args_parse_with_real_yt_dlp(tmp_path):
"""Every option songtrace passes must exist in the bundled yt-dlp."""
import pytest
pytest.importorskip("yt_dlp")
from yt_dlp import parse_options
cfg = Config(whisper_url="http://x/v1", ytdlp_pot_provider_url="http://p:1",
ytdlp_cookies=tmp_path / "c.txt")
parse_options(acquire._ydl_args(cfg, tmp_path, "tv", "https://yt/x"))
class FakeYtDlp:
def __init__(self, version, behaviour, seen):
self.version, self._behaviour, self._seen = version, behaviour, seen
def run(self, args, log):
client = next((a for a in args if a.startswith("youtube:")), None)
self._seen.append((self.version, client))
self._behaviour(self.version, client)
def _fake_candidates(monkeypatch, behaviour, versions=("2026.09.01", "2026.08.19"),
refreshed=None):
"""Stub ytdlp.candidates; `refreshed` is what a refresh turns up."""
seen = []
def candidates(cfg, log, refresh=False):
vs = refreshed if refresh and refreshed else versions
return [FakeYtDlp(v, behaviour, seen) for v in vs]
monkeypatch.setattr(acquire.ytdlp, "candidates", candidates)
return seen
def test_download_falls_through_to_the_next_client(tmp_path, monkeypatch):
from songtrace.ytdlp import YtDlpError
def behaviour(version, client):
if client is None:
raise YtDlpError("ERROR: HTTP Error 403: Forbidden")
(tmp_path / "source_video.webm").write_bytes(b"v")
seen = _fake_candidates(monkeypatch, behaviour)
cfg = Config(whisper_url="http://x/v1", ytdlp_clients=["default", "tv"])
logged = []
video, thumb = acquire.download_youtube("https://yt/x", tmp_path, cfg,
logged.append)
assert video.name == "source_video.webm"
assert thumb is None
assert seen == [("2026.09.01", None),
("2026.09.01", "youtube:player_client=tv")]
assert any("403" in m for m in logged)
def test_download_falls_back_to_the_bundled_copy(tmp_path, monkeypatch):
from songtrace.ytdlp import YtDlpError
def behaviour(version, client):
if version == "2026.09.01":
raise YtDlpError("rejected songtrace's options")
(tmp_path / "source_video.webm").write_bytes(b"v")
seen = _fake_candidates(monkeypatch, behaviour)
cfg = Config(whisper_url="http://x/v1", ytdlp_clients=["default"])
acquire.download_youtube("https://yt/x", tmp_path, cfg, lambda m: None)
assert [v for v, _ in seen] == ["2026.09.01", "2026.08.19"]
def test_download_retries_with_a_release_found_after_failing(tmp_path, monkeypatch):
from songtrace.ytdlp import YtDlpError
def behaviour(version, client):
if version != "2026.10.01":
raise YtDlpError("ERROR: HTTP Error 403: Forbidden")
(tmp_path / "source_video.webm").write_bytes(b"v")
seen = _fake_candidates(monkeypatch, behaviour,
refreshed=("2026.10.01", "2026.08.19"))
cfg = Config(whisper_url="http://x/v1", ytdlp_clients=["default"])
acquire.download_youtube("https://yt/x", tmp_path, cfg, lambda m: None)
assert [v for v, _ in seen] == ["2026.09.01", "2026.08.19", "2026.10.01"]
def test_download_reports_versions_and_clients_when_all_fail(tmp_path, monkeypatch):
import pytest
from songtrace.pipeline import PipelineError
from songtrace.ytdlp import YtDlpError
def behaviour(version, client):
raise YtDlpError("ERROR: HTTP Error 403: Forbidden")
seen = _fake_candidates(monkeypatch, behaviour)
cfg = Config(whisper_url="http://x/v1", ytdlp_clients=["default", "tv"])
with pytest.raises(PipelineError) as e:
acquire.download_youtube("https://yt/x", tmp_path, cfg, lambda m: None)
assert "2026.09.01, 2026.08.19" in str(e.value)
assert "default, tv" in str(e.value)
assert "403" in str(e.value)
assert len(seen) == 4
def test_download_with_no_configured_clients_still_tries_default(tmp_path, monkeypatch):
seen = _fake_candidates(
monkeypatch,
lambda v, c: (tmp_path / "source_video.mp4").write_bytes(b"v"))
cfg = Config(whisper_url="http://x/v1", ytdlp_clients=[])
acquire.download_youtube("https://yt/x", tmp_path, cfg, lambda m: None)
assert seen == [("2026.09.01", None)]
+75
View File
@@ -1,11 +1,23 @@
import json
import numpy as np
import pytest
import soundfile as sf
from songtrace.config import Config
from songtrace.lyrics import normalize_lyrics
from songtrace.pipeline import Ctx, PipelineError, SongInput
from songtrace.stages import align
from songtrace.whisper_client import TranscriptSegment
REAL_UNTRANSCRIBED_SINGING = align.untranscribed_singing
@pytest.fixture(autouse=True)
def no_untranscribed_singing(monkeypatch):
"""The tests have no vocals.wav; those about missed singing set their own."""
monkeypatch.setattr(align, "untranscribed_singing", lambda vocals, segments: [])
LYRICS = "it's one two three\nfour five six seven eight"
@@ -160,3 +172,66 @@ def test_align_flags_missing_span_for_unmatched_singing(tmp_path, monkeypatch):
meta = json.loads((tmp_path / "w" / "align_meta.json").read_text())
assert meta["dropped_lines"] == []
assert meta["missing_spans"] == [[19.0, 24.0]]
def _tone(seconds, sr, level):
t = np.arange(int(seconds * sr)) / sr
return level * np.sin(2 * np.pi * 220 * t)
def test_untranscribed_singing_finds_sung_audio_the_transcript_skips(
tmp_path):
sr = 16000
rng = np.random.default_rng(0)
audio = np.concatenate([
_tone(2, sr, 0.3), # 0-2s: sung and transcribed
np.zeros(2 * sr), # 2-4s: silence
_tone(5, sr, 0.3), # 4-9s: sung, never transcribed
np.zeros(3 * sr), # 9-12s: silence
_tone(1, sr, 0.3), # 12-13s: too short to count
np.zeros(3 * sr),
]) + 0.001 * rng.standard_normal(16 * sr) # separation bleed
path = tmp_path / "vocals.wav"
sf.write(path, audio.astype(np.float32), sr)
segments = [TranscriptSegment(0.0, 2.0, "one two")]
spans = REAL_UNTRANSCRIBED_SINGING(path, segments)
assert len(spans) == 1
assert spans[0][0] == pytest.approx(4.0, abs=0.1)
assert spans[0][1] == pytest.approx(9.0, abs=0.1)
def test_lines_whisper_missed_are_aligned_in_the_untranscribed_singing(
tmp_path, monkeypatch):
lyrics = "one two three\nfour five six\nseven eight nine"
transcript = [{"start": 1.0, "end": 3.0, "text": "one two three"},
{"start": 40.0, "end": 42.0, "text": "seven eight nine"}]
monkeypatch.setattr(align, "untranscribed_singing",
lambda vocals, segments: [(20.0, 26.0)])
rows = run_align(tmp_path, monkeypatch, lyrics, transcript)
check_invariants(rows, lyrics)
middle = [r for r in rows if r["line_index"] == 1]
assert all(19.0 <= r["start"] and r["end"] <= 27.0 for r in middle)
meta = json.loads((tmp_path / "w" / "align_meta.json").read_text())
assert meta["dropped_lines"] == []
def test_stars_bracket_each_line_and_take_no_words(tmp_path, monkeypatch):
lyrics = "one two\n...\nthree four"
transcript = [{"start": 0.0, "end": 10.0, "text": "one two three four"}]
seen = []
def spy(vocals_path, start, end, tokens, device):
seen.append(tokens)
# each star claims a long stretch, so a word given a star's span shows
spans = [(start + i, start + i + (5.0 if t == "*" else 0.5), 0.9)
for i, t in enumerate(tokens)]
return spans
monkeypatch.setattr(align, "force_align_section", spy)
ctx = make_ctx(tmp_path, lyrics, transcript)
align.align_stage().run(ctx)
rows = json.loads((ctx.work / "alignment.json").read_text())
assert seen == [["*", "one", "two", "*", "three", "four", "*"]]
timed = [r for r in rows if r["word"] != "..."]
assert all(r["end"] - r["start"] == pytest.approx(0.5) for r in timed)
check_invariants(rows, lyrics)
+189
View File
@@ -0,0 +1,189 @@
import json
import pytest
from songtrace import edits
LYRICS = [["one", "two"], ["three", "four"], ["five", "six"]]
def gen(start, line, pitch=0, dur=0.4, low=False, text="la"):
return {"start": start, "duration": dur, "pitch": pitch, "text": text,
"low_confidence": low, "line_index": line}
def edited(*lines):
data = edits._empty(LYRICS)
for index, notes in lines:
edits.set_line(data, index, notes)
return data
def note(start, pitch=5, dur=0.4, text="la", kind=":"):
return {"start": start, "duration": dur, "pitch": pitch, "text": text, "kind": kind}
GENERATED = [gen(1.0, 0), gen(1.5, 0), gen(4.0, 1), gen(4.5, 1), gen(8.0, 2), gen(8.5, 2)]
BREAKS = [3.0, 6.0]
def test_no_edits_leaves_the_generated_song_alone():
assert edits.merge(GENERATED, BREAKS, edits._empty(LYRICS)) == (GENERATED, BREAKS)
def test_an_edited_line_replaces_its_generated_notes():
notes, _ = edits.merge(GENERATED, BREAKS, edited(
(1, [note(4.2, pitch=7, kind="*"), note(4.8, pitch=9)])))
line1 = [n for n in notes if n["line_index"] == 1]
assert [(n["start"], n["pitch"], n["kind"]) for n in line1] == [
(4.2, 7, "*"), (4.8, 9, ":")]
assert not any(n["low_confidence"] for n in line1)
assert [n["line_index"] for n in notes] == [0, 0, 1, 1, 2, 2]
def test_neighbours_give_way_to_an_edited_line():
# line 1 edited to run from 1.7s to 8.6s, over its neighbours' notes
notes, _ = edits.merge(GENERATED, BREAKS, edited(
(1, [note(1.7), note(8.2, dur=0.4)])))
by_line = {}
for n in notes:
by_line.setdefault(n["line_index"], []).append(
(round(n["start"], 2), round(n["start"] + n["duration"], 2)))
assert by_line[0] == [(1.0, 1.4), (1.5, 1.7)] # cut back to end at 1.7s
assert by_line[2] == [(8.6, 8.9)] # 8.0 note gone, 8.5 starts at 8.6
def test_breaks_beside_an_edited_line_move_and_the_rest_stay():
data = edited((2, [note(9.0), note(9.5)]))
_, breaks = edits.merge(GENERATED, BREAKS, data)
assert breaks[0] == 3.0 # lines 0-1: generated
assert breaks[1] == pytest.approx((4.9 + 9.0) / 2) # lines 1-2: re-centred
def test_an_edit_follows_its_line_when_lines_are_added_above(tmp_path):
data = edited((1, [note(4.0)]))
edits.save(tmp_path, data)
moved = edits.load(tmp_path, [["zero"]] + LYRICS)
assert list(moved["lines"]) == ["2"] and moved["lines"]["2"]["line_index"] == 2
assert moved["stale"] == []
assert json.loads((tmp_path / edits.EDITS_FILE).read_text())["lyrics"][0] == ["zero"]
def test_an_edit_whose_words_changed_is_set_aside(tmp_path):
edits.save(tmp_path, edited((1, [note(4.0)]), (2, [note(8.0)])))
changed = [LYRICS[0], ["three", "more"], LYRICS[2]]
data = edits.load(tmp_path, changed)
assert list(data["lines"]) == ["2"]
assert [s["words"] for s in data["stale"]] == [["three", "four"]]
def test_validate_notes_checks_and_orders():
assert [n["start"] for n in edits.validate_notes([note(2.0), note(1.0)])] == [1.0, 2.0]
for bad in (note(1.0, kind="X"), note(1.0, dur=0), note(-1.0), note(1.0, text=" "),
{"start": 1.0}):
with pytest.raises(ValueError):
edits.validate_notes([bad])
def test_saving_no_edits_removes_the_file(tmp_path):
edits.save(tmp_path, edited((0, [note(1.0)])))
data = edits.load(tmp_path, LYRICS)
edits.clear_line(data, 0)
edits.save(tmp_path, data)
assert not (tmp_path / edits.EDITS_FILE).exists()
def _write_generated(work, notes, warnings):
(work / "notes_sec.json").write_text(json.dumps(
{"notes": notes, "breaks": BREAKS, "warnings": warnings}))
def test_finalize_without_edits_passes_warnings_through(tmp_path):
_write_generated(tmp_path, GENERATED, ["anything at all"])
final = edits.finalize(tmp_path, LYRICS)
assert final["warnings"] == ["anything at all"]
assert final["edited_lines"] == [] and final["stale"] == []
assert json.loads((tmp_path / edits.FINAL_FILE).read_text()) == final
def test_finalize_stops_reporting_a_line_the_user_fixed(tmp_path):
notes = [gen(1.0, 0), gen(4.0, 1, low=True), gen(8.0, 2)]
_write_generated(tmp_path, notes, ["1 low-confidence note near 0:04–0:04",
"missing lyrics near 0:20–0:25"])
edits.save(tmp_path, edited((1, [note(4.0)])))
final = edits.finalize(tmp_path, LYRICS)
assert final["warnings"] == ["missing lyrics near 0:20–0:25"]
assert final["edited_lines"] == [1]
def test_finalize_reports_stale_edits(tmp_path):
_write_generated(tmp_path, GENERATED, [])
edits.save(tmp_path, edited((1, [note(4.0)])))
final = edits.finalize(tmp_path, [LYRICS[0], ["other"], LYRICS[2]])
assert final["warnings"] == ["1 edited line no longer matches the lyrics; "
"set aside in the editor"]
def test_a_grid_set_in_the_editor_is_checked():
data = edits._empty(LYRICS)
edits.set_grid(data, "240.5", 1500)
assert data["grid"] == {"bpm": 240.5, "gap_ms": 1500.0}
for bpm, gap in ((0, 0), (-1, 0), (float("nan"), 0), (3000, 0), (300, -1), ("x", 0)):
with pytest.raises(ValueError):
edits.set_grid(data, bpm, gap)
def test_a_grid_alone_keeps_the_edits_file_and_survives_new_lyrics(tmp_path):
data = edits._empty(LYRICS)
edits.set_grid(data, 300, 1000)
edits.save(tmp_path, data)
assert (tmp_path / edits.EDITS_FILE).exists()
loaded = edits.load(tmp_path, [["zero"]] + LYRICS)
assert loaded["grid"] == {"bpm": 300.0, "gap_ms": 1000.0}
assert edits.clear_grid(loaded) and not edits.clear_grid(loaded)
edits.save(tmp_path, loaded)
assert not (tmp_path / edits.EDITS_FILE).exists()
def test_finalize_reports_the_grid(tmp_path):
_write_generated(tmp_path, GENERATED, [])
assert edits.finalize(tmp_path, LYRICS)["grid"] is None
data = edits._empty(LYRICS)
edits.set_grid(data, 300, 1000)
edits.save(tmp_path, data)
assert edits.finalize(tmp_path, LYRICS)["grid"] == {"bpm": 300.0, "gap_ms": 1000.0}
def test_moved_breaks_are_checked():
data = edits._empty(LYRICS)
edits.set_breaks(data, {"1": 3.5, 2: "6.25"})
assert data["breaks"] == {"1": 3.5, "2": 6.25}
edits.set_breaks(data, {1: None})
assert data["breaks"] == {"2": 6.25}
for bad in ({0: 1.0}, {3: 1.0}, {"x": 1.0}, {1: -1}, {1: "soon"}, {1: float("inf")}):
with pytest.raises(ValueError):
edits.set_breaks(data, bad)
def test_a_moved_break_stays_put_between_its_lines():
data = edits._empty(LYRICS)
edits.set_breaks(data, {1: 2.5})
notes, breaks = edits.merge(GENERATED, BREAKS, data)
assert notes == GENERATED # moving a break locks no notes
assert breaks == [2.5, 6.0]
def test_a_moved_break_never_lands_inside_a_line():
data = edits._empty(LYRICS)
edits.set_breaks(data, {1: 1.0, 2: 9.0}) # into line 0's notes, past line 2's start
_, breaks = edits.merge(GENERATED, BREAKS, data)
assert breaks == [pytest.approx(1.9), 8.0]
def test_moved_breaks_follow_their_lines_and_keep_the_file(tmp_path):
data = edits._empty(LYRICS)
edits.set_breaks(data, {1: 3.5, 2: 6.5})
edits.save(tmp_path, data)
assert (tmp_path / edits.EDITS_FILE).exists()
moved = edits.load(tmp_path, [["zero"], LYRICS[0], LYRICS[1], ["other"]])
assert moved["breaks"] == {"2": 3.5} # line 2's words changed: its break goes
+85 -2
View File
@@ -169,7 +169,7 @@ def test_grid_stage_writes_song_txt(tmp_path, monkeypatch):
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", lambda p: 120.0)
ctx = make_ctx(work, language="English", genre="Pop", year=1999)
stage = grid_stage_mod.grid_stage()
assert stage.outputs(ctx) == [work / "song.txt"]
assert stage.outputs(ctx) == [work / "song.txt", work / "notes_final.json"]
stage.run(ctx)
song = parse_ultrastar((work / "song.txt").read_text(encoding="utf-8"))
assert [k for k, _ in song.headers] == [
@@ -222,7 +222,7 @@ def test_grid_stage_honours_zero_bpm_override(tmp_path, monkeypatch):
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", boom)
captured = {}
def fake_to_ultrastar(notes, breaks, file_bpm, headers):
def fake_to_ultrastar(notes, breaks, file_bpm, headers, gap_ms=None):
captured["bpm"] = file_bpm
return to_ultrastar(notes, breaks, 240.0, headers)
@@ -257,3 +257,86 @@ def test_grid_stage_reports_no_notes_as_pipeline_error(tmp_path, monkeypatch):
def test_grid_stage_registered():
names = [s.name for s in all_stages()]
assert names[-1] == "package" and names[-2] == "grid"
def _regenerate(work, pitch):
"""What a rebuild leaves: fresh notes for both lines, at `pitch`."""
(work / "notes_sec.json").write_text(json.dumps({
"notes": [{**note(1.0, 0.5, pitch, "one"), "line_index": 0},
{**note(3.0, 0.5, pitch, "three"), "line_index": 1}],
"breaks": [2.0], "warnings": []}))
def test_an_edited_line_survives_a_rebuild(tmp_path, monkeypatch):
from songtrace import edits
work = tmp_path / "w"
write_work(work)
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", lambda p: 120.0)
ctx = make_ctx(work)
ctx.inp.lyrics_text = "one\nthree"
data = edits.load(work, [["one"], ["three"]])
edits.set_line(data, 1, [{"start": 3.2, "duration": 0.6, "pitch": 9,
"text": "three", "kind": "*"}])
edits.save(work, data)
for regenerated_pitch in (0, 4):
_regenerate(work, regenerated_pitch)
grid_stage_mod.grid_stage().run(ctx)
song = parse_ultrastar((work / "song.txt").read_text(encoding="utf-8"))
notes = [n for n in song.body if isinstance(n, Note)]
assert [(n.kind, n.pitch, n.text) for n in notes] == [
(":", regenerated_pitch, "one"), ("*", 9, "three")]
def test_rechart_replaces_the_notes_and_keeps_headers_and_tempo(tmp_path):
from songtrace.grid import rechart
from songtrace.ultrastar import serialize_ultrastar
txt = tmp_path / "song.txt"
txt.write_text(serialize_ultrastar(to_ultrastar(
[note(1.0, 0.5)], [], 300.0,
[("TITLE", "Song"), ("VIDEO", "Band - Song.mp4")])), encoding="utf-8")
rechart(txt, [{**note(2.0, 0.5, 7, "new"), "kind": "F"}], [])
song = parse_ultrastar(txt.read_text(encoding="utf-8"))
assert [k for k, _ in song.headers] == ["TITLE", "VIDEO", "BPM", "GAP"]
assert get_header(song, "BPM") == "300" and get_header(song, "GAP") == "2000"
assert [(n.kind, n.pitch, n.text) for n in song.body] == [("F", 7, "new")]
def test_an_explicit_gap_sets_the_grid():
# 300 BPM: 0.05s beats; notes at 1.02s and 1.27s against GAP 1.0s
song = to_ultrastar([note(1.02, 0.1), note(1.27, 0.1)], [], 300.0, [], gap_ms=1000)
assert get_header(song, "GAP") == "1000"
assert [n.beat for n in song.body] == [0, 5]
def test_a_gap_after_the_first_note_moves_back_by_whole_beats():
# GAP 1.13s is after the first note at 1.02s: back 3 beats to 0.98s, so
# the grid lines stay where they were set and the note is on beat 1
song = to_ultrastar([note(1.02, 0.1)], [], 300.0, [], gap_ms=1130)
assert get_header(song, "GAP") == "980"
assert song.body[0].beat == 1
def test_the_grid_stage_uses_the_editors_grid_and_records_its_own(tmp_path, monkeypatch):
from songtrace import edits
work = tmp_path / "w"
write_work(work)
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", lambda p: 120.0)
ctx = make_ctx(work)
data = edits.load(work, [["x"]])
edits.set_grid(data, 300.0, 900)
edits.save(work, data)
grid_stage_mod.grid_stage().run(ctx)
song = parse_ultrastar((work / "song.txt").read_text(encoding="utf-8"))
assert (get_header(song, "BPM"), get_header(song, "GAP")) == ("300", "900")
assert json.loads((work / "tempo.json").read_text()) == {"bpm": 240.0}
def test_rechart_can_set_a_new_bpm_and_gap(tmp_path):
from songtrace.grid import rechart
from songtrace.ultrastar import serialize_ultrastar
txt = tmp_path / "song.txt"
txt.write_text(serialize_ultrastar(to_ultrastar([note(1.0, 0.5)], [], 240.0, [])),
encoding="utf-8")
rechart(txt, [note(1.02, 0.1)], [], 300.0, 1000)
song = parse_ultrastar(txt.read_text(encoding="utf-8"))
assert (get_header(song, "BPM"), get_header(song, "GAP")) == ("300", "1000")
+129
View File
@@ -0,0 +1,129 @@
import sys
import types
import pytest
from songtrace import models
from songtrace.stages import align, pitch
@pytest.fixture
def isolated_callbacks(monkeypatch):
"""release() with only the callbacks a test registers."""
monkeypatch.setattr(models, "_callbacks", [])
return models._callbacks
def fake_torch(available=True, initialised=True, emptied=None, reserved=None):
"""A torch whose allocator reports `reserved` bytes, then 0 once emptied."""
state = {"reserved": 4 * 2 ** 20 if reserved is None else reserved}
def empty_cache():
emptied.append(True)
state["reserved"] = 0
mod = types.ModuleType("torch")
mod.cuda = types.SimpleNamespace(
is_available=lambda: available,
is_initialized=lambda: initialised,
memory_reserved=lambda: state["reserved"],
empty_cache=empty_cache)
return mod
def test_release_without_torch_or_callbacks_does_nothing(monkeypatch,
isolated_callbacks):
monkeypatch.delitem(sys.modules, "torch", raising=False)
assert models.release() == 0
def test_release_runs_every_registered_callback(isolated_callbacks):
called = []
models.on_release(lambda: called.append("a"))
models.on_release(lambda: called.append("b"))
models.release()
assert called == ["a", "b"]
def test_on_release_returns_the_function_so_it_works_as_a_decorator():
def drop():
pass
assert models.on_release(drop) is drop
models._callbacks.remove(drop)
def test_release_empties_the_allocator_when_cuda_is_in_use(monkeypatch,
isolated_callbacks):
emptied = []
monkeypatch.setitem(sys.modules, "torch", fake_torch(emptied=emptied))
assert models.release() == 4 * 2 ** 20
assert emptied == [True]
def test_release_leaves_the_allocator_alone_without_a_gpu(monkeypatch,
isolated_callbacks):
emptied = []
monkeypatch.setitem(sys.modules, "torch",
fake_torch(available=False, emptied=emptied))
assert models.release() == 0
assert emptied == []
def test_release_does_not_initialise_cuda_just_to_clean_up(monkeypatch,
isolated_callbacks):
"""A run that never touched the GPU must not create a context on the way out."""
emptied = []
monkeypatch.setitem(sys.modules, "torch",
fake_torch(initialised=False, emptied=emptied))
assert models.release() == 0
assert emptied == []
def test_align_drops_its_cached_model():
align._model_cache["cuda"] = object()
align.unload_model()
assert align._model_cache == {}
def test_align_registers_its_unload():
assert align.unload_model in models._callbacks
def test_pitch_drops_the_model_torchcrepe_cached(monkeypatch):
torchcrepe = types.ModuleType("torchcrepe")
def infer():
pass
infer.model = object()
infer.capacity = "full"
torchcrepe.infer = infer
monkeypatch.setitem(sys.modules, "torchcrepe", torchcrepe)
pitch.unload_model()
assert not hasattr(infer, "model")
assert not hasattr(infer, "capacity")
def test_pitch_unload_is_fine_when_torchcrepe_was_never_imported(monkeypatch):
monkeypatch.delitem(sys.modules, "torchcrepe", raising=False)
pitch.unload_model()
def test_pitch_unload_is_fine_when_no_model_was_cached(monkeypatch):
torchcrepe = types.ModuleType("torchcrepe")
torchcrepe.infer = lambda: None
monkeypatch.setitem(sys.modules, "torchcrepe", torchcrepe)
pitch.unload_model()
def test_pitch_registers_its_unload():
assert pitch.unload_model in models._callbacks
def test_release_reports_nothing_freed_when_the_allocator_was_already_empty(
monkeypatch, isolated_callbacks):
emptied = []
monkeypatch.setitem(sys.modules, "torch",
fake_torch(emptied=emptied, reserved=0))
assert models.release() == 0
assert emptied == [True]
+54 -32
View File
@@ -4,7 +4,7 @@ import numpy as np
import pytest
from songtrace.config import Config
from songtrace.notes import (
MIN_NOTE_SEC, TimedNote, build_notes, hz_to_ultrastar, segment_span,
MIN_NOTE_SEC, TimedNote, _warnings, build_notes, hz_to_ultrastar, segment_span,
syllabify,
)
from songtrace.pipeline import Ctx, SongInput
@@ -78,7 +78,7 @@ def test_syllabify():
assert syllabify("reminds", "English") == ["re", "minds"]
assert syllabify("a", "English") == ["a"]
assert syllabify("xyzzy", None) == ["xyzzy"]
assert syllabify("She's", "English") == ["She's"]
assert syllabify("It's", "English") == ["It's"]
def test_syllabify_preserves_word_characters():
@@ -96,12 +96,12 @@ def test_syllabify_language_without_pyphen_dict_is_not_mishandled_as_english():
def test_build_notes_basic_line():
times, f0, conf = flat_pitch()
rows = [
{"word": "She's", "start": 1.0, "end": 1.5, "score": 0.9, "line_index": 0},
{"word": "It's", "start": 1.0, "end": 1.5, "score": 0.9, "line_index": 0},
{"word": "got", "start": 1.6, "end": 2.0, "score": 0.9, "line_index": 0},
{"word": "Now", "start": 5.0, "end": 5.5, "score": 0.9, "line_index": 1},
]
notes, breaks, warnings = build_notes(rows, times, f0, conf, "English")
assert notes[0].text == "She's" # line-initial: no leading space
assert notes[0].text == "It's" # line-initial: no leading space
assert notes[1].text == " got" # mid-line: leading space
assert notes[2].text == "Now" # new line: no leading space
assert len(breaks) == 1 and 2.0 <= breaks[0] <= 5.0
@@ -127,11 +127,36 @@ def test_build_notes_unvoiced_word_flagged():
assert warnings[0] == "1 low-confidence note near 0:01–0:01"
def test_build_notes_low_score_word_flagged():
def _scored_rows(scores, dur=0.4):
return [{"word": w, "start": 1.0 + i, "end": 1.0 + i + dur, "score": sc,
"line_index": 0}
for i, (w, sc) in enumerate(zip(["one", "two", "three", "four", "five"], scores))]
def test_build_notes_flags_a_word_scoring_far_below_the_rest():
times, f0, conf = flat_pitch()
rows = [{"word": "hey", "start": 1.0, "end": 1.4, "score": 0.1, "line_index": 0}]
notes, _, warnings = build_notes(rows, times, f0, conf, "English")
assert all(n.low_confidence for n in notes) and len(warnings) == 1
notes, _, warnings = build_notes(_scored_rows([0.4, 0.4, 0.05, 0.4, 0.4]),
times, f0, conf, "English")
assert [n.low_confidence for n in notes] == [False, False, True, False, False]
assert len(warnings) == 1
def test_build_notes_judges_scores_against_the_song_not_a_fixed_cutoff():
# sung vocals score low throughout; uniformly low is not a reason to flag
times, f0, conf = flat_pitch()
notes, _, warnings = build_notes(_scored_rows([0.05] * 5), times, f0, conf, "English")
assert not any(n.low_confidence for n in notes) and warnings == []
def test_build_notes_flags_words_too_short_or_too_long_to_be_sung():
times, f0, conf = flat_pitch()
for dur in (0.05, 3.5):
rows = _scored_rows([0.4] * 3)
rows[1]["end"] = rows[1]["start"] + dur
rows[2]["start"], rows[2]["end"] = rows[1]["end"] + 0.1, rows[1]["end"] + 0.5
notes, _, _ = build_notes(rows, times, f0, conf, "English")
# flat pitch: one note per word
assert [n.low_confidence for n in notes] == [False, True, False], dur
def test_build_notes_unvoiced_word_inherits_previous_pitch():
@@ -278,7 +303,7 @@ def test_notes_stage_writes_json(tmp_path):
times, f0, conf = flat_pitch()
np.savez(work / "pitch.npz", times=times, f0=f0, conf=conf)
(work / "alignment.json").write_text(json.dumps([
{"word": "She's", "start": 1.0, "end": 1.5, "score": 0.9, "line_index": 0},
{"word": "It's", "start": 1.0, "end": 1.5, "score": 0.9, "line_index": 0},
{"word": "gone", "start": 3.0, "end": 3.5, "score": 0.9, "line_index": 1},
]))
ctx = Ctx(inp=SongInput(title="T", artist="A", lyrics_text="x", language="English"),
@@ -287,7 +312,7 @@ def test_notes_stage_writes_json(tmp_path):
assert stage.outputs(ctx) == [work / "notes_sec.json"]
stage.run(ctx)
data = json.loads((work / "notes_sec.json").read_text())
assert [n["text"] for n in data["notes"]] == ["She's", "gone"]
assert [n["text"] for n in data["notes"]] == ["It's", "gone"]
assert data["breaks"] == [2.25]
assert data["warnings"] == []
assert TimedNote(**data["notes"][0]).pitch == 0
@@ -297,15 +322,19 @@ def test_notes_stage_registered():
assert "notes" in [s.name for s in all_stages()]
def test_build_notes_skips_notes_within_missing_span():
def test_build_notes_keeps_matched_words_within_missing_span():
# A missing span covers whole Whisper segments, so it can hold matched
# lines alongside the unmatched singing; those lines must keep their notes.
times, f0, conf = flat_pitch()
rows = [
{"word": "keep", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0},
{"word": "skip", "start": 5.0, "end": 5.4, "score": 0.9, "line_index": 1},
{"word": "held", "start": 5.0, "end": 5.4, "score": 0.9, "line_index": 1},
]
notes, _, _ = build_notes(rows, times, f0, conf, "English",
missing_spans=[(4.5, 6.0)])
assert [n.text for n in notes] == ["keep"]
notes, breaks, warnings = build_notes(rows, times, f0, conf, "English",
missing_spans=[(4.5, 6.0)])
assert [n.text for n in notes] == ["keep", "held"]
assert breaks == [(1.4 + 5.0) / 2]
assert warnings == ["missing lyrics near 0:04–0:06"]
def test_warnings_report_dropped_lines():
@@ -325,25 +354,18 @@ def test_warnings_report_missing_spans():
assert warnings == ["missing lyrics near 1:21–1:29"]
def test_build_notes_skipped_row_mid_line_does_not_corrupt_spacing_or_breaks():
# "gone" falls inside a missing span and is skipped; "away" is the same
# line's next word and must still get its leading space, and the break
# before the next line must use the last *emitted* word's end (3.4, from
# "away"), not the skipped row's end (2.4).
times, f0, conf = flat_pitch()
rows = [
{"word": "keep", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0},
{"word": "gone", "start": 2.0, "end": 2.4, "score": 0.9, "line_index": 0},
{"word": "away", "start": 3.0, "end": 3.4, "score": 0.9, "line_index": 0},
{"word": "next", "start": 5.0, "end": 5.4, "score": 0.9, "line_index": 1},
]
notes, breaks, _ = build_notes(rows, times, f0, conf, "English",
missing_spans=[(1.9, 2.5)])
assert [n.text for n in notes] == ["keep", " away", "next"]
assert breaks == [(3.4 + 5.0) / 2]
def test_notes_carry_line_index():
times, f0, conf = flat_pitch()
rows = [{"word": "hey", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 3}]
notes, _, _ = build_notes(rows, times, f0, conf, "English")
assert notes[0].line_index == 3
def test_warnings_group_flagged_notes_close_together():
def note(start, low):
return TimedNote(start, 0.3, 0, "la", low, 0)
notes = [note(10.0, True), note(10.5, False), note(11.0, True), # 0.7s apart
note(20.0, True)] # far from both
assert _warnings(notes) == [
"2 low-confidence notes near 0:10–0:11",
"1 low-confidence note near 0:20–0:20"]
+21 -3
View File
@@ -49,19 +49,37 @@ def make_pipeline_inputs(tmp_path, cover=None):
return inp, cfg
def test_package_copies_compatible_video(tmp_path, monkeypatch):
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
def test_package_remuxes_compatible_video_without_reencoding(tmp_path, monkeypatch):
calls = []
monkeypatch.setattr(package, "ffmpeg",
lambda *a: (calls.append(a), Path(a[-1]).write_bytes(b"m")))
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("webm", "vp9"))
ctx = make_ctx(tmp_path, cover=make_cover(tmp_path))
package.package_stage().run(ctx)
out = tmp_path / "out" / "A - T"
assert (out / "A - T.txt").exists()
assert (out / "A - T.mp3").exists()
assert (out / "A - T.webm").read_bytes() == b"v"
assert (out / "A - T.webm").exists()
video_call = next(c for c in calls if c[-1].endswith("A - T.webm"))
assert "-c" in video_call and "copy" in video_call, "the video was re-encoded"
img = Image.open(out / "A - T.jpg")
assert img.width == img.height
@pytest.mark.parametrize("probe,name", [(("webm", "vp9"), "A - T.webm"),
(("avi", "mpeg4"), "A - T.mp4")])
def test_package_strips_the_videos_audio_track(tmp_path, monkeypatch, probe, name):
"""#MP3 is the song's audio; a video that keeps its own plays as an echo."""
calls = []
monkeypatch.setattr(package, "ffmpeg",
lambda *a: (calls.append(a), Path(a[-1]).write_bytes(b"m")))
monkeypatch.setattr(package, "ffprobe_codec", lambda p: probe)
ctx = make_ctx(tmp_path, cover=make_cover(tmp_path))
package.package_stage().run(ctx)
video_call = next(c for c in calls if c[-1].endswith(name))
assert "-an" in video_call
def test_package_reencodes_and_rewrites_video_header(tmp_path, monkeypatch):
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("avi", "mpeg4"))
+46
View File
@@ -53,3 +53,49 @@ def test_read_warnings_is_empty_without_a_notes_file(tmp_path):
def test_read_warnings_reads_the_notes_file(tmp_path):
(tmp_path / "notes_sec.json").write_text(json.dumps({"warnings": ["w1", "w2"]}))
assert read_warnings(tmp_path) == ["w1", "w2"]
def test_gpu_is_released_after_every_stage(tmp_path, monkeypatch):
"""Not just at the end: a later stage may wait minutes on the Whisper server."""
from songtrace import models
cfg, inp, calls = make_cfg(tmp_path), make_inp(), []
monkeypatch.setattr(models, "release", lambda: calls.append("release"))
stages = [fake_stage("s1", calls), fake_stage("s2", calls)]
run_pipeline(inp, cfg, log=lambda m: None, stages=stages)
assert calls == ["s1", "release", "s2", "release"]
def test_gpu_is_released_when_a_stage_fails(tmp_path, monkeypatch):
import pytest
from songtrace import models
from songtrace.pipeline import PipelineError
cfg, inp, calls = make_cfg(tmp_path), make_inp(), []
monkeypatch.setattr(models, "release", lambda: calls.append("release"))
def boom(ctx):
raise PipelineError("boom")
stages = [Stage(name="s1", outputs=lambda ctx: [ctx.work / "s1.done"], run=boom)]
with pytest.raises(PipelineError):
run_pipeline(inp, cfg, log=lambda m: None, stages=stages)
assert calls == ["release"]
def test_a_cached_stage_does_not_pay_for_a_release(tmp_path, monkeypatch):
from songtrace import models
cfg, inp, calls = make_cfg(tmp_path), make_inp(), []
stages = [fake_stage("s1", calls)]
run_pipeline(inp, cfg, log=lambda m: None, stages=stages)
calls.clear()
monkeypatch.setattr(models, "release", lambda: calls.append("release"))
run_pipeline(inp, cfg, log=lambda m: None, stages=stages)
assert calls == []
def test_a_release_that_freed_memory_is_reported_in_the_log(tmp_path, monkeypatch):
from songtrace import models
cfg, inp, lines = make_cfg(tmp_path), make_inp(), []
monkeypatch.setattr(models, "release", lambda: 512 * 2 ** 20)
run_pipeline(inp, cfg, log=lines.append, stages=[fake_stage("s1", [])])
assert "[s1] released 512 MiB of GPU memory" in lines
def test_a_release_that_freed_nothing_stays_out_of_the_log(tmp_path, monkeypatch):
from songtrace import models
cfg, inp, lines = make_cfg(tmp_path), make_inp(), []
monkeypatch.setattr(models, "release", lambda: 0)
run_pipeline(inp, cfg, log=lines.append, stages=[fake_stage("s1", [])])
assert not any("released" in line for line in lines)
+18
View File
@@ -17,3 +17,21 @@ def test_pitch_stage_writes_npz(tmp_path, monkeypatch):
data = np.load(work / "pitch.npz")
assert np.allclose(data["f0"], 220.0)
assert data["times"].shape == data["conf"].shape
def test_mute_silence_drops_bleed_but_keeps_quiet_singing():
# 60 frames of singing around -30 dB, one quiet sung frame at -50 dB, and
# 40 frames of bleed at -60 dB: only the bleed is more than 25 dB below
# the song's loud level.
loudness = np.array([-30.0] * 60 + [-50.0] + [-60.0] * 40)
conf = np.full_like(loudness, 0.8)
pitch.mute_silence(conf, loudness)
assert (conf[:61] == 0.8).all()
assert (conf[61:] == 0.0).all()
def test_mute_silence_is_relative_to_the_songs_level():
loudness = np.array([-45.0] * 60 + [-75.0] * 40)
conf = np.full_like(loudness, 0.8)
pitch.mute_silence(conf, loudness)
assert (conf[:60] == 0.8).all() and (conf[60:] == 0.0).all()
+12 -1
View File
@@ -5,7 +5,7 @@ import pytest
from songtrace.config import Config
from songtrace.pipeline import STAGE_ORDER, PipelineError, SongInput
from songtrace.rebuild import (FIELD_STAGE, changed_fields, plan,
from songtrace.rebuild import (FIELD_STAGE, changed_fields, earliest, plan,
relocate_work, superseded_output)
@@ -124,3 +124,14 @@ def test_superseded_output_refuses_a_path_outside_the_output_root(tmp_path):
def test_superseded_output_refuses_the_output_root_itself(tmp_path):
root = tmp_path / "out"
assert superseded_output(str(root), str(root / "A - T"), root) is None
def test_earliest_picks_the_earlier_stage():
assert earliest("package", "separate") == "separate"
assert earliest("separate", "package") == "separate"
def test_earliest_ignores_unset_stages():
assert earliest(None, "grid") == "grid"
assert earliest("grid", None) == "grid"
assert earliest(None, None) is None
+15
View File
@@ -0,0 +1,15 @@
import shutil
import subprocess
from pathlib import Path
import pytest
def test_static_js_unit_tests():
"""The page's own logic, run under Node's test runner."""
node = shutil.which("node")
if node is None:
pytest.skip("node is not installed")
tests = sorted(str(p) for p in (Path(__file__).parent / "js").glob("*.test.mjs"))
proc = subprocess.run([node, "--test", *tests], capture_output=True, text=True)
assert proc.returncode == 0, proc.stdout + proc.stderr
+31 -2
View File
@@ -1,6 +1,7 @@
from songtrace.lyrics import normalize_lyrics
from songtrace.whisper_client import TranscriptSegment
from songtrace.stages.transcribe import classify_lyrics_match, lyrics_similarity, vocal_sections
from songtrace.stages.transcribe import (classify_lyrics_match, lyrics_similarity,
recovered_spans, vocal_sections)
def seg(s, e, t=""):
return TranscriptSegment(start=s, end=e, text=t)
@@ -53,7 +54,7 @@ def test_classify_lyrics_match_keeps_partially_matched_lines():
def test_classify_lyrics_match_scopes_missing_span_to_adlib_not_whole_section():
# matched line and an unmatched ad-lib close enough together that
# vocal_sections would merge them into one section; missing_spans must
# stay tight around the ad-lib so the matched line keeps its notes.
# stay tight around the ad-lib so the review marks the right place.
ref = normalize_lyrics("it's one two three\nfour five six seven eight")
segs = [seg(1, 4, "it's one two three four five six seven eight"),
seg(6, 8, "completely unrelated ad lib")]
@@ -64,3 +65,31 @@ def test_classify_lyrics_match_scopes_missing_span_to_adlib_not_whole_section():
assert missing == [(5.0, 9.0)]
for a, b in missing:
assert not (a <= 1.0 < b) # matched line's start (1.0) untouched
THREE_LINES = "one two three\nfour five six\nseven eight nine"
def test_lines_whisper_missed_are_kept_when_singing_went_untranscribed():
ref = normalize_lyrics(THREE_LINES)
segs = [seg(0, 2, "one two three"), seg(20, 22, "seven eight nine")]
assert classify_lyrics_match(segs, ref)[0] == [1]
dropped, _ = classify_lyrics_match(segs, ref, unheard=[(8.0, 14.0)])
assert dropped == []
def test_untranscribed_singing_elsewhere_does_not_keep_a_line():
ref = normalize_lyrics(THREE_LINES)
segs = [seg(0, 2, "one two three"), seg(20, 22, "seven eight nine")]
dropped, _ = classify_lyrics_match(segs, ref, unheard=[(30.0, 35.0)])
assert dropped == [1]
def test_recovered_spans_are_the_singing_between_heard_neighbours():
ref = normalize_lyrics(THREE_LINES)
segs = [seg(10, 12, "four five six"), seg(20, 22, "seven eight nine")]
unheard = [(3.0, 9.0), (14.0, 18.0), (30.0, 35.0)]
# line 0 comes before anything heard, so only the singing before line 1
# can stand in for it; the other spans have no missing line around them
assert recovered_spans(segs, ref, unheard) == [(3.0, 9.0)]
assert classify_lyrics_match(segs, ref, unheard=unheard)[0] == []
+5 -5
View File
@@ -10,8 +10,8 @@ def load(name):
return (FIXTURES / name).read_text(encoding="utf-8", errors="replace")
def test_parse_headers_and_notes():
song = parse_ultrastar(load("sanctuary.txt"))
assert get_header(song, "artist") == "Utada Hikaru"
song = parse_ultrastar(load("chart_crlf.txt"))
assert get_header(song, "artist") == "Test Artist"
assert parse_bpm(get_header(song, "BPM")) == 217.98
first = song.body[0]
assert first == Note(kind=":", beat=0, length=3, pitch=16, text="ah")
@@ -19,16 +19,16 @@ def test_parse_headers_and_notes():
assert any(n.kind == "F" for n in song.body if isinstance(n, Note))
def test_note_text_keeps_leading_space():
song = parse_ultrastar(load("sanctuary.txt"))
song = parse_ultrastar(load("chart_crlf.txt"))
notes = [n for n in song.body if isinstance(n, Note)]
assert notes[1].text == " ah"
def test_negative_pitch():
song = parse_ultrastar(load("sweet_child.txt"))
song = parse_ultrastar(load("chart_latin1.txt"))
assert any(n.pitch == -9 for n in song.body if isinstance(n, Note))
def test_round_trip():
for name in ("sweet_child.txt", "sanctuary.txt"):
for name in ("chart_latin1.txt", "chart_crlf.txt"):
song = parse_ultrastar(load(name))
again = parse_ultrastar(serialize_ultrastar(song))
assert again == song
+728 -12
View File
@@ -584,11 +584,19 @@ def _submit_body(html):
return html[start:html.index("\n});\n", start)]
def _listener_body(html, prefix):
start = html.index(prefix)
return html[start:html.index("\n});\n", start)]
def _rebuild_flaws(html):
"""Ways rebuild mode could quietly stop working."""
flaws, submit = [], _submit_body(html)
if "rebuildOf ?" not in submit or "/rebuild" not in submit:
flaws.append("the form always posts to /api/jobs, so edits create a new song")
if "form.reset()" in submit or "exitRebuild()" in submit:
flaws.append("a successful submission empties the form, so correcting "
"or varying the song just sent means retyping it")
actions = _fn_body(html, "renderActions")
if not re.search(r"if \(.*dataset\.jobId === job\.id.*\) return;", actions):
@@ -596,9 +604,39 @@ def _rebuild_flaws(html):
if '"failed"' not in actions or '"done"' not in actions:
flaws.append("the action row does not key off the job status")
start = _fn_body(html, "startRebuild")
if "/input" not in start or "form.elements[" not in start:
load = _fn_body(html, "loadInputs")
if "/input" not in load or "form.elements[" not in load:
flaws.append("rebuild mode does not prefill the form from the job's input")
select = _fn_body(html, "selectJob")
if "loadInputs(" not in select:
flaws.append("selecting a queue entry does not bring its inputs back up")
if "formDirty" not in select or "confirm(" not in select:
flaws.append("selecting a queue entry discards unsaved edits without asking")
if "if (rebuildOf === id) exitRebuild();" not in _fn_body(html, "deleteJob"):
flaws.append("deleting the loaded job leaves the form pointed at it")
# selecting a queue entry loads it, so the sidebar button is now the only
# way back to a blank form
create = _listener_body(html, 'createSong.addEventListener("click"')
if "exitRebuild()" not in create:
flaws.append("the queue's CREATE SONG button does not clear the form")
if "formDirty" not in create or "confirm(" not in create:
flaws.append("the queue's CREATE SONG button discards edits without asking")
if "clearDetail(" not in create:
flaws.append("CREATE SONG empties the form but leaves the previous "
"song's review and player sitting under it")
if "composing" not in create or "composing" not in _fn_body(html, "refreshJobs"):
flaws.append("the blank page CREATE SONG made is selected back over by "
"the next poll of the queue")
mode = _fn_body(html, "setFormMode")
if 'querySelector("details").open' not in mode:
flaws.append("prefilled genre/year/language/bpm stay hidden behind the "
"closed Advanced summary, so they rebuild unseen")
if "submitButton.disabled" not in _fn_body(html, "setSubmitLock"):
flaws.append("the form lets you submit a rebuild the API will answer 409")
return flaws
@@ -631,7 +669,7 @@ def test_rebuild_full_and_delete_controls_are_wired_up():
def _empty_state_flaws(html):
refresh = _fn_body(html, "refreshJobs")
if "clearDetail()" not in refresh:
if "clearDetail(" not in refresh:
return ["refreshJobs never resets the panel when no job is selected"]
return []
@@ -640,6 +678,54 @@ def test_review_panel_resets_when_no_job_is_selected():
assert _empty_state_flaws(_index_html()) == []
def _playback_flaws(html):
"""Ways the review could stop tracking the media element."""
flaws = []
source = _fn_body(html, "setPlayerSource")
if "media/audio" not in source:
flaws.append("the player is not pointed at the job's packaged audio, "
"which is the file the game plays and the notes track")
if "media/video" not in source:
flaws.append("the packaged video is never shown alongside the audio")
# the mp3 and the video carry the same recording; playing both is an echo
if not re.search(r"<video[^>]*\bmuted\b", html):
flaws.append("the background video is not muted, so its own audio "
"plays on top of the mp3")
pump = _fn_body(html, "pumpPlayback")
if "slaveVideo(" not in pump:
flaws.append("the picture is never pulled back onto the audio clock, "
"so it drifts out of sync over a long song")
if "requestAnimationFrame" not in _fn_body(html, "pumpPlayback"):
flaws.append("sync is not driven by the frame loop, so the playhead "
"steps at timeupdate's ~4Hz instead of following the audio")
if "media.paused" not in _fn_body(html, "focusActive"):
flaws.append("the view is scrolled even while paused, fighting a reader "
"who scrolled away to look at something else")
sync = _fn_body(html, "syncPlayback")
if "clearEntry(" not in sync:
flaws.append("the line the playhead left keeps its highlight")
clear = _fn_body(html, "clearEntry")
for name in ("review-word-sung", "review-word-active", "review-line-active"):
if name not in clear:
flaws.append(f"{name} is never taken off a line the playhead left")
# every branch that gives up on rendering a review has to unload the media
# too, or the previous song stays loaded and playable under the new one
render = _fn_body(html, "renderLyricsReview")
if render.count("resetPlayer()") < render.count("emptyNote("):
flaws.append("a review the page cannot render leaves the previous "
"song's media loaded in the player")
return flaws
def test_review_playback_tracks_the_media_element():
assert _playback_flaws(_index_html()) == []
# each mutation is a plausible regression; the checker above must reject all of them
MUTATIONS = [
(_focus_flaws, "queue: card moved unconditionally",
@@ -672,9 +758,52 @@ MUTATIONS = [
'if (!confirm("Delete this song? This removes all its files and cannot be undone.")) return;',
""),
(_rebuild_full_and_delete_flaws, "rebuild-full: no longer offered on a done job",
'["edit", "rebuild-full", "delete"]', '["edit", "delete"]'),
'["rebuild-full", "delete"]', '["delete"]'),
(_rebuild_flaws, "prefill: selecting a queue entry no longer loads its inputs",
" if (reload) await loadInputs(id, JOBS.find(j => j.id === id)?.status);\n", ""),
(_rebuild_flaws, "prefill: unsaved edits discarded without asking",
' if (reload && formDirty && !confirm("Discard your unsaved edits to this form?")) return;\n',
""),
(_rebuild_flaws, "prefill: deleting the loaded job leaves the form pointed at it",
" if (rebuildOf === id) exitRebuild();\n", ""),
(_rebuild_flaws, "prefill: advanced fields stay hidden behind a closed summary",
'form.querySelector("details").open =', "const unusedAdvancedOpen ="),
(_rebuild_flaws, "prefill: an in-progress job can be submitted for rebuild anyway",
"submitButton.disabled = locked;", ""),
(_rebuild_flaws, "submit: a successful submission empties the form again",
" rebuildOf = null;\n formDirty = false;\n setFormMode(null, null);\n refreshJobs();",
" exitRebuild();\n refreshJobs();"),
(_rebuild_flaws, "create: the queue button no longer clears the form",
" exitRebuild();\n clearDetail(\"nothing selected\");\n", ""),
(_rebuild_flaws, "create: the review is left behind under a blank form",
' clearDetail("nothing selected");\n', ""),
(_rebuild_flaws, "create: the next poll selects a song back into the blank page",
" composing = true;\n", ""),
(_rebuild_flaws, "create: the queue button discards edits without asking",
' if (formDirty && !confirm("Discard your unsaved edits to this form?")) return;\n composing = true;',
" composing = true;"),
(_playback_flaws, "playback: sync falls back to the timeupdate event's ~4Hz",
"syncFrame = media.paused ? null : requestAnimationFrame(pumpPlayback);",
"syncFrame = null;"),
(_playback_flaws, "playback: the view is scrolled even while paused",
"if (!activeEntry || media.paused) return;", "if (!activeEntry) return;"),
(_playback_flaws, "playback: the line the playhead left keeps its highlight",
" if (activeEntry) clearEntry(activeEntry);\n", ""),
(_playback_flaws, "playback: a word stays lit after the playhead moves on",
'w.el.classList.remove("review-word-sung", "review-word-active");',
'w.el.classList.remove("review-word-sung");'),
(_playback_flaws, "playback: an unrenderable review leaves the old song loaded",
" resetPlayer();\n", ""),
(_playback_flaws, "playback: the player is not pointed at the packaged audio",
'const src = available?.audio ? `/api/jobs/${job.id}/media/audio` : "";',
'const src = "";'),
(_playback_flaws, "playback: the background video plays its own audio too",
'<video id="player-video" muted playsinline',
'<video id="player-video" playsinline'),
(_playback_flaws, "playback: the picture is never re-synced to the audio",
" slaveVideo(false);\n", ""),
(_empty_state_flaws, "empty state: clearDetail no longer wired into refreshJobs",
"if (selectedId) refreshDetail();\n else clearDetail();",
"if (selectedId) refreshDetail();\n else clearDetail(composing ? \"nothing selected\" : undefined);",
"if (selectedId) refreshDetail();"),
]
@@ -705,13 +834,13 @@ def test_narrow_stage_labels_stay_in_the_accessibility_tree():
def test_rebuild_mode_reuses_the_one_form():
html = _index_html()
assert html.count("<form") == 1
assert html.count('name="lyrics"') == 1 # one song form, not a rebuild copy
assert _rebuild_flaws(html) == []
def test_index_has_rebuild_hooks():
html = _index_html()
for hook in ("review-actions", "review-lineage", "cancel-rebuild",
for hook in ("review-actions", "review-lineage", "create-song",
'data-keep="video"', 'data-keep="cover"'):
assert hook in html, hook
@@ -1283,13 +1412,16 @@ def test_review_endpoint_groups_notes_by_line(tmp_path):
wait_status(client, job_id, "done")
body = client.get(f"/api/jobs/{job_id}/review").json()
assert body["lines"] == [
{"index": 0, "text": "hi", "dropped": False,
"notes": [{"start": 1.0, "end": 1.4, "pitch": 0, "low_confidence": False}],
{"index": 0, "text": "hi", "dropped": False, "edited": False,
"notes": [{"start": 1.0, "end": 1.4, "pitch": 0, "low_confidence": False,
"text": "hi", "kind": ":"}],
"words": []},
{"index": 1, "text": "there", "dropped": False,
"notes": [{"start": 5.0, "end": 5.3, "pitch": 2, "low_confidence": True}],
{"index": 1, "text": "there", "dropped": False, "edited": False,
"notes": [{"start": 5.0, "end": 5.3, "pitch": 2, "low_confidence": True,
"text": "there", "kind": ":"}],
"words": []},
{"index": 2, "text": "gone", "dropped": True, "edited": False, "notes": [],
"words": []},
{"index": 2, "text": "gone", "dropped": True, "notes": [], "words": []},
]
assert body["missing_spans"] == [{"start": 8.0, "end": 9.5}]
@@ -1358,3 +1490,587 @@ def test_review_missing_when_pipeline_never_wrote_notes(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
job_id = make_done_job(client)
assert client.get(f"/api/jobs/{job_id}/review").status_code == 404
# --- review playback -------------------------------------------------------
MEDIA_FILES = {"A - T.mp3": b"ID3audio", "A - T.mp4": b"\0\0\0\x18ftypvideo",
"A - T.jpg": b"\xff\xd8cover"}
MEDIA_HEADERS = [("TITLE", "T"), ("ARTIST", "A"), ("MP3", "A - T.mp3"),
("COVER", "A - T.jpg"), ("VIDEO", "A - T.mp4")]
def media_runner_factory(files=None, headers=None, write_txt=True):
"""A run that leaves behind a song folder, the way package does."""
def runner(inp, cfg, log=print, force_from=None, stages=None):
slug = song_slug(inp)
out = cfg.output_dir / slug
out.mkdir(parents=True, exist_ok=True)
for name, body in (MEDIA_FILES if files is None else files).items():
(out / name).write_bytes(body)
if write_txt:
rows = [f"#{k}:{v}" for k, v in
(MEDIA_HEADERS if headers is None else headers)]
(out / f"{slug}.txt").write_text("\n".join(rows) + "\n: 0 4 0 la\nE\n",
encoding="utf-8")
return PipelineResult(output_dir=out)
return runner
def test_media_serves_the_files_the_song_txt_names(tmp_path):
client, _ = make_client(tmp_path, media_runner_factory())
job_id = make_done_job(client)
for kind, name in (("audio", "A - T.mp3"), ("video", "A - T.mp4"),
("cover", "A - T.jpg")):
resp = client.get(f"/api/jobs/{job_id}/media/{kind}")
assert resp.status_code == 200, kind
assert resp.content == MEDIA_FILES[name], kind
def test_media_serves_whatever_the_header_was_rewritten_to(tmp_path):
"""package transcodes to .mp4 and rewrites #VIDEO; the extension is not fixed."""
files = {**MEDIA_FILES, "A - T.webm": b"transcoded"}
headers = [(k, "A - T.webm" if k == "VIDEO" else v) for k, v in MEDIA_HEADERS]
client, _ = make_client(tmp_path, media_runner_factory(files, headers))
job_id = make_done_job(client)
assert client.get(f"/api/jobs/{job_id}/media/video").content == b"transcoded"
def test_media_supports_range_requests(tmp_path):
"""Without 206 the browser cannot seek, which is the point of the player."""
client, _ = make_client(tmp_path, media_runner_factory())
job_id = make_done_job(client)
resp = client.get(f"/api/jobs/{job_id}/media/video", headers={"Range": "bytes=0-3"})
assert resp.status_code == 206
assert resp.headers["content-range"] == f"bytes 0-3/{len(MEDIA_FILES['A - T.mp4'])}"
assert resp.content == MEDIA_FILES["A - T.mp4"][:4]
def test_media_of_an_unknown_kind_is_404(tmp_path):
client, _ = make_client(tmp_path, media_runner_factory())
job_id = make_done_job(client)
assert client.get(f"/api/jobs/{job_id}/media/instrumental").status_code == 404
def test_media_refuses_a_header_pointing_outside_the_song_folder(tmp_path):
secret = tmp_path / "secret.txt"
secret.write_text("not yours")
headers = [(k, "../../secret.txt" if k == "MP3" else v) for k, v in MEDIA_HEADERS]
client, _ = make_client(tmp_path, media_runner_factory(headers=headers))
job_id = make_done_job(client)
assert client.get(f"/api/jobs/{job_id}/media/audio").status_code == 404
def test_media_refuses_an_absolute_header_path(tmp_path):
secret = tmp_path / "secret.txt"
secret.write_text("not yours")
headers = [(k, str(secret) if k == "MP3" else v) for k, v in MEDIA_HEADERS]
client, _ = make_client(tmp_path, media_runner_factory(headers=headers))
job_id = make_done_job(client)
assert client.get(f"/api/jobs/{job_id}/media/audio").status_code == 404
def test_media_404s_when_the_named_file_is_missing(tmp_path):
client, _ = make_client(tmp_path, media_runner_factory(files={}))
job_id = make_done_job(client)
assert client.get(f"/api/jobs/{job_id}/media/audio").status_code == 404
def test_media_404s_when_the_header_was_dropped(tmp_path):
"""package strips #COVER when there is no usable cover image."""
headers = [(k, v) for k, v in MEDIA_HEADERS if k != "COVER"]
client, _ = make_client(tmp_path, media_runner_factory(headers=headers))
job_id = make_done_job(client)
assert client.get(f"/api/jobs/{job_id}/media/cover").status_code == 404
assert client.get(f"/api/jobs/{job_id}/media/audio").status_code == 200
def test_media_404s_when_there_is_no_song_txt(tmp_path):
client, _ = make_client(tmp_path, media_runner_factory(write_txt=False))
job_id = make_done_job(client)
assert client.get(f"/api/jobs/{job_id}/media/audio").status_code == 404
def test_media_of_a_failed_job_is_404(tmp_path):
def bad_runner(*a, **kw):
raise PipelineError("boom")
client, _ = make_client(tmp_path, bad_runner)
job_id = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"}).json()["id"]
wait_status(client, job_id, "failed")
assert client.get(f"/api/jobs/{job_id}/media/audio").status_code == 404
def test_media_of_a_running_job_is_409(tmp_path):
release = threading.Event()
def blocking(inp, cfg, log=print, force_from=None, stages=None):
release.wait(5)
return PipelineResult(output_dir=cfg.output_dir / "X")
client, _ = make_client(tmp_path, blocking)
job_id = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"}).json()["id"]
try:
wait_status(client, job_id, "running")
assert client.get(f"/api/jobs/{job_id}/media/audio").status_code == 409
finally:
release.set()
wait_status(client, job_id, "done")
def test_media_of_an_unknown_job_is_404(tmp_path):
client, _ = make_client(tmp_path, media_runner_factory())
assert client.get("/api/jobs/nope/media/audio").status_code == 404
def test_review_reports_which_media_the_player_can_load(tmp_path):
notes = [{"start": 1.0, "duration": 0.4, "pitch": 0, "text": "hi",
"low_confidence": False, "line_index": 0}]
review = review_runner_factory(notes)
media = media_runner_factory(headers=[(k, v) for k, v in MEDIA_HEADERS
if k != "COVER"])
def runner(inp, cfg, **kw):
media(inp, cfg, **kw)
return review(inp, cfg, **kw)
client, _ = make_client(tmp_path, runner)
job_id = make_done_job(client, lyrics="hi")
body = client.get(f"/api/jobs/{job_id}/review").json()
assert body["media"] == {"audio": True, "video": True, "cover": False}
# --- restarting a rebuild from a chosen stage ------------------------------
def force_from_runner_factory(seen):
def runner(inp, cfg, log=print, force_from=None, stages=None):
seen.append(force_from)
out = cfg.output_dir / song_slug(inp)
out.mkdir(parents=True, exist_ok=True)
return PipelineResult(output_dir=out)
return runner
def rebuild_with(client, job_id, **extra):
data = {"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"}
data.update(extra)
return client.post(f"/api/jobs/{job_id}/rebuild", data=data)
def test_rebuild_without_edits_restarts_from_the_stage_asked_for(tmp_path):
"""The point of the control: a change in songtrace invalidates no field."""
seen = []
client, _ = make_client(tmp_path, force_from_runner_factory(seen))
parent = make_done_job(client)
resp = rebuild_with(client, parent, force_from="package")
assert resp.status_code == 201
wait_status(client, resp.json()["id"], "done")
assert seen[-1] == "package"
def test_rebuild_without_a_stage_still_plans_from_the_edit(tmp_path):
seen = []
client, _ = make_client(tmp_path, force_from_runner_factory(seen))
parent = make_done_job(client)
resp = rebuild_with(client, parent, lyrics="different words")
wait_status(client, resp.json()["id"], "done")
assert seen[-1] == "align"
def test_an_edit_wins_when_it_invalidates_more_than_the_stage_asked_for(tmp_path):
"""A choice may widen what is redone, never narrow it below correctness."""
seen = []
client, _ = make_client(tmp_path, force_from_runner_factory(seen))
parent = make_done_job(client)
resp = rebuild_with(client, parent, lyrics="different words",
force_from="package")
wait_status(client, resp.json()["id"], "done")
assert seen[-1] == "align"
def test_the_stage_asked_for_wins_when_it_is_earlier_than_the_edit(tmp_path):
seen = []
client, _ = make_client(tmp_path, force_from_runner_factory(seen))
parent = make_done_job(client)
resp = rebuild_with(client, parent, title="Renamed", force_from="separate")
wait_status(client, resp.json()["id"], "done")
assert seen[-1] == "separate"
def test_rebuild_rejects_a_stage_that_is_not_in_the_pipeline(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
parent = make_done_job(client)
resp = rebuild_with(client, parent, force_from="deechoing")
assert resp.status_code == 400
assert "deechoing" in resp.json()["detail"]
def test_a_blank_stage_is_the_same_as_not_asking(tmp_path):
seen = []
client, _ = make_client(tmp_path, force_from_runner_factory(seen))
parent = make_done_job(client)
resp = rebuild_with(client, parent, force_from="")
wait_status(client, resp.json()["id"], "done")
assert seen[-1] is None
def test_rebuild_from_scratch_still_restarts_at_the_first_stage(tmp_path):
seen = []
client, _ = make_client(tmp_path, force_from_runner_factory(seen))
parent = make_done_job(client)
resp = client.post(f"/api/jobs/{parent}/rebuild-full")
wait_status(client, resp.json()["id"], "done")
assert seen[-1] == STAGE_ORDER[0]
def test_the_restart_control_offers_every_stage():
html = _index_html()
assert 'name="force_from"' in html
stages = _fn_body(html, "loadStages")
assert "force_from" in stages and "STAGES.map" in stages
mode = _fn_body(html, "setFormMode")
assert "restart-field" in mode, "the control is shown outside rebuild mode"
def test_a_textareas_crlf_does_not_read_as_a_lyrics_edit(tmp_path):
"""Browsers submit textareas with CRLF; the stored lyrics use LF."""
seen = []
client, _ = make_client(tmp_path, force_from_runner_factory(seen))
parent = make_done_job(client, lyrics="one\ntwo")
resp = rebuild_with(client, parent, lyrics="one\r\ntwo")
wait_status(client, resp.json()["id"], "done")
assert seen[-1] is None, "the rebuild redid the alignment for nothing"
body = client.get(f"/api/jobs/{resp.json()['id']}/input").json()
assert body["lyrics"] == "one\ntwo"
def test_submitting_crlf_lyrics_stores_them_with_lf(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
job_id = make_done_job(client, lyrics="one\r\ntwo")
assert client.get(f"/api/jobs/{job_id}/input").json()["lyrics"] == "one\ntwo"
def test_rebuild_all_rebuilds_every_song_from_the_stage(tmp_path):
seen = []
client, _ = make_client(tmp_path, capture_runner_factory(seen))
first = make_done_job(client, title="One")
second = make_done_job(client, title="Two")
resp = client.post("/api/rebuild-all", data={"force_from": "pitch"})
assert resp.status_code == 201
body = resp.json()
assert len(body["ids"]) == 2 and body["skipped"] == []
jobs = [wait_status(client, i, "done") for i in body["ids"]]
assert {j["rebuilt_from"] for j in jobs} == {first, second}
assert [s["force_from"] for s in seen[2:]] == ["pitch", "pitch"]
by_title = lambda runs: sorted((r["inp"] for r in runs), key=lambda i: i.title)
assert by_title(seen[2:]) == by_title(seen[:2])
def test_rebuild_all_skips_jobs_that_were_already_rebuilt(tmp_path):
client, _ = make_client(tmp_path, capture_runner_factory([]))
parent = make_done_job(client)
renamed = client.post(f"/api/jobs/{parent}/rebuild",
data=rebuild_form(title="Renamed")).json()["id"]
wait_status(client, renamed, "done")
body = client.post("/api/rebuild-all", data={"force_from": "notes"}).json()
assert len(body["ids"]) == 1
job = wait_status(client, body["ids"][0], "done")
assert job["rebuilt_from"] == renamed
def test_rebuild_all_takes_the_latest_job_for_a_song(tmp_path):
client, _ = make_client(tmp_path, capture_runner_factory([]))
make_done_job(client)
latest = make_done_job(client)
body = client.post("/api/rebuild-all", data={"force_from": "grid"}).json()
assert len(body["ids"]) == 1
assert wait_status(client, body["ids"][0], "done")["rebuilt_from"] == latest
def test_rebuild_all_skips_songs_in_progress(tmp_path):
gate = threading.Event()
def blocking_runner(inp, cfg, log=print, force_from=None, stages=None):
gate.wait(timeout=5)
return PipelineResult(output_dir=cfg.output_dir / song_slug(inp))
client, _ = make_client(tmp_path, blocking_runner)
try:
busy = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"}).json()["id"]
body = client.post("/api/rebuild-all", data={"force_from": "pitch"}).json()
assert body == {"ids": [], "skipped": [{"id": busy, "reason": "still in progress"}]}
finally:
gate.set()
def test_rebuild_all_from_acquire_skips_a_song_whose_upload_is_gone(tmp_path):
client, _ = make_client(tmp_path, capture_runner_factory([]))
job_id = client.post("/api/jobs", data={"title": "T", "artist": "A", "lyrics": "x"},
files={"video": ("v.mp4", io.BytesIO(b"data"), "video/mp4")}
).json()["id"]
wait_status(client, job_id, "done")
shutil.rmtree(tmp_path / "work" / "uploads", ignore_errors=True)
body = client.post("/api/rebuild-all", data={"force_from": "acquire"}).json()
assert body["ids"] == []
assert body["skipped"][0]["id"] == job_id
assert "upload" in body["skipped"][0]["reason"]
def test_rebuild_all_rejects_an_unknown_stage(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
make_done_job(client)
assert client.post("/api/rebuild-all", data={"force_from": "nope"}).status_code == 400
assert client.post("/api/rebuild-all", data={}).status_code == 422
def test_rebuild_all_lists_the_current_job_of_each_song(tmp_path):
client, _ = make_client(tmp_path, capture_runner_factory([]))
one = make_done_job(client, title="One")
two = make_done_job(client, title="Two")
rerun = client.post(f"/api/jobs/{one}/rerun").json()["id"]
wait_status(client, rerun, "done")
songs = client.get("/api/rebuild-all").json()
assert [(s["id"], s["title"], s["rebuilt_from"]) for s in songs] == [
(rerun, "One", one), (two, "Two", None)]
assert all(s["status"] == "done" and s["finished_at"] for s in songs)
def test_rebuild_all_rebuilds_only_the_songs_selected(tmp_path):
client, _ = make_client(tmp_path, capture_runner_factory([]))
make_done_job(client, title="One")
two = make_done_job(client, title="Two")
resp = client.post("/api/rebuild-all", data={"force_from": "pitch", "ids": [two]})
body = resp.json()
assert len(body["ids"]) == 1 and body["skipped"] == []
assert wait_status(client, body["ids"][0], "done")["rebuilt_from"] == two
def test_rebuild_all_reports_a_selected_job_that_was_since_rebuilt(tmp_path):
client, _ = make_client(tmp_path, capture_runner_factory([]))
old = make_done_job(client)
rerun = client.post(f"/api/jobs/{old}/rerun").json()["id"]
wait_status(client, rerun, "done")
body = client.post("/api/rebuild-all", data={"force_from": "pitch", "ids": [old]}).json()
assert body == {"ids": [], "skipped": [
{"id": old, "reason": "no longer the current job for its song"}]}
def test_a_job_is_only_reported_finished_once_it_has_finished(tmp_path, monkeypatch):
"""Done must mean done: a slow upload cleanup used to show a 'done' job
with its upload still there and no finished_at."""
from songtrace import web
real_cleanup = web._cleanup_uploads
def slow_cleanup(*args):
time.sleep(0.3)
real_cleanup(*args)
monkeypatch.setattr(web, "_cleanup_uploads", slow_cleanup)
client, cfg = make_client(tmp_path, ok_runner)
job_id = client.post("/api/jobs", data={"title": "T", "artist": "A", "lyrics": "x"},
files={"video": ("v.mp4", io.BytesIO(b"data"), "video/mp4")}
).json()["id"]
job = wait_status(client, job_id, "done")
assert job["finished_at"] is not None
assert not (cfg.work_dir / "uploads" / job_id / "v.mp4").exists()
def chart_runner(inp, cfg, log=print, force_from=None, stages=None):
"""Leaves what a real build does for the editor: generated notes and a
chart in the work dir, and the packaged chart."""
from songtrace.grid import to_ultrastar
from songtrace.ultrastar import serialize_ultrastar
work = cfg.work_dir / song_slug(inp)
work.mkdir(parents=True, exist_ok=True)
notes = [{"start": 1.0, "duration": 0.5, "pitch": 0, "text": "one",
"low_confidence": False, "line_index": 0},
{"start": 3.0, "duration": 0.5, "pitch": 2, "text": "three",
"low_confidence": True, "line_index": 1}]
warnings = ["1 low-confidence note near 0:03–0:03"]
(work / "notes_sec.json").write_text(json.dumps(
{"notes": notes, "breaks": [2.0], "warnings": warnings}))
chart = serialize_ultrastar(to_ultrastar(notes, [2.0], 300.0, [("TITLE", inp.title)]))
(work / "song.txt").write_text(chart, encoding="utf-8")
out = cfg.output_dir / song_slug(inp)
out.mkdir(parents=True, exist_ok=True)
(out / f"{song_slug(inp)}.txt").write_text(chart, encoding="utf-8")
return PipelineResult(output_dir=out, warnings=warnings)
def make_charted_job(client, lyrics="one\nthree"):
job_id = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": lyrics, "youtube_url": "http://y"}).json()["id"]
wait_status(client, job_id, "done")
return job_id
EDIT = {"notes": [{"start": 3.1, "duration": 0.5, "pitch": 9, "text": "three",
"kind": "*"}]}
def test_saving_a_line_rewrites_both_charts(tmp_path):
from songtrace.ultrastar import Note, parse_ultrastar
client, cfg = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client)
resp = client.put(f"/api/jobs/{job_id}/lines/1", json=EDIT)
assert resp.status_code == 200
line = resp.json()["lines"][1]
assert line["edited"] and line["notes"][0]["kind"] == "*" and line["notes"][0]["pitch"] == 9
assert resp.json()["grid"] == {"bpm": 300.0, "gap_ms": 1000.0}
for txt in (cfg.work_dir / "A - T" / "song.txt", cfg.output_dir / "A - T" / "A - T.txt"):
notes = [n for n in parse_ultrastar(txt.read_text(encoding="utf-8")).body
if isinstance(n, Note)]
assert [(n.kind, n.pitch) for n in notes] == [(":", 0), ("*", 9)], txt
# the fixed line was the one being warned about
assert client.get(f"/api/jobs/{job_id}").json()["warnings"] == []
def test_reverting_a_line_returns_it_to_the_generated_notes(tmp_path):
client, _ = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client)
client.put(f"/api/jobs/{job_id}/lines/1", json=EDIT)
resp = client.delete(f"/api/jobs/{job_id}/lines/1")
assert resp.status_code == 200
line = resp.json()["lines"][1]
assert not line["edited"] and line["notes"][0]["pitch"] == 2
assert client.delete(f"/api/jobs/{job_id}/lines/1").status_code == 404
def test_line_edits_are_checked(tmp_path):
client, _ = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client)
assert client.put(f"/api/jobs/{job_id}/lines/5", json=EDIT).status_code == 404
bad = {"notes": [{**EDIT["notes"][0], "kind": "X"}]}
assert client.put(f"/api/jobs/{job_id}/lines/1", json=bad).status_code == 400
assert client.put(f"/api/jobs/{job_id}/lines/1", json={"x": 1}).status_code == 400
def test_a_song_cannot_be_edited_while_it_is_being_rebuilt(tmp_path):
gate = threading.Event()
def runner(inp, cfg, log=print, force_from=None, stages=None):
if force_from:
gate.wait(timeout=5)
return chart_runner(inp, cfg)
client, _ = make_client(tmp_path, runner)
try:
job_id = make_charted_job(client)
client.post(f"/api/jobs/{job_id}/rebuild-full")
assert client.put(f"/api/jobs/{job_id}/lines/1", json=EDIT).status_code == 409
finally:
gate.set()
def test_a_stale_edit_can_be_discarded(tmp_path):
from songtrace import edits
client, cfg = make_client(tmp_path, chart_runner)
first = make_charted_job(client)
client.put(f"/api/jobs/{first}/lines/1", json=EDIT)
# the same song again with that line's words changed
second = make_charted_job(client, lyrics="one\nthree more")
work = cfg.work_dir / "A - T"
assert len(edits.load(work, [["one"], ["three", "more"]])["stale"]) == 1
resp = client.delete(f"/api/jobs/{second}/stale/0")
assert resp.status_code == 200 and resp.json()["stale"] == []
assert not (work / edits.EDITS_FILE).exists()
assert client.delete(f"/api/jobs/{second}/stale/0").status_code == 404
def test_pitch_curve_is_in_note_semitones_with_gaps_where_unvoiced(tmp_path):
import numpy as np
def runner(inp, cfg, log=print, force_from=None, stages=None):
result = chart_runner(inp, cfg)
times = np.arange(0, 0.1, 0.01)
f0 = np.array([440.0] * 4 + [261.63] * 4 + [0.0] * 2) # A4, C4, nothing
conf = np.array([0.9] * 4 + [0.9, 0.9, 0.1, 0.1] + [0.9] * 2)
np.savez(cfg.work_dir / song_slug(inp) / "pitch.npz", times=times, f0=f0, conf=conf)
return result
client, _ = make_client(tmp_path, runner)
job_id = make_charted_job(client)
body = client.get(f"/api/jobs/{job_id}/pitch").json()
assert body["start"] == 0.0 and body["step"] == pytest.approx(0.02)
assert body["pitch"][:2] == [9.0, 9.0] # A4 is 9 above C4
assert body["pitch"][2] == pytest.approx(0.0, abs=0.01) # C4
assert body["pitch"][3:] == [None, None] # unsure, then silent
def test_pitch_curve_is_404_without_a_pitch_track(tmp_path):
client, _ = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client)
assert client.get(f"/api/jobs/{job_id}/pitch").status_code == 404
def _chart_header(path, key):
from songtrace.ultrastar import get_header, parse_ultrastar
return get_header(parse_ultrastar(path.read_text(encoding="utf-8")), key)
def test_setting_the_grid_recharts_and_survives_line_saves(tmp_path):
client, cfg = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client)
resp = client.put(f"/api/jobs/{job_id}/grid", json={"bpm": 240, "gap_ms": 900})
assert resp.status_code == 200
body = resp.json()
assert body["grid"] == {"bpm": 240.0, "gap_ms": 900.0} and body["grid_edited"]
packaged = cfg.output_dir / "A - T" / "A - T.txt"
assert (_chart_header(packaged, "BPM"), _chart_header(packaged, "GAP")) == ("240", "900")
client.put(f"/api/jobs/{job_id}/lines/1", json=EDIT)
assert (_chart_header(packaged, "BPM"), _chart_header(packaged, "GAP")) == ("240", "900")
def test_resetting_the_grid_returns_to_the_pipelines_tempo(tmp_path):
client, cfg = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client)
(cfg.work_dir / "A - T" / "tempo.json").write_text(json.dumps({"bpm": 300.0}))
client.put(f"/api/jobs/{job_id}/grid", json={"bpm": 240, "gap_ms": 900})
resp = client.delete(f"/api/jobs/{job_id}/grid")
assert resp.status_code == 200
body = resp.json()
assert body["grid"] == {"bpm": 300.0, "gap_ms": 1000.0} # GAP back at the first note
assert not body["grid_edited"] and body["auto_bpm"] == 300.0
assert client.delete(f"/api/jobs/{job_id}/grid").status_code == 404
def test_a_bad_grid_is_refused(tmp_path):
client, _ = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client)
for body in ({"bpm": 0, "gap_ms": 0}, {"bpm": 300, "gap_ms": -5}, {"bpm": "x"}, {}):
assert client.put(f"/api/jobs/{job_id}/grid", json=body).status_code == 400
def test_reset_on_a_song_built_before_tempo_was_recorded_restores_its_bpm(tmp_path):
client, _ = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client) # charted at 300 BPM, no tempo.json
client.put(f"/api/jobs/{job_id}/grid", json={"bpm": 240, "gap_ms": 900})
body = client.delete(f"/api/jobs/{job_id}/grid").json()
assert body["grid"]["bpm"] == 300.0 and body["auto_bpm"] == 300.0
def test_moving_a_break_recharts_and_is_reported(tmp_path):
from songtrace.ultrastar import LineBreak, parse_ultrastar
client, cfg = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client)
assert client.get(f"/api/jobs/{job_id}/review").json()["breaks"] == [
{"line": 1, "time": 2.0, "moved": False}]
resp = client.put(f"/api/jobs/{job_id}/breaks", json={"1": 2.8})
assert resp.status_code == 200
assert resp.json()["breaks"] == [{"line": 1, "time": 2.8, "moved": True}]
assert not any(line["edited"] for line in resp.json()["lines"])
packaged = cfg.output_dir / "A - T" / "A - T.txt"
body = parse_ultrastar(packaged.read_text(encoding="utf-8")).body
# 300 BPM: 0.05s beats from GAP 1.0s, so 2.8s is beat 36
assert [b.beat for b in body if isinstance(b, LineBreak)] == [36]
back = client.put(f"/api/jobs/{job_id}/breaks", json={"1": None}).json()
assert back["breaks"] == [{"line": 1, "time": 2.0, "moved": False}]
def test_a_bad_break_is_refused(tmp_path):
client, _ = make_client(tmp_path, chart_runner)
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
+139
View File
@@ -0,0 +1,139 @@
import os
import time
import pytest
from songtrace import ytdlp
from songtrace.config import Config
from songtrace.ytdlp import YtDlp, YtDlpError
def fake_package(path, version="2026.09.01", body=""):
"""A stand-in yt_dlp that answers --version and otherwise runs `body`."""
pkg = path / "yt_dlp"
pkg.mkdir(parents=True)
(pkg / "__init__.py").touch()
(pkg / "__main__.py").write_text(
"import sys\n"
f"if '--version' in sys.argv: print({version!r}); sys.exit()\n"
+ body)
return path
@pytest.fixture
def cache(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
monkeypatch.setattr(ytdlp, "bundled", lambda: YtDlp("2026.8.19"))
return ytdlp.cache_dir()
@pytest.fixture
def pypi(monkeypatch):
"""Stubs PyPI and pip: `latest` is the newest release, `installs` logs pip."""
state = {"latest": "2026.9.1", "installs": []}
monkeypatch.setattr(ytdlp, "_latest_release", lambda: state["latest"])
def pip_install(target, version):
state["installs"].append(version)
fake_package(target, version)
monkeypatch.setattr(ytdlp, "_pip_install", pip_install)
return state
def cfg(version="auto"):
return Config(whisper_url="http://x/v1", ytdlp_version=version)
def test_run_uses_the_install_on_its_path_and_logs_its_output(tmp_path):
path = fake_package(tmp_path, body="print('[youtube] hello', sys.argv[1:])\n")
logged = []
YtDlp("2026.09.01", path).run(["--", "https://yt/x"], logged.append)
assert logged == ["[youtube] hello ['--ignore-config', '--', 'https://yt/x']"]
def test_run_raises_the_error_line(tmp_path):
path = fake_package(tmp_path, body=(
"print('ERROR: HTTP Error 403: Forbidden', file=sys.stderr)\n"
"print('trailing noise')\nsys.exit(1)\n"))
with pytest.raises(YtDlpError, match="^ERROR: HTTP Error 403"):
YtDlp("2026.09.01", path).run([], lambda m: None)
def test_run_flags_a_usage_error_as_incompatible(tmp_path):
path = fake_package(tmp_path, body=(
"print('error: no such option: --retries', file=sys.stderr)\n"
"sys.exit(2)\n"))
with pytest.raises(YtDlpError, match="2026.09.01 rejected.*--retries"):
YtDlp("2026.09.01", path).run([], lambda m: None)
def test_auto_installs_a_newer_release_and_tries_it_first(cache, pypi):
found = ytdlp.candidates(cfg(), lambda m: None)
assert [y.version for y in found] == ["2026.9.1", "2026.8.19"]
assert found[0].path == cache / "2026.9.1"
assert not [p for p in cache.iterdir() if p.name.startswith(".tmp-")]
def test_auto_checks_pypi_once_a_day(cache, pypi):
ytdlp.candidates(cfg(), lambda m: None)
pypi["latest"] = "2026.10.1"
found = ytdlp.candidates(cfg(), lambda m: None)
assert found[0].version == "2026.9.1"
found = ytdlp.candidates(cfg(), lambda m: None, refresh=True)
assert found[0].version == "2026.10.1"
assert not (cache / "2026.9.1").exists()
old = time.time() - ytdlp.CHECK_INTERVAL - 1
os.utime(cache / ".checked", (old, old))
pypi["latest"] = "2026.11.1"
assert ytdlp.candidates(cfg(), lambda m: None)[0].version == "2026.11.1"
def test_auto_uses_only_bundled_when_it_is_newest(cache, pypi):
pypi["latest"] = "2026.8.19"
assert [y.version for y in ytdlp.candidates(cfg(), lambda m: None)] == [
"2026.8.19"]
assert pypi["installs"] == []
def test_auto_survives_pypi_being_unreachable(cache, pypi, monkeypatch):
ytdlp.candidates(cfg(), lambda m: None)
def down():
raise OSError("network unreachable")
monkeypatch.setattr(ytdlp, "_latest_release", down)
logged = []
found = ytdlp.candidates(cfg(), logged.append, refresh=True)
assert [y.version for y in found] == ["2026.9.1", "2026.8.19"]
assert any("network unreachable" in m for m in logged)
def test_an_install_that_fails_its_check_is_discarded(cache, pypi, monkeypatch):
monkeypatch.setattr(ytdlp, "_pip_install",
lambda target, v: fake_package(target, "2026.1.1"))
logged = []
assert [y.version for y in ytdlp.candidates(cfg(), logged.append)] == [
"2026.8.19"]
assert any("failed its check" in m for m in logged)
assert list(cache.iterdir()) == [cache / ".checked"]
def test_bundled_never_touches_pypi(cache, pypi):
pypi["latest"] = None
assert [y.version for y in ytdlp.candidates(cfg("bundled"), print)] == [
"2026.8.19"]
assert not cache.exists()
def test_pin_installs_that_release_even_if_older(cache, pypi):
found = ytdlp.candidates(cfg("2026.07.01"), lambda m: None)
assert [y.version for y in found] == ["2026.07.01", "2026.8.19"]
assert pypi["installs"] == ["2026.07.01"]
def test_pin_matching_bundled_installs_nothing(cache, pypi):
found = ytdlp.candidates(cfg("2026.08.19"), lambda m: None)
assert [y.version for y in found] == ["2026.8.19"]
assert pypi["installs"] == []
def test_pin_must_be_a_release_number(cache, pypi):
with pytest.raises(YtDlpError, match="ytdlp_version"):
ytdlp.candidates(cfg("latest; rm -rf /"), lambda m: None)