Applied AI · Working build v0.8.0 · Private, unreleased
OpenSong Self-hosted music generation
Songs on your own GPU. Type a style and lyrics and get a full song with vocals, or hand it a recording and get a cover in a new style or an engraved, printable score. Your lyrics and recordings stay on your own network.
Status
Working, private and unreleased; last change 24 Sep 2026
Version
0.8.0, client-only (the engine is unchanged since 0.7.1); 7 tagged releases, all on 20–21 Sep 2026
Licence
None declared for the code. Weights: YuE2 is CC BY-NC 4.0 plus a permission for individuals to monetize their outputs (commercial use by companies needs a separate licence); SheetSage2 is CC BY-NC 4.0. Downloaded, never redistributed.
Runs on
Engine: one NVIDIA GPU (measured on a 24 GB RTX 3090 under Windows). App: your own machine, in Electron or a browser.
Stack
Python · FastAPI · PyTorch + torchao int8 · YuE2-3B + YuE2-Vae (runtime shims, no fork) · SheetSage2 · React 19 in an Electron shell
Links
None: the repository is private
Measured · figures marked recorded are older · how
The constraint
A music model whose quick start asks for a 24 GB card, fitted to 12 GB and then to 8 GB, without forking it.
At a glance
peak memory on a 4:35 song with the tightest attention chunking, down from 21,337 MiB without it recorded
8,804MiB
peak for the int8 model under an emulated 8 GB budget recorded
5,763MiB
left on the card after a cancelled job, down from 7,222 MiB recorded
32MiB
API routes in 3,439 lines of first-party code
25
Marked figures recorded on one RTX 3090, 21 Sep 2026, not re-run · the rest measured · how
How these were measured
8,804 and 21,337 MiB: PyTorch’s peak-allocation counter on a 4:35 cover, with the acoustic stage’s attention chunked at 256 (its tightest setting for Full quality) and unchunked, read from the engine’s own memory log and written beside the setting in server/app/config.py.
5,763 MiB: the same counter for the int8 model with the allocator capped at 6 GiB, the share an 8 GB card leaves, on the same 24 GB card.
7,222 → 32 MiB: the memory log’s end-of-job reading after a cancel, before and after the fix, on the deployed engine.
25 routes in 3,439 lines:grep -cE '^@app\.(get|post|delete)\("/api' server/app/main.py, and git ls-files over the server, client, desktop-shell, script and tool sources piped to wc -l at HEAD: 36 files, 3,100 lines non-blank. Run read-only on 12 Oct 2026.
Fig. 1 The Create tab: style, lyrics with section tags, and the length, seed and guidance controls. The real client with mocked API responses; no metrics are shown. Small screens get the client’s own narrow layout, cropped to the text fields.
The short version
Bounded the memory of a model built for 24 GB cards with runtime shims, not a fork: 21,337 → 8,804 MiB on a 4:35 song, and 5,763 MiB for the int8 model under an emulated 8 GB budget.
Measured the trade-off with a single-variable rig, then published the correction: roughly two minutes on a 4.5-minute song (about 47% slower in the stage it touches), not the “few percent” first claimed.
Caught a leak with an always-on GPU memory recorder: a cancelled job left 7,222 MiB of model weights on the card; now it leaves 32.
Case 1 of 5
A music model built for 24 GB cards, fitted to 12 GB and then to 8 GB
Two runtime shims and a CPU-side quantizer cut a full-length song’s peak from about 21 GiB to under 9 GiB, and to under 6 GiB for the int8 model, without a fork.
YuE2, a 3.6-billion-parameter open music model, has a quick start that asks for a 24 GB card. On a 4:35 song it peaked at 21,337 MiB, mostly in the acoustic stage (the NAR), which attended over the whole song at once. Upstream’s NAR can chunk its attention, but the pipeline never passes the setting. ComfyUI, another runtime, already bounds this stage; OpenSong does it on upstream’s own pipeline, with a shim that chunks attention by query. Full quality’s peak fell to 8,804 MiB at the tightest chunk size; a card with more free memory now gets bigger chunks and a bigger peak, on purpose (Case 2).
Before writing any quantization, I compared a third-party int8 build with the full model by ear on matched score, seed, plan mode and length, FLAC against FLAC, and it sounded nearly as good. Only that runtime could load the file, so Compact quantizes upstream’s own weights to int8 on the CPU, before they reach the card. Under an emulated 8 GB budget the one-shot prompt prefill then missed the 6 GiB ceiling by 389 MiB, so a second shim feeds the prompt through in 512-token pieces. Models that won’t fit the live card are greyed out, with the reason shown.
Peak GPU memory on a 4:35 cover, MiB; bars scaled to the 24 GB card. One RTX 3090, recorded 21 Sep 2026; the 8 GB budget was emulated on it.
Upstream path, unbounded
21,337
Full, bounded (chunk 256)
8,804
Compact int8, 8 GB budget
5,763
Fig. 2 The model picker, from the Options tab. Its 12 GB and 8 GB fits are sizing claims made on a 24 GB card, not runs on those cards. Real client, mocked API responses.How we know
Peaks are PyTorch’s own counters, logged by the engine on one RTX 3090 on 21 Sep 2026 and written beside each setting in server/app/config.py; the 8 GB case capped the allocator at 6 GiB on the same card. The shims (chunked NAR attention, chunked prefill, int8 on the CPU) are in server/app/engine.py. The parameter count comes from Hugging Face’s model API, read 12 Oct 2026. Nothing here was re-run for this page.
Case 2 of 5
Measuring what a trade-off really costs, then publishing the correction
A single-variable rig put the bound’s cost at about 47% in its stage; the next release said so, and the engine now sizes the bound to the card.
To measure what the bound really costs, I built an A/B rig. It starts a second engine on the same card with one variable pinned, the NAR chunk size, waits for that engine’s health check to confirm the setting, replays the same request, times each phase from the job’s progress events and reads the peak from the memory log. Crashed runs are recorded as results. Chunk 4,096 matched unbounded speed for about a third less memory, so Full quality now sizes the chunk to the free memory at load, with no slider for the user.
Acoustic-stage time, seconds per 1,000 tokens (lower is faster)
Chunk 256
51.8 s
Chunk 2,048
37.9 s
Chunk 4,096
35.2 s
Unbounded
35.2 s
Peak GPU memory, MiB; bars scaled to the 24 GB card
Chunk 256
8,849
Chunk 2,048
11,106
Chunk 4,096
13,729
Unbounded
21,038
Fig. 3 One run per setting on one 4.5-minute song, RTX 3090, recorded 21 Sep 2026. A different song and separate runs from Case 1’s, so the peaks differ slightly (8,849 against 8,804 MiB at chunk 256). Highlighted: what a card with 20 GiB free now gets.
How we know
The rig is a Node script, scripts/ab_nar.mjs: it waits until the second engine’s health check reports the pinned chunk, times each phase from the job’s progress events and reads each peak from the memory log. One run per setting, one song, one card, 21 Sep 2026. The sizing rule, _nar_chunk_for in server/app/engine.py, picks 4,096 with 20 GiB or more free, 2,048 with 14, otherwise 256; the correction is in the 0.7.0 release notes, and tag times come from the git history. 51.8 ÷ 35.2 = 1.47.
Case 3 of 5
A flight recorder for GPU memory caught a leak that reading the code missed
An always-on memory log caught a cancelled job leaving 7,222 MiB of model weights on the card; the fix leaves 32.
Once, the card was found holding 22,850 of its 24,576 MiB with the engine idle. Later full-length runs released memory cleanly, and three theories reasoned from the source had all been wrong: this was a state to catch, not a rule to read. So the engine got a flight recorder: one greppable line per load, job start, job end and cache release, giving what the process holds and what the card reports. The gap is labelled “others”, because the transcriber is a separate process on the same card. Any job that keeps more than 512 MiB is flagged, and every exit path writes its line, failures included.
The recorder is switched on for every job.
It catches the trigger live: a cancelled generation leaves about 7,200 MiB on the card.
The cause: only completion moved the model back to the CPU. Cancel and failure now park it too.
Checked on the deployed engine: a cancel leaves 32 MiB, where it left 7,222.
How we know
The recorder is server/app/vram.py. The engine writes its end-of-job line in a finally block and parks the model in Engine._park (server/app/engine.py). The idle reading (21 Sep 2026) is noted at the top of the recorder; the before-and-after figures come from its log, 21 Sep 2026. Times are commit times, US Central.
Case 4 of 5
One GPU, one owner
Every GPU job goes through one queue, progress never invents a total, and a deploy isn’t done until the new version answers.
A music model, a transcriber and a model reload must never share the card. So one worker thread owns the pipeline, and generating, transcribing and switching models all go through its single queue. A queued job cancels at once; a running one sets a flag the pipeline polls. The transcriber has its own Python environment and runs as a subprocess, killed on cancel.
Progress shows a percentage only where a real total exists. Before the acoustic and decoder steps reported theirs, the bar sat frozen for 166 s of a 427 s song; planning shows a count and transcription shows elapsed time, because neither has a knowable end. A deploy ends with a restart that won’t report success until the health endpoint answers with the version just shipped.
Fig. 4 Every GPU job takes this path. Engraving a score needs no GPU, so it skips the queue; each render is cached, and a hand-edited score is stored under a SHA-256 hash of its ABC.How we know
Queueing, cancelling and model switching are Engine.cancel, Engine.reload and Engine._run in server/app/engine.py; the transcriber subprocess is in server/app/sheetsage.py and the score cache in server/app/main.py. The 166-second freeze is recorded in the engine’s progress shim, 21 Sep 2026. The version check is the last step of the restart script that ends a deploy, written after a restart once left the previous version answering.
Case 5 of 5
Running a Linux-first model on a Windows GPU server
A cuDNN shim that keeps CUDA graphs ran about 2.4 times faster per second of audio than the eager fallback, with upstream’s files untouched.
Upstream’s quick start targets Linux. On Windows, the PyTorch wheels register the flash-attention operator without building it, and upstream’s auto-detection only checks registration, so generation crashed. Eager mode worked but was slow. The fix is a shim that makes “auto” mean cuDNN for attention and keeps CUDA graphs.
Networking had a similar trap. The server’s LAN hostname resolves to IPv6 first and a plain 0.0.0.0 bind is IPv4-only, so an IPv4 curl got a 200 while browsers reached nothing. The server now builds its own dual-stack socket.
Seconds to generate one second of audio (lower is faster). Two short clips, RTX 3090, recorded 20 Sep 2026.
Eager (30 s in 107 s)
3.57 s
cuDNN shim + graphs (15 s in 22.7 s)
1.51 s
How we know
Both timings are recorded in server/app/config.py (20 Sep 2026): 107 ÷ 30 = 3.57 and 22.7 ÷ 15 = 1.51, a ratio of 2.36. The clips differ in length, so the ratio is approximate. The attention shim is in server/app/engine.py and the dual-stack socket in server/serve.py.