| name | add-songs |
| description | Add MP3s to the nano training corpus and run them through the full data-prep pipeline (upload, prepare, tokenize, optional melody/auto-tag/transcribe/ structure/key-detect, phonemize, pack). Use this skill when the user wants to add training data, ingest songs, build or grow the corpus, prepare data for training, run tokenize/melody/pack/tag/transcribe/structure/phonemize, or asks how to get their MP3s into the model. |
| allowed-tools | Read, Bash |
Add songs to the nano corpus
Gets raw MP3s into a train-ready token cache. This is the data side of the
project โ everything before train-model.
Mental model
nano is one bespoke model trained at scale on one kind of data. More of the
same data helps; variety does not โ do not curate for genre/style diversity.
- Recommended corpus size: ~50k songs minimum for coherent output. Below ~10k
the model produces noise (pipeline-validation only). No hard ceiling โ the raw
audio lives in R2 object storage (the old ~500k figure was the retired
nano-corpus Volume's inode cap).
- For the cost-per-1,000-songs table and end-to-end walkthrough, read
README.modal.md โ don't restate the numbers here.
- For the per-stage data shapes, see the Data Flow section of
CLAUDE.md.
Storage
Raw audio lives in Cloudflare R2 (the nano-audio bucket, under
waves/wave_<id>/), mounted via modal_common.corpus_mount(). The legacy
nano-corpus Volume is retired. Everything else is on Modal Volumes:
| Store | Holds |
|---|
nano-audio (R2) | Raw MP3 files under waves/wave_<id>/ |
nano-tokens | .pt token files, packed/ shards (incl. .mel.bin + .stem.bin), tags.json, lyrics/, structure/, keys.json, phonemes/ |
nano-melody | <name>.mel.npy chroma sidecars (own volume โ keeps nano-tokens under its inode cap) |
nano-stems | <name>.stems.npy stem-token sidecars ([4,K,T] int16; own volume, same inode rationale) |
nano-ckpts | Training checkpoints |
All Modal fan-out steps below are launched with --detach and are resumable โ
re-run the same command to continue; it's safe to close your terminal.
The one-command path: the wave orchestrator
For a real ingest, don't run the stages by hand โ modal_ingest_wave.py runs
ONE wave end-to-end, calling each deployed stage app in order and marking
progress in /tokens/waves/wave_<id>/status.json (a re-run skips completed
stages):
NANO_CODEC=spectrostream modal run --detach diskrot/modal_ingest_wave.py --wave-id 17
- Prerequisite: every stage app must be
modal deployed once (with the
diskrot source baked in) so the orchestrator can look it up by name โ
modal run apps are ephemeral and can't be. See the module docstring for
the deploy list, and re-deploy a stage after editing it (a re-deploy does
NOT rescue an in-flight call โ stop the stale orchestrator first).
NANO_CODEC must be set identically at every stage deploy AND at the
orchestrator run (the v9 corpus is SpectroStream) โ the #1 footgun.
- Cost levers:
--stems-sample-pct (default 50) samples the expensive GPU
stems stage; structure is similarly sampleable. Wave-by-wave model +
one-time R2 setup: README.waves.md.
The manual stages below are the ร -la-carte reference (small corpora, re-running
a single stage, local smoke tests).
Pipeline (run in order)
1. Upload your MP3s
Raw audio goes to the R2 nano-audio bucket under a wave prefix (S3-compatible
upload โ rclone / aws s3 cp / the Cloudflare UI). One-time R2 + r2-creds +
NANO_AUDIO_* setup is in README.waves.md.
rclone copy /path/to/mp3s/ r2:nano-audio/waves/wave_0/
(No crawler โ you supply your own MP3s. See README.waves.md
for the wave-by-wave ingestion model.)
2. Prepare โ validate, dedupe, drop
Dry-run first (no deletions), inspect the report, then apply:
modal volume create nano-tokens
modal run --detach diskrot/modal_prepare.py
modal run --detach diskrot/modal_prepare.py --apply
Drops files that fail ffprobe, byte-identical duplicates (SHA-256), clips <20s,
and files >5:30. Why the length cap: long DJ mixes / album rips OOM the L4
tokenizer and distort the per-file crop sampler. Resumable via
/tokens/prepare_manifest.json. Add --quality-gate to also decode each
file and drop clipped / mostly-silent / dead / low-bitrate garbage
(diskrot.audio_quality) โ raises the training-data floor.
2b. Audio near-dup dedup โ recommended
modal run --detach diskrot/modal_audio_dedup.py
modal run --detach diskrot/modal_audio_dedup.py --apply
Catches acoustic near-duplicates SHA-256 misses (same song re-encoded โ
byte-different, identical sound): chromaprint fingerprint โ 64-bit SimHash โ
LSH grouping, keeps the best copy per group. After prepare, before tokenize.
3. Tokenize โ MP3 โ DAC tokens
Modal (fan-out, the scale path):
modal run --detach diskrot/modal_tokenize.py
Local (validation / small corpora):
python -m diskrot.tokenize --corpus /path/to/mp3s --out ./token_cache
Output: per-song int16 .pt files ([K, T]) on nano-tokens (or ./token_cache).
NANO_CODEC picks the codec โ DAC (default: mono, K=9, 86 Hz) or
spectrostream (the v9 corpus: joint stereo, K=32 stored, 25 Hz) โ and must
match every later stage and the train launch.
4. Extract melody (chroma) โ optional, needed for melody conditioning / /cover
modal run --detach diskrot/modal_melody.py
Writes a per-song <name>.mel.npy (12-bin chromagram, forced to the song's DAC
frame count) to the dedicated nano-melody volume. Run after tokenize (it
reads each .pt on nano-tokens for the frame count) and before pack. CPU,
cheap, resumable (skips songs that already have chroma). There is no local CLI for
this step (use diskrot.melody.extract_chroma programmatically for a local smoke
corpus). Skip it if you won't use melody conditioning โ the rest of the pipeline
works unchanged (tags+lyrics only).
Why its own volume: this adds one small file per song. nano-tokens already
holds ~one .pt per song and sits near the 500k-inode volume cap, so co-locating
the chroma there would push it over mid-run โ hence nano-melody. The loose .pt
/ .mel.npy are only inputs to pack โ prunable after packing (training reads only
the shards).
4b. Extract stems โ optional, only for stem conditioning / /addstem (currently deferred)
NANO_CODEC=spectrostream modal run --detach diskrot/modal_stems.py --wave-id <id> --sample-pct 50
Demucs-separates each song into 4 stems + codec-tokenizes them to a per-song
<name>.stems.npy on nano-stems. GPU fan-out โ the most expensive
optional stage, hence --sample-pct (ingest default 50; non-sampled songs are
flagged absent by the pack's present mask). After tokenize, before pack.
Skip unless you'll train with use_stem_conditioning โ it's False in
the current DEFAULTS (deferred for v9), so today this stage is prep-ahead
only. Calibrate cost with --limit first.
5. Pack โ .pt files (+ chroma) โ sharded mmap layout
Required once before training. Auto-detected by the trainer.
modal run --detach diskrot/modal_pack_cache.py
python -m diskrot.pack_cache --cache-dir ./token_cache --mel-cache-dir ./token_cache
Writes packed/packed_NNN.bin + per-shard JSON + packed_index.json on
nano-tokens. The Modal wrapper mounts nano-melody and auto-detects
*.mel.npy there, writing the parallel packed_NNN.mel.bin chroma sidecar (at the
same offsets) back onto nano-tokens; the local packer needs --mel-cache-dir to
do so. If you ran the stems stage, --stem-cache-dir likewise folds the
.stems.npy files into a parallel packed_NNN.stem.bin sidecar with a
per-song present mask. Shards are written atomically and a re-run skips
complete-and-valid shards.
6. Auto-tag โ optional, needed for text conditioning
modal run --detach diskrot/modal_auto_tag.py
python -m diskrot.auto_tag --corpus /path/to/mp3s --out ./tags.json
The audio-LLM captioner (Qwen2-Audio over the whole song โ A100 fan-out)
writes a rich natural-language description per song into tags.json, plus the
per-song vocal-gender judgment and (v5) per-stem captions.
NANO_CAPTIONER=bart selects the legacy single-window LP-MusicCaps path.
Re-running only processes new files; --redo upgrades legacy entries
(entries are stamped with CAPTIONER_MARKER, so it's resumable). Calibrate
cost with --limit 50 first โ this and transcribe dominate wave cost.
7. Transcribe lyrics โ optional, needed for lyric conditioning, expensive
modal run --detach diskrot/modal_transcribe.py
python -m diskrot.transcribe_lyrics --corpus /path/to/mp3s --out ./lyrics
Whisper (large-v3-turbo + VAD) on the raw mix โ the Modal stage is
Demucs-free (separation is a no-op-to-worse ASR input; vocal gender comes
from the auto-tag captioner, not an F0 pass), into a sharded lyrics/ dir.
One of the two costliest steps (with auto-tag) โ skip it unless you will
actually use lyric conditioning at inference.
7b. Filter hallucinated lyrics โ recommended after step 7 completes
modal run --detach diskrot/modal_filter_lyrics.py
modal run --detach diskrot/modal_filter_lyrics.py --apply
Nulls Whisper-invented captions over instrumentals ("Thank you." etc. โ ~24% of
with-words entries) so they train as <instrumental>, not <vocals> with
garbage words. CPU, seconds, idempotent. Only after the transcribe fleet has
fully finished (its orchestrator's in-memory flush clobbers concurrent edits),
and before phonemize.
7c. Forced-align lyrics โ optional, recommended for vocals
modal run --detach diskrot/modal_align_lyrics.py --wave-id N
modal run --detach diskrot/modal_align_lyrics.py --wave-id N --apply
Sharpens Whisper's loose word timestamps with a CTC forced aligner (torchaudio
MMS_FA; --use-demucs for vocal-isolated, sharper but costlier). In-place and
idempotent (aligned stamp), refinement-only โ failures keep the original
timestamps. After transcribe + filter, before/around phonemize; tighter onsets
help the sung-alignment learning.
8. Structure โ optional, needed for section markers ([chorus] etc.)
modal run --detach diskrot/modal_structure.py
allin1 (Demucs + joint beat/segment model) โ sharded structure/ dir with
per-song sections + bpm (the tempo marker source). Loaded at train time, not
packed โ a partial pass just yields <no_section>. Expensive (L4 fan-out).
9. Phonemize โ recommended if you ran transcribe (step 7)
modal run --detach diskrot/modal_phonemize.py
Pre-runs g2p per song into a sharded phonemes/ dir so the DataLoader doesn't
pay ~20โ200 ms/song of live g2p at train time (which can starve the multi-GPU
step). CPU, ~$1, resumable. Re-run after any re-transcribe.
10. Key detect โ optional, needs the melody-packed shards (steps 4+5)
modal run --detach diskrot/modal_key_detect.py
Krumhansl key estimate over the packed chroma โ keys.json, the <key_*>
header-marker source (enables "[a minor]" prompts). CPU, ~$1, resumable; songs
without an estimate get <unknown_key>.
Decision points
- Need tags? Only if you'll train/serve text-conditioned (the default). Run step 6.
- Need lyrics? Only if you'll use lyric conditioning. Run step 7 (expensive),
then 7b (filter hallucinations โ cheap) and step 9 (phonemize โ cheap, protects
training throughput).
- Need melody /
/cover? Run step 4 (melody) then repack (step 5) so the chroma
sidecar lands. Cheap (CPU) โ worth it if you want the humโre-render capability.
With the sidecar packed, step 10 (key detect) is ~free and adds key control.
- Need stems /
/addstem? Step 4b + repack โ but only if the run will set
use_stem_conditioning (deferred in the current DEFAULTS); otherwise skip
the priciest optional stage.
- Need section markers (
[verse]/[chorus])? Run step 8 (expensive).
- Local vs Modal? Modal for real fan-out scale; local for a smoke corpus to
exercise the pipeline.
Verify the cache is train-ready
On nano-tokens you should have packed/packed_index.json (required); if you ran
the melody step, packed/ also has packed_NNN.mel.bin (and packed_index.json
reports "has_melody": true); if you ran stems, packed_NNN.stem.bin too; and
per optional pass: tags.json (step 6), lyrics/ (7), structure/ (8),
phonemes/ (9), keys.json (10). Quick check:
modal volume ls nano-tokens
modal volume ls nano-tokens packed | head
Next step
Cache is packed โ go to the train-model skill. A fast pre-flight that exercises
the data path on synthetic tokens (no corpus needed): pytest -m "not benchmark"
(see the run-tests skill).