Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
902a0403f6 | ||
|
|
0fc4ad0e08 | ||
|
|
4a4da08fa0 | ||
|
|
bb5a530032 | ||
|
|
33d66c2af8 | ||
|
|
c3660d267a | ||
|
|
2d7bde3d74 | ||
|
|
82147bf295 | ||
|
|
b5e6cd326b | ||
|
|
449f742d4b | ||
|
|
b781c6cb0c | ||
|
|
3fbf39f9d6 | ||
|
|
82cafa96bf | ||
|
|
30e4f8f202 | ||
|
|
bdfd7d8429 | ||
|
|
2aeb16decb | ||
|
|
9a11db5794 | ||
|
|
381a499f2b | ||
|
|
793a78972d | ||
|
|
fa4a78f278 | ||
|
|
5e2afc1254 | ||
|
|
7a86726c5c | ||
|
|
211e75c448 | ||
|
|
d4cdb4292d | ||
|
|
15124e512f | ||
|
|
2fbff3553c | ||
|
|
8dd6ae5a17 | ||
|
|
4dff0dde4c | ||
|
|
c0343edb64 | ||
|
|
4749a7810f | ||
|
|
060669fda7 | ||
|
|
15f01c50eb | ||
|
|
f3c43e0410 | ||
|
|
f5273b12e0 | ||
|
|
2eea510784 | ||
|
|
895c65bb4e | ||
|
|
e3a1bb4809 | ||
|
|
c1368505c2 | ||
|
|
d484d6f1d2 | ||
|
|
6f5e273c6c | ||
|
|
4272189bca | ||
|
|
936641e13c | ||
|
|
793817ce42 | ||
|
|
f3125370d3 | ||
|
|
6d297a8bfc | ||
|
|
4af78fdd1d | ||
|
|
cb485d3c6b | ||
|
|
f187bfd6c0 | ||
|
|
22c7f6d03f | ||
|
|
7c4359eb39 | ||
|
|
ba8bf9f4af | ||
|
|
be67e01943 | ||
|
|
b5850042be | ||
|
|
55e8297672 | ||
|
|
a7622c2d01 | ||
|
|
c489dd5742 | ||
|
|
f1b967b487 | ||
|
|
e141cc5773 | ||
|
|
92948b2ea7 | ||
|
|
c8c3d941ca | ||
|
|
7b8fce5b6e | ||
|
|
cb10400a21 | ||
|
|
98e4f9532f | ||
|
|
77bebcf9e1 | ||
|
|
986f26b8ed | ||
|
|
d557ac6386 | ||
|
|
5023ea69d3 | ||
|
|
0599f6b665 | ||
|
|
2debfb171c | ||
|
|
ac904cb09f | ||
|
|
8e89b08571 | ||
|
|
6a98ea0f21 | ||
|
|
903e83dbcc | ||
|
|
a4e0b45953 | ||
|
|
ffd662fb38 | ||
|
|
43f0554796 | ||
|
|
ebb7fc4433 | ||
|
|
cf1c7410ab | ||
|
|
5bc4855264 | ||
|
|
a650c2f7ec | ||
|
|
283da7e1a9 | ||
|
|
292fa25d5e | ||
|
|
0eaab6aeed | ||
|
|
7a48942f1c |
@@ -0,0 +1,6 @@
|
|||||||
|
deb.debian.org
|
||||||
|
security.debian.org
|
||||||
|
pypi.org
|
||||||
|
files.pythonhosted.org
|
||||||
|
download.pytorch.org
|
||||||
|
download-r2.pytorch.org
|
||||||
@@ -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
|
||||||
@@ -1,91 +1,190 @@
|
|||||||
# songtrace
|
# songtrace
|
||||||
|
|
||||||
Turn a song and its lyrics into a playable UltraStar chart.
|
Self-hosted karaoke chart builder for [UltraStar](https://usdx.eu/format/) and
|
||||||
|
compatible games such as [Melody Mania](https://melodymania.org/). Give it a
|
||||||
A CLI and minimal web UI that turns a song — title, artist, lyrics you supply,
|
song's video and lyrics and it charts the notes, timing and pitch, ready for
|
||||||
and a YouTube URL or local video file — into a playable
|
your collection. Fix anything in the built-in editor; your edits survive when
|
||||||
[UltraStar](https://usdx.eu/format/)-format song folder for
|
you rebuild your whole collection after a songtrace update.
|
||||||
[Melody Mania](https://melodymania.net/), a commercial UltraStar Deluxe fork.
|
|
||||||
|
|
||||||
The pipeline:
|
|
||||||
|
|
||||||
1. **acquire** — download audio/video via `yt-dlp` (or use a local video file)
|
|
||||||
2. **separate** — split vocals/instrumental with Demucs
|
|
||||||
3. **transcribe** — send the vocal stem to your own intranet Whisper server
|
|
||||||
(OpenAI-compatible, or [onerahmet/openai-whisper-asr-webservice](https://github.com/ahmetoner/whisper-asr-webservice)),
|
|
||||||
as a sanity check that the supplied lyrics match the audio and to locate
|
|
||||||
vocal sections
|
|
||||||
4. **align** — forced-align the *supplied* reference lyrics against the vocal
|
|
||||||
stem with wav2vec2 (torchaudio's MMS_FA) to get word timings
|
|
||||||
5. **pitch** — track pitch on the vocal stem with torchcrepe
|
|
||||||
6. **notes** — turn word timings + pitch into UltraStar notes
|
|
||||||
7. **grid** — quantise notes to a beat grid
|
|
||||||
8. **package** — write the final song folder (`.txt` + `.mp3` + video + cover)
|
|
||||||
|
|
||||||
The shipped `.mp3` is the full original mix (vocals included) — the
|
|
||||||
separated stems are used for analysis only and are not included in the
|
|
||||||
output.
|
|
||||||
|
|
||||||
**The output is a best-effort draft**, not a finished song. Every run ends by
|
|
||||||
printing the low-confidence regions it found (e.g.
|
|
||||||
`3 low-confidence notes near 0:42–0:47`) — open the result in an UltraStar
|
|
||||||
editor such as [Yass](http://www.yass-hq.de/) or
|
|
||||||
[USDX](https://usdx.eu/) and touch those spots up before playing.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Config is TOML. Resolution order:
|
|
||||||
|
|
||||||
1. `--config <path>` passed explicitly on the command line
|
|
||||||
2. the `SONGTRACE_CONFIG` environment variable
|
|
||||||
3. `~/.config/songtrace/config.toml`
|
|
||||||
|
|
||||||
If you used this project under its former name, move your existing config once:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pip uninstall karaoke-maker # removes the old console script
|
|
||||||
mv ~/.config/karaoke-maker ~/.config/songtrace
|
|
||||||
```
|
|
||||||
|
|
||||||
The Docker deployment needs no config migration — the image sets
|
|
||||||
`SONGTRACE_CONFIG` itself and the config is bind-mounted to a fixed path.
|
|
||||||
Run `docker compose down` before upgrading (or `docker compose up -d
|
|
||||||
--remove-orphans`) since the service key changed and Compose won't replace
|
|
||||||
the old container in place.
|
|
||||||
|
|
||||||
Copy the example and fill in your Whisper server:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cp config.example.toml config.toml
|
|
||||||
$EDITOR config.toml # set whisper_url at minimum
|
|
||||||
```
|
|
||||||
|
|
||||||
See `config.example.toml` for every available field (`whisper_url`,
|
|
||||||
`whisper_api_key`, `whisper_model`, `whisper_api`, `output_dir`, `work_dir`,
|
|
||||||
`creator`, `demucs_model`, `crepe_model`, `device`, `bind_host`, `port`).
|
|
||||||
|
|
||||||
`whisper_api` selects the wire format: `"openai"` (default) speaks the
|
|
||||||
OpenAI-compatible `POST {whisper_url}/audio/transcriptions` API; `"asr-webservice"`
|
|
||||||
speaks [onerahmet/openai-whisper-asr-webservice](https://github.com/ahmetoner/whisper-asr-webservice)'s
|
|
||||||
`POST {whisper_url}/asr` API instead (point `whisper_url` at that server's base
|
|
||||||
URL with no `/v1` suffix — `whisper_model`/`whisper_api_key` are unused for
|
|
||||||
this backend, since the model is fixed by how that server was started).
|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cp config.example.toml config.toml
|
|
||||||
$EDITOR config.toml
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
The web UI is then at `http://localhost:8000`. Downloaded model weights
|
|
||||||
(Demucs, wav2vec2, ...) are cached under `./data` on the host so they survive
|
|
||||||
container recreation.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Run the CLI inside the container instead of the web UI:
|
## What it does
|
||||||
|
|
||||||
|
You give it a title, artist, the lyrics, and a YouTube URL or a video file.
|
||||||
|
songtrace writes a song folder (`.txt` chart, `.mp3`, video and cover) into
|
||||||
|
your collection:
|
||||||
|
|
||||||
|
1. **acquire**: downloads the video with `yt-dlp`, or takes your file
|
||||||
|
2. **separate**: splits vocals from the backing track (Demucs)
|
||||||
|
3. **transcribe**: has your Whisper server transcribe the vocals, to check
|
||||||
|
the lyrics match the recording and to find where the singing is
|
||||||
|
4. **align**: places each word of *your* lyrics on the vocals (wav2vec2)
|
||||||
|
5. **pitch**: tracks the sung pitch (torchcrepe)
|
||||||
|
6. **notes**: turns word timings and pitch into notes
|
||||||
|
7. **grid**: snaps the notes to a beat grid
|
||||||
|
8. **package**: writes the song folder
|
||||||
|
|
||||||
|
The result is a strong draft, not a finished chart. The web UI flags the notes
|
||||||
|
it is least sure of, plays the song back against the chart, and lets you fix
|
||||||
|
what it got wrong before you sing it.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **An NVIDIA GPU with CUDA.** songtrace is developed on a 12 GB card.
|
||||||
|
Separating the vocals (Demucs) alone takes about 3.7 GB, and alignment holds
|
||||||
|
a whole stretch of singing in memory at once, so a long song whose vocals
|
||||||
|
never pause needs the most. Cards under 12 GB are untested. Each stage gives
|
||||||
|
the GPU back when it finishes (see [Sharing the GPU](#sharing-the-gpu)).
|
||||||
|
`device = "cpu"` in the config runs without a GPU, but it is untested, far
|
||||||
|
slower, and needs plenty of system RAM.
|
||||||
|
- **Docker** with the
|
||||||
|
[NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/),
|
||||||
|
so the container can see the GPU.
|
||||||
|
- **A Whisper server of your own.** songtrace does not include one. Either an
|
||||||
|
OpenAI-compatible API or
|
||||||
|
[whisper-asr-webservice](https://github.com/ahmetoner/whisper-asr-webservice)
|
||||||
|
works. If it shares songtrace's GPU, it needs its own share of the memory.
|
||||||
|
- **Disk space:** a few GB of model weights, downloaded on first use, plus a
|
||||||
|
working folder per song holding its source video and separated tracks,
|
||||||
|
typically several hundred MB.
|
||||||
|
|
||||||
|
## Quick start (Docker)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
$EDITOR config.toml # set whisper_url to your Whisper server
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8000`, fill in the form and press **CREATE SONG**. To
|
||||||
|
update, `git pull` and run the same `docker compose` command again.
|
||||||
|
|
||||||
|
Finished songs go to `output_dir` (`/data/output` in the container). To write
|
||||||
|
them straight into your game's song collection, mount that folder there in
|
||||||
|
`docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./config.toml:/config/config.toml:ro
|
||||||
|
- ./data:/data
|
||||||
|
- /path/to/your/UltraStar/songs:/data/output
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything else lives under `./data` on the host (model weights, and each
|
||||||
|
song's working files), so it survives recreating the container.
|
||||||
|
|
||||||
|
## Reviewing and editing a song
|
||||||
|
|
||||||
|
Pick a song in the queue. The review panel plays the packaged song with its
|
||||||
|
video, highlights the line and words being sung, and shows the chart on a note
|
||||||
|
sheet above the lyrics. Playback works like the game: the `#MP3` is the clock
|
||||||
|
and the video follows it muted, so a word that lights up late here is late in
|
||||||
|
the game too.
|
||||||
|
|
||||||
|
The note sheet shows one line at a time on a pitch-by-beat grid, with the
|
||||||
|
pitch songtrace detected drawn over the notes. Where a note is right the curve
|
||||||
|
runs through it, so a wrong one stands out. Fix it there and **SAVE LINE**; the
|
||||||
|
song's chart is rewritten on the spot.
|
||||||
|
|
||||||
|
- **Notes:** drag a note to move it, drag its ends to resize it. Everything
|
||||||
|
snaps to the beat grid and to semitones. Double-click empty space to add a
|
||||||
|
note, or a note to change its syllable. With the sheet focused, the arrow
|
||||||
|
keys move notes, S splits, M merges, G/F/R make a note golden, freestyle or
|
||||||
|
rap, Ctrl+Z undoes and Ctrl+S saves. Press **?** for every shortcut.
|
||||||
|
- **Line breaks:** the ↵ markers either side of a line. Drag one to move it,
|
||||||
|
and double-click it to hand it back to songtrace.
|
||||||
|
- **Tempo:** the **GRID** bar sets the chart's BPM and GAP. It redraws as you
|
||||||
|
type and charts on **APPLY**. ×2 and ÷2 fix a tempo detected at double or
|
||||||
|
half speed, and **ALIGN TO NOTE** puts the selected note on a beat.
|
||||||
|
**RESET** goes back to songtrace's choice.
|
||||||
|
- **Undoing an edit:** **REVERT** returns a line to the generated notes.
|
||||||
|
|
||||||
|
Edits are kept apart from what the pipeline generates and laid back over it on
|
||||||
|
every rebuild, so they survive all of them, collection-wide ones included. If
|
||||||
|
you change a line's lyrics, its edit is set aside rather than applied to
|
||||||
|
different words, and the sheet lists those for you to discard. Songs with edits
|
||||||
|
are marked ✎ in the queue.
|
||||||
|
|
||||||
|
## Rebuilding
|
||||||
|
|
||||||
|
Select a song and its inputs come back up in the form; change them and press
|
||||||
|
**REBUILD SONG**. Only the stages your change affects are rerun: fixing a line
|
||||||
|
of lyrics realigns without downloading or separating again.
|
||||||
|
|
||||||
|
A songtrace update can improve a stage without any of your inputs changing.
|
||||||
|
**Restart from** on the rebuild form redoes a stage anyway, and
|
||||||
|
**REBUILD ALL FROM…**, under CREATE SONG, does it for your whole collection:
|
||||||
|
pick the stage the update improved, untick any songs to leave alone, and each
|
||||||
|
song is rebuilt from there. Your edits are kept.
|
||||||
|
|
||||||
|
Stages in order: `acquire`, `separate`, `transcribe`, `align`, `pitch`,
|
||||||
|
`notes`, `grid`, `package`. Rebuilding from `notes` onward needs no GPU.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`config.toml` is TOML; `config.example.toml` lists every setting. songtrace
|
||||||
|
reads, in order: `--config <path>`, the `SONGTRACE_CONFIG` environment variable
|
||||||
|
(set to `/config/config.toml` in the container), then
|
||||||
|
`~/.config/songtrace/config.toml`.
|
||||||
|
|
||||||
|
`whisper_api` chooses how to talk to Whisper: `"openai"` (the default) uses
|
||||||
|
`POST {whisper_url}/audio/transcriptions`, and `"asr-webservice"` uses
|
||||||
|
whisper-asr-webservice's `POST {whisper_url}/asr`. For the latter, give
|
||||||
|
`whisper_url` without a `/v1` suffix; `whisper_model` and `whisper_api_key`
|
||||||
|
are ignored, since that server's model is fixed when it starts.
|
||||||
|
|
||||||
|
`creator` sets the `#CREATOR` header in every chart; it defaults to
|
||||||
|
`songtrace`.
|
||||||
|
|
||||||
|
## Sharing the GPU
|
||||||
|
|
||||||
|
Three stages load a model: `separate` (Demucs), `align` (wav2vec2) and `pitch`
|
||||||
|
(torchcrepe). Each releases its model and returns the memory to the driver as
|
||||||
|
soon as it finishes, rather than holding it for the life of the server. This
|
||||||
|
matters most while `transcribe` waits on your Whisper server, which can take
|
||||||
|
minutes. The job log says how much each stage gave back:
|
||||||
|
|
||||||
|
```
|
||||||
|
[separate] released 3704 MiB of GPU memory
|
||||||
|
```
|
||||||
|
|
||||||
|
Reloading the models costs seconds per song. The CUDA context itself, a few
|
||||||
|
hundred MB, stays with the process; stop the container to free it.
|
||||||
|
|
||||||
|
## YouTube download failures
|
||||||
|
|
||||||
|
YouTube changes its defences every few weeks, so songtrace keeps `yt-dlp`
|
||||||
|
current by itself. Once a day, and again when a download fails, it checks PyPI
|
||||||
|
for a newer stable release, installs it under `/data/models/cache`, and tries
|
||||||
|
it before the copy it shipped with. That copy is never modified and is always
|
||||||
|
the fallback, so a broken release costs a retry, not the download. The job log
|
||||||
|
names the `yt-dlp` version behind each attempt. `ytdlp_version` pins a release,
|
||||||
|
or `"bundled"` turns the updates off.
|
||||||
|
|
||||||
|
If downloads still fail with `HTTP Error 403: Forbidden`:
|
||||||
|
|
||||||
|
1. **The network is rate-limited or flagged.** Datacenter and VPN ranges are
|
||||||
|
throttled hardest. Try another network before changing config.
|
||||||
|
2. **A PO token is required.** If the log says so, run
|
||||||
|
[bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider)
|
||||||
|
alongside songtrace, install its plugin into the image, and point
|
||||||
|
`ytdlp_pot_provider_url` at it.
|
||||||
|
3. **Other levers:** `ytdlp_clients` tries each listed YouTube player client
|
||||||
|
in turn. `ytdlp_cookies` takes a Netscape cookie jar for age-gated videos;
|
||||||
|
don't use one from an account you care about, as YouTube bans automated
|
||||||
|
access. `ytdlp_impersonate` presents a browser's TLS fingerprint (for
|
||||||
|
example `"chrome"`).
|
||||||
|
|
||||||
|
To check the JavaScript runtime `yt-dlp` needs for YouTube is present, run
|
||||||
|
`docker compose exec songtrace yt-dlp -v --simulate <url>`; the `JS runtimes:`
|
||||||
|
line must not say `none`. A failed download keeps its working folder, so
|
||||||
|
running the job again resumes it.
|
||||||
|
|
||||||
|
## Command line
|
||||||
|
|
||||||
|
The web UI is the main way to use songtrace, but every song can also be made
|
||||||
|
from the command line, in the container or on a host with the requirements
|
||||||
|
above and `ffmpeg`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose run --rm songtrace songtrace make \
|
docker compose run --rm songtrace songtrace make \
|
||||||
@@ -93,18 +192,6 @@ docker compose run --rm songtrace songtrace make \
|
|||||||
--lyrics /data/lyrics.txt --youtube https://youtu.be/...
|
--lyrics /data/lyrics.txt --youtube https://youtu.be/...
|
||||||
```
|
```
|
||||||
|
|
||||||
GPU passthrough requires the [NVIDIA Container
|
|
||||||
Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/)
|
|
||||||
installed on the host; `docker-compose.yml` requests one GPU via the
|
|
||||||
`nvidia` device driver.
|
|
||||||
|
|
||||||
## Bare-metal
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pip install -e .
|
|
||||||
songtrace make --help
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
```
|
||||||
songtrace make --title TITLE --artist ARTIST --lyrics LYRICS_FILE \
|
songtrace make --title TITLE --artist ARTIST --lyrics LYRICS_FILE \
|
||||||
(--youtube URL | --video PATH) \
|
(--youtube URL | --video PATH) \
|
||||||
@@ -112,35 +199,28 @@ songtrace make --title TITLE --artist ARTIST --lyrics LYRICS_FILE \
|
|||||||
[--bpm BPM] [--force-from STAGE] [--config CONFIG]
|
[--bpm BPM] [--force-from STAGE] [--config CONFIG]
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the web UI instead with `songtrace serve [--config CONFIG]`.
|
`--force-from <stage>` redoes that stage and everything after it, reusing the
|
||||||
|
cached output of the stages before. On a bare-metal host, install with
|
||||||
|
`pip install -e .` and run the web UI with `songtrace serve`.
|
||||||
|
|
||||||
## Re-running a stage
|
## Development
|
||||||
|
|
||||||
Each stage caches its output under `work_dir`. If a later stage needs
|
|
||||||
another pass (e.g. you want to retry alignment with different lyrics, or
|
|
||||||
pitch tracking looks off), re-run with `--force-from <stage>` to redo that
|
|
||||||
stage and everything after it, reusing the cached output of everything
|
|
||||||
before it:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
songtrace make --title "..." --artist "..." --lyrics lyrics.txt \
|
|
||||||
--youtube https://youtu.be/... --force-from align
|
|
||||||
```
|
|
||||||
|
|
||||||
Valid stages, in pipeline order: `acquire`, `separate`, `transcribe`,
|
|
||||||
`align`, `pitch`, `notes`, `grid`, `package`.
|
|
||||||
|
|
||||||
## 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`:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pip install -e '.[dev]'
|
pip install -e '.[dev]'
|
||||||
playwright install chromium
|
pytest
|
||||||
python scripts/screenshot_webui.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`pytest` also runs the web UI's JavaScript tests in `tests/js` when Node is
|
||||||
|
installed. Pull requests run the full suite in CI.
|
||||||
|
|
||||||
|
songtrace is developed on a private forge and published to
|
||||||
|
[GitHub](https://github.com/branderson/songtrace) as a read-only mirror; see
|
||||||
|
`mirror/README.md`.
|
||||||
|
|
||||||
|
`scripts/screenshot_webui.py` regenerates `docs/images/webui.png` from a
|
||||||
|
seeded copy of the app (`playwright install chromium` first). With `ffmpeg` on
|
||||||
|
`PATH`, the shot includes the player.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[AGPLv3](LICENSE)
|
[AGPLv3](LICENSE)
|
||||||
|
|||||||
+13
-3
@@ -1,13 +1,23 @@
|
|||||||
whisper_url = "http://whisper.intranet:9000/v1"
|
whisper_url = "http://whisper.example:9000/v1"
|
||||||
# whisper_api_key = ""
|
# whisper_api_key = ""
|
||||||
# whisper_model = "whisper-1"
|
# whisper_model = "whisper-1"
|
||||||
# whisper_api = "openai" # or "asr-webservice" for onerahmet/openai-whisper-asr-webservice
|
# whisper_api = "openai" # or "asr-webservice" for onerahmet/openai-whisper-asr-webservice
|
||||||
# (set whisper_url without the /v1 suffix, e.g. "http://whisper.intranet:9000")
|
# (set whisper_url without the /v1 suffix, e.g. "http://whisper.example:9000")
|
||||||
output_dir = "/data/output"
|
output_dir = "/data/output"
|
||||||
work_dir = "/data/work"
|
work_dir = "/data/work"
|
||||||
creator = "brad"
|
# creator = "Your Name" # the #CREATOR header in every chart
|
||||||
# demucs_model = "htdemucs_ft"
|
# demucs_model = "htdemucs_ft"
|
||||||
# crepe_model = "full"
|
# crepe_model = "full"
|
||||||
# device = "cuda"
|
# device = "cuda"
|
||||||
bind_host = "0.0.0.0"
|
bind_host = "0.0.0.0"
|
||||||
# port = 8000
|
# 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: 57 KiB After Width: | Height: | Size: 99 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
# GitHub mirror
|
||||||
|
|
||||||
|
songtrace is developed on a private forge. `github-mirror.sh` publishes its
|
||||||
|
`main` and the tags on `main` to
|
||||||
|
[github.com/branderson/songtrace](https://github.com/branderson/songtrace),
|
||||||
|
hourly, from a host that can push to GitHub. Issues and pull requests are off
|
||||||
|
there.
|
||||||
|
|
||||||
|
The mirror's `main` is the forge's plus one commit, which adds a read-only notice
|
||||||
|
under the README's title. Every forge commit is in the mirror unchanged, and
|
||||||
|
each sync fast-forwards. If the README loses its `# ` title, the sync stops
|
||||||
|
instead of publishing without the notice.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Set up the timer as described at the top of
|
||||||
|
`songtrace-github-mirror.service`. Give it a deploy key with write access to
|
||||||
|
the GitHub repository.
|
||||||
|
2. Run `github-mirror.sh --dry-run` to build what a sync would publish without
|
||||||
|
pushing it.
|
||||||
|
3. Once the first sync has pushed, run `github-mirror.sh --settings` (needs
|
||||||
|
`gh`). It turns off issues, pull requests, projects, wiki and discussions.
|
||||||
|
|
||||||
|
The timer runs a copy of the script, not the checkout's. Reinstall it after
|
||||||
|
reviewing a change to `mirror/`.
|
||||||
Executable
+132
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Publishes main and its release tags to a read-only mirror on GitHub.
|
||||||
|
#
|
||||||
|
# MIRROR_SOURCE=<forge clone URL> mirror/github-mirror.sh sync
|
||||||
|
# ... mirror/github-mirror.sh --dry-run build and print, push nothing
|
||||||
|
# mirror/github-mirror.sh --settings configure the GitHub repo (needs gh)
|
||||||
|
# mirror/github-mirror.sh --transform DIR rewrite a checkout in place
|
||||||
|
#
|
||||||
|
# Runs on a host that can push to GitHub, never in a dejima session: a sandbox
|
||||||
|
# may push only to its own forge.
|
||||||
|
#
|
||||||
|
# The GitHub copy is not byte-for-byte. Its main is one extra commit on top of
|
||||||
|
# the forge's main, whose tree is the forge's with the rewrites in transform()
|
||||||
|
# applied, and whose parents are the forge commit and the previous mirror
|
||||||
|
# commit. So every forge SHA is in the mirror unchanged, and each sync is a
|
||||||
|
# fast-forward for anyone tracking it. Tags are pushed as they are, pointing at
|
||||||
|
# the untransformed forge commits.
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# MIRROR_SOURCE forge clone URL (required to sync)
|
||||||
|
# MIRROR_REPO GitHub repository, default branderson/songtrace
|
||||||
|
# MIRROR_TARGET push URL, default git@github.com:$MIRROR_REPO.git
|
||||||
|
# MIRROR_STATE working bare repository, default ~/.cache/songtrace-github-mirror.git
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MIRROR_REPO="${MIRROR_REPO:-branderson/songtrace}"
|
||||||
|
MIRROR_TARGET="${MIRROR_TARGET:-git@github.com:$MIRROR_REPO.git}"
|
||||||
|
MIRROR_STATE="${MIRROR_STATE:-${XDG_CACHE_HOME:-$HOME/.cache}/songtrace-github-mirror.git}"
|
||||||
|
|
||||||
|
say() { echo "github-mirror: $*"; }
|
||||||
|
die() { say "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
WORK=""
|
||||||
|
cleanup() {
|
||||||
|
[ -n "$WORK" ] || return 0
|
||||||
|
git -C "$MIRROR_STATE" worktree remove --force "$WORK" 2>/dev/null || true
|
||||||
|
rm -rf "$WORK"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rewrites a checkout for GitHub. Each rewrite is checked, so a README that
|
||||||
|
# drifted away from what this expects stops the mirror instead of publishing
|
||||||
|
# the forge's version. Running it twice changes nothing.
|
||||||
|
transform() {
|
||||||
|
local readme="$1/README.md"
|
||||||
|
# perl -i would write a symlink's target back as the README, publishing it.
|
||||||
|
[ -f "$readme" ] && [ ! -L "$readme" ] || die "README.md is missing or a symlink in $1"
|
||||||
|
|
||||||
|
# The first thing a visitor to a mirror does is open an issue or a pull
|
||||||
|
# request. Under the title.
|
||||||
|
grep -q '^> \*\*This is a read-only mirror' "$readme" || perl -0pi -e '
|
||||||
|
s{^(# [^\n]*)\n}{$1\n\n> **This is a read-only mirror.** songtrace is developed on a private forge,\n> and issues and pull requests are not accepted here.\n}m
|
||||||
|
' "$readme"
|
||||||
|
grep -q '^> \*\*This is a read-only mirror' "$readme" \
|
||||||
|
|| die "README.md: no title to put the mirror notice under"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Issues, pull requests, wiki and the like off. Settings rather than content,
|
||||||
|
# so a sync does not reapply them; run once after creating the repository.
|
||||||
|
settings() {
|
||||||
|
command -v gh >/dev/null || die "--settings needs the gh CLI"
|
||||||
|
gh api -X PATCH "repos/$MIRROR_REPO" --silent \
|
||||||
|
-F has_issues=false -F has_pull_requests=false -F has_projects=false \
|
||||||
|
-F has_wiki=false -F has_discussions=false
|
||||||
|
}
|
||||||
|
|
||||||
|
sync() {
|
||||||
|
[ -n "${MIRROR_SOURCE:-}" ] || die "MIRROR_SOURCE is not set"
|
||||||
|
local git=(git -C "$MIRROR_STATE")
|
||||||
|
trap cleanup EXIT
|
||||||
|
[ -d "$MIRROR_STATE" ] || git init -q --bare "$MIRROR_STATE"
|
||||||
|
|
||||||
|
"${git[@]}" fetch -q --no-tags --prune "$MIRROR_SOURCE" \
|
||||||
|
'+refs/heads/main:refs/source/main' '+refs/tags/*:refs/source/tags/*'
|
||||||
|
local src prev=""
|
||||||
|
src="$("${git[@]}" rev-parse refs/source/main)"
|
||||||
|
|
||||||
|
# An absent main (exit 2) is a first run; anything else is a real failure.
|
||||||
|
local rc=0
|
||||||
|
git ls-remote -q --exit-code "$MIRROR_TARGET" refs/heads/main >/dev/null || rc=$?
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
"${git[@]}" fetch -q --no-tags "$MIRROR_TARGET" '+refs/heads/main:refs/mirror/main'
|
||||||
|
prev="$("${git[@]}" rev-parse refs/mirror/main)"
|
||||||
|
elif [ "$rc" -ne 2 ]; then
|
||||||
|
die "cannot read $MIRROR_TARGET"
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
"${git[@]}" worktree add -q --detach "$WORK" "$src"
|
||||||
|
transform "$WORK"
|
||||||
|
git -C "$WORK" add -A
|
||||||
|
local tree
|
||||||
|
tree="$(git -C "$WORK" write-tree)"
|
||||||
|
|
||||||
|
if [ -n "$prev" ] && [ "$("${git[@]}" rev-parse "$prev^1")" = "$src" ] \
|
||||||
|
&& [ "$("${git[@]}" rev-parse "$prev^{tree}")" = "$tree" ]; then
|
||||||
|
say "already at ${src:0:12}"
|
||||||
|
else
|
||||||
|
# The forge commit's committer and date, so the same input always
|
||||||
|
# makes the same commit.
|
||||||
|
local name email date commit
|
||||||
|
IFS=$'\t' read -r name email date < <("${git[@]}" log -1 --format='%cn%x09%ce%x09%cI' "$src")
|
||||||
|
commit="$(GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$email" GIT_AUTHOR_DATE="$date" \
|
||||||
|
GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$email" GIT_COMMITTER_DATE="$date" \
|
||||||
|
"${git[@]}" commit-tree "$tree" -p "$src" ${prev:+-p "$prev"} \
|
||||||
|
-m "Mirror ${src:0:12} for GitHub" \
|
||||||
|
-m "README header added by mirror/github-mirror.sh.")"
|
||||||
|
"${git[@]}" update-ref refs/mirror/next "$commit"
|
||||||
|
[ -n "$DRY_RUN" ] || "${git[@]}" push -q "$MIRROR_TARGET" "$commit:refs/heads/main"
|
||||||
|
say "main is ${src:0:12} as ${commit:0:12}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Only tags on main: one on another branch would publish unreleased work.
|
||||||
|
local ref
|
||||||
|
local refspecs=()
|
||||||
|
while IFS= read -r ref; do
|
||||||
|
if "${git[@]}" merge-base --is-ancestor "$ref" "$src"; then
|
||||||
|
refspecs+=("$ref:refs/tags/${ref#refs/source/tags/}")
|
||||||
|
say "tag ${ref#refs/source/tags/}"
|
||||||
|
fi
|
||||||
|
done < <("${git[@]}" for-each-ref --format='%(refname)' refs/source/tags/)
|
||||||
|
[ "${#refspecs[@]}" -eq 0 ] || [ -n "$DRY_RUN" ] \
|
||||||
|
|| "${git[@]}" push -q "$MIRROR_TARGET" "${refspecs[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
DRY_RUN=""
|
||||||
|
case "${1:-}" in
|
||||||
|
"") sync ;;
|
||||||
|
--dry-run) DRY_RUN=1; sync ;;
|
||||||
|
--settings) settings ;;
|
||||||
|
--transform) [ -n "${2:-}" ] || die "--transform needs a directory"; transform "$2" ;;
|
||||||
|
*) sed -n '4,7p' "$0" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# User unit for the GitHub mirror, on a host that can push to GitHub. Never in
|
||||||
|
# a dejima session: a sandbox may push only to its own forge.
|
||||||
|
#
|
||||||
|
# install -m 755 mirror/github-mirror.sh ~/.local/bin/songtrace-github-mirror
|
||||||
|
# cp mirror/songtrace-github-mirror.service mirror/songtrace-github-mirror.timer ~/.config/systemd/user/
|
||||||
|
# systemctl --user daemon-reload
|
||||||
|
# systemctl --user enable --now songtrace-github-mirror.timer
|
||||||
|
#
|
||||||
|
# A copy, not a link into the checkout: the timer holds a key that can push to
|
||||||
|
# GitHub, and a session working in this checkout can edit the script.
|
||||||
|
# Reinstall it after reviewing an update.
|
||||||
|
#
|
||||||
|
# ~/.config/songtrace/github-mirror.env sets MIRROR_SOURCE, the forge
|
||||||
|
# clone URL, and whatever else mirror/github-mirror.sh documents. The GitHub
|
||||||
|
# key, a deploy key with write access to the repository, belongs behind an ssh
|
||||||
|
# Host alias named in MIRROR_TARGET, so it is not also offered to the forge:
|
||||||
|
#
|
||||||
|
# MIRROR_SOURCE=git@forge.example.com:owner/songtrace.git
|
||||||
|
# MIRROR_TARGET=git@github-songtrace:branderson/songtrace.git
|
||||||
|
[Unit]
|
||||||
|
Description=Mirror songtrace's main and release tags to GitHub
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
EnvironmentFile=%h/.config/songtrace/github-mirror.env
|
||||||
|
ExecStart=%h/.local/bin/songtrace-github-mirror
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Mirror songtrace's main and release tags to GitHub, hourly
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=hourly
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
+6
-1
@@ -5,7 +5,12 @@ description = "Generate UltraStar/Melody Mania karaoke songs from a video and re
|
|||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
dependencies = [
|
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",
|
"demucs",
|
||||||
"torch",
|
"torch",
|
||||||
"torchaudio",
|
"torchaudio",
|
||||||
|
|||||||
+167
-12
@@ -6,22 +6,52 @@ with a fixed set of jobs covering every status, then screenshots it.
|
|||||||
One-time setup: pip install -e '.[dev]' && playwright install chromium
|
One-time setup: pip install -e '.[dev]' && playwright install chromium
|
||||||
Run: python scripts/screenshot_webui.py
|
Run: python scripts/screenshot_webui.py
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import numpy as np
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
from songtrace.config import Config
|
from songtrace.config import Config
|
||||||
from songtrace.pipeline import PipelineError, PipelineResult
|
from songtrace.grid import to_ultrastar
|
||||||
|
from songtrace.pipeline import PipelineError, PipelineResult, song_slug
|
||||||
|
from songtrace.ultrastar import serialize_ultrastar
|
||||||
from songtrace.web import create_app
|
from songtrace.web import create_app
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
OUT_IMAGE = REPO_ROOT / "docs" / "images" / "webui.png"
|
OUT_IMAGE = REPO_ROOT / "docs" / "images" / "webui.png"
|
||||||
STAGES = ["acquire", "separate", "transcribe", "align", "pitch", "notes", "grid", "package"]
|
STAGES = ["acquire", "separate", "transcribe", "align", "pitch", "notes", "grid", "package"]
|
||||||
|
DEFAULT_LYRICS = "[verse]\n...\n[chorus]\n...\n"
|
||||||
|
|
||||||
|
# public domain (16th century English folk song, author unknown)
|
||||||
|
GREENSLEEVES_LYRICS = """\
|
||||||
|
Alas, my love, you do me wrong
|
||||||
|
To cast me off discourteously
|
||||||
|
For I have loved you well and long
|
||||||
|
Delighting in your company
|
||||||
|
|
||||||
|
Greensleeves was all my joy
|
||||||
|
Greensleeves was my delight
|
||||||
|
Greensleeves was my heart of gold
|
||||||
|
And who but my lady Greensleeves
|
||||||
|
|
||||||
|
I have been ready at your hand
|
||||||
|
To grant whatever you would crave
|
||||||
|
I have both waged life and land
|
||||||
|
Your love and good will for to have
|
||||||
|
|
||||||
|
Greensleeves was all my joy
|
||||||
|
Greensleeves was my delight
|
||||||
|
Greensleeves was my heart of gold
|
||||||
|
And who but my lady Greensleeves
|
||||||
|
"""
|
||||||
|
|
||||||
# order matters: the app has one worker thread, so each job runs in submission
|
# order matters: the app has one worker thread, so each job runs in submission
|
||||||
# order. "running" must be submitted after the done/failed ones complete and
|
# order. "running" must be submitted after the done/failed ones complete and
|
||||||
@@ -33,12 +63,59 @@ SONGS = [
|
|||||||
("Pink Moon", "Nick Drake", "done",
|
("Pink Moon", "Nick Drake", "done",
|
||||||
{"warnings": ["2 low-confidence notes near 0:38–0:41",
|
{"warnings": ["2 low-confidence notes near 0:38–0:41",
|
||||||
"1 low-confidence note near 1:22–1:24"]}),
|
"1 low-confidence note near 1:22–1:24"]}),
|
||||||
|
("Landslide", "Fleetwood Mac", "done",
|
||||||
|
{"warnings": ["1 low-confidence note near 0:52–0:55"]}),
|
||||||
|
("Greensleeves", "Traditional", "done",
|
||||||
|
{"lyrics": GREENSLEEVES_LYRICS,
|
||||||
|
"warnings": ["1 low-confidence note near 0:45–0:47",
|
||||||
|
"1 line dropped (not sung) near \"for i have loved\"",
|
||||||
|
"missing lyrics near 2:10–2:18"],
|
||||||
|
"review": {
|
||||||
|
"lines": [
|
||||||
|
# Pitches are the real Greensleeves melody (E natural minor, as
|
||||||
|
# arranged by Gilbert DeBenedetti, "Level 4 - Flowing"), converted
|
||||||
|
# to UltraStar semitones (0 = C4 = MIDI 60): E5=16 F#5=18 G5=19
|
||||||
|
# A5=21 B4=11 D5=14.
|
||||||
|
{"index": 0, "text": "Alas, my love, you do me wrong",
|
||||||
|
"dropped": False,
|
||||||
|
"notes": [{"start": 10.0, "end": 10.4, "pitch": 16, "low_confidence": False},
|
||||||
|
{"start": 10.4, "end": 10.6, "pitch": 18, "low_confidence": False},
|
||||||
|
{"start": 10.6, "end": 11.2, "pitch": 19, "low_confidence": False},
|
||||||
|
{"start": 11.2, "end": 11.5, "pitch": 21, "low_confidence": False},
|
||||||
|
{"start": 11.5, "end": 11.7, "pitch": 19, "low_confidence": False},
|
||||||
|
{"start": 11.7, "end": 11.9, "pitch": 18, "low_confidence": False},
|
||||||
|
{"start": 11.9, "end": 12.9, "pitch": 14, "low_confidence": False}],
|
||||||
|
"words": [{"text": "Alas,", "start": 10.0, "end": 10.4},
|
||||||
|
{"text": "my", "start": 10.4, "end": 10.6},
|
||||||
|
{"text": "love,", "start": 10.6, "end": 11.2},
|
||||||
|
{"text": "you", "start": 11.2, "end": 11.5},
|
||||||
|
{"text": "do", "start": 11.5, "end": 11.7},
|
||||||
|
{"text": "me", "start": 11.7, "end": 11.9},
|
||||||
|
{"text": "wrong", "start": 11.9, "end": 12.9}]},
|
||||||
|
{"index": 1, "text": "To cast me off discourteously",
|
||||||
|
"dropped": False,
|
||||||
|
"notes": [{"start": 44.0, "end": 44.3, "pitch": 11, "low_confidence": False},
|
||||||
|
{"start": 44.3, "end": 44.6, "pitch": 16, "low_confidence": False},
|
||||||
|
{"start": 44.6, "end": 44.8, "pitch": 18, "low_confidence": False},
|
||||||
|
{"start": 44.8, "end": 45.0, "pitch": 19, "low_confidence": False},
|
||||||
|
{"start": 45.0, "end": 45.4, "pitch": 18, "low_confidence": True}],
|
||||||
|
"words": [{"text": "To", "start": 44.0, "end": 44.3},
|
||||||
|
{"text": "cast", "start": 44.3, "end": 44.6},
|
||||||
|
{"text": "me", "start": 44.6, "end": 44.8},
|
||||||
|
{"text": "off", "start": 44.8, "end": 45.0},
|
||||||
|
{"text": "discourteously", "start": 45.0, "end": 45.4}]},
|
||||||
|
{"index": 2, "text": "For I have loved you well and long",
|
||||||
|
"dropped": True, "notes": [], "words": []},
|
||||||
|
],
|
||||||
|
"missing_spans": [{"start": 130.0, "end": 138.0}],
|
||||||
|
}}),
|
||||||
("Such Great Heights", "The Postal Service", "failed",
|
("Such Great Heights", "The Postal Service", "failed",
|
||||||
{"error": "yt-dlp: unable to download video data: HTTP Error 403: Forbidden"}),
|
{"error": "yt-dlp: unable to download video data: HTTP Error 403: Forbidden"}),
|
||||||
("Time Escaping", "Big Thief", "running", {}),
|
("Time Escaping", "Big Thief", "running", {}),
|
||||||
("Vampire in the Corner", "Magdalena Bay", "queued", {}),
|
("Vampire in the Corner", "Magdalena Bay", "queued", {}),
|
||||||
]
|
]
|
||||||
SELECTED = "Pink Moon"
|
SELECTED = "Greensleeves"
|
||||||
|
REVIEW_HEADING = "REVIEW"
|
||||||
|
|
||||||
running_event = threading.Event()
|
running_event = threading.Event()
|
||||||
|
|
||||||
@@ -51,6 +128,43 @@ def make_runner():
|
|||||||
if kind == "done":
|
if kind == "done":
|
||||||
for s in STAGES:
|
for s in STAGES:
|
||||||
log(f"[{s}] running")
|
log(f"[{s}] running")
|
||||||
|
if "review" in extra:
|
||||||
|
work = cfg.work_dir / song_slug(inp)
|
||||||
|
work.mkdir(parents=True, exist_ok=True)
|
||||||
|
# one note per word in the seeded lines, so each sings its word
|
||||||
|
notes = [
|
||||||
|
{**n, "duration": n["end"] - n["start"],
|
||||||
|
"text": (" " if k else "") + w["text"], "line_index": line["index"]}
|
||||||
|
for line in extra["review"]["lines"]
|
||||||
|
for k, (n, w) in enumerate(zip(line["notes"], line["words"]))
|
||||||
|
]
|
||||||
|
sung = [line for line in extra["review"]["lines"] if line["notes"]]
|
||||||
|
breaks = [(a["notes"][-1]["end"] + b["notes"][0]["start"]) / 2
|
||||||
|
for a, b in zip(sung, sung[1:])]
|
||||||
|
(work / "notes_sec.json").write_text(json.dumps({
|
||||||
|
"notes": notes, "breaks": breaks, "warnings": extra["warnings"]}))
|
||||||
|
# a chart for the beat grid, and a sung pitch with a little
|
||||||
|
# vibrato for the curve over the notes
|
||||||
|
(work / "song.txt").write_text(serialize_ultrastar(to_ultrastar(
|
||||||
|
notes, breaks, 240.0, [("TITLE", inp.title)])), encoding="utf-8")
|
||||||
|
times = np.arange(0, 60, 0.01)
|
||||||
|
f0, conf = np.zeros_like(times), np.zeros_like(times)
|
||||||
|
for n in notes:
|
||||||
|
m = (times >= n["start"] + 0.02) & (times < n["start"] + n["duration"] - 0.02)
|
||||||
|
semis = n["pitch"] + 0.2 * np.sin(2 * np.pi * 5.5 * times[m])
|
||||||
|
f0[m], conf[m] = 440 * 2 ** ((semis - 9) / 12), 0.9
|
||||||
|
np.savez(work / "pitch.npz", times=times, f0=f0, conf=conf)
|
||||||
|
(work / "align_meta.json").write_text(json.dumps({
|
||||||
|
"dropped_lines": [line["index"] for line in extra["review"]["lines"]
|
||||||
|
if line["dropped"]],
|
||||||
|
"missing_spans": [[s["start"], s["end"]]
|
||||||
|
for s in extra["review"]["missing_spans"]],
|
||||||
|
}))
|
||||||
|
(work / "alignment.json").write_text(json.dumps([
|
||||||
|
{"word": w["text"], "start": w["start"], "end": w["end"],
|
||||||
|
"score": 0.9, "line_index": line["index"]}
|
||||||
|
for line in extra["review"]["lines"] for w in line.get("words", [])
|
||||||
|
]))
|
||||||
out = cfg.output_dir / f"{inp.artist} - {inp.title}"
|
out = cfg.output_dir / f"{inp.artist} - {inp.title}"
|
||||||
out.mkdir(parents=True, exist_ok=True)
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
return PipelineResult(output_dir=out, warnings=extra["warnings"])
|
return PipelineResult(output_dir=out, warnings=extra["warnings"])
|
||||||
@@ -65,22 +179,50 @@ def make_runner():
|
|||||||
return 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",
|
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)
|
work_dir=tmp_path / "work", bind_host="127.0.0.1", port=8811)
|
||||||
app = create_app(cfg, runner=make_runner())
|
app = create_app(cfg, runner=make_runner())
|
||||||
server = uvicorn.Server(uvicorn.Config(app, host=cfg.bind_host, port=cfg.port,
|
server = uvicorn.Server(uvicorn.Config(app, host=cfg.bind_host, port=cfg.port,
|
||||||
log_level="warning"))
|
log_level="warning"))
|
||||||
threading.Thread(target=server.run, daemon=True).start()
|
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:
|
def seed_jobs(base_url: str) -> None:
|
||||||
lyrics = "[verse]\n...\n[chorus]\n...\n"
|
|
||||||
with httpx.Client(timeout=10) as client:
|
with httpx.Client(timeout=10) as client:
|
||||||
for title, artist, kind, _ in SONGS:
|
for title, artist, kind, extra in SONGS:
|
||||||
resp = client.post(f"{base_url}/api/jobs", data={
|
resp = client.post(f"{base_url}/api/jobs", data={
|
||||||
"title": title, "artist": artist, "lyrics": lyrics,
|
"title": title, "artist": artist,
|
||||||
|
"lyrics": extra.get("lyrics", DEFAULT_LYRICS),
|
||||||
"youtube_url": "https://youtu.be/00000000000"})
|
"youtube_url": "https://youtu.be/00000000000"})
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
job_id = resp.json()["id"]
|
job_id = resp.json()["id"]
|
||||||
@@ -95,13 +237,25 @@ def seed_jobs(base_url: str) -> None:
|
|||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
def capture(base_url: str) -> None:
|
def capture(base_url: str, with_player: bool) -> None:
|
||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
browser = p.chromium.launch()
|
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.goto(base_url, wait_until="networkidle")
|
||||||
page.get_by_text(SELECTED, exact=True).click()
|
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)
|
||||||
|
page.wait_for_selector(".sheet-curve polyline")
|
||||||
|
# scrolled to the review, the panel in use, with the note sheet whole
|
||||||
|
page.evaluate("""() => {
|
||||||
|
const main = document.getElementById('main');
|
||||||
|
main.scrollTo(0, document.getElementById('review').offsetTop - 12);
|
||||||
|
}""")
|
||||||
|
page.wait_for_timeout(400)
|
||||||
OUT_IMAGE.parent.mkdir(parents=True, exist_ok=True)
|
OUT_IMAGE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
page.screenshot(path=str(OUT_IMAGE))
|
page.screenshot(path=str(OUT_IMAGE))
|
||||||
browser.close()
|
browser.close()
|
||||||
@@ -109,10 +263,11 @@ def capture(base_url: str) -> None:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
base_url = start_server(Path(tmp))
|
base_url, cfg = start_server(Path(tmp))
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
seed_jobs(base_url)
|
seed_jobs(base_url)
|
||||||
capture(base_url)
|
with_player = install_media(cfg)
|
||||||
|
capture(base_url, with_player)
|
||||||
print(f"wrote {OUT_IMAGE}")
|
print(f"wrote {OUT_IMAGE}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import tomllib
|
import tomllib
|
||||||
from dataclasses import dataclass, fields
|
from dataclasses import dataclass, field, fields
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -18,9 +18,18 @@ class Config:
|
|||||||
device: str = "cuda"
|
device: str = "cuda"
|
||||||
bind_host: str = "127.0.0.1"
|
bind_host: str = "127.0.0.1"
|
||||||
port: int = 8000
|
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:
|
def load_config(path: Path | None = None) -> Config:
|
||||||
|
|||||||
@@ -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
@@ -3,7 +3,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
import numpy as np
|
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:
|
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,
|
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:
|
if not notes:
|
||||||
raise ValueError("no notes to place on the beat grid")
|
raise ValueError("no notes to place on the beat grid")
|
||||||
gap_ms = round(notes[0]["start"] * 1000)
|
|
||||||
spb = 60 / (4 * file_bpm)
|
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:
|
def beat(t: float) -> int:
|
||||||
return round((t - gap_ms / 1000) / spb)
|
return round((t - gap_ms / 1000) / spb)
|
||||||
@@ -49,10 +62,27 @@ def to_ultrastar(notes: list[dict], breaks: list[float], file_bpm: float,
|
|||||||
else:
|
else:
|
||||||
b = max(beat(t), cursor)
|
b = max(beat(t), cursor)
|
||||||
length = max(1, beat(t + n["duration"]) - beat(t))
|
length = max(1, beat(t + n["duration"]) - beat(t))
|
||||||
body.append(Note(kind=":", beat=b, length=length, pitch=n["pitch"],
|
body.append(Note(kind=n.get("kind", ":"), beat=b, length=length,
|
||||||
text=n["text"]))
|
pitch=n["pitch"], text=n["text"]))
|
||||||
cursor = b + length
|
cursor = b + length
|
||||||
out_headers = list(headers)
|
out_headers = list(headers)
|
||||||
out_headers.append(("BPM", format_bpm(file_bpm)))
|
out_headers.append(("BPM", format_bpm(file_bpm)))
|
||||||
out_headers.append(("GAP", str(gap_ms)))
|
out_headers.append(("GAP", str(gap_ms)))
|
||||||
return UltraStarSong(headers=out_headers, body=body)
|
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")
|
||||||
|
|||||||
@@ -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)
|
||||||
+62
-19
@@ -7,7 +7,17 @@ import pyphen
|
|||||||
from .languages import LANGUAGES
|
from .languages import LANGUAGES
|
||||||
|
|
||||||
CONF_MASK = 0.5 # pitch frames below this are unvoiced
|
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
|
SPLIT_SEMITONES = 0.8
|
||||||
MIN_SEG_SEC = 0.05
|
MIN_SEG_SEC = 0.05
|
||||||
MIN_NOTE_SEC = 0.05 # alignment may hand us zero-length words
|
MIN_NOTE_SEC = 0.05 # alignment may hand us zero-length words
|
||||||
@@ -23,6 +33,8 @@ class TimedNote:
|
|||||||
pitch: int # UltraStar semitone (0 = C4 = MIDI 60)
|
pitch: int # UltraStar semitone (0 = C4 = MIDI 60)
|
||||||
text: str # display text incl. leading space / "~"
|
text: str # display text incl. leading space / "~"
|
||||||
low_confidence: bool
|
low_confidence: bool
|
||||||
|
line_index: int
|
||||||
|
kind: str = ":" # UltraStar note type; generated notes are all normal
|
||||||
|
|
||||||
|
|
||||||
def hz_to_midi_float(hz: float) -> float:
|
def hz_to_midi_float(hz: float) -> float:
|
||||||
@@ -78,10 +90,12 @@ def syllabify(word: str, language: str | None) -> list[str]:
|
|||||||
return parts or [word]
|
return parts or [word]
|
||||||
|
|
||||||
|
|
||||||
def build_notes(rows: list[dict], times, f0, conf, language: str | None
|
def build_notes(rows: list[dict], times, f0, conf, language: str | None,
|
||||||
|
dropped_lines=(), missing_spans=(), ref_lines=None
|
||||||
) -> tuple[list[TimedNote], list[float], list[str]]:
|
) -> tuple[list[TimedNote], list[float], list[str]]:
|
||||||
notes: list[TimedNote] = []
|
notes: list[TimedNote] = []
|
||||||
breaks: list[float] = []
|
breaks: list[float] = []
|
||||||
|
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
|
prev_line, prev_end = None, 0.0
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if prev_line is not None and row["line_index"] > prev_line:
|
if prev_line is not None and row["line_index"] > prev_line:
|
||||||
@@ -92,22 +106,26 @@ def build_notes(rows: list[dict], times, f0, conf, language: str | None
|
|||||||
if not segs:
|
if not segs:
|
||||||
pitch = notes[-1].pitch if notes else 0
|
pitch = notes[-1].pitch if notes else 0
|
||||||
notes.append(_note(row["start"], row["end"] - row["start"], pitch,
|
notes.append(_note(row["start"], row["end"] - row["start"], pitch,
|
||||||
_text(row["word"], 0, line_initial), True))
|
_text(row["word"], 0, line_initial), True,
|
||||||
|
row["line_index"]))
|
||||||
continue
|
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)
|
sylls = syllabify(row["word"], language)
|
||||||
if len(segs) < len(sylls):
|
if len(segs) < len(sylls):
|
||||||
segs = _subdivide(segs, len(sylls))
|
segs = _subdivide(segs, len(sylls))
|
||||||
for i, (s, e, midi) in enumerate(segs):
|
for i, (s, e, midi) in enumerate(segs):
|
||||||
text = _text(sylls[i], i, line_initial) if i < len(sylls) else "~"
|
text = _text(sylls[i], i, line_initial) if i < len(sylls) else "~"
|
||||||
notes.append(_note(s, e - s, round(midi) - 60, text, low))
|
notes.append(_note(s, e - s, round(midi) - 60, text, low,
|
||||||
|
row["line_index"]))
|
||||||
_deoverlap(notes)
|
_deoverlap(notes)
|
||||||
return notes, breaks, _warnings(notes)
|
return notes, breaks, _warnings(notes, dropped_lines, missing_spans, ref_lines or [])
|
||||||
|
|
||||||
|
|
||||||
def _note(start: float, duration: float, pitch: int, text: str,
|
def _note(start: float, duration: float, pitch: int, text: str,
|
||||||
low: bool) -> TimedNote:
|
low: bool, line_index: int) -> TimedNote:
|
||||||
return TimedNote(float(start), float(duration), int(pitch), text, low)
|
return TimedNote(float(start), float(duration), int(pitch), text, low,
|
||||||
|
int(line_index))
|
||||||
|
|
||||||
|
|
||||||
def _deoverlap(notes: list[TimedNote]) -> None:
|
def _deoverlap(notes: list[TimedNote]) -> None:
|
||||||
@@ -153,15 +171,40 @@ def _fmt(t: float) -> str:
|
|||||||
return f"{int(t // 60)}:{int(t % 60):02d}"
|
return f"{int(t // 60)}:{int(t % 60):02d}"
|
||||||
|
|
||||||
|
|
||||||
def _warnings(notes: list[TimedNote]) -> list[str]:
|
def low_confidence_warnings(notes: list[TimedNote]) -> list[str]:
|
||||||
warnings, run = [], []
|
runs: list[list[TimedNote]] = []
|
||||||
for n in list(notes) + [None]:
|
for n in notes:
|
||||||
if n is not None and n.low_confidence:
|
if not n.low_confidence:
|
||||||
run.append(n)
|
continue
|
||||||
elif run:
|
last = runs[-1][-1] if runs else None
|
||||||
first, last = run[0], run[-1]
|
if last is not None and n.start - (last.start + last.duration) < WARN_GAP_SEC:
|
||||||
warnings.append(
|
runs[-1].append(n)
|
||||||
f"{len(run)} low-confidence note{'' if len(run) == 1 else 's'} "
|
else:
|
||||||
f"near {_fmt(first.start)}–{_fmt(last.start + last.duration)}")
|
runs.append([n])
|
||||||
run = []
|
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 = 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 ""
|
||||||
|
warnings.append(
|
||||||
|
f"{n} line{'' if n == 1 else 's'} dropped (not sung) near \"{text}\"")
|
||||||
|
for a, b in missing_spans:
|
||||||
|
warnings.append(f"missing lyrics near {_fmt(a)}–{_fmt(b)}")
|
||||||
return warnings
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _consecutive_runs(indices) -> list[list[int]]:
|
||||||
|
runs = []
|
||||||
|
for i in sorted(indices):
|
||||||
|
if runs and i == runs[-1][-1] + 1:
|
||||||
|
runs[-1].append(i)
|
||||||
|
else:
|
||||||
|
runs.append([i])
|
||||||
|
return runs
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from . import models
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
|
||||||
class PipelineError(Exception):
|
class PipelineError(Exception):
|
||||||
@@ -45,6 +46,16 @@ def song_slug(inp: SongInput) -> str:
|
|||||||
name = f"{inp.artist} - {inp.title}"
|
name = f"{inp.artist} - {inp.title}"
|
||||||
return name.replace("/", "").replace("\0", "")
|
return name.replace("/", "").replace("\0", "")
|
||||||
|
|
||||||
|
def read_warnings(work: Path) -> list[str]:
|
||||||
|
# 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", [])
|
||||||
|
|
||||||
def _real_stages() -> list[Stage]:
|
def _real_stages() -> list[Stage]:
|
||||||
from . import stages
|
from . import stages
|
||||||
return stages.all_stages()
|
return stages.all_stages()
|
||||||
@@ -68,9 +79,14 @@ def run_pipeline(inp: SongInput, cfg: Config, log=print,
|
|||||||
log(f"[{stage.name}] cached, skipping")
|
log(f"[{stage.name}] cached, skipping")
|
||||||
continue
|
continue
|
||||||
log(f"[{stage.name}] running")
|
log(f"[{stage.name}] running")
|
||||||
stage.run(ctx)
|
# per stage rather than per job: each model is used by one stage, so
|
||||||
warnings = []
|
# holding it past that only keeps VRAM from whatever else wants it
|
||||||
notes_file = work / "notes_sec.json"
|
try:
|
||||||
if notes_file.exists():
|
stage.run(ctx)
|
||||||
warnings = json.loads(notes_file.read_text()).get("warnings", [])
|
finally:
|
||||||
return PipelineResult(output_dir=cfg.output_dir / song_slug(inp), warnings=warnings)
|
# 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))
|
||||||
|
|||||||
@@ -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)
|
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:
|
def relocate_work(old: SongInput, new: SongInput, cfg: Config) -> bool:
|
||||||
"""Move the cached work dir when title/artist changed its slug.
|
"""Move the cached work dir when title/artist changed its slug.
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,19 @@ import json
|
|||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .. import ytdlp
|
||||||
|
from ..config import Config
|
||||||
from ..media import ffmpeg
|
from ..media import ffmpeg
|
||||||
from ..pipeline import Ctx, PipelineError, Stage
|
from ..pipeline import Ctx, PipelineError, Stage
|
||||||
|
|
||||||
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"}
|
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]:
|
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]
|
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]
|
videos = [p for p in files if p.suffix.lower() not in IMAGE_EXTENSIONS]
|
||||||
if not videos:
|
if not videos:
|
||||||
@@ -18,17 +23,50 @@ def _classify_downloads(dest: Path) -> tuple[Path, Path | None]:
|
|||||||
return video, thumbs[0] if thumbs else None
|
return video, thumbs[0] if thumbs else None
|
||||||
|
|
||||||
|
|
||||||
def download_youtube(url: str, dest: Path) -> tuple[Path, Path | None]:
|
def _ydl_args(cfg: Config, dest: Path, client: str, url: str) -> list[str]:
|
||||||
import yt_dlp
|
args = [
|
||||||
opts = {
|
"--output", str(dest / "source_video.%(ext)s"),
|
||||||
"outtmpl": str(dest / "source_video.%(ext)s"),
|
"--format", "bestvideo*+bestaudio/best",
|
||||||
"format": "bestvideo*+bestaudio/best",
|
"--write-thumbnail", "--no-playlist", "--no-progress",
|
||||||
"writethumbnail": True,
|
"--retries", "10", "--fragment-retries", "10",
|
||||||
"noplaylist": True,
|
"--extractor-retries", "5",
|
||||||
}
|
]
|
||||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
if client != "default":
|
||||||
ydl.extract_info(url, download=True)
|
args += ["--extractor-args", f"youtube:player_client={client}"]
|
||||||
return _classify_downloads(dest)
|
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:
|
def _run(ctx: Ctx) -> None:
|
||||||
@@ -36,7 +74,8 @@ def _run(ctx: Ctx) -> None:
|
|||||||
raise PipelineError("provide exactly one of youtube_url / video_path")
|
raise PipelineError("provide exactly one of youtube_url / video_path")
|
||||||
thumb = None
|
thumb = None
|
||||||
if ctx.inp.youtube_url:
|
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:
|
else:
|
||||||
video = ctx.work / f"source_video{ctx.inp.video_path.suffix}"
|
video = ctx.work / f"source_video{ctx.inp.video_path.suffix}"
|
||||||
shutil.copyfile(ctx.inp.video_path, video)
|
shutil.copyfile(ctx.inp.video_path, video)
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..lyrics import RefLyrics, alignment_token, normalize_lyrics
|
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 ..pipeline import Ctx, PipelineError, Stage
|
||||||
from .transcribe import 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
|
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 = {}
|
_model_cache = {}
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +32,11 @@ def _get_model(device: str):
|
|||||||
return _model_cache[device]
|
return _model_cache[device]
|
||||||
|
|
||||||
|
|
||||||
|
@on_release
|
||||||
|
def unload_model() -> None:
|
||||||
|
_model_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
def force_align_section(vocals_path: Path, start: float, end: float,
|
def force_align_section(vocals_path: Path, start: float, end: float,
|
||||||
tokens: list[str], device: str) -> list[tuple[float, float, float]]:
|
tokens: list[str], device: str) -> list[tuple[float, float, float]]:
|
||||||
import torch
|
import torch
|
||||||
@@ -32,7 +52,7 @@ def force_align_section(vocals_path: Path, start: float, end: float,
|
|||||||
waveform[:, lo:hi], sr, bundle.sample_rate)
|
waveform[:, lo:hi], sr, bundle.sample_rate)
|
||||||
# MMS_FA only models a-z and apostrophe; anything else becomes the star token.
|
# MMS_FA only models a-z and apostrophe; anything else becomes the star token.
|
||||||
vocab = bundle.get_dict()
|
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():
|
with torch.inference_mode():
|
||||||
emission, _ = _get_model(device)(waveform.to(device))
|
emission, _ = _get_model(device)(waveform.to(device))
|
||||||
spans = bundle.get_aligner()(emission[0], bundle.get_tokenizer()(tokens))
|
spans = bundle.get_aligner()(emission[0], bundle.get_tokenizer()(tokens))
|
||||||
@@ -42,16 +62,48 @@ def force_align_section(vocals_path: Path, start: float, end: float,
|
|||||||
sum(t.score for t in s) / len(s)) for s in spans]
|
sum(t.score for t in s) / len(s)) for s in spans]
|
||||||
|
|
||||||
|
|
||||||
def _assign_lines_to_sections(ref: RefLyrics, sections) -> list[list[int]]:
|
def untranscribed_singing(vocals_path: Path, segments,
|
||||||
"""Split lyric lines across vocal sections proportionally to section duration."""
|
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
|
total = sum(b - a for a, b in sections) or 1.0
|
||||||
n_lines = len(ref.lines)
|
n = len(line_indices)
|
||||||
groups, elapsed, start = [], 0.0, 0
|
groups, elapsed, start = [], 0.0, 0
|
||||||
for i, (a, b) in enumerate(sections):
|
for i, (a, b) in enumerate(sections):
|
||||||
elapsed += b - a
|
elapsed += b - a
|
||||||
stop = n_lines if i == len(sections) - 1 else min(
|
stop = n if i == len(sections) - 1 else min(n, round(n * elapsed / total))
|
||||||
n_lines, round(n_lines * elapsed / total))
|
groups.append(line_indices[start:max(stop, start)])
|
||||||
groups.append(list(range(start, max(stop, start))))
|
|
||||||
start = max(stop, start)
|
start = max(stop, start)
|
||||||
return groups
|
return groups
|
||||||
|
|
||||||
@@ -85,9 +137,20 @@ def _enforce_monotonic(rows: list[dict]) -> None:
|
|||||||
def _align_section(vocals: Path, sec: tuple[float, float], words: list[tuple[str, int]],
|
def _align_section(vocals: Path, sec: tuple[float, float], words: list[tuple[str, int]],
|
||||||
language: str | None, device: str) -> list[dict]:
|
language: str | None, device: str) -> list[dict]:
|
||||||
tokens = [alignment_token(w, language) for w, _ in words]
|
tokens = [alignment_token(w, language) for w, _ in words]
|
||||||
alignable = [t for t in tokens if t]
|
# A star before, between and after lines lets speech, laughter or an
|
||||||
aligned = iter(force_align_section(vocals, sec[0], sec[1], alignable, device)
|
# ad-lib there go unexplained rather than claim the next line's letters.
|
||||||
if alignable else [])
|
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)
|
rows, blank = [], (None, None, 0.0)
|
||||||
for (word, line_index), token in zip(words, tokens):
|
for (word, line_index), token in zip(words, tokens):
|
||||||
start, end, score = next(aligned, blank) if token else blank
|
start, end, score = next(aligned, blank) if token else blank
|
||||||
@@ -99,23 +162,36 @@ def _align_section(vocals: Path, sec: tuple[float, float], words: list[tuple[str
|
|||||||
|
|
||||||
def _run(ctx: Ctx) -> None:
|
def _run(ctx: Ctx) -> None:
|
||||||
ref = normalize_lyrics(ctx.inp.lyrics_text)
|
ref = normalize_lyrics(ctx.inp.lyrics_text)
|
||||||
segments = load_transcript(ctx)
|
segments = sorted(load_transcript(ctx), key=lambda s: s.start)
|
||||||
sim = lyrics_similarity(segments, ref)
|
sim = lyrics_similarity(segments, ref)
|
||||||
if sim < SIMILARITY_THRESHOLD:
|
if sim < SIMILARITY_THRESHOLD:
|
||||||
raise PipelineError(
|
raise PipelineError(
|
||||||
f"lyrics don't match audio (similarity {sim:.2f}) — "
|
f"lyrics don't match audio (similarity {sim:.2f}) — "
|
||||||
"wrong lyrics or wrong recording?")
|
"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)]
|
|
||||||
vocals = ctx.work / "vocals.wav"
|
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 = []
|
rows = []
|
||||||
for sec, line_idxs in zip(sections, _assign_lines_to_sections(ref, sections)):
|
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]]
|
words = [(w, li) for li in line_idxs for w in ref.lines[li]]
|
||||||
if words:
|
if words:
|
||||||
rows.extend(_align_section(vocals, sec, words, ctx.inp.language,
|
rows.extend(_align_section(vocals, sec, words, ctx.inp.language,
|
||||||
ctx.cfg.device))
|
ctx.cfg.device))
|
||||||
_enforce_monotonic(rows)
|
_enforce_monotonic(rows)
|
||||||
(ctx.work / "alignment.json").write_text(json.dumps(rows))
|
(ctx.work / "alignment.json").write_text(json.dumps(rows))
|
||||||
|
(ctx.work / "align_meta.json").write_text(json.dumps({
|
||||||
|
"dropped_lines": dropped_lines,
|
||||||
|
"missing_spans": [list(s) for s in missing_spans],
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
def align_stage() -> Stage:
|
def align_stage() -> Stage:
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
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 ..pipeline import Ctx, PipelineError, Stage, song_slug
|
||||||
from ..ultrastar import serialize_ultrastar
|
from ..ultrastar import serialize_ultrastar
|
||||||
|
|
||||||
|
|
||||||
def _run(ctx: Ctx) -> None:
|
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())
|
meta = json.loads((ctx.work / "meta.json").read_text())
|
||||||
if ctx.inp.bpm_override is not None:
|
if ctx.inp.bpm_override is not None:
|
||||||
bpm = ctx.inp.bpm_override
|
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"))
|
bpm = grid.scale_bpm(grid.detect_bpm(ctx.work / "instrumental.wav"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise PipelineError("could not detect a usable tempo - is the audio silent?")
|
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)
|
slug = song_slug(ctx.inp)
|
||||||
video_ext = Path(meta["video_file"]).suffix
|
video_ext = Path(meta["video_file"]).suffix
|
||||||
headers = [("TITLE", ctx.inp.title), ("ARTIST", ctx.inp.artist)]
|
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"),
|
headers += [("CREATOR", ctx.cfg.creator), ("MP3", f"{slug}.mp3"),
|
||||||
("COVER", f"{slug}.jpg"), ("VIDEO", f"{slug}{video_ext}")]
|
("COVER", f"{slug}.jpg"), ("VIDEO", f"{slug}{video_ext}")]
|
||||||
try:
|
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:
|
except ValueError:
|
||||||
raise PipelineError("no notes were generated - the lyrics may not match the audio")
|
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")
|
(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:
|
def grid_stage() -> Stage:
|
||||||
return Stage(name="grid",
|
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)
|
run=_run)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from dataclasses import asdict
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from ..lyrics import normalize_lyrics
|
||||||
from ..notes import build_notes
|
from ..notes import build_notes
|
||||||
from ..pipeline import Ctx, Stage
|
from ..pipeline import Ctx, Stage
|
||||||
|
|
||||||
@@ -10,8 +11,15 @@ from ..pipeline import Ctx, Stage
|
|||||||
def _run(ctx: Ctx) -> None:
|
def _run(ctx: Ctx) -> None:
|
||||||
rows = json.loads((ctx.work / "alignment.json").read_text())
|
rows = json.loads((ctx.work / "alignment.json").read_text())
|
||||||
data = np.load(ctx.work / "pitch.npz")
|
data = np.load(ctx.work / "pitch.npz")
|
||||||
|
meta_path = ctx.work / "align_meta.json"
|
||||||
|
meta = json.loads(meta_path.read_text()) if meta_path.exists() else {}
|
||||||
|
dropped_lines = meta.get("dropped_lines", [])
|
||||||
|
missing_spans = [tuple(s) for s in meta.get("missing_spans", [])]
|
||||||
|
ref_lines = normalize_lyrics(ctx.inp.lyrics_text).lines
|
||||||
notes, breaks, warnings = build_notes(
|
notes, breaks, warnings = build_notes(
|
||||||
rows, data["times"], data["f0"], data["conf"], ctx.inp.language)
|
rows, data["times"], data["f0"], data["conf"], ctx.inp.language,
|
||||||
|
dropped_lines=dropped_lines, missing_spans=missing_spans,
|
||||||
|
ref_lines=ref_lines)
|
||||||
(ctx.work / "notes_sec.json").write_text(json.dumps({
|
(ctx.work / "notes_sec.json").write_text(json.dumps({
|
||||||
"notes": [asdict(n) for n in notes],
|
"notes": [asdict(n) for n in notes],
|
||||||
"breaks": breaks,
|
"breaks": breaks,
|
||||||
|
|||||||
@@ -51,11 +51,15 @@ def _run(ctx: Ctx) -> None:
|
|||||||
meta = json.loads((ctx.work / "meta.json").read_text())
|
meta = json.loads((ctx.work / "meta.json").read_text())
|
||||||
src_video = ctx.work / meta["video_file"]
|
src_video = ctx.work / meta["video_file"]
|
||||||
fmt, codec = ffprobe_codec(src_video)
|
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:
|
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:
|
else:
|
||||||
ffmpeg("-i", str(src_video), "-c:v", "libx264", "-crf", "20",
|
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")
|
_set_header(txt, "VIDEO", f"{slug}.mp4")
|
||||||
cover_src = ctx.inp.cover_path
|
cover_src = ctx.inp.cover_path
|
||||||
if cover_src is None and meta.get("thumbnail"):
|
if cover_src is None and meta.get("thumbnail"):
|
||||||
|
|||||||
@@ -1,9 +1,32 @@
|
|||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from ..models import on_release
|
||||||
from ..pipeline import Ctx, Stage
|
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):
|
def track_pitch(vocals_path: Path, model: str, device: str):
|
||||||
import torchaudio
|
import torchaudio
|
||||||
@@ -20,6 +43,7 @@ def track_pitch(vocals_path: Path, model: str, device: str):
|
|||||||
return_periodicity=True, batch_size=512)
|
return_periodicity=True, batch_size=512)
|
||||||
f0 = f0[0].cpu().numpy()
|
f0 = f0[0].cpu().numpy()
|
||||||
conf = periodicity[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
|
times = np.arange(len(f0)) * 0.01
|
||||||
return times, f0, conf
|
return times, f0, conf
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,112 @@ def _tokens(text: str) -> list[str]:
|
|||||||
return [t for w in text.split() if (t := alignment_token(w))]
|
return [t for w in text.split() if (t := alignment_token(w))]
|
||||||
|
|
||||||
|
|
||||||
|
def match_lyrics(segments, ref: RefLyrics):
|
||||||
|
heard, seg_of = [], []
|
||||||
|
for si, s in enumerate(segments):
|
||||||
|
for t in _tokens(s.text):
|
||||||
|
heard.append(t)
|
||||||
|
seg_of.append(si)
|
||||||
|
pairs = [(t, li) for w, li in zip(ref.words, ref.word_line_index)
|
||||||
|
if (t := alignment_token(w))]
|
||||||
|
expected = [t for t, _ in pairs]
|
||||||
|
line_of = [li for _, li in pairs]
|
||||||
|
matcher = difflib.SequenceMatcher(a=heard, b=expected)
|
||||||
|
return matcher, seg_of, line_of
|
||||||
|
|
||||||
|
|
||||||
def lyrics_similarity(segments, ref: RefLyrics) -> float:
|
def lyrics_similarity(segments, ref: RefLyrics) -> float:
|
||||||
heard = [t for s in segments for t in _tokens(s.text)]
|
matcher, _, _ = match_lyrics(segments, ref)
|
||||||
expected = [t for w in ref.words if (t := alignment_token(w))]
|
return matcher.ratio()
|
||||||
return difflib.SequenceMatcher(a=heard, b=expected).ratio()
|
|
||||||
|
|
||||||
|
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), 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":
|
||||||
|
continue
|
||||||
|
sis = [seg_of[i] for i in range(ai1, ai2)]
|
||||||
|
start = max(0.0, min(segments[si].start for si in sis) - pad)
|
||||||
|
end = max(segments[si].end for si in sis) + pad
|
||||||
|
spans.append((start, end))
|
||||||
|
return dropped_lines, _merge_spans(spans)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_spans(spans: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||||
|
merged: list[list[float]] = []
|
||||||
|
for a, b in sorted(spans):
|
||||||
|
if merged and a <= merged[-1][1]:
|
||||||
|
merged[-1][1] = max(merged[-1][1], b)
|
||||||
|
else:
|
||||||
|
merged.append([a, b])
|
||||||
|
return [tuple(s) for s in merged]
|
||||||
|
|
||||||
|
|
||||||
def _run(ctx: Ctx) -> None:
|
def _run(ctx: Ctx) -> None:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+963
-85
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,965 @@
|
|||||||
|
// 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 BREAK_REACH = 3; // seconds past the line the page grows to show a break
|
||||||
|
const EDGE = 6; // px from a note's end that grabs it for resizing
|
||||||
|
const KINDS = { "*": "golden", F: "free", R: "rap", G: "golden-rap" };
|
||||||
|
const KEYS_OPEN = "songtrace.sheet-keys";
|
||||||
|
|
||||||
|
// 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 it grows to
|
||||||
|
// show them, but not across a long instrumental: one further away is
|
||||||
|
// pinned to the edge instead (see shownBreakTime)
|
||||||
|
for (const slot of breakSlots()) {
|
||||||
|
const t = breakTime(slot);
|
||||||
|
want.t0 = Math.min(want.t0, Math.max(t - 0.3, span.start - BREAK_REACH));
|
||||||
|
want.t1 = Math.max(want.t1, Math.min(t + 0.3, span.end + BREAK_REACH));
|
||||||
|
}
|
||||||
|
layout = reset || !layout ? want : {
|
||||||
|
t0: Math.min(layout.t0, want.t0), t1: Math.max(layout.t1, want.t1),
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// where a break is drawn and grabbed: on the page, pinned to its edge
|
||||||
|
// when it lies beyond it
|
||||||
|
function shownBreakTime(slot) {
|
||||||
|
const edge = (layout.t1 - layout.t0) * 0.003; // a few pixels in
|
||||||
|
const t = breakTime(slot);
|
||||||
|
const shown = Math.min(Math.max(t, layout.t0 + edge), layout.t1 - edge);
|
||||||
|
return { t: shown, pinned: shown !== t ? (t < shown ? "left" : "right") : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const undo = () => { if (hist?.canUndo) { hist.undo(); fit(false); render(); } };
|
||||||
|
const redo = () => { if (hist?.canRedo) { hist.redo(); fit(false); render(); } };
|
||||||
|
const one = () => (selected.size === 1 ? [...selected][0] : null);
|
||||||
|
|
||||||
|
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, pinned } = shownBreakTime(slot);
|
||||||
|
const value = hist.present[slot.which];
|
||||||
|
const byHand = value !== null &&
|
||||||
|
(value !== baseline[slot.which] || review.breaks?.find(b => b.line === slot.line)?.moved);
|
||||||
|
const cls = `sheet-break${byHand ? " sheet-break-moved" : ""}`;
|
||||||
|
el("line", { x1: x(t), x2: x(t), y1: 0, y2: PLOT_HEIGHT, class: cls }, marks);
|
||||||
|
const label = el("text", { x: pinned === "right" ? x(t) - 3 : x(t) + 3, y: 11,
|
||||||
|
"text-anchor": pinned === "right" ? "end" : "start",
|
||||||
|
class: `${cls} sheet-break-label` }, marks);
|
||||||
|
label.textContent = pinned === "right" ? "↵ →" : pinned === "left" ? "← ↵" : "↵";
|
||||||
|
}
|
||||||
|
|
||||||
|
syllables.replaceChildren(...notes().map(n => {
|
||||||
|
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(shownBreakTime(s).t)) <= 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`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+387
-22
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import math
|
import math
|
||||||
import queue
|
import queue
|
||||||
import re
|
import re
|
||||||
@@ -5,21 +6,28 @@ import shutil
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from pathlib import Path
|
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.concurrency import run_in_threadpool
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from . import edits, grid
|
||||||
from . import rebuild as rebuild_policy
|
from . import rebuild as rebuild_policy
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .pipeline import (STAGE_ORDER, PipelineError, SongInput, run_pipeline,
|
from .lyrics import normalize_lyrics
|
||||||
song_slug)
|
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"
|
STATIC = Path(__file__).parent / "static"
|
||||||
STAGE_LINE = re.compile(r"^\[(\w+)\] (?:running|cached, skipping)")
|
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
|
@dataclass
|
||||||
@@ -35,6 +43,7 @@ class Job:
|
|||||||
error: str | None = None
|
error: str | None = None
|
||||||
inp: SongInput | None = None
|
inp: SongInput | None = None
|
||||||
rebuilt_from: str | None = None
|
rebuilt_from: str | None = None
|
||||||
|
force_stage: str | None = None
|
||||||
created_at: float = field(default_factory=time.time)
|
created_at: float = field(default_factory=time.time)
|
||||||
started_at: float | None = None
|
started_at: float | None = None
|
||||||
finished_at: float | None = None
|
finished_at: float | None = None
|
||||||
@@ -71,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
|
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
|
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>.
|
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()
|
uploads = (cfg.work_dir / "uploads").resolve()
|
||||||
dirs = {uploads / job_id}
|
dirs = {uploads / job_id}
|
||||||
@@ -91,25 +100,62 @@ def _cleanup_uploads(cfg: Config, job_id: str, video_path: Path | None) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_jobs(cfg: Config) -> list[Job]:
|
||||||
|
discovered = []
|
||||||
|
if not cfg.work_dir.exists():
|
||||||
|
return discovered
|
||||||
|
for sidecar in cfg.work_dir.glob("*/input.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(sidecar.read_text())
|
||||||
|
for key in ("video_path", "cover_path"):
|
||||||
|
if data.get(key) is not None:
|
||||||
|
data[key] = Path(data[key])
|
||||||
|
inp = SongInput(**data)
|
||||||
|
slug = song_slug(inp)
|
||||||
|
out = cfg.output_dir / slug
|
||||||
|
done = out.exists() and any(out.iterdir())
|
||||||
|
mtime = sidecar.stat().st_mtime
|
||||||
|
warnings = read_warnings(sidecar.parent) if done else []
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
discovered.append(Job(
|
||||||
|
id=uuid.uuid4().hex, title=inp.title, artist=inp.artist, inp=inp,
|
||||||
|
status="done" if done else "failed",
|
||||||
|
output_dir=str(out) if done else None,
|
||||||
|
warnings=warnings,
|
||||||
|
error=None if done else "interrupted before completion",
|
||||||
|
created_at=mtime, finished_at=mtime))
|
||||||
|
discovered.sort(key=lambda j: j.created_at)
|
||||||
|
return discovered
|
||||||
|
|
||||||
|
|
||||||
def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
||||||
jobs: dict[str, Job] = {}
|
jobs: dict[str, Job] = {}
|
||||||
order: list[str] = []
|
order: list[str] = []
|
||||||
lock = threading.Lock()
|
lock = threading.Lock()
|
||||||
|
edit_lock = threading.Lock()
|
||||||
todo: queue.Queue[str] = queue.Queue()
|
todo: queue.Queue[str] = queue.Queue()
|
||||||
|
|
||||||
|
for job in _discover_jobs(cfg):
|
||||||
|
jobs[job.id] = job
|
||||||
|
order.append(job.id)
|
||||||
|
|
||||||
def _prepare_rebuild(job: Job, log) -> str | None:
|
def _prepare_rebuild(job: Job, log) -> str | None:
|
||||||
"""Relocate cached work and report the stage this rebuild restarts from."""
|
"""Relocate cached work and report the stage this rebuild restarts from."""
|
||||||
if not job.rebuilt_from:
|
if not job.rebuilt_from:
|
||||||
return None
|
return job.force_stage
|
||||||
with lock:
|
with lock:
|
||||||
parent = jobs.get(job.rebuilt_from)
|
parent = jobs.get(job.rebuilt_from)
|
||||||
if parent is None:
|
if parent is None:
|
||||||
return None
|
return job.force_stage
|
||||||
force_from = rebuild_policy.plan(parent.inp, job.inp)
|
planned = rebuild_policy.plan(parent.inp, job.inp)
|
||||||
changed = rebuild_policy.changed_fields(parent.inp, job.inp)
|
changed = rebuild_policy.changed_fields(parent.inp, job.inp)
|
||||||
rebuild_policy.relocate_work(parent.inp, job.inp, cfg)
|
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'}"
|
log(f"[rebuild] restarting from {force_from or 'the first incomplete stage'}"
|
||||||
f" ({', '.join(changed) if changed else 'nothing'} changed)")
|
f" ({', '.join(changed) if changed else 'nothing'} changed)")
|
||||||
return force_from
|
return force_from
|
||||||
@@ -128,6 +174,15 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
|||||||
shutil.rmtree(old, ignore_errors=True)
|
shutil.rmtree(old, ignore_errors=True)
|
||||||
log(f"[rebuild] removed superseded {old.name}")
|
log(f"[rebuild] removed superseded {old.name}")
|
||||||
|
|
||||||
|
def _write_sidecar(job: Job) -> None:
|
||||||
|
work = cfg.work_dir / song_slug(job.inp)
|
||||||
|
work.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = asdict(job.inp)
|
||||||
|
for key in ("video_path", "cover_path"):
|
||||||
|
if data[key] is not None:
|
||||||
|
data[key] = str(data[key])
|
||||||
|
(work / "input.json").write_text(json.dumps(data))
|
||||||
|
|
||||||
def worker():
|
def worker():
|
||||||
while True:
|
while True:
|
||||||
job_id = todo.get()
|
job_id = todo.get()
|
||||||
@@ -142,32 +197,31 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
|||||||
if m := STAGE_LINE.match(msg):
|
if m := STAGE_LINE.match(msg):
|
||||||
job.stage = m[1]
|
job.stage = m[1]
|
||||||
|
|
||||||
|
status, error = "done", None
|
||||||
try:
|
try:
|
||||||
force_from = _prepare_rebuild(job, log)
|
force_from = _prepare_rebuild(job, log)
|
||||||
|
_write_sidecar(job)
|
||||||
result = runner(job.inp, cfg, log=log, force_from=force_from)
|
result = runner(job.inp, cfg, log=log, force_from=force_from)
|
||||||
with lock:
|
with lock:
|
||||||
job.output_dir = str(result.output_dir)
|
job.output_dir = str(result.output_dir)
|
||||||
job.warnings = list(result.warnings)
|
job.warnings = list(result.warnings)
|
||||||
_drop_superseded_output(job, log)
|
_drop_superseded_output(job, log)
|
||||||
with lock:
|
|
||||||
job.status = "done"
|
|
||||||
# uploads survive a failure so the job stays rerunnable; acquire
|
# uploads survive a failure so the job stays rerunnable; acquire
|
||||||
# has already copied the video into the work dir by now.
|
# has already copied the video into the work dir by now.
|
||||||
_cleanup_uploads(cfg, job_id, job.inp.video_path)
|
_cleanup_uploads(cfg, job_id, job.inp.video_path)
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
with lock:
|
status, error = "failed", str(e)
|
||||||
job.status = "failed"
|
# last, and all at once: a client that sees the job finished must
|
||||||
job.error = str(e)
|
# find everything finishing it does already done
|
||||||
finally:
|
with lock:
|
||||||
with lock:
|
job.status, job.error, job.finished_at = status, error, time.time()
|
||||||
job.finished_at = time.time()
|
|
||||||
|
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
def _enqueue(job_id: str, inp: SongInput,
|
def _enqueue(job_id: str, inp: SongInput, rebuilt_from: str | None = None,
|
||||||
rebuilt_from: str | None = None) -> dict:
|
force_stage: str | None = None) -> dict:
|
||||||
job = Job(id=job_id, title=inp.title, artist=inp.artist, inp=inp,
|
job = Job(id=job_id, title=inp.title, artist=inp.artist, inp=inp,
|
||||||
rebuilt_from=rebuilt_from)
|
rebuilt_from=rebuilt_from, force_stage=force_stage)
|
||||||
with lock:
|
with lock:
|
||||||
jobs[job_id] = job
|
jobs[job_id] = job
|
||||||
order.append(job_id)
|
order.append(job_id)
|
||||||
@@ -196,6 +250,7 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
|||||||
cover: UploadFile | None = File(None)):
|
cover: UploadFile | None = File(None)):
|
||||||
youtube_url, genre, language = (youtube_url or None, genre or None,
|
youtube_url, genre, language = (youtube_url or None, genre or None,
|
||||||
language or None)
|
language or None)
|
||||||
|
lyrics = lyrics.replace("\r\n", "\n")
|
||||||
has_video = bool(video and video.filename)
|
has_video = bool(video and video.filename)
|
||||||
if bool(youtube_url) == has_video:
|
if bool(youtube_url) == has_video:
|
||||||
raise HTTPException(400, "provide exactly one of youtube_url / video")
|
raise HTTPException(400, "provide exactly one of youtube_url / video")
|
||||||
@@ -216,6 +271,12 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
|||||||
parent = _finished_job(job_id)
|
parent = _finished_job(job_id)
|
||||||
return _enqueue(uuid.uuid4().hex, parent.inp, rebuilt_from=job_id)
|
return _enqueue(uuid.uuid4().hex, parent.inp, rebuilt_from=job_id)
|
||||||
|
|
||||||
|
@app.post("/api/jobs/{job_id}/rebuild-full", status_code=201)
|
||||||
|
def rebuild_full(job_id: str):
|
||||||
|
parent = _finished_job(job_id)
|
||||||
|
return _enqueue(uuid.uuid4().hex, parent.inp, rebuilt_from=job_id,
|
||||||
|
force_stage=STAGE_ORDER[0])
|
||||||
|
|
||||||
def _require_source(parent_inp: SongInput, inp: SongInput,
|
def _require_source(parent_inp: SongInput, inp: SongInput,
|
||||||
force_from: str | None) -> None:
|
force_from: str | None) -> None:
|
||||||
"""Refuse a rebuild that will rerun acquire against an upload we no longer have."""
|
"""Refuse a rebuild that will rerun acquire against an upload we no longer have."""
|
||||||
@@ -235,13 +296,20 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
|||||||
lyrics: str = Form(...), youtube_url: str | None = Form(None),
|
lyrics: str = Form(...), youtube_url: str | None = Form(None),
|
||||||
genre: str | None = Form(None), year: int | None = Form(None),
|
genre: str | None = Form(None), year: int | None = Form(None),
|
||||||
language: str | None = Form(None), bpm: float | None = Form(None),
|
language: str | None = Form(None), bpm: float | None = Form(None),
|
||||||
|
force_from: str | None = Form(None),
|
||||||
video: UploadFile | None = File(None),
|
video: UploadFile | None = File(None),
|
||||||
cover: UploadFile | None = File(None)):
|
cover: UploadFile | None = File(None)):
|
||||||
parent = _finished_job(job_id)
|
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
|
# a cleared field arrives as "" from some clients; "" and None must not
|
||||||
# look like different inputs to plan()
|
# look like different inputs to plan()
|
||||||
youtube_url, genre, language = (youtube_url or None, genre or None,
|
youtube_url, genre, language = (youtube_url or None, genre or None,
|
||||||
language 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)
|
has_video = bool(video and video.filename)
|
||||||
if youtube_url and has_video:
|
if youtube_url and has_video:
|
||||||
raise HTTPException(400, "provide exactly one of youtube_url / video")
|
raise HTTPException(400, "provide exactly one of youtube_url / video")
|
||||||
@@ -259,8 +327,64 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
|||||||
youtube_url=youtube_url, video_path=video_path,
|
youtube_url=youtube_url, video_path=video_path,
|
||||||
genre=genre, year=year, language=language,
|
genre=genre, year=year, language=language,
|
||||||
cover_path=cover_path, bpm_override=bpm)
|
cover_path=cover_path, bpm_override=bpm)
|
||||||
_require_source(parent.inp, inp, rebuild_policy.plan(parent.inp, inp))
|
_require_source(parent.inp, inp, rebuild_policy.earliest(
|
||||||
return _enqueue(new_id, inp, rebuilt_from=job_id)
|
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)
|
||||||
|
|
||||||
|
def _edit_counts(job: Job) -> dict:
|
||||||
|
"""How much of the song was edited by hand, from its edits file."""
|
||||||
|
path = cfg.work_dir / song_slug(job.inp) / edits.EDITS_FILE
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {"edited_lines": 0, "stale_edits": 0}
|
||||||
|
return {"edited_lines": len(data.get("lines", {})),
|
||||||
|
"stale_edits": len(data.get("stale", []))}
|
||||||
|
|
||||||
|
@app.get("/api/rebuild-all")
|
||||||
|
def rebuild_all_candidates():
|
||||||
|
return [{"id": j.id, "title": j.title, "artist": j.artist,
|
||||||
|
"status": j.status, "rebuilt_from": j.rebuilt_from,
|
||||||
|
"finished_at": j.finished_at, **_edit_counts(j)}
|
||||||
|
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")
|
@app.get("/api/stages")
|
||||||
def list_stages():
|
def list_stages():
|
||||||
@@ -268,11 +392,16 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/api/jobs")
|
@app.get("/api/jobs")
|
||||||
def list_jobs():
|
def list_jobs():
|
||||||
|
# edits belong to the song as it is now, so only the job standing for
|
||||||
|
# it carries them; the runs it replaced would claim them twice
|
||||||
|
counts = {j.id: _edit_counts(j) for j in _current_songs()}
|
||||||
|
none = {"edited_lines": 0, "stale_edits": 0}
|
||||||
with lock:
|
with lock:
|
||||||
return [{"id": j.id, "title": j.title, "artist": j.artist,
|
return [{"id": j.id, "title": j.title, "artist": j.artist,
|
||||||
"status": j.status, "stage": j.stage,
|
"status": j.status, "stage": j.stage,
|
||||||
"created_at": j.created_at, "started_at": j.started_at,
|
"created_at": j.created_at, "started_at": j.started_at,
|
||||||
"finished_at": j.finished_at, "rebuilt_from": j.rebuilt_from}
|
"finished_at": j.finished_at, "rebuilt_from": j.rebuilt_from,
|
||||||
|
**counts.get(j.id, none)}
|
||||||
for j in (jobs[i] for i in reversed(order))]
|
for j in (jobs[i] for i in reversed(order))]
|
||||||
|
|
||||||
@app.get("/api/jobs/{job_id}")
|
@app.get("/api/jobs/{job_id}")
|
||||||
@@ -303,6 +432,242 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
|||||||
"video_filename": inp.video_path.name if inp.video_path else None,
|
"video_filename": inp.video_path.name if inp.video_path else None,
|
||||||
"cover_filename": inp.cover_path.name if inp.cover_path else None}
|
"cover_filename": inp.cover_path.name if inp.cover_path else None}
|
||||||
|
|
||||||
|
@app.delete("/api/jobs/{job_id}", status_code=204)
|
||||||
|
def delete_job(job_id: str):
|
||||||
|
with lock:
|
||||||
|
job = jobs.get(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise HTTPException(404)
|
||||||
|
if job.status in ("queued", "running"):
|
||||||
|
raise HTTPException(409, "job is still in progress")
|
||||||
|
slug = song_slug(job.inp)
|
||||||
|
if any(other.status in ("queued", "running") and song_slug(other.inp) == slug
|
||||||
|
for other_id, other in jobs.items() if other_id != job_id):
|
||||||
|
raise HTTPException(409, "another job for this song is in progress")
|
||||||
|
jobs.pop(job_id, None)
|
||||||
|
if job_id in order:
|
||||||
|
order.remove(job_id)
|
||||||
|
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 / 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())
|
||||||
|
meta_file = work / "align_meta.json"
|
||||||
|
meta = json.loads(meta_file.read_text()) if meta_file.exists() else {}
|
||||||
|
dropped = set(meta.get("dropped_lines", []))
|
||||||
|
missing_spans = [{"start": s, "end": e}
|
||||||
|
for s, e in meta.get("missing_spans", [])]
|
||||||
|
align_file = work / "alignment.json"
|
||||||
|
align_rows = json.loads(align_file.read_text()) if align_file.exists() else []
|
||||||
|
words_by_line: dict[int, list] = {}
|
||||||
|
for r in align_rows:
|
||||||
|
words_by_line.setdefault(r["line_index"], []).append(
|
||||||
|
{"text": r["word"], "start": r["start"], "end": r["end"]})
|
||||||
|
ref = normalize_lyrics(job.inp.lyrics_text)
|
||||||
|
by_line: dict[int, list] = {}
|
||||||
|
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"],
|
||||||
|
"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)]
|
||||||
|
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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
Vendored
+36
@@ -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
|
||||||
Vendored
+21
@@ -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
|
||||||
@@ -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
|
||||||
|
});
|
||||||
@@ -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
@@ -24,7 +24,7 @@ def test_local_video(tmp_path, monkeypatch):
|
|||||||
assert any("audio.wav" in str(a[-1]) for a in calls)
|
assert any("audio.wav" in str(a[-1]) for a in calls)
|
||||||
|
|
||||||
def test_youtube(tmp_path, monkeypatch):
|
def test_youtube(tmp_path, monkeypatch):
|
||||||
def fake_dl(url, dest):
|
def fake_dl(url, dest, cfg, log):
|
||||||
video = dest / "source_video.webm"
|
video = dest / "source_video.webm"
|
||||||
video.write_bytes(b"v")
|
video.write_bytes(b"v")
|
||||||
thumb = dest / "source_video.jpg"
|
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")
|
(tmp_path / "source_video.mp4").write_bytes(b"much larger contents here")
|
||||||
video, _ = acquire._classify_downloads(tmp_path)
|
video, _ = acquire._classify_downloads(tmp_path)
|
||||||
assert video.name == "source_video.mp4"
|
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)]
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
import soundfile as sf
|
||||||
|
|
||||||
from songtrace.config import Config
|
from songtrace.config import Config
|
||||||
from songtrace.lyrics import normalize_lyrics
|
from songtrace.lyrics import normalize_lyrics
|
||||||
from songtrace.pipeline import Ctx, PipelineError, SongInput
|
from songtrace.pipeline import Ctx, PipelineError, SongInput
|
||||||
from songtrace.stages import align
|
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"
|
LYRICS = "it's one two three\nfour five six seven eight"
|
||||||
|
|
||||||
@@ -137,3 +149,89 @@ def test_words_split_across_sections(tmp_path, monkeypatch):
|
|||||||
check_invariants(rows, LYRICS)
|
check_invariants(rows, LYRICS)
|
||||||
assert rows[3]["end"] <= 5.0
|
assert rows[3]["end"] <= 5.0
|
||||||
assert rows[4]["start"] >= 29.0
|
assert rows[4]["start"] >= 29.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_align_drops_lines_never_sung(tmp_path, monkeypatch):
|
||||||
|
lyrics = "it's one two three\nfour five six seven eight\nnine ten eleven twelve"
|
||||||
|
transcript = [{"start": 1.0, "end": 4.0,
|
||||||
|
"text": "it's one two three four five six seven eight"}]
|
||||||
|
rows = run_align(tmp_path, monkeypatch, lyrics, transcript)
|
||||||
|
assert {r["line_index"] for r in rows} == {0, 1}
|
||||||
|
meta = json.loads((tmp_path / "w" / "align_meta.json").read_text())
|
||||||
|
assert meta["dropped_lines"] == [2]
|
||||||
|
assert meta["missing_spans"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_align_flags_missing_span_for_unmatched_singing(tmp_path, monkeypatch):
|
||||||
|
lyrics = "it's one two three\nfour five six seven eight"
|
||||||
|
transcript = [
|
||||||
|
{"start": 1.0, "end": 4.0, "text": "it's one two three four five six seven eight"},
|
||||||
|
{"start": 20.0, "end": 23.0, "text": "completely unrelated ad lib bridge"},
|
||||||
|
]
|
||||||
|
run_align(tmp_path, monkeypatch, lyrics, transcript)
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", lambda p: 120.0)
|
||||||
ctx = make_ctx(work, language="English", genre="Pop", year=1999)
|
ctx = make_ctx(work, language="English", genre="Pop", year=1999)
|
||||||
stage = grid_stage_mod.grid_stage()
|
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)
|
stage.run(ctx)
|
||||||
song = parse_ultrastar((work / "song.txt").read_text(encoding="utf-8"))
|
song = parse_ultrastar((work / "song.txt").read_text(encoding="utf-8"))
|
||||||
assert [k for k, _ in song.headers] == [
|
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)
|
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", boom)
|
||||||
captured = {}
|
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
|
captured["bpm"] = file_bpm
|
||||||
return to_ultrastar(notes, breaks, 240.0, headers)
|
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():
|
def test_grid_stage_registered():
|
||||||
names = [s.name for s in all_stages()]
|
names = [s.name for s in all_stages()]
|
||||||
assert names[-1] == "package" and names[-2] == "grid"
|
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")
|
||||||
|
|||||||
@@ -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]
|
||||||
+84
-10
@@ -4,7 +4,7 @@ import numpy as np
|
|||||||
import pytest
|
import pytest
|
||||||
from songtrace.config import Config
|
from songtrace.config import Config
|
||||||
from songtrace.notes import (
|
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,
|
syllabify,
|
||||||
)
|
)
|
||||||
from songtrace.pipeline import Ctx, SongInput
|
from songtrace.pipeline import Ctx, SongInput
|
||||||
@@ -78,7 +78,7 @@ def test_syllabify():
|
|||||||
assert syllabify("reminds", "English") == ["re", "minds"]
|
assert syllabify("reminds", "English") == ["re", "minds"]
|
||||||
assert syllabify("a", "English") == ["a"]
|
assert syllabify("a", "English") == ["a"]
|
||||||
assert syllabify("xyzzy", None) == ["xyzzy"]
|
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():
|
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():
|
def test_build_notes_basic_line():
|
||||||
times, f0, conf = flat_pitch()
|
times, f0, conf = flat_pitch()
|
||||||
rows = [
|
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": "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},
|
{"word": "Now", "start": 5.0, "end": 5.5, "score": 0.9, "line_index": 1},
|
||||||
]
|
]
|
||||||
notes, breaks, warnings = build_notes(rows, times, f0, conf, "English")
|
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[1].text == " got" # mid-line: leading space
|
||||||
assert notes[2].text == "Now" # new line: no leading space
|
assert notes[2].text == "Now" # new line: no leading space
|
||||||
assert len(breaks) == 1 and 2.0 <= breaks[0] <= 5.0
|
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"
|
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()
|
times, f0, conf = flat_pitch()
|
||||||
rows = [{"word": "hey", "start": 1.0, "end": 1.4, "score": 0.1, "line_index": 0}]
|
notes, _, warnings = build_notes(_scored_rows([0.4, 0.4, 0.05, 0.4, 0.4]),
|
||||||
notes, _, warnings = build_notes(rows, times, f0, conf, "English")
|
times, f0, conf, "English")
|
||||||
assert all(n.low_confidence for n in notes) and len(warnings) == 1
|
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():
|
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()
|
times, f0, conf = flat_pitch()
|
||||||
np.savez(work / "pitch.npz", times=times, f0=f0, conf=conf)
|
np.savez(work / "pitch.npz", times=times, f0=f0, conf=conf)
|
||||||
(work / "alignment.json").write_text(json.dumps([
|
(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},
|
{"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"),
|
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"]
|
assert stage.outputs(ctx) == [work / "notes_sec.json"]
|
||||||
stage.run(ctx)
|
stage.run(ctx)
|
||||||
data = json.loads((work / "notes_sec.json").read_text())
|
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["breaks"] == [2.25]
|
||||||
assert data["warnings"] == []
|
assert data["warnings"] == []
|
||||||
assert TimedNote(**data["notes"][0]).pitch == 0
|
assert TimedNote(**data["notes"][0]).pitch == 0
|
||||||
@@ -295,3 +320,52 @@ def test_notes_stage_writes_json(tmp_path):
|
|||||||
|
|
||||||
def test_notes_stage_registered():
|
def test_notes_stage_registered():
|
||||||
assert "notes" in [s.name for s in all_stages()]
|
assert "notes" in [s.name for s in all_stages()]
|
||||||
|
|
||||||
|
|
||||||
|
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": "held", "start": 5.0, "end": 5.4, "score": 0.9, "line_index": 1},
|
||||||
|
]
|
||||||
|
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():
|
||||||
|
times, f0, conf = flat_pitch()
|
||||||
|
rows = [{"word": "keep", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0}]
|
||||||
|
ref_lines = [["keep"], ["the", "outro"]]
|
||||||
|
_, _, warnings = build_notes(rows, times, f0, conf, "English",
|
||||||
|
dropped_lines=[1], ref_lines=ref_lines)
|
||||||
|
assert warnings == ['1 line dropped (not sung) near "the outro"']
|
||||||
|
|
||||||
|
|
||||||
|
def test_warnings_report_missing_spans():
|
||||||
|
times, f0, conf = flat_pitch()
|
||||||
|
rows = [{"word": "keep", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0}]
|
||||||
|
_, _, warnings = build_notes(rows, times, f0, conf, "English",
|
||||||
|
missing_spans=[(81.0, 89.5)])
|
||||||
|
assert warnings == ["missing lyrics near 1:21–1:29"]
|
||||||
|
|
||||||
|
|
||||||
|
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
@@ -49,19 +49,37 @@ def make_pipeline_inputs(tmp_path, cover=None):
|
|||||||
return inp, cfg
|
return inp, cfg
|
||||||
|
|
||||||
|
|
||||||
def test_package_copies_compatible_video(tmp_path, monkeypatch):
|
def test_package_remuxes_compatible_video_without_reencoding(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
|
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"))
|
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("webm", "vp9"))
|
||||||
ctx = make_ctx(tmp_path, cover=make_cover(tmp_path))
|
ctx = make_ctx(tmp_path, cover=make_cover(tmp_path))
|
||||||
package.package_stage().run(ctx)
|
package.package_stage().run(ctx)
|
||||||
out = tmp_path / "out" / "A - T"
|
out = tmp_path / "out" / "A - T"
|
||||||
assert (out / "A - T.txt").exists()
|
assert (out / "A - T.txt").exists()
|
||||||
assert (out / "A - T.mp3").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")
|
img = Image.open(out / "A - T.jpg")
|
||||||
assert img.width == img.height
|
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):
|
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, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
|
||||||
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("avi", "mpeg4"))
|
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("avi", "mpeg4"))
|
||||||
|
|||||||
+54
-1
@@ -1,7 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from songtrace.config import Config
|
from songtrace.config import Config
|
||||||
from songtrace.pipeline import SongInput, Stage, run_pipeline, song_slug
|
from songtrace.pipeline import SongInput, Stage, read_warnings, run_pipeline, song_slug
|
||||||
|
|
||||||
def make_cfg(tmp_path):
|
def make_cfg(tmp_path):
|
||||||
return Config(whisper_url="http://x/v1", output_dir=tmp_path / "out",
|
return Config(whisper_url="http://x/v1", output_dir=tmp_path / "out",
|
||||||
@@ -46,3 +46,56 @@ def test_warnings_surfaced(tmp_path):
|
|||||||
st = Stage(name="n", outputs=lambda ctx: [ctx.work / "n.done"], run=run)
|
st = Stage(name="n", outputs=lambda ctx: [ctx.work / "n.done"], run=run)
|
||||||
result = run_pipeline(inp, cfg, log=lambda m: None, stages=[st])
|
result = run_pipeline(inp, cfg, log=lambda m: None, stages=[st])
|
||||||
assert result.warnings == ["w1"]
|
assert result.warnings == ["w1"]
|
||||||
|
|
||||||
|
def test_read_warnings_is_empty_without_a_notes_file(tmp_path):
|
||||||
|
assert read_warnings(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)
|
||||||
|
|||||||
@@ -17,3 +17,21 @@ def test_pitch_stage_writes_npz(tmp_path, monkeypatch):
|
|||||||
data = np.load(work / "pitch.npz")
|
data = np.load(work / "pitch.npz")
|
||||||
assert np.allclose(data["f0"], 220.0)
|
assert np.allclose(data["f0"], 220.0)
|
||||||
assert data["times"].shape == data["conf"].shape
|
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
@@ -5,7 +5,7 @@ import pytest
|
|||||||
|
|
||||||
from songtrace.config import Config
|
from songtrace.config import Config
|
||||||
from songtrace.pipeline import STAGE_ORDER, PipelineError, SongInput
|
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)
|
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):
|
def test_superseded_output_refuses_the_output_root_itself(tmp_path):
|
||||||
root = tmp_path / "out"
|
root = tmp_path / "out"
|
||||||
assert superseded_output(str(root), str(root / "A - T"), root) is None
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from songtrace.lyrics import normalize_lyrics
|
from songtrace.lyrics import normalize_lyrics
|
||||||
from songtrace.whisper_client import TranscriptSegment
|
from songtrace.whisper_client import TranscriptSegment
|
||||||
from songtrace.stages.transcribe import lyrics_similarity, vocal_sections
|
from songtrace.stages.transcribe import (classify_lyrics_match, lyrics_similarity,
|
||||||
|
recovered_spans, vocal_sections)
|
||||||
|
|
||||||
def seg(s, e, t=""):
|
def seg(s, e, t=""):
|
||||||
return TranscriptSegment(start=s, end=e, text=t)
|
return TranscriptSegment(start=s, end=e, text=t)
|
||||||
@@ -26,3 +27,69 @@ def test_similarity_low_for_wrong_lyrics():
|
|||||||
ref = normalize_lyrics("completely different words here entirely")
|
ref = normalize_lyrics("completely different words here entirely")
|
||||||
segs = [seg(0, 2, "it's one two three four five six seven eight")]
|
segs = [seg(0, 2, "it's one two three four five six seven eight")]
|
||||||
assert lyrics_similarity(segs, ref) < 0.4
|
assert lyrics_similarity(segs, ref) < 0.4
|
||||||
|
|
||||||
|
def test_classify_lyrics_match_flags_extra_reference_line():
|
||||||
|
ref = normalize_lyrics("it's one two three\nfour five six seven eight\nnine ten eleven")
|
||||||
|
segs = [seg(1, 4, "it's one two three four five six seven eight")]
|
||||||
|
dropped, missing = classify_lyrics_match(segs, ref)
|
||||||
|
assert dropped == [2]
|
||||||
|
assert missing == []
|
||||||
|
|
||||||
|
def test_classify_lyrics_match_flags_unmatched_singing():
|
||||||
|
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(20, 23, "completely unrelated ad lib bridge")]
|
||||||
|
dropped, missing = classify_lyrics_match(segs, ref)
|
||||||
|
assert dropped == []
|
||||||
|
assert missing == [(19.0, 24.0)]
|
||||||
|
|
||||||
|
def test_classify_lyrics_match_keeps_partially_matched_lines():
|
||||||
|
ref = normalize_lyrics("it's one two three\nfour five six seven eight")
|
||||||
|
# line 1 heard with one word garbled ("five" -> "fife"): still a replace/equal
|
||||||
|
# match, so the line stays matched rather than dropped
|
||||||
|
segs = [seg(1, 4, "it's one two three four fife six seven eight")]
|
||||||
|
dropped, _ = classify_lyrics_match(segs, ref)
|
||||||
|
assert dropped == []
|
||||||
|
|
||||||
|
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 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")]
|
||||||
|
sections = vocal_sections(segs)
|
||||||
|
assert sections == [(0.0, 9.0)] # confirms the two segments do merge
|
||||||
|
dropped, missing = classify_lyrics_match(segs, ref)
|
||||||
|
assert dropped == []
|
||||||
|
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] == []
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ def load(name):
|
|||||||
return (FIXTURES / name).read_text(encoding="utf-8", errors="replace")
|
return (FIXTURES / name).read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
def test_parse_headers_and_notes():
|
def test_parse_headers_and_notes():
|
||||||
song = parse_ultrastar(load("sanctuary.txt"))
|
song = parse_ultrastar(load("chart_crlf.txt"))
|
||||||
assert get_header(song, "artist") == "Utada Hikaru"
|
assert get_header(song, "artist") == "Test Artist"
|
||||||
assert parse_bpm(get_header(song, "BPM")) == 217.98
|
assert parse_bpm(get_header(song, "BPM")) == 217.98
|
||||||
first = song.body[0]
|
first = song.body[0]
|
||||||
assert first == Note(kind=":", beat=0, length=3, pitch=16, text="ah")
|
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))
|
assert any(n.kind == "F" for n in song.body if isinstance(n, Note))
|
||||||
|
|
||||||
def test_note_text_keeps_leading_space():
|
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)]
|
notes = [n for n in song.body if isinstance(n, Note)]
|
||||||
assert notes[1].text == " ah"
|
assert notes[1].text == " ah"
|
||||||
|
|
||||||
def test_negative_pitch():
|
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))
|
assert any(n.pitch == -9 for n in song.body if isinstance(n, Note))
|
||||||
|
|
||||||
def test_round_trip():
|
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))
|
song = parse_ultrastar(load(name))
|
||||||
again = parse_ultrastar(serialize_ultrastar(song))
|
again = parse_ultrastar(serialize_ultrastar(song))
|
||||||
assert again == song
|
assert again == song
|
||||||
|
|||||||
+1163
-10
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
Reference in New Issue
Block a user