Upload folder using huggingface_hub
Browse files- .gitignore +24 -0
- Dockerfile +13 -0
- README.md +98 -3
- data/midi_files/.gitattributes +55 -0
- data/midi_files/README.md +27 -0
- data/midi_files/metadata.csv +0 -0
- data/tokenized_cache.pkl +3 -0
- data/tokenizer.json +1 -0
- docs/HLD.md +76 -0
- docs/LLD.md +96 -0
- docs/flow_diagram.drawio +194 -0
- inference_config.xml +25 -0
- requirements.txt +10 -0
- run.py +68 -0
- runs/events.out.tfevents.1780546479.pop-os.1489904.0 +3 -0
- runs/events.out.tfevents.1780547909.pop-os.1514727.0 +3 -0
- runs/events.out.tfevents.1780548128.pop-os.1518614.0 +3 -0
- runs/loss_train/events.out.tfevents.1780548163.pop-os.1518614.1 +3 -0
- runs/loss_val/events.out.tfevents.1780548163.pop-os.1518614.2 +3 -0
- scripts/download_data.sh +9 -0
- scripts/generate.sh +8 -0
- scripts/train.sh +10 -0
- src/01_config.py +92 -0
- src/02_tokenizer.py +250 -0
- src/03_dataset.py +186 -0
- src/__init__.py +0 -0
- src/s00_main.py +167 -0
- src/s01_config.py +92 -0
- src/s02_tokenizer.py +250 -0
- src/s03_dataset.py +198 -0
- src/s04_model.py +310 -0
- src/s05_trainer.py +235 -0
- src/s06_generator.py +144 -0
- src/s07_utils.py +49 -0
- src/s08_xml_generator.py +101 -0
- tests/__init__.py +0 -0
- tests/test_pipeline.py +268 -0
.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*$py.class
|
| 4 |
+
*.so
|
| 5 |
+
*.egg-info/
|
| 6 |
+
dist/
|
| 7 |
+
build/
|
| 8 |
+
.eggs/
|
| 9 |
+
*.egg
|
| 10 |
+
.env
|
| 11 |
+
.venv
|
| 12 |
+
env/
|
| 13 |
+
venv/
|
| 14 |
+
data/
|
| 15 |
+
checkpoints/
|
| 16 |
+
output/
|
| 17 |
+
*.mid
|
| 18 |
+
*.midi
|
| 19 |
+
runs/
|
| 20 |
+
*.pt
|
| 21 |
+
*.pth
|
| 22 |
+
*.ckpt
|
| 23 |
+
.DS_Store
|
| 24 |
+
*.log
|
Dockerfile
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
RUN mkdir -p data checkpoints output runs
|
| 11 |
+
|
| 12 |
+
ENTRYPOINT ["python", "-m", "src.s00_main"]
|
| 13 |
+
CMD ["train+generate"]
|
README.md
CHANGED
|
@@ -1,3 +1,98 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Music Generation LLM
|
| 2 |
+
|
| 3 |
+
A LLaMA-style Transformer model for symbolic music generation, trained on MIDI data.
|
| 4 |
+
|
| 5 |
+
## Architecture
|
| 6 |
+
|
| 7 |
+
- **Model**: LLaMA-style Transformer with RoPE, GQA, SwiGLU, RMSNorm
|
| 8 |
+
- **Tokenizer**: REMI (REvamped MIDI-derived) — SOTA for symbolic music
|
| 9 |
+
- **Dataset**: `drengskapur/midi-classical-music` — 4,796 classical MIDI files (~50MB)
|
| 10 |
+
- **Training**: AdamW + Cosine LR warmup + AMP + Gradient Checkpointing
|
| 11 |
+
|
| 12 |
+
## Key Features
|
| 13 |
+
|
| 14 |
+
- **Memory Efficient**: Grouped Query Attention, gradient checkpointing, mixed precision
|
| 15 |
+
- **OOM Safe**: Conservative batch sizes, AMP, lazy data loading
|
| 16 |
+
- **SOTA Techniques**: RoPE, SwiGLU, RMSNorm, KV-cache, top-p/top-k sampling
|
| 17 |
+
|
| 18 |
+
## Quick Start
|
| 19 |
+
|
| 20 |
+
```bash
|
| 21 |
+
# Install dependencies
|
| 22 |
+
pip install -r requirements.txt
|
| 23 |
+
|
| 24 |
+
# Train + Generate (default)
|
| 25 |
+
python3 -m src.s00_main train+generate
|
| 26 |
+
|
| 27 |
+
# Train only
|
| 28 |
+
python3 -m src.s00_main train --epochs 20 --batch-size 4
|
| 29 |
+
|
| 30 |
+
# Generate from checkpoint
|
| 31 |
+
python3 -m src.s00_main generate --temperature 0.85
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
## Project Structure
|
| 35 |
+
|
| 36 |
+
```
|
| 37 |
+
music_gen_llm/
|
| 38 |
+
├── src/
|
| 39 |
+
│ ├── s00_main.py # Entry point — orchestrates pipeline
|
| 40 |
+
│ ├── s01_config.py # All configuration dataclasses
|
| 41 |
+
│ ├── s02_tokenizer.py # REMI MIDI tokenizer
|
| 42 |
+
│ ├── s03_dataset.py # Data download + tokenization + DataLoader
|
| 43 |
+
│ ├── s04_model.py # MusicTransformer (LLaMA-style)
|
| 44 |
+
│ ├── s05_trainer.py # Training loop with AMP + checkpointing
|
| 45 |
+
│ ├── s06_generator.py # Autoregressive generation with KV-cache
|
| 46 |
+
│ └── s07_utils.py # Logging, memory monitoring, seeding
|
| 47 |
+
├── tests/
|
| 48 |
+
│ └── test_pipeline.py # 7 unit tests covering all components
|
| 49 |
+
├── scripts/
|
| 50 |
+
│ ├── download_data.sh # Dataset setup
|
| 51 |
+
│ ├── train.sh # Training launcher
|
| 52 |
+
│ └── generate.sh # Generation launcher
|
| 53 |
+
├── docs/
|
| 54 |
+
│ ├── README.md # This file
|
| 55 |
+
│ ├── HLD.md # High-Level Design
|
| 56 |
+
│ ├── LLD.md # Low-Level Design
|
| 57 |
+
│ └── flow_diagram.drawio # Execution flow diagram
|
| 58 |
+
├── data/ # Downloaded MIDI + tokenized cache
|
| 59 |
+
├── checkpoints/ # Saved model weights
|
| 60 |
+
├── output/ # Generated MIDI files
|
| 61 |
+
├── requirements.txt
|
| 62 |
+
├── Dockerfile
|
| 63 |
+
└── .gitignore
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
## Execution Flow
|
| 67 |
+
|
| 68 |
+
```
|
| 69 |
+
s00_main.py → s01_config.py → s02_tokenizer.py → s03_dataset.py → s04_model.py → s05_trainer.py → s06_generator.py
|
| 70 |
+
│ │ │ │ │ │ │
|
| 71 |
+
Entry point Load configs Init tokenizer Download & tokenize Build model Train loop Generate MIDI
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
## Model Specifications
|
| 75 |
+
|
| 76 |
+
| Parameter | Value |
|
| 77 |
+
|---------------|-------------------------------------|
|
| 78 |
+
| Dim | 256 |
|
| 79 |
+
| Layers | 6 |
|
| 80 |
+
| Heads | 8 (Q) / 4 (KV) — GQA |
|
| 81 |
+
| Hidden (FFN) | 448 (SwiGLU) |
|
| 82 |
+
| Max Seq Len | 1024 |
|
| 83 |
+
| Vocab Size | 485 (REMI tokens) |
|
| 84 |
+
| Parameters | ~5M |
|
| 85 |
+
| Precision | BF16/FP16 (AMP) |
|
| 86 |
+
|
| 87 |
+
## Algorithms Used
|
| 88 |
+
|
| 89 |
+
1. **Rotary Position Embeddings (RoPE)** — Su et al. 2021
|
| 90 |
+
2. **Grouped Query Attention (GQA)** — Ainslie et al. 2023, from LLaMA-2
|
| 91 |
+
3. **SwiGLU Activation** — Shazeer 2020, from LLaMA
|
| 92 |
+
4. **RMS Layer Normalization** — Zhang & Sennrich 2019
|
| 93 |
+
5. **REMI Tokenization** — Huang & Yang 2020
|
| 94 |
+
6. **Cosine Annealing with Warmup** — Loshchilov & Hutter 2017
|
| 95 |
+
7. **Gradient Checkpointing** — Chen et al. 2016
|
| 96 |
+
8. **KV-Cache** — Standard for efficient autoregressive decoding
|
| 97 |
+
9. **Nucleus (Top-p) + Top-k Sampling** — Holtzman et al. 2020
|
| 98 |
+
10. **Repetition Penalty** — Keskar et al. 2019
|
data/midi_files/.gitattributes
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.lz4 filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
# Audio files - uncompressed
|
| 38 |
+
*.pcm filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
*.sam filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
*.raw filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
# Audio files - compressed
|
| 42 |
+
*.aac filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
*.flac filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
*.ogg filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
*.wav filter=lfs diff=lfs merge=lfs -text
|
| 47 |
+
# Image files - uncompressed
|
| 48 |
+
*.bmp filter=lfs diff=lfs merge=lfs -text
|
| 49 |
+
*.gif filter=lfs diff=lfs merge=lfs -text
|
| 50 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 51 |
+
*.tiff filter=lfs diff=lfs merge=lfs -text
|
| 52 |
+
# Image files - compressed
|
| 53 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 54 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 55 |
+
*.webp filter=lfs diff=lfs merge=lfs -text
|
data/midi_files/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
pretty_name: MIDI Classical Music
|
| 3 |
+
dataset_name: midi-classical-music
|
| 4 |
+
tags:
|
| 5 |
+
- music
|
| 6 |
+
- classical
|
| 7 |
+
- midi
|
| 8 |
+
- dataset
|
| 9 |
+
- composers
|
| 10 |
+
- music-analysis
|
| 11 |
+
- music-generation
|
| 12 |
+
license: mit
|
| 13 |
+
language:
|
| 14 |
+
- en
|
| 15 |
+
size_categories:
|
| 16 |
+
- 1K<n<10K
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
# MIDI Classical Music
|
| 20 |
+
|
| 21 |
+
This dataset contains a comprehensive collection of MIDI files representing classical music compositions from various renowned composers.
|
| 22 |
+
|
| 23 |
+
The collection includes works from composers such as Bach, Beethoven, Chopin, Mozart, and many others.
|
| 24 |
+
|
| 25 |
+
The dataset is organized into directories by composer, with each directory containing MIDI files of their compositions.
|
| 26 |
+
|
| 27 |
+
The dataset is ideal for music analysis, machine learning models for music generation, and other music-related research and applications.
|
data/midi_files/metadata.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/tokenized_cache.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5b744a77aa1a6d3d25a24f5d1ff4dcfb0071295a45c7b92961fa45469dbb9936
|
| 3 |
+
size 6125102
|
data/tokenizer.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"vocab_size": 485}
|
docs/HLD.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# High-Level Design (HLD)
|
| 2 |
+
|
| 3 |
+
## System Overview
|
| 4 |
+
|
| 5 |
+
Music Generation LLM is a symbolic music generation system that learns patterns from
|
| 6 |
+
classical MIDI music and generates new compositions. It operates entirely on symbolic
|
| 7 |
+
note representations (MIDI events), not raw audio waveforms.
|
| 8 |
+
|
| 9 |
+
## Architecture Diagram
|
| 10 |
+
|
| 11 |
+
```
|
| 12 |
+
┌─────────────────────────────────────────────────────────┐
|
| 13 |
+
│ USER INTERFACE │
|
| 14 |
+
│ CLI: train / generate / both │
|
| 15 |
+
└────────────────────────┬────────────────────────────────┘
|
| 16 |
+
│
|
| 17 |
+
┌────────────────────────▼────────────────────────────────┐
|
| 18 |
+
│ ORCHESTRATOR (s00_main) │
|
| 19 |
+
│ Parses args, wires components │
|
| 20 |
+
└──────┬─────────────────┬────────────────────┬───────────┘
|
| 21 |
+
│ │ │
|
| 22 |
+
┌──────▼──────┐ ┌───────▼───────┐ ┌────────▼────────┐
|
| 23 |
+
│ DATA │ │ MODEL │ │ GENERATION │
|
| 24 |
+
│ PIPELINE │ │ PIPELINE │ │ PIPELINE │
|
| 25 |
+
├─────────────┤ ├───────────────┤ ├─────────────────┤
|
| 26 |
+
│ HF Download │ │ MusicTrans- │ │ Autoregressive │
|
| 27 |
+
│ MIDI Parse │ │ former Build │ │ w/ KV-cache │
|
| 28 |
+
│ REMI Token │ │ Train Loop │ │ Top-p/k Sample │
|
| 29 |
+
│ DataLoader │ │ AMP + Ckpt │ │ MIDI Export │
|
| 30 |
+
└─────────────┘ └───────────────┘ └─────────────────┘
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
## Component Responsibilities
|
| 34 |
+
|
| 35 |
+
### Data Pipeline (s02 + s03)
|
| 36 |
+
- Download 4,796 MIDI files from HuggingFace
|
| 37 |
+
- Parse with `pretty_midi` library
|
| 38 |
+
- Tokenize using REMI scheme (Note On/Off, Velocity, TimeShift, Bar, Position)
|
| 39 |
+
- Create PyTorch DataLoaders with padding and random cropping
|
| 40 |
+
|
| 41 |
+
### Model (s04)
|
| 42 |
+
- LLaMA-style Transformer (6 layers, 256 dim, 8 heads)
|
| 43 |
+
- Grouped Query Attention with 4 KV heads (50% memory reduction)
|
| 44 |
+
- SwiGLU feed-forward with RMSNorm
|
| 45 |
+
- RoPE for positional encoding
|
| 46 |
+
- Gradient checkpointing support
|
| 47 |
+
|
| 48 |
+
### Training (s05)
|
| 49 |
+
- AdamW optimizer with cosine LR warmup
|
| 50 |
+
- Mixed precision (BF16/FP16) via PyTorch AMP
|
| 51 |
+
- Gradient accumulation (effective batch = 32)
|
| 52 |
+
- Early stopping with patience
|
| 53 |
+
- TensorBoard logging
|
| 54 |
+
|
| 55 |
+
### Generation (s06)
|
| 56 |
+
- Autoregressive decoding with KV-cache
|
| 57 |
+
- Temperature scaling + Top-k + Top-p (nucleus) sampling
|
| 58 |
+
- Repetition penalty for diverse output
|
| 59 |
+
- Direct MIDI file export
|
| 60 |
+
|
| 61 |
+
## Data Flow
|
| 62 |
+
|
| 63 |
+
```
|
| 64 |
+
MIDI Files → REMI Tokens → Training → Trained Model → Generation → MIDI Output
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
## Constraints & Decisions
|
| 68 |
+
|
| 69 |
+
| Decision | Rationale |
|
| 70 |
+
|------------------------------|----------------------------------------------|
|
| 71 |
+
| Symbolic (MIDI) not audio | 100x smaller data, trainable on consumer GPU |
|
| 72 |
+
| GQA over standard MHA | 50% KV-cache memory reduction |
|
| 73 |
+
| Gradient checkpointing | ~50% memory savings, ~20% speed cost |
|
| 74 |
+
| BF16 mixed precision | 50% memory, maintains numeric stability |
|
| 75 |
+
| REMI tokenization | Best published results for symbolic music |
|
| 76 |
+
| 5M params (not 100M+) | Fits in <=4GB VRAM, fast training |
|
docs/LLD.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Low-Level Design (LLD)
|
| 2 |
+
|
| 3 |
+
## Module Details
|
| 4 |
+
|
| 5 |
+
### s02_tokenizer.py — REMI Tokenizer
|
| 6 |
+
|
| 7 |
+
**Vocabulary Layout** (485 tokens total):
|
| 8 |
+
```
|
| 9 |
+
[0] PAD
|
| 10 |
+
[1] BOS (Beginning of Sequence)
|
| 11 |
+
[2] EOS (End of Sequence)
|
| 12 |
+
[3] SEP (Separator)
|
| 13 |
+
[4-131] NoteOn (MIDI pitch 0-127)
|
| 14 |
+
[132-259] NoteOff (MIDI pitch 0-127)
|
| 15 |
+
[260-291] Velocity (32 bins, each = 4 MIDI velocity units)
|
| 16 |
+
[292-391] TimeShift (10ms steps, 10ms-1000ms)
|
| 17 |
+
[392-451] Tempo (40-200 BPM, 60 bins)
|
| 18 |
+
[452-483] Position (32 positions per bar)
|
| 19 |
+
[484] Bar (bar delimiter)
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
**Encoding Algorithm**:
|
| 23 |
+
1. Collect all non-drum notes across instruments
|
| 24 |
+
2. Sort by onset time, then pitch
|
| 25 |
+
3. For each note: emit [TimeShift, Position, Velocity, NoteOn, TimeShift(duration), NoteOff]
|
| 26 |
+
4. Insert Bar tokens at measure boundaries
|
| 27 |
+
5. Wrap with BOS/EOS
|
| 28 |
+
|
| 29 |
+
### s04_model.py — MusicTransformer
|
| 30 |
+
|
| 31 |
+
**Layer Stack** (per block):
|
| 32 |
+
```
|
| 33 |
+
Input → RMSNorm → GQA (with RoPE) → Residual Add
|
| 34 |
+
→ RMSNorm → SwiGLU FFN → Residual Add → Output
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
**Grouped Query Attention**:
|
| 38 |
+
- 8 query heads, 4 key-value heads
|
| 39 |
+
- Each KV head serves 2 query heads (n_rep = 2)
|
| 40 |
+
- Head dim = 256/8 = 32
|
| 41 |
+
- Uses PyTorch 2.0 `scaled_dot_product_attention` (Flash Attention backend when available)
|
| 42 |
+
|
| 43 |
+
**RoPE Implementation**:
|
| 44 |
+
- Precompute sin/cos frequencies: `freq[i] = 1 / (θ^(2i/d))`
|
| 45 |
+
- Apply rotation: `q' = q * cos + rotate_half(q) * sin`
|
| 46 |
+
- Device-compatible real-valued implementation (no complex tensors)
|
| 47 |
+
|
| 48 |
+
**SwiGLU FFN**:
|
| 49 |
+
- `output = W2(SiLU(W1(x)) * W3(x))`
|
| 50 |
+
- Hidden dim = 448 (nearest multiple of 64 to `2/3 * 4 * 256`)
|
| 51 |
+
|
| 52 |
+
### s05_trainer.py — Training Loop
|
| 53 |
+
|
| 54 |
+
**Optimization Details**:
|
| 55 |
+
- AdamW: β₁=0.9, β₂=0.95, wd=0.1
|
| 56 |
+
- LR schedule: linear warmup (200 steps) → cosine decay to 1e-6
|
| 57 |
+
- Gradient accumulation: 4 steps (effective batch = 4 × 4 = 16)
|
| 58 |
+
- Max gradient norm: 1.0
|
| 59 |
+
|
| 60 |
+
**Memory Budget (estimated for 512 seq len)**:
|
| 61 |
+
| Component | Memory |
|
| 62 |
+
|------------------------|-----------|
|
| 63 |
+
| Model weights (FP32) | ~20MB |
|
| 64 |
+
| Gradients | ~20MB |
|
| 65 |
+
| Optimizer states | ~40MB |
|
| 66 |
+
| Activations (w/ ckpt) | ~50MB |
|
| 67 |
+
| Data batch | ~4MB |
|
| 68 |
+
| **Total** | **~134MB**|
|
| 69 |
+
|
| 70 |
+
### s06_generator.py — Inference
|
| 71 |
+
|
| 72 |
+
**Decoding Pipeline**:
|
| 73 |
+
```
|
| 74 |
+
logits → temperature_scale → repetition_penalty → top_k_filter → top_p_filter → softmax → multinomial_sample
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
**KV-Cache**:
|
| 78 |
+
- Each layer stores (K, V) tensors after each forward pass
|
| 79 |
+
- New tokens only compute attention against cached KV + new KV
|
| 80 |
+
- Reset between generations to prevent cross-contamination
|
| 81 |
+
|
| 82 |
+
## Class Diagram
|
| 83 |
+
|
| 84 |
+
```
|
| 85 |
+
MusicTokenizer MusicTransformer Trainer
|
| 86 |
+
├── midi_to_tokens() ├── TransformerBlock (×6) ├── _train_epoch()
|
| 87 |
+
├── tokens_to_midi() │ ├── GroupedQueryAttention ├── _validate()
|
| 88 |
+
├── decode_token() │ │ ├── wq, wk, wv, wo ├── _save_checkpoint()
|
| 89 |
+
├── save()/load() │ │ ├── RoPE application └── load_checkpoint()
|
| 90 |
+
│ │ │ └── KV-cache
|
| 91 |
+
└── Token constants │ ├── SwiGLU FFN
|
| 92 |
+
│ └── RMSNorm (×2)
|
| 93 |
+
├── token_emb (weight-tied)
|
| 94 |
+
├── output projection
|
| 95 |
+
└── forward() / from_config()
|
| 96 |
+
```
|
docs/flow_diagram.drawio
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<mxfile host="65bd71144e">
|
| 2 |
+
<diagram name="Execution Flow" id="exec-flow">
|
| 3 |
+
<mxGraphModel dx="1363" dy="1186" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1600" pageHeight="900" math="0" shadow="0">
|
| 4 |
+
<root>
|
| 5 |
+
<mxCell id="0"/>
|
| 6 |
+
<mxCell id="1" parent="0"/>
|
| 7 |
+
<mxCell id="title" value="Music Generation LLM — Execution Flow" style="text;html=1;align=center;verticalAlign=middle;fontSize=18;fontStyle=1;" parent="1" vertex="1">
|
| 8 |
+
<mxGeometry x="400" y="20" width="400" height="40" as="geometry"/>
|
| 9 |
+
</mxCell>
|
| 10 |
+
<mxCell id="s00" value="s00_main.py Entry Point CLI Args Parser" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" parent="1" vertex="1">
|
| 11 |
+
<mxGeometry x="520" y="80" width="160" height="60" as="geometry"/>
|
| 12 |
+
</mxCell>
|
| 13 |
+
<mxCell id="s01" value="s01_config.py ModelConfig, TrainConfig DataConfig, GenConfig" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" parent="1" vertex="1">
|
| 14 |
+
<mxGeometry x="520" y="180" width="160" height="60" as="geometry"/>
|
| 15 |
+
</mxCell>
|
| 16 |
+
<mxCell id="s02" value="s02_tokenizer.py REMI Tokenizer 485 token vocab" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;" parent="1" vertex="1">
|
| 17 |
+
<mxGeometry x="520" y="280" width="160" height="60" as="geometry"/>
|
| 18 |
+
</mxCell>
|
| 19 |
+
<mxCell id="s03" value="s03_dataset.py HF Download → Parse → Tokenize → DataLoader" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;" parent="1" vertex="1">
|
| 20 |
+
<mxGeometry x="520" y="380" width="160" height="60" as="geometry"/>
|
| 21 |
+
</mxCell>
|
| 22 |
+
<mxCell id="s04" value="s04_model.py MusicTransformer RoPE+GQA+SwiGLU+RMSNorm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=12;" parent="1" vertex="1">
|
| 23 |
+
<mxGeometry x="300" y="480" width="180" height="60" as="geometry"/>
|
| 24 |
+
</mxCell>
|
| 25 |
+
<mxCell id="s05" value="s05_trainer.py AMP + Grad Ckpt AdamW + Cosine LR" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" parent="1" vertex="1">
|
| 26 |
+
<mxGeometry x="300" y="580" width="180" height="60" as="geometry"/>
|
| 27 |
+
</mxCell>
|
| 28 |
+
<mxCell id="s06" value="s06_generator.py KV-Cache Decoding Top-p/k + RepPenalty" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" parent="1" vertex="1">
|
| 29 |
+
<mxGeometry x="720" y="580" width="180" height="60" as="geometry"/>
|
| 30 |
+
</mxCell>
|
| 31 |
+
<mxCell id="out_ckpt" value="checkpoints/ best.pt" style="shape=cylinder3;whiteSpace=wrap;html=1;size=8;fillColor=#f5f5f5;strokeColor=#666666;fontSize=11;" parent="1" vertex="1">
|
| 32 |
+
<mxGeometry x="340" y="680" width="100" height="60" as="geometry"/>
|
| 33 |
+
</mxCell>
|
| 34 |
+
<mxCell id="out_midi" value="output/ generated_*.mid" style="shape=cylinder3;whiteSpace=wrap;html=1;size=8;fillColor=#f5f5f5;strokeColor=#666666;fontSize=11;" parent="1" vertex="1">
|
| 35 |
+
<mxGeometry x="760" y="680" width="100" height="60" as="geometry"/>
|
| 36 |
+
</mxCell>
|
| 37 |
+
<mxCell id="s07" value="s07_utils.py • Logging • Memory Monitor • Seeding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=11;" parent="1" vertex="1">
|
| 38 |
+
<mxGeometry x="80" y="380" width="140" height="80" as="geometry"/>
|
| 39 |
+
</mxCell>
|
| 40 |
+
<mxCell id="e1" parent="1" source="s00" target="s01" edge="1">
|
| 41 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 42 |
+
</mxCell>
|
| 43 |
+
<mxCell id="e2" parent="1" source="s01" target="s02" edge="1">
|
| 44 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 45 |
+
</mxCell>
|
| 46 |
+
<mxCell id="e3" parent="1" source="s02" target="s03" edge="1">
|
| 47 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 48 |
+
</mxCell>
|
| 49 |
+
<mxCell id="e4" parent="1" source="s03" target="s04" edge="1">
|
| 50 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 51 |
+
</mxCell>
|
| 52 |
+
<mxCell id="e5" value="train" style="edgeStyle=orthogonalEdgeStyle;" parent="1" source="s04" target="s05" edge="1">
|
| 53 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 54 |
+
</mxCell>
|
| 55 |
+
<mxCell id="e6" value="generate" style="edgeStyle=orthogonalEdgeStyle;" parent="1" source="s04" target="s06" edge="1">
|
| 56 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 57 |
+
</mxCell>
|
| 58 |
+
<mxCell id="e7" parent="1" source="s05" target="out_ckpt" edge="1">
|
| 59 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 60 |
+
</mxCell>
|
| 61 |
+
<mxCell id="e8" parent="1" source="s06" target="out_midi" edge="1">
|
| 62 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 63 |
+
</mxCell>
|
| 64 |
+
<mxCell id="e9" style="dashed=1;" parent="1" source="s07" target="s03" edge="1">
|
| 65 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 66 |
+
</mxCell>
|
| 67 |
+
<mxCell id="e10" value="loads best.pt" style="dashed=1;" parent="1" source="out_ckpt" target="s06" edge="1">
|
| 68 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 69 |
+
</mxCell>
|
| 70 |
+
</root>
|
| 71 |
+
</mxGraphModel>
|
| 72 |
+
</diagram>
|
| 73 |
+
<diagram name="Model Architecture" id="model-arch">
|
| 74 |
+
<mxGraphModel dx="477" dy="415" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1200" pageHeight="800" math="0" shadow="0">
|
| 75 |
+
<root>
|
| 76 |
+
<mxCell id="0"/>
|
| 77 |
+
<mxCell id="1" parent="0"/>
|
| 78 |
+
<mxCell id="t2" value="MusicTransformer Architecture" style="text;html=1;align=center;fontSize=16;fontStyle=1;" parent="1" vertex="1">
|
| 79 |
+
<mxGeometry x="350" y="20" width="300" height="30" as="geometry"/>
|
| 80 |
+
</mxCell>
|
| 81 |
+
<mxCell id="inp" value="Token IDs [batch, seq_len]" style="shape=parallelogram;whiteSpace=wrap;html=1;fillColor=#d5e8d4;" parent="1" vertex="1">
|
| 82 |
+
<mxGeometry x="420" y="70" width="160" height="40" as="geometry"/>
|
| 83 |
+
</mxCell>
|
| 84 |
+
<mxCell id="emb" value="Token Embedding (weight-tied with output)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;" parent="1" vertex="1">
|
| 85 |
+
<mxGeometry x="400" y="140" width="200" height="40" as="geometry"/>
|
| 86 |
+
</mxCell>
|
| 87 |
+
<mxCell id="block" value="" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666;dashed=1;" parent="1" vertex="1">
|
| 88 |
+
<mxGeometry x="300" y="210" width="400" height="300" as="geometry"/>
|
| 89 |
+
</mxCell>
|
| 90 |
+
<mxCell id="blabel" value="TransformerBlock × 6" style="text;html=1;align=center;fontSize=13;fontStyle=1;" parent="1" vertex="1">
|
| 91 |
+
<mxGeometry x="400" y="215" width="200" height="25" as="geometry"/>
|
| 92 |
+
</mxCell>
|
| 93 |
+
<mxCell id="rms1" value="RMSNorm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;" parent="1" vertex="1">
|
| 94 |
+
<mxGeometry x="430" y="250" width="140" height="30" as="geometry"/>
|
| 95 |
+
</mxCell>
|
| 96 |
+
<mxCell id="gqa" value="Grouped Query Attention 8Q / 4KV heads + RoPE Flash Attention / SDPA" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;" parent="1" vertex="1">
|
| 97 |
+
<mxGeometry x="390" y="295" width="220" height="50" as="geometry"/>
|
| 98 |
+
</mxCell>
|
| 99 |
+
<mxCell id="res1" value="+ Residual" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;" parent="1" vertex="1">
|
| 100 |
+
<mxGeometry x="450" y="355" width="100" height="30" as="geometry"/>
|
| 101 |
+
</mxCell>
|
| 102 |
+
<mxCell id="rms2" value="RMSNorm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;" parent="1" vertex="1">
|
| 103 |
+
<mxGeometry x="430" y="395" width="140" height="30" as="geometry"/>
|
| 104 |
+
</mxCell>
|
| 105 |
+
<mxCell id="ffn" value="SwiGLU FFN SiLU(W1·x) ⊙ W3·x → W2" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;" parent="1" vertex="1">
|
| 106 |
+
<mxGeometry x="400" y="435" width="200" height="40" as="geometry"/>
|
| 107 |
+
</mxCell>
|
| 108 |
+
<mxCell id="res2" value="+ Residual" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;" parent="1" vertex="1">
|
| 109 |
+
<mxGeometry x="450" y="485" width="100" height="25" as="geometry"/>
|
| 110 |
+
</mxCell>
|
| 111 |
+
<mxCell id="fnorm" value="RMSNorm (final)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;" parent="1" vertex="1">
|
| 112 |
+
<mxGeometry x="430" y="540" width="140" height="30" as="geometry"/>
|
| 113 |
+
</mxCell>
|
| 114 |
+
<mxCell id="proj" value="Linear Projection → Vocab" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;" parent="1" vertex="1">
|
| 115 |
+
<mxGeometry x="400" y="590" width="200" height="35" as="geometry"/>
|
| 116 |
+
</mxCell>
|
| 117 |
+
<mxCell id="out" value="Logits [batch, seq, 485]" style="shape=parallelogram;whiteSpace=wrap;html=1;fillColor=#d5e8d4;" parent="1" vertex="1">
|
| 118 |
+
<mxGeometry x="420" y="650" width="160" height="40" as="geometry"/>
|
| 119 |
+
</mxCell>
|
| 120 |
+
<mxCell id="a1" parent="1" source="inp" target="emb" edge="1">
|
| 121 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 122 |
+
</mxCell>
|
| 123 |
+
<mxCell id="a2" parent="1" source="emb" target="rms1" edge="1">
|
| 124 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 125 |
+
</mxCell>
|
| 126 |
+
<mxCell id="a3" parent="1" source="rms1" target="gqa" edge="1">
|
| 127 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 128 |
+
</mxCell>
|
| 129 |
+
<mxCell id="a4" parent="1" source="gqa" target="res1" edge="1">
|
| 130 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 131 |
+
</mxCell>
|
| 132 |
+
<mxCell id="a5" parent="1" source="res1" target="rms2" edge="1">
|
| 133 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 134 |
+
</mxCell>
|
| 135 |
+
<mxCell id="a6" parent="1" source="rms2" target="ffn" edge="1">
|
| 136 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 137 |
+
</mxCell>
|
| 138 |
+
<mxCell id="a7" parent="1" source="ffn" target="res2" edge="1">
|
| 139 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 140 |
+
</mxCell>
|
| 141 |
+
<mxCell id="a8" parent="1" source="res2" target="fnorm" edge="1">
|
| 142 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 143 |
+
</mxCell>
|
| 144 |
+
<mxCell id="a9" parent="1" source="fnorm" target="proj" edge="1">
|
| 145 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 146 |
+
</mxCell>
|
| 147 |
+
<mxCell id="a10" parent="1" source="proj" target="out" edge="1">
|
| 148 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 149 |
+
</mxCell>
|
| 150 |
+
</root>
|
| 151 |
+
</mxGraphModel>
|
| 152 |
+
</diagram>
|
| 153 |
+
<diagram name="UML Class Diagram" id="uml-class">
|
| 154 |
+
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900" math="0" shadow="0">
|
| 155 |
+
<root>
|
| 156 |
+
<mxCell id="0"/>
|
| 157 |
+
<mxCell id="1" parent="0"/>
|
| 158 |
+
<!-- MusicTokenizer -->
|
| 159 |
+
<mxCell id="tok" value="MusicTokenizer ───────────── - vocab_size: int - pad_id: int - bos_id: int - eos_id: int ───────────── + midi_to_tokens(midi, max_len) + tokens_to_midi(tokens) + decode_token(token_id) + save(path) / load(path)" style="shape=mxgraph.uml25.class;whiteSpace=wrap;html=1;align=left;spacingLeft=8;fontSize=11;fillColor=#dae8fc;" vertex="1" parent="1">
|
| 160 |
+
<mxGeometry x="40" y="100" width="260" height="200" as="geometry"/>
|
| 161 |
+
</mxCell>
|
| 162 |
+
<!-- MusicTransformer -->
|
| 163 |
+
<mxCell id="model" value="MusicTransformer ───────────── - config: ModelConfig - token_emb: Embedding - layers: ModuleList[TransformerBlock] - norm: RMSNorm - output: Linear - freqs: Tensor (RoPE) ───────────── + forward(input_ids, targets, use_cache) + reset_caches() + count_parameters() + from_config(config)" style="shape=mxgraph.uml25.class;whiteSpace=wrap;html=1;align=left;spacingLeft=8;fontSize=11;fillColor=#e1d5e7;" vertex="1" parent="1">
|
| 164 |
+
<mxGeometry x="540" y="60" width="300" height="240" as="geometry"/>
|
| 165 |
+
</mxCell>
|
| 166 |
+
<!-- TransformerBlock -->
|
| 167 |
+
<mxCell id="tblock" value="TransformerBlock ───────────── - attention: GroupedQueryAttention - feed_forward: SwiGLU - norm1, norm2: RMSNorm ───────────── + forward(x, freqs, mask, use_cache)" style="shape=mxgraph.uml25.class;whiteSpace=wrap;html=1;align=left;spacingLeft=8;fontSize=11;fillColor=#f5f5f5;" vertex="1" parent="1">
|
| 168 |
+
<mxGeometry x="560" y="370" width="260" height="140" as="geometry"/>
|
| 169 |
+
</mxCell>
|
| 170 |
+
<!-- Trainer -->
|
| 171 |
+
<mxCell id="trainer" value="Trainer ───────────── - model: MusicTransformer - optimizer: AdamW - scheduler: CosineWarmup - scaler: GradScaler ───────────── + train() + _train_epoch(epoch) + _validate() + _save_checkpoint(name) + load_checkpoint(path)" style="shape=mxgraph.uml25.class;whiteSpace=wrap;html=1;align=left;spacingLeft=8;fontSize=11;fillColor=#f8cecc;" vertex="1" parent="1">
|
| 172 |
+
<mxGeometry x="960" y="60" width="240" height="220" as="geometry"/>
|
| 173 |
+
</mxCell>
|
| 174 |
+
<!-- Generator functions -->
|
| 175 |
+
<mxCell id="gen" value="Generator (functions) ───────────── + generate(model, tokenizer, config) + generate_midi_file(model, tok, config, path) + top_k_top_p_filter(logits, k, p) + apply_repetition_penalty(logits, past, pen)" style="shape=mxgraph.uml25.class;whiteSpace=wrap;html=1;align=left;spacingLeft=8;fontSize=11;fillColor=#f8cecc;" vertex="1" parent="1">
|
| 176 |
+
<mxGeometry x="960" y="340" width="320" height="120" as="geometry"/>
|
| 177 |
+
</mxCell>
|
| 178 |
+
<!-- Relationships -->
|
| 179 |
+
<mxCell id="r1" value="uses" style="endArrow=open;dashed=1;" edge="1" source="trainer" target="model" parent="1">
|
| 180 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 181 |
+
</mxCell>
|
| 182 |
+
<mxCell id="r2" value="contains ×6" style="endArrow=diamond;endFill=1;" edge="1" source="tblock" target="model" parent="1">
|
| 183 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 184 |
+
</mxCell>
|
| 185 |
+
<mxCell id="r3" value="uses" style="endArrow=open;dashed=1;" edge="1" source="gen" target="model" parent="1">
|
| 186 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 187 |
+
</mxCell>
|
| 188 |
+
<mxCell id="r4" value="uses" style="endArrow=open;dashed=1;" edge="1" source="gen" target="tok" parent="1">
|
| 189 |
+
<mxGeometry relative="1" as="geometry"/>
|
| 190 |
+
</mxCell>
|
| 191 |
+
</root>
|
| 192 |
+
</mxGraphModel>
|
| 193 |
+
</diagram>
|
| 194 |
+
</mxfile>
|
inference_config.xml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="utf-8"?>
|
| 2 |
+
<generation>
|
| 3 |
+
<!-- Sampling Hyperparameters -->
|
| 4 |
+
<settings>
|
| 5 |
+
<temperature>0.85</temperature>
|
| 6 |
+
<top_k>40</top_k>
|
| 7 |
+
<top_p>0.92</top_p>
|
| 8 |
+
<repetition_penalty>1.15</repetition_penalty>
|
| 9 |
+
<max_tokens>512</max_tokens>
|
| 10 |
+
<seed>42</seed>
|
| 11 |
+
</settings>
|
| 12 |
+
|
| 13 |
+
<!-- Seed notes to prompt the model with.
|
| 14 |
+
pitch: MIDI note (60 = Middle C)
|
| 15 |
+
velocity: note loudness (1 to 127)
|
| 16 |
+
duration_ms: note length in milliseconds
|
| 17 |
+
delay_ms: pause time before starting the note (0 for chords)
|
| 18 |
+
-->
|
| 19 |
+
<prompt>
|
| 20 |
+
<note pitch="60" velocity="100" duration_ms="300" delay_ms="0"/> <!-- C4 -->
|
| 21 |
+
<note pitch="64" velocity="100" duration_ms="300" delay_ms="0"/> <!-- E4 -->
|
| 22 |
+
<note pitch="67" velocity="100" duration_ms="300" delay_ms="0"/> <!-- G4 -->
|
| 23 |
+
<note pitch="72" velocity="100" duration_ms="600" delay_ms="300"/> <!-- C5 -->
|
| 24 |
+
</prompt>
|
| 25 |
+
</generation>
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.1.0
|
| 2 |
+
miditok>=3.0.0
|
| 3 |
+
pretty_midi>=0.2.10
|
| 4 |
+
datasets>=2.14.0
|
| 5 |
+
numpy>=1.24.0
|
| 6 |
+
tqdm>=4.65.0
|
| 7 |
+
tensorboard>=2.14.0
|
| 8 |
+
matplotlib>=3.7.0
|
| 9 |
+
scipy>=1.11.0
|
| 10 |
+
mido>=1.3.0
|
run.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Standalone script: download dataset, train model, generate music."""
|
| 3 |
+
import subprocess
|
| 4 |
+
import sys
|
| 5 |
+
import os
|
| 6 |
+
import glob
|
| 7 |
+
|
| 8 |
+
BASE = os.path.dirname(os.path.abspath(__file__))
|
| 9 |
+
DATA_DIR = os.path.join(BASE, "data")
|
| 10 |
+
MIDI_DIR = os.path.join(DATA_DIR, "midi_files")
|
| 11 |
+
|
| 12 |
+
def download_dataset():
|
| 13 |
+
if os.path.exists(MIDI_DIR) and glob.glob(os.path.join(MIDI_DIR, "**", "*.mid"), recursive=True):
|
| 14 |
+
print(f"Dataset already exists at {MIDI_DIR}")
|
| 15 |
+
return
|
| 16 |
+
|
| 17 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 18 |
+
|
| 19 |
+
print("Downloading MIDI dataset via git clone...")
|
| 20 |
+
env = os.environ.copy()
|
| 21 |
+
env["GIT_LFS_SKIP_SMUDGE"] = "1"
|
| 22 |
+
|
| 23 |
+
result = subprocess.run(
|
| 24 |
+
["git", "clone", "--depth", "1",
|
| 25 |
+
"https://huggingface.co/datasets/drengskapur/midi-classical-music",
|
| 26 |
+
MIDI_DIR],
|
| 27 |
+
env=env,
|
| 28 |
+
capture_output=True,
|
| 29 |
+
text=True,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
if result.returncode != 0:
|
| 33 |
+
print(f"Git clone failed: {result.stderr}")
|
| 34 |
+
# Fallback: use Python to download
|
| 35 |
+
print("Trying Python download fallback...")
|
| 36 |
+
from huggingface_hub import snapshot_download
|
| 37 |
+
snapshot_download(
|
| 38 |
+
repo_id="drengskapur/midi-classical-music",
|
| 39 |
+
repo_type="dataset",
|
| 40 |
+
local_dir=MIDI_DIR,
|
| 41 |
+
allow_patterns=["*.mid"],
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
midi_files = glob.glob(os.path.join(MIDI_DIR, "**", "*.mid"), recursive=True)
|
| 45 |
+
print(f"Downloaded {len(midi_files)} MIDI files")
|
| 46 |
+
|
| 47 |
+
def main():
|
| 48 |
+
# Step 1: Download
|
| 49 |
+
download_dataset()
|
| 50 |
+
|
| 51 |
+
# Remove stale cache
|
| 52 |
+
cache = os.path.join(DATA_DIR, "tokenized_cache.pkl")
|
| 53 |
+
if os.path.exists(cache):
|
| 54 |
+
os.remove(cache)
|
| 55 |
+
print("Removed stale cache")
|
| 56 |
+
|
| 57 |
+
# Step 2: Train + Generate
|
| 58 |
+
sys.path.insert(0, BASE)
|
| 59 |
+
os.chdir(BASE)
|
| 60 |
+
|
| 61 |
+
args = sys.argv[1:] if len(sys.argv) > 1 else ["train+generate", "--epochs", "20", "--batch-size", "4", "--seq-len", "512"]
|
| 62 |
+
sys.argv = ["s00_main"] + args
|
| 63 |
+
|
| 64 |
+
from src.s00_main import main as run_main
|
| 65 |
+
run_main()
|
| 66 |
+
|
| 67 |
+
if __name__ == "__main__":
|
| 68 |
+
main()
|
runs/events.out.tfevents.1780546479.pop-os.1489904.0
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:05a052723dcdaec46d99857c6081329ce2d450a0d6e4b7b5a29c1bacd6eab1c4
|
| 3 |
+
size 88
|
runs/events.out.tfevents.1780547909.pop-os.1514727.0
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a301b83b83b19017e42151abb022093b5a651ae5ad1c30ca5098e09a4a59c18d
|
| 3 |
+
size 88
|
runs/events.out.tfevents.1780548128.pop-os.1518614.0
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:aa823d6e7e3a241bee1d8620d39bc4ebb5e975e3c7bdecde79e9d9819c97dcb8
|
| 3 |
+
size 288
|
runs/loss_train/events.out.tfevents.1780548163.pop-os.1518614.1
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:cebdba9eeb972ca737552a0dedff012e34c98f55f7b95764354466af7575d79f
|
| 3 |
+
size 298
|
runs/loss_val/events.out.tfevents.1780548163.pop-os.1518614.2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c851a9631104d1a512019f516518183199f4d6ceb0e9ba9dafdf0be4d363e6e8
|
| 3 |
+
size 298
|
scripts/download_data.sh
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Download and prepare the dataset
|
| 3 |
+
set -e
|
| 4 |
+
cd "$(dirname "$0")/.."
|
| 5 |
+
echo "Installing dependencies..."
|
| 6 |
+
pip install -r requirements.txt
|
| 7 |
+
echo "Dataset will be downloaded automatically during training via HuggingFace datasets."
|
| 8 |
+
echo "Dataset: drengskapur/midi-classical-music (~50MB, 4.8K MIDI files)"
|
| 9 |
+
echo "Done!"
|
scripts/generate.sh
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Generate music from trained model
|
| 3 |
+
set -e
|
| 4 |
+
cd "$(dirname "$0")/.."
|
| 5 |
+
|
| 6 |
+
python -m src.s00_main generate \
|
| 7 |
+
--temperature ${TEMP:-0.85} \
|
| 8 |
+
--max-tokens ${MAX_TOKENS:-1024}
|
scripts/train.sh
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Train the music generation model
|
| 3 |
+
set -e
|
| 4 |
+
cd "$(dirname "$0")/.."
|
| 5 |
+
|
| 6 |
+
python -m src.s00_main train \
|
| 7 |
+
--epochs ${EPOCHS:-50} \
|
| 8 |
+
--batch-size ${BATCH_SIZE:-8} \
|
| 9 |
+
--lr ${LR:-3e-4} \
|
| 10 |
+
--seq-len ${SEQ_LEN:-1024}
|
src/01_config.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration for the Music Generation LLM.
|
| 3 |
+
Tuned for constrained hardware (<=8GB VRAM, <=16GB RAM).
|
| 4 |
+
"""
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class ModelConfig:
|
| 12 |
+
vocab_size: int = 0 # Set dynamically from tokenizer
|
| 13 |
+
dim: int = 256
|
| 14 |
+
n_layers: int = 6
|
| 15 |
+
n_heads: int = 8
|
| 16 |
+
n_kv_heads: int = 4 # Grouped Query Attention: fewer KV heads saves memory
|
| 17 |
+
max_seq_len: int = 1024
|
| 18 |
+
hidden_dim: int = 0 # Auto-calculated as 4 * dim * 2/3 rounded to multiple of 64
|
| 19 |
+
dropout: float = 0.1
|
| 20 |
+
rope_theta: float = 10000.0
|
| 21 |
+
|
| 22 |
+
def __post_init__(self):
|
| 23 |
+
if self.hidden_dim == 0:
|
| 24 |
+
# SwiGLU hidden dim: 4 * dim * 2/3 (LLaMA convention)
|
| 25 |
+
self.hidden_dim = int(2 * (4 * self.dim) / 3)
|
| 26 |
+
# Round to nearest multiple of 64 for hardware efficiency
|
| 27 |
+
self.hidden_dim = 64 * ((self.hidden_dim + 63) // 64)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class TrainConfig:
|
| 32 |
+
batch_size: int = 8
|
| 33 |
+
grad_accum_steps: int = 4 # Effective batch = 32
|
| 34 |
+
learning_rate: float = 3e-4
|
| 35 |
+
weight_decay: float = 0.1
|
| 36 |
+
max_epochs: int = 50
|
| 37 |
+
warmup_steps: int = 200
|
| 38 |
+
max_grad_norm: float = 1.0
|
| 39 |
+
use_amp: bool = True # Mixed precision to save memory
|
| 40 |
+
grad_checkpoint: bool = True # Gradient checkpointing for OOM prevention
|
| 41 |
+
eval_interval: int = 500
|
| 42 |
+
save_interval: int = 1000
|
| 43 |
+
log_interval: int = 50
|
| 44 |
+
patience: int = 10 # Early stopping patience (epochs)
|
| 45 |
+
min_delta: float = 0.001 # Minimum improvement for early stopping
|
| 46 |
+
num_workers: int = 2 # DataLoader workers (low for constrained RAM)
|
| 47 |
+
pin_memory: bool = True
|
| 48 |
+
prefetch_factor: int = 2
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class DataConfig:
|
| 53 |
+
dataset_name: str = "drengskapur/midi-classical-music"
|
| 54 |
+
max_seq_len: int = 1024
|
| 55 |
+
train_split: float = 0.9
|
| 56 |
+
val_split: float = 0.1
|
| 57 |
+
tokenizer_params: str = "REMI" # REMI tokenization — SOTA for symbolic music
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@dataclass
|
| 61 |
+
class GenConfig:
|
| 62 |
+
temperature: float = 0.85
|
| 63 |
+
top_k: int = 40
|
| 64 |
+
top_p: float = 0.92
|
| 65 |
+
max_tokens: int = 1024
|
| 66 |
+
repetition_penalty: float = 1.15
|
| 67 |
+
seed: int = 42
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass
|
| 71 |
+
class PathConfig:
|
| 72 |
+
base_dir: Path = field(default_factory=lambda: Path(__file__).resolve().parent.parent)
|
| 73 |
+
data_dir: Path = field(init=False)
|
| 74 |
+
checkpoint_dir: Path = field(init=False)
|
| 75 |
+
output_dir: Path = field(init=False)
|
| 76 |
+
log_dir: Path = field(init=False)
|
| 77 |
+
tokenizer_path: Path = field(init=False)
|
| 78 |
+
|
| 79 |
+
def __post_init__(self):
|
| 80 |
+
self.data_dir = self.base_dir / "data"
|
| 81 |
+
self.checkpoint_dir = self.base_dir / "checkpoints"
|
| 82 |
+
self.output_dir = self.base_dir / "output"
|
| 83 |
+
self.log_dir = self.base_dir / "runs"
|
| 84 |
+
self.tokenizer_path = self.data_dir / "tokenizer.json"
|
| 85 |
+
for d in [self.data_dir, self.checkpoint_dir, self.output_dir, self.log_dir]:
|
| 86 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def get_device() -> torch.device:
|
| 90 |
+
if torch.cuda.is_available():
|
| 91 |
+
return torch.device("cuda")
|
| 92 |
+
return torch.device("cpu")
|
src/02_tokenizer.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MIDI Tokenizer using REMI (REvamped MIDI-derived) representation.
|
| 3 |
+
State-of-the-art tokenization for symbolic music generation.
|
| 4 |
+
Handles: Note On/Off, Velocity, Time Shift, Tempo, Time Signature.
|
| 5 |
+
"""
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
# Special token IDs
|
| 16 |
+
PAD_TOKEN = 0
|
| 17 |
+
BOS_TOKEN = 1
|
| 18 |
+
EOS_TOKEN = 2
|
| 19 |
+
SEP_TOKEN = 3
|
| 20 |
+
|
| 21 |
+
# Event type offsets (after special tokens)
|
| 22 |
+
SPECIAL_OFFSET = 4
|
| 23 |
+
|
| 24 |
+
# REMI vocabulary layout:
|
| 25 |
+
# [PAD, BOS, EOS, SEP, NoteOn_0..127, NoteOff_0..127, Velocity_0..31,
|
| 26 |
+
# TimeShift_0..99, Tempo_0..59, Position_0..31, Bar]
|
| 27 |
+
NOTE_ON_OFFSET = SPECIAL_OFFSET
|
| 28 |
+
NOTE_ON_COUNT = 128
|
| 29 |
+
NOTE_OFF_OFFSET = NOTE_ON_OFFSET + NOTE_ON_COUNT
|
| 30 |
+
NOTE_OFF_COUNT = 128
|
| 31 |
+
VELOCITY_OFFSET = NOTE_OFF_OFFSET + NOTE_OFF_COUNT
|
| 32 |
+
VELOCITY_COUNT = 32 # Quantized to 32 bins
|
| 33 |
+
TIMESHIFT_OFFSET = VELOCITY_OFFSET + VELOCITY_COUNT
|
| 34 |
+
TIMESHIFT_COUNT = 100 # 10ms to 1000ms in 10ms steps
|
| 35 |
+
TEMPO_OFFSET = TIMESHIFT_OFFSET + TIMESHIFT_COUNT
|
| 36 |
+
TEMPO_COUNT = 60 # 40-200 BPM quantized
|
| 37 |
+
POSITION_OFFSET = TEMPO_OFFSET + TEMPO_COUNT
|
| 38 |
+
POSITION_COUNT = 32 # 32 positions per bar (supports up to 32nd notes)
|
| 39 |
+
BAR_OFFSET = POSITION_OFFSET + POSITION_COUNT
|
| 40 |
+
BAR_COUNT = 1
|
| 41 |
+
|
| 42 |
+
VOCAB_SIZE = BAR_OFFSET + BAR_COUNT
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class MusicTokenizer:
|
| 46 |
+
"""Efficient REMI tokenizer for MIDI to token conversion."""
|
| 47 |
+
|
| 48 |
+
def __init__(self):
|
| 49 |
+
self.vocab_size = VOCAB_SIZE
|
| 50 |
+
self.pad_id = PAD_TOKEN
|
| 51 |
+
self.bos_id = BOS_TOKEN
|
| 52 |
+
self.eos_id = EOS_TOKEN
|
| 53 |
+
|
| 54 |
+
def note_on_token(self, pitch: int) -> int:
|
| 55 |
+
return NOTE_ON_OFFSET + max(0, min(127, pitch))
|
| 56 |
+
|
| 57 |
+
def note_off_token(self, pitch: int) -> int:
|
| 58 |
+
return NOTE_OFF_OFFSET + max(0, min(127, pitch))
|
| 59 |
+
|
| 60 |
+
def velocity_token(self, velocity: int) -> int:
|
| 61 |
+
# Quantize 0-127 to 0-31 bins
|
| 62 |
+
return VELOCITY_OFFSET + min(31, velocity // 4)
|
| 63 |
+
|
| 64 |
+
def timeshift_token(self, ms: float) -> int:
|
| 65 |
+
# Quantize to 10ms steps, capped at 1000ms
|
| 66 |
+
idx = max(0, min(99, int(ms / 10)))
|
| 67 |
+
return TIMESHIFT_OFFSET + idx
|
| 68 |
+
|
| 69 |
+
def tempo_token(self, bpm: float) -> int:
|
| 70 |
+
# Map BPM range 40-200 to 0-59
|
| 71 |
+
idx = max(0, min(59, int((bpm - 40) / (160 / 59))))
|
| 72 |
+
return TEMPO_OFFSET + idx
|
| 73 |
+
|
| 74 |
+
def position_token(self, pos: int) -> int:
|
| 75 |
+
return POSITION_OFFSET + max(0, min(31, pos))
|
| 76 |
+
|
| 77 |
+
def bar_token(self) -> int:
|
| 78 |
+
return BAR_OFFSET
|
| 79 |
+
|
| 80 |
+
def decode_token(self, token_id: int) -> dict:
|
| 81 |
+
"""Decode a token ID back to its event type and value."""
|
| 82 |
+
if token_id == PAD_TOKEN:
|
| 83 |
+
return {"type": "PAD", "value": 0}
|
| 84 |
+
if token_id == BOS_TOKEN:
|
| 85 |
+
return {"type": "BOS", "value": 0}
|
| 86 |
+
if token_id == EOS_TOKEN:
|
| 87 |
+
return {"type": "EOS", "value": 0}
|
| 88 |
+
if token_id == SEP_TOKEN:
|
| 89 |
+
return {"type": "SEP", "value": 0}
|
| 90 |
+
if NOTE_ON_OFFSET <= token_id < NOTE_OFF_OFFSET:
|
| 91 |
+
return {"type": "NoteOn", "value": token_id - NOTE_ON_OFFSET}
|
| 92 |
+
if NOTE_OFF_OFFSET <= token_id < VELOCITY_OFFSET:
|
| 93 |
+
return {"type": "NoteOff", "value": token_id - NOTE_OFF_OFFSET}
|
| 94 |
+
if VELOCITY_OFFSET <= token_id < TIMESHIFT_OFFSET:
|
| 95 |
+
return {"type": "Velocity", "value": (token_id - VELOCITY_OFFSET) * 4}
|
| 96 |
+
if TIMESHIFT_OFFSET <= token_id < TEMPO_OFFSET:
|
| 97 |
+
return {"type": "TimeShift", "value": (token_id - TIMESHIFT_OFFSET) * 10}
|
| 98 |
+
if TEMPO_OFFSET <= token_id < POSITION_OFFSET:
|
| 99 |
+
return {"type": "Tempo", "value": 40 + (token_id - TEMPO_OFFSET) * (160 / 59)}
|
| 100 |
+
if POSITION_OFFSET <= token_id < BAR_OFFSET:
|
| 101 |
+
return {"type": "Position", "value": token_id - POSITION_OFFSET}
|
| 102 |
+
if token_id == BAR_OFFSET:
|
| 103 |
+
return {"type": "Bar", "value": 0}
|
| 104 |
+
return {"type": "Unknown", "value": token_id}
|
| 105 |
+
|
| 106 |
+
def midi_to_tokens(self, midi_obj, max_len: Optional[int] = None) -> list[int]:
|
| 107 |
+
"""
|
| 108 |
+
Convert a pretty_midi.PrettyMIDI object to REMI token sequence.
|
| 109 |
+
Uses note-level events sorted by onset time.
|
| 110 |
+
"""
|
| 111 |
+
tokens = [self.bos_id]
|
| 112 |
+
|
| 113 |
+
# Collect all notes across instruments
|
| 114 |
+
all_notes = []
|
| 115 |
+
for inst in midi_obj.instruments:
|
| 116 |
+
if inst.is_drum:
|
| 117 |
+
continue
|
| 118 |
+
for note in inst.notes:
|
| 119 |
+
all_notes.append(note)
|
| 120 |
+
|
| 121 |
+
if not all_notes:
|
| 122 |
+
tokens.append(self.eos_id)
|
| 123 |
+
return tokens
|
| 124 |
+
|
| 125 |
+
# Sort by start time, then by pitch
|
| 126 |
+
all_notes.sort(key=lambda n: (n.start, n.pitch))
|
| 127 |
+
|
| 128 |
+
# Get tempo changes
|
| 129 |
+
tempos = midi_obj.get_tempo_changes()
|
| 130 |
+
current_tempo = 120.0
|
| 131 |
+
if len(tempos[1]) > 0:
|
| 132 |
+
current_tempo = tempos[1][0]
|
| 133 |
+
tokens.append(self.tempo_token(current_tempo))
|
| 134 |
+
|
| 135 |
+
# Compute bar duration
|
| 136 |
+
bar_duration = 60.0 / current_tempo * 4 # Assume 4/4
|
| 137 |
+
current_bar = 0
|
| 138 |
+
tokens.append(self.bar_token())
|
| 139 |
+
|
| 140 |
+
prev_time = 0.0
|
| 141 |
+
for note in all_notes:
|
| 142 |
+
# Bar tracking
|
| 143 |
+
note_bar = int(note.start / bar_duration)
|
| 144 |
+
while current_bar < note_bar:
|
| 145 |
+
current_bar += 1
|
| 146 |
+
tokens.append(self.bar_token())
|
| 147 |
+
|
| 148 |
+
# Time shift from previous event
|
| 149 |
+
dt = note.start - prev_time
|
| 150 |
+
if dt > 0:
|
| 151 |
+
# Break into chunks of max 1000ms
|
| 152 |
+
while dt > 1.0:
|
| 153 |
+
tokens.append(self.timeshift_token(1000))
|
| 154 |
+
dt -= 1.0
|
| 155 |
+
if dt > 0.005: # Ignore < 5ms
|
| 156 |
+
tokens.append(self.timeshift_token(dt * 1000))
|
| 157 |
+
|
| 158 |
+
# Position within bar
|
| 159 |
+
pos_in_bar = (note.start % bar_duration) / bar_duration
|
| 160 |
+
pos_idx = int(pos_in_bar * 32)
|
| 161 |
+
tokens.append(self.position_token(pos_idx))
|
| 162 |
+
|
| 163 |
+
# Velocity then NoteOn
|
| 164 |
+
tokens.append(self.velocity_token(note.velocity))
|
| 165 |
+
tokens.append(self.note_on_token(note.pitch))
|
| 166 |
+
|
| 167 |
+
# Note duration as timeshift + NoteOff
|
| 168 |
+
dur = note.end - note.start
|
| 169 |
+
if dur > 0:
|
| 170 |
+
while dur > 1.0:
|
| 171 |
+
tokens.append(self.timeshift_token(1000))
|
| 172 |
+
dur -= 1.0
|
| 173 |
+
if dur > 0.005:
|
| 174 |
+
tokens.append(self.timeshift_token(dur * 1000))
|
| 175 |
+
tokens.append(self.note_off_token(note.pitch))
|
| 176 |
+
|
| 177 |
+
prev_time = note.start
|
| 178 |
+
|
| 179 |
+
if max_len and len(tokens) >= max_len - 1:
|
| 180 |
+
break
|
| 181 |
+
|
| 182 |
+
tokens.append(self.eos_id)
|
| 183 |
+
|
| 184 |
+
if max_len:
|
| 185 |
+
tokens = tokens[:max_len]
|
| 186 |
+
|
| 187 |
+
return tokens
|
| 188 |
+
|
| 189 |
+
def tokens_to_midi(self, tokens: list[int]):
|
| 190 |
+
"""Convert REMI tokens back to a PrettyMIDI object."""
|
| 191 |
+
import pretty_midi
|
| 192 |
+
|
| 193 |
+
midi = pretty_midi.PrettyMIDI(initial_tempo=120.0)
|
| 194 |
+
inst = pretty_midi.Instrument(program=0, name="Piano")
|
| 195 |
+
|
| 196 |
+
current_time = 0.0
|
| 197 |
+
current_velocity = 80
|
| 198 |
+
active_notes = {} # pitch -> (start_time, velocity)
|
| 199 |
+
|
| 200 |
+
for token_id in tokens:
|
| 201 |
+
event = self.decode_token(token_id)
|
| 202 |
+
etype = event["type"]
|
| 203 |
+
val = event["value"]
|
| 204 |
+
|
| 205 |
+
if etype in ("PAD", "BOS", "EOS", "SEP", "Bar", "Position"):
|
| 206 |
+
continue
|
| 207 |
+
elif etype == "Tempo":
|
| 208 |
+
pass # Could adjust timing but simpler to ignore
|
| 209 |
+
elif etype == "TimeShift":
|
| 210 |
+
current_time += val / 1000.0
|
| 211 |
+
elif etype == "Velocity":
|
| 212 |
+
current_velocity = max(1, min(127, val))
|
| 213 |
+
elif etype == "NoteOn":
|
| 214 |
+
active_notes[val] = (current_time, current_velocity)
|
| 215 |
+
elif etype == "NoteOff":
|
| 216 |
+
if val in active_notes:
|
| 217 |
+
start, vel = active_notes.pop(val)
|
| 218 |
+
if current_time > start:
|
| 219 |
+
note = pretty_midi.Note(
|
| 220 |
+
velocity=vel,
|
| 221 |
+
pitch=val,
|
| 222 |
+
start=start,
|
| 223 |
+
end=current_time,
|
| 224 |
+
)
|
| 225 |
+
inst.notes.append(note)
|
| 226 |
+
|
| 227 |
+
# Close any remaining active notes
|
| 228 |
+
for pitch, (start, vel) in active_notes.items():
|
| 229 |
+
note = pretty_midi.Note(
|
| 230 |
+
velocity=vel, pitch=pitch, start=start, end=current_time + 0.5
|
| 231 |
+
)
|
| 232 |
+
inst.notes.append(note)
|
| 233 |
+
|
| 234 |
+
midi.instruments.append(inst)
|
| 235 |
+
return midi
|
| 236 |
+
|
| 237 |
+
def save(self, path: Path):
|
| 238 |
+
data = {"vocab_size": self.vocab_size}
|
| 239 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 240 |
+
with open(path, "w") as f:
|
| 241 |
+
json.dump(data, f)
|
| 242 |
+
|
| 243 |
+
@classmethod
|
| 244 |
+
def load(cls, path: Path) -> "MusicTokenizer":
|
| 245 |
+
tok = cls()
|
| 246 |
+
if path.exists():
|
| 247 |
+
with open(path) as f:
|
| 248 |
+
data = json.load(f)
|
| 249 |
+
tok.vocab_size = data.get("vocab_size", VOCAB_SIZE)
|
| 250 |
+
return tok
|
src/03_dataset.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data pipeline: downloads MIDI dataset, tokenizes, creates PyTorch DataLoaders.
|
| 3 |
+
Uses HuggingFace datasets for efficient streaming + caching.
|
| 4 |
+
Memory-efficient: processes files lazily, doesn't hold entire dataset in RAM.
|
| 5 |
+
"""
|
| 6 |
+
import logging
|
| 7 |
+
import pickle
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import torch
|
| 13 |
+
from torch.utils.data import Dataset, DataLoader
|
| 14 |
+
|
| 15 |
+
from src.s01_config import DataConfig, PathConfig, TrainConfig
|
| 16 |
+
from src.s02_tokenizer import MusicTokenizer
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class MidiTokenDataset(Dataset):
|
| 22 |
+
"""
|
| 23 |
+
PyTorch dataset of pre-tokenized MIDI sequences.
|
| 24 |
+
Stores token IDs as memory-mapped numpy arrays for RAM efficiency.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self, token_sequences: list[list[int]], max_seq_len: int, pad_id: int = 0):
|
| 28 |
+
self.max_seq_len = max_seq_len
|
| 29 |
+
self.pad_id = pad_id
|
| 30 |
+
# Filter out empty or too-short sequences
|
| 31 |
+
self.sequences = [s for s in token_sequences if len(s) >= 10]
|
| 32 |
+
logger.info(f"Dataset: {len(self.sequences)} sequences, max_len={max_seq_len}")
|
| 33 |
+
|
| 34 |
+
def __len__(self):
|
| 35 |
+
return len(self.sequences)
|
| 36 |
+
|
| 37 |
+
def __getitem__(self, idx):
|
| 38 |
+
seq = self.sequences[idx]
|
| 39 |
+
|
| 40 |
+
# For training: input = seq[:-1], target = seq[1:]
|
| 41 |
+
if len(seq) > self.max_seq_len + 1:
|
| 42 |
+
# Random crop for data augmentation
|
| 43 |
+
start = np.random.randint(0, len(seq) - self.max_seq_len)
|
| 44 |
+
seq = seq[start : start + self.max_seq_len + 1]
|
| 45 |
+
|
| 46 |
+
input_ids = seq[:-1]
|
| 47 |
+
target_ids = seq[1:]
|
| 48 |
+
|
| 49 |
+
# Pad to max_seq_len
|
| 50 |
+
pad_len = self.max_seq_len - len(input_ids)
|
| 51 |
+
if pad_len > 0:
|
| 52 |
+
input_ids = input_ids + [self.pad_id] * pad_len
|
| 53 |
+
target_ids = target_ids + [self.pad_id] * pad_len
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
torch.tensor(input_ids, dtype=torch.long),
|
| 57 |
+
torch.tensor(target_ids, dtype=torch.long),
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def download_and_tokenize(
|
| 62 |
+
data_config: DataConfig,
|
| 63 |
+
path_config: PathConfig,
|
| 64 |
+
tokenizer: MusicTokenizer,
|
| 65 |
+
) -> tuple[list[list[int]], list[list[int]]]:
|
| 66 |
+
"""
|
| 67 |
+
Download MIDI dataset from HuggingFace and tokenize all files.
|
| 68 |
+
Returns (train_sequences, val_sequences).
|
| 69 |
+
Caches tokenized data to disk for fast reload.
|
| 70 |
+
"""
|
| 71 |
+
cache_path = path_config.data_dir / "tokenized_cache.pkl"
|
| 72 |
+
|
| 73 |
+
if cache_path.exists():
|
| 74 |
+
logger.info("Loading tokenized data from cache...")
|
| 75 |
+
with open(cache_path, "rb") as f:
|
| 76 |
+
data = pickle.load(f)
|
| 77 |
+
return data["train"], data["val"]
|
| 78 |
+
|
| 79 |
+
logger.info(f"Downloading dataset: {data_config.dataset_name}")
|
| 80 |
+
from datasets import load_dataset
|
| 81 |
+
import pretty_midi
|
| 82 |
+
import io
|
| 83 |
+
import tempfile
|
| 84 |
+
import os
|
| 85 |
+
|
| 86 |
+
ds = load_dataset(data_config.dataset_name, split="train", trust_remote_code=True)
|
| 87 |
+
|
| 88 |
+
all_sequences = []
|
| 89 |
+
errors = 0
|
| 90 |
+
|
| 91 |
+
logger.info(f"Tokenizing {len(ds)} MIDI files...")
|
| 92 |
+
for i, item in enumerate(ds):
|
| 93 |
+
try:
|
| 94 |
+
midi_bytes = item.get("midi") or item.get("audio") or item.get("file")
|
| 95 |
+
if midi_bytes is None:
|
| 96 |
+
# Try getting the bytes from any binary column
|
| 97 |
+
for key, val in item.items():
|
| 98 |
+
if isinstance(val, (bytes, dict)):
|
| 99 |
+
if isinstance(val, dict) and "bytes" in val:
|
| 100 |
+
midi_bytes = val["bytes"]
|
| 101 |
+
break
|
| 102 |
+
elif isinstance(val, bytes):
|
| 103 |
+
midi_bytes = val
|
| 104 |
+
break
|
| 105 |
+
|
| 106 |
+
if midi_bytes is None:
|
| 107 |
+
errors += 1
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
if isinstance(midi_bytes, bytes):
|
| 111 |
+
# Write to temp file since pretty_midi needs a file path
|
| 112 |
+
with tempfile.NamedTemporaryFile(suffix=".mid", delete=False) as tmp:
|
| 113 |
+
tmp.write(midi_bytes)
|
| 114 |
+
tmp_path = tmp.name
|
| 115 |
+
try:
|
| 116 |
+
midi = pretty_midi.PrettyMIDI(tmp_path)
|
| 117 |
+
tokens = tokenizer.midi_to_tokens(midi, max_len=data_config.max_seq_len + 1)
|
| 118 |
+
if len(tokens) >= 20:
|
| 119 |
+
all_sequences.append(tokens)
|
| 120 |
+
finally:
|
| 121 |
+
os.unlink(tmp_path)
|
| 122 |
+
elif isinstance(midi_bytes, str):
|
| 123 |
+
# It's a file path
|
| 124 |
+
midi = pretty_midi.PrettyMIDI(midi_bytes)
|
| 125 |
+
tokens = tokenizer.midi_to_tokens(midi, max_len=data_config.max_seq_len + 1)
|
| 126 |
+
if len(tokens) >= 20:
|
| 127 |
+
all_sequences.append(tokens)
|
| 128 |
+
|
| 129 |
+
except Exception as e:
|
| 130 |
+
errors += 1
|
| 131 |
+
if errors <= 5:
|
| 132 |
+
logger.warning(f"Error processing item {i}: {e}")
|
| 133 |
+
|
| 134 |
+
if (i + 1) % 200 == 0:
|
| 135 |
+
logger.info(f" Processed {i+1}/{len(ds)}, valid={len(all_sequences)}, errors={errors}")
|
| 136 |
+
|
| 137 |
+
logger.info(f"Tokenization complete: {len(all_sequences)} sequences, {errors} errors")
|
| 138 |
+
|
| 139 |
+
# Split into train/val
|
| 140 |
+
np.random.seed(42)
|
| 141 |
+
indices = np.random.permutation(len(all_sequences))
|
| 142 |
+
split = int(len(all_sequences) * data_config.train_split)
|
| 143 |
+
|
| 144 |
+
train_seqs = [all_sequences[i] for i in indices[:split]]
|
| 145 |
+
val_seqs = [all_sequences[i] for i in indices[split:]]
|
| 146 |
+
|
| 147 |
+
# Cache to disk
|
| 148 |
+
with open(cache_path, "wb") as f:
|
| 149 |
+
pickle.dump({"train": train_seqs, "val": val_seqs}, f)
|
| 150 |
+
logger.info(f"Cached tokenized data: train={len(train_seqs)}, val={len(val_seqs)}")
|
| 151 |
+
|
| 152 |
+
return train_seqs, val_seqs
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def create_dataloaders(
|
| 156 |
+
data_config: DataConfig,
|
| 157 |
+
train_config: TrainConfig,
|
| 158 |
+
path_config: PathConfig,
|
| 159 |
+
tokenizer: MusicTokenizer,
|
| 160 |
+
) -> tuple[DataLoader, DataLoader]:
|
| 161 |
+
"""Create train and validation DataLoaders."""
|
| 162 |
+
train_seqs, val_seqs = download_and_tokenize(data_config, path_config, tokenizer)
|
| 163 |
+
|
| 164 |
+
train_ds = MidiTokenDataset(train_seqs, data_config.max_seq_len, tokenizer.pad_id)
|
| 165 |
+
val_ds = MidiTokenDataset(val_seqs, data_config.max_seq_len, tokenizer.pad_id)
|
| 166 |
+
|
| 167 |
+
train_loader = DataLoader(
|
| 168 |
+
train_ds,
|
| 169 |
+
batch_size=train_config.batch_size,
|
| 170 |
+
shuffle=True,
|
| 171 |
+
num_workers=train_config.num_workers,
|
| 172 |
+
pin_memory=train_config.pin_memory,
|
| 173 |
+
prefetch_factor=train_config.prefetch_factor,
|
| 174 |
+
drop_last=True,
|
| 175 |
+
)
|
| 176 |
+
val_loader = DataLoader(
|
| 177 |
+
val_ds,
|
| 178 |
+
batch_size=train_config.batch_size,
|
| 179 |
+
shuffle=False,
|
| 180 |
+
num_workers=train_config.num_workers,
|
| 181 |
+
pin_memory=train_config.pin_memory,
|
| 182 |
+
prefetch_factor=train_config.prefetch_factor,
|
| 183 |
+
drop_last=False,
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
return train_loader, val_loader
|
src/__init__.py
ADDED
|
File without changes
|
src/s00_main.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Main entry point for Music Generation LLM.
|
| 3 |
+
Orchestrates: config → data → model → train → generate.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python -m src.s00_main train # Train the model
|
| 7 |
+
python -m src.s00_main generate # Generate music from trained model
|
| 8 |
+
python -m src.s00_main train+generate # Train then generate
|
| 9 |
+
"""
|
| 10 |
+
import argparse
|
| 11 |
+
import logging
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
|
| 17 |
+
# Add project root to path
|
| 18 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 19 |
+
|
| 20 |
+
from src.s01_config import ModelConfig, TrainConfig, DataConfig, GenConfig, PathConfig, get_device
|
| 21 |
+
from src.s02_tokenizer import MusicTokenizer
|
| 22 |
+
from src.s03_dataset import create_dataloaders
|
| 23 |
+
from src.s04_model import MusicTransformer
|
| 24 |
+
from src.s05_trainer import Trainer
|
| 25 |
+
from src.s06_generator import generate_midi_file
|
| 26 |
+
from src.s07_utils import setup_logging, set_seed, log_memory_usage, clear_memory
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def train_pipeline(
|
| 32 |
+
model_config: ModelConfig,
|
| 33 |
+
train_config: TrainConfig,
|
| 34 |
+
data_config: DataConfig,
|
| 35 |
+
path_config: PathConfig,
|
| 36 |
+
tokenizer: MusicTokenizer,
|
| 37 |
+
):
|
| 38 |
+
"""Full training pipeline."""
|
| 39 |
+
logger.info("=" * 60)
|
| 40 |
+
logger.info("MUSIC GENERATION LLM — TRAINING")
|
| 41 |
+
logger.info("=" * 60)
|
| 42 |
+
|
| 43 |
+
# Create data loaders
|
| 44 |
+
logger.info("Preparing data...")
|
| 45 |
+
train_loader, val_loader = create_dataloaders(
|
| 46 |
+
data_config, train_config, path_config, tokenizer
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Build model
|
| 50 |
+
model_config.vocab_size = tokenizer.vocab_size
|
| 51 |
+
model = MusicTransformer.from_config(model_config)
|
| 52 |
+
logger.info(f"Model: {model.count_parameters():,} parameters")
|
| 53 |
+
log_memory_usage("Pre-training")
|
| 54 |
+
|
| 55 |
+
# Train
|
| 56 |
+
trainer = Trainer(model, train_loader, val_loader, train_config, path_config)
|
| 57 |
+
trainer.train()
|
| 58 |
+
|
| 59 |
+
# Save tokenizer
|
| 60 |
+
tokenizer.save(path_config.tokenizer_path)
|
| 61 |
+
log_memory_usage("Post-training")
|
| 62 |
+
clear_memory()
|
| 63 |
+
return model
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def generate_pipeline(
|
| 67 |
+
model_config: ModelConfig,
|
| 68 |
+
gen_config: GenConfig,
|
| 69 |
+
path_config: PathConfig,
|
| 70 |
+
tokenizer: MusicTokenizer,
|
| 71 |
+
model: MusicTransformer | None = None,
|
| 72 |
+
):
|
| 73 |
+
"""Generate music from trained model."""
|
| 74 |
+
logger.info("=" * 60)
|
| 75 |
+
logger.info("MUSIC GENERATION LLM — GENERATING")
|
| 76 |
+
logger.info("=" * 60)
|
| 77 |
+
|
| 78 |
+
if model is None:
|
| 79 |
+
best_ckpt = path_config.checkpoint_dir / "best.pt"
|
| 80 |
+
if not best_ckpt.exists():
|
| 81 |
+
logger.error(f"No checkpoint found at {best_ckpt}. Train first!")
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
model_config.vocab_size = tokenizer.vocab_size
|
| 85 |
+
model = MusicTransformer.from_config(model_config)
|
| 86 |
+
ckpt = torch.load(best_ckpt, map_location=get_device(), weights_only=False)
|
| 87 |
+
model.load_state_dict(ckpt["model_state_dict"])
|
| 88 |
+
logger.info(f"Loaded model from {best_ckpt}")
|
| 89 |
+
|
| 90 |
+
# Generate multiple samples
|
| 91 |
+
for i in range(3):
|
| 92 |
+
output_path = path_config.output_dir / f"generated_{i+1}.mid"
|
| 93 |
+
gen_config_i = GenConfig(
|
| 94 |
+
temperature=gen_config.temperature,
|
| 95 |
+
top_k=gen_config.top_k,
|
| 96 |
+
top_p=gen_config.top_p,
|
| 97 |
+
max_tokens=gen_config.max_tokens,
|
| 98 |
+
repetition_penalty=gen_config.repetition_penalty,
|
| 99 |
+
seed=gen_config.seed + i,
|
| 100 |
+
)
|
| 101 |
+
generate_midi_file(model, tokenizer, gen_config_i, output_path)
|
| 102 |
+
logger.info(f"Generated sample {i+1}: {output_path}")
|
| 103 |
+
|
| 104 |
+
clear_memory()
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def main():
|
| 108 |
+
parser = argparse.ArgumentParser(description="Music Generation LLM")
|
| 109 |
+
parser.add_argument(
|
| 110 |
+
"mode",
|
| 111 |
+
choices=["train", "generate", "train+generate"],
|
| 112 |
+
default="train+generate",
|
| 113 |
+
nargs="?",
|
| 114 |
+
help="Operation mode",
|
| 115 |
+
)
|
| 116 |
+
parser.add_argument("--epochs", type=int, default=None, help="Override max epochs")
|
| 117 |
+
parser.add_argument("--batch-size", type=int, default=None, help="Override batch size")
|
| 118 |
+
parser.add_argument("--lr", type=float, default=None, help="Override learning rate")
|
| 119 |
+
parser.add_argument("--seq-len", type=int, default=None, help="Override max sequence length")
|
| 120 |
+
parser.add_argument("--temperature", type=float, default=None, help="Generation temperature")
|
| 121 |
+
parser.add_argument("--max-tokens", type=int, default=None, help="Max generation tokens")
|
| 122 |
+
args = parser.parse_args()
|
| 123 |
+
|
| 124 |
+
setup_logging()
|
| 125 |
+
set_seed(42)
|
| 126 |
+
|
| 127 |
+
# Initialize configs
|
| 128 |
+
model_config = ModelConfig()
|
| 129 |
+
train_config = TrainConfig()
|
| 130 |
+
data_config = DataConfig()
|
| 131 |
+
gen_config = GenConfig()
|
| 132 |
+
path_config = PathConfig()
|
| 133 |
+
|
| 134 |
+
# Apply overrides
|
| 135 |
+
if args.epochs:
|
| 136 |
+
train_config.max_epochs = args.epochs
|
| 137 |
+
if args.batch_size:
|
| 138 |
+
train_config.batch_size = args.batch_size
|
| 139 |
+
if args.lr:
|
| 140 |
+
train_config.learning_rate = args.lr
|
| 141 |
+
if args.seq_len:
|
| 142 |
+
data_config.max_seq_len = args.seq_len
|
| 143 |
+
model_config.max_seq_len = args.seq_len
|
| 144 |
+
if args.temperature:
|
| 145 |
+
gen_config.temperature = args.temperature
|
| 146 |
+
if args.max_tokens:
|
| 147 |
+
gen_config.max_tokens = args.max_tokens
|
| 148 |
+
|
| 149 |
+
# Tokenizer
|
| 150 |
+
tokenizer = MusicTokenizer()
|
| 151 |
+
model_config.vocab_size = tokenizer.vocab_size
|
| 152 |
+
|
| 153 |
+
logger.info(f"Device: {get_device()}")
|
| 154 |
+
logger.info(f"Vocab size: {tokenizer.vocab_size}")
|
| 155 |
+
logger.info(f"Model dim: {model_config.dim}, layers: {model_config.n_layers}, "
|
| 156 |
+
f"heads: {model_config.n_heads}, kv_heads: {model_config.n_kv_heads}")
|
| 157 |
+
|
| 158 |
+
model = None
|
| 159 |
+
if "train" in args.mode:
|
| 160 |
+
model = train_pipeline(model_config, train_config, data_config, path_config, tokenizer)
|
| 161 |
+
|
| 162 |
+
if "generate" in args.mode:
|
| 163 |
+
generate_pipeline(model_config, gen_config, path_config, tokenizer, model)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
if __name__ == "__main__":
|
| 167 |
+
main()
|
src/s01_config.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration for the Music Generation LLM.
|
| 3 |
+
Tuned for constrained hardware (<=8GB VRAM, <=16GB RAM).
|
| 4 |
+
"""
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class ModelConfig:
|
| 12 |
+
vocab_size: int = 0 # Set dynamically from tokenizer
|
| 13 |
+
dim: int = 256
|
| 14 |
+
n_layers: int = 6
|
| 15 |
+
n_heads: int = 8
|
| 16 |
+
n_kv_heads: int = 4 # Grouped Query Attention: fewer KV heads saves memory
|
| 17 |
+
max_seq_len: int = 1024
|
| 18 |
+
hidden_dim: int = 0 # Auto-calculated as 4 * dim * 2/3 rounded to multiple of 64
|
| 19 |
+
dropout: float = 0.1
|
| 20 |
+
rope_theta: float = 10000.0
|
| 21 |
+
|
| 22 |
+
def __post_init__(self):
|
| 23 |
+
if self.hidden_dim == 0:
|
| 24 |
+
# SwiGLU hidden dim: 4 * dim * 2/3 (LLaMA convention)
|
| 25 |
+
self.hidden_dim = int(2 * (4 * self.dim) / 3)
|
| 26 |
+
# Round to nearest multiple of 64 for hardware efficiency
|
| 27 |
+
self.hidden_dim = 64 * ((self.hidden_dim + 63) // 64)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class TrainConfig:
|
| 32 |
+
batch_size: int = 8
|
| 33 |
+
grad_accum_steps: int = 4 # Effective batch = 32
|
| 34 |
+
learning_rate: float = 3e-4
|
| 35 |
+
weight_decay: float = 0.1
|
| 36 |
+
max_epochs: int = 50
|
| 37 |
+
warmup_steps: int = 200
|
| 38 |
+
max_grad_norm: float = 1.0
|
| 39 |
+
use_amp: bool = True # Mixed precision to save memory
|
| 40 |
+
grad_checkpoint: bool = True # Gradient checkpointing for OOM prevention
|
| 41 |
+
eval_interval: int = 500
|
| 42 |
+
save_interval: int = 1000
|
| 43 |
+
log_interval: int = 50
|
| 44 |
+
patience: int = 10 # Early stopping patience (epochs)
|
| 45 |
+
min_delta: float = 0.001 # Minimum improvement for early stopping
|
| 46 |
+
num_workers: int = 2 # DataLoader workers (low for constrained RAM)
|
| 47 |
+
pin_memory: bool = True
|
| 48 |
+
prefetch_factor: int = 2
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class DataConfig:
|
| 53 |
+
dataset_name: str = "drengskapur/midi-classical-music"
|
| 54 |
+
max_seq_len: int = 1024
|
| 55 |
+
train_split: float = 0.9
|
| 56 |
+
val_split: float = 0.1
|
| 57 |
+
tokenizer_params: str = "REMI" # REMI tokenization — SOTA for symbolic music
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@dataclass
|
| 61 |
+
class GenConfig:
|
| 62 |
+
temperature: float = 0.85
|
| 63 |
+
top_k: int = 40
|
| 64 |
+
top_p: float = 0.92
|
| 65 |
+
max_tokens: int = 1024
|
| 66 |
+
repetition_penalty: float = 1.15
|
| 67 |
+
seed: int = 42
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass
|
| 71 |
+
class PathConfig:
|
| 72 |
+
base_dir: Path = field(default_factory=lambda: Path(__file__).resolve().parent.parent)
|
| 73 |
+
data_dir: Path = field(init=False)
|
| 74 |
+
checkpoint_dir: Path = field(init=False)
|
| 75 |
+
output_dir: Path = field(init=False)
|
| 76 |
+
log_dir: Path = field(init=False)
|
| 77 |
+
tokenizer_path: Path = field(init=False)
|
| 78 |
+
|
| 79 |
+
def __post_init__(self):
|
| 80 |
+
self.data_dir = self.base_dir / "data"
|
| 81 |
+
self.checkpoint_dir = self.base_dir / "checkpoints"
|
| 82 |
+
self.output_dir = self.base_dir / "output"
|
| 83 |
+
self.log_dir = self.base_dir / "runs"
|
| 84 |
+
self.tokenizer_path = self.data_dir / "tokenizer.json"
|
| 85 |
+
for d in [self.data_dir, self.checkpoint_dir, self.output_dir, self.log_dir]:
|
| 86 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def get_device() -> torch.device:
|
| 90 |
+
if torch.cuda.is_available():
|
| 91 |
+
return torch.device("cuda")
|
| 92 |
+
return torch.device("cpu")
|
src/s02_tokenizer.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MIDI Tokenizer using REMI (REvamped MIDI-derived) representation.
|
| 3 |
+
State-of-the-art tokenization for symbolic music generation.
|
| 4 |
+
Handles: Note On/Off, Velocity, Time Shift, Tempo, Time Signature.
|
| 5 |
+
"""
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
# Special token IDs
|
| 16 |
+
PAD_TOKEN = 0
|
| 17 |
+
BOS_TOKEN = 1
|
| 18 |
+
EOS_TOKEN = 2
|
| 19 |
+
SEP_TOKEN = 3
|
| 20 |
+
|
| 21 |
+
# Event type offsets (after special tokens)
|
| 22 |
+
SPECIAL_OFFSET = 4
|
| 23 |
+
|
| 24 |
+
# REMI vocabulary layout:
|
| 25 |
+
# [PAD, BOS, EOS, SEP, NoteOn_0..127, NoteOff_0..127, Velocity_0..31,
|
| 26 |
+
# TimeShift_0..99, Tempo_0..59, Position_0..31, Bar]
|
| 27 |
+
NOTE_ON_OFFSET = SPECIAL_OFFSET
|
| 28 |
+
NOTE_ON_COUNT = 128
|
| 29 |
+
NOTE_OFF_OFFSET = NOTE_ON_OFFSET + NOTE_ON_COUNT
|
| 30 |
+
NOTE_OFF_COUNT = 128
|
| 31 |
+
VELOCITY_OFFSET = NOTE_OFF_OFFSET + NOTE_OFF_COUNT
|
| 32 |
+
VELOCITY_COUNT = 32 # Quantized to 32 bins
|
| 33 |
+
TIMESHIFT_OFFSET = VELOCITY_OFFSET + VELOCITY_COUNT
|
| 34 |
+
TIMESHIFT_COUNT = 100 # 10ms to 1000ms in 10ms steps
|
| 35 |
+
TEMPO_OFFSET = TIMESHIFT_OFFSET + TIMESHIFT_COUNT
|
| 36 |
+
TEMPO_COUNT = 60 # 40-200 BPM quantized
|
| 37 |
+
POSITION_OFFSET = TEMPO_OFFSET + TEMPO_COUNT
|
| 38 |
+
POSITION_COUNT = 32 # 32 positions per bar (supports up to 32nd notes)
|
| 39 |
+
BAR_OFFSET = POSITION_OFFSET + POSITION_COUNT
|
| 40 |
+
BAR_COUNT = 1
|
| 41 |
+
|
| 42 |
+
VOCAB_SIZE = BAR_OFFSET + BAR_COUNT
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class MusicTokenizer:
|
| 46 |
+
"""Efficient REMI tokenizer for MIDI to token conversion."""
|
| 47 |
+
|
| 48 |
+
def __init__(self):
|
| 49 |
+
self.vocab_size = VOCAB_SIZE
|
| 50 |
+
self.pad_id = PAD_TOKEN
|
| 51 |
+
self.bos_id = BOS_TOKEN
|
| 52 |
+
self.eos_id = EOS_TOKEN
|
| 53 |
+
|
| 54 |
+
def note_on_token(self, pitch: int) -> int:
|
| 55 |
+
return NOTE_ON_OFFSET + max(0, min(127, pitch))
|
| 56 |
+
|
| 57 |
+
def note_off_token(self, pitch: int) -> int:
|
| 58 |
+
return NOTE_OFF_OFFSET + max(0, min(127, pitch))
|
| 59 |
+
|
| 60 |
+
def velocity_token(self, velocity: int) -> int:
|
| 61 |
+
# Quantize 0-127 to 0-31 bins
|
| 62 |
+
return VELOCITY_OFFSET + min(31, velocity // 4)
|
| 63 |
+
|
| 64 |
+
def timeshift_token(self, ms: float) -> int:
|
| 65 |
+
# Quantize to 10ms steps, capped at 1000ms
|
| 66 |
+
idx = max(0, min(99, int(ms / 10)))
|
| 67 |
+
return TIMESHIFT_OFFSET + idx
|
| 68 |
+
|
| 69 |
+
def tempo_token(self, bpm: float) -> int:
|
| 70 |
+
# Map BPM range 40-200 to 0-59
|
| 71 |
+
idx = max(0, min(59, int((bpm - 40) / (160 / 59))))
|
| 72 |
+
return TEMPO_OFFSET + idx
|
| 73 |
+
|
| 74 |
+
def position_token(self, pos: int) -> int:
|
| 75 |
+
return POSITION_OFFSET + max(0, min(31, pos))
|
| 76 |
+
|
| 77 |
+
def bar_token(self) -> int:
|
| 78 |
+
return BAR_OFFSET
|
| 79 |
+
|
| 80 |
+
def decode_token(self, token_id: int) -> dict:
|
| 81 |
+
"""Decode a token ID back to its event type and value."""
|
| 82 |
+
if token_id == PAD_TOKEN:
|
| 83 |
+
return {"type": "PAD", "value": 0}
|
| 84 |
+
if token_id == BOS_TOKEN:
|
| 85 |
+
return {"type": "BOS", "value": 0}
|
| 86 |
+
if token_id == EOS_TOKEN:
|
| 87 |
+
return {"type": "EOS", "value": 0}
|
| 88 |
+
if token_id == SEP_TOKEN:
|
| 89 |
+
return {"type": "SEP", "value": 0}
|
| 90 |
+
if NOTE_ON_OFFSET <= token_id < NOTE_OFF_OFFSET:
|
| 91 |
+
return {"type": "NoteOn", "value": token_id - NOTE_ON_OFFSET}
|
| 92 |
+
if NOTE_OFF_OFFSET <= token_id < VELOCITY_OFFSET:
|
| 93 |
+
return {"type": "NoteOff", "value": token_id - NOTE_OFF_OFFSET}
|
| 94 |
+
if VELOCITY_OFFSET <= token_id < TIMESHIFT_OFFSET:
|
| 95 |
+
return {"type": "Velocity", "value": (token_id - VELOCITY_OFFSET) * 4}
|
| 96 |
+
if TIMESHIFT_OFFSET <= token_id < TEMPO_OFFSET:
|
| 97 |
+
return {"type": "TimeShift", "value": (token_id - TIMESHIFT_OFFSET) * 10}
|
| 98 |
+
if TEMPO_OFFSET <= token_id < POSITION_OFFSET:
|
| 99 |
+
return {"type": "Tempo", "value": 40 + (token_id - TEMPO_OFFSET) * (160 / 59)}
|
| 100 |
+
if POSITION_OFFSET <= token_id < BAR_OFFSET:
|
| 101 |
+
return {"type": "Position", "value": token_id - POSITION_OFFSET}
|
| 102 |
+
if token_id == BAR_OFFSET:
|
| 103 |
+
return {"type": "Bar", "value": 0}
|
| 104 |
+
return {"type": "Unknown", "value": token_id}
|
| 105 |
+
|
| 106 |
+
def midi_to_tokens(self, midi_obj, max_len: Optional[int] = None) -> list[int]:
|
| 107 |
+
"""
|
| 108 |
+
Convert a pretty_midi.PrettyMIDI object to REMI token sequence.
|
| 109 |
+
Uses note-level events sorted by onset time.
|
| 110 |
+
"""
|
| 111 |
+
tokens = [self.bos_id]
|
| 112 |
+
|
| 113 |
+
# Collect all notes across instruments
|
| 114 |
+
all_notes = []
|
| 115 |
+
for inst in midi_obj.instruments:
|
| 116 |
+
if inst.is_drum:
|
| 117 |
+
continue
|
| 118 |
+
for note in inst.notes:
|
| 119 |
+
all_notes.append(note)
|
| 120 |
+
|
| 121 |
+
if not all_notes:
|
| 122 |
+
tokens.append(self.eos_id)
|
| 123 |
+
return tokens
|
| 124 |
+
|
| 125 |
+
# Sort by start time, then by pitch
|
| 126 |
+
all_notes.sort(key=lambda n: (n.start, n.pitch))
|
| 127 |
+
|
| 128 |
+
# Get tempo changes
|
| 129 |
+
tempos = midi_obj.get_tempo_changes()
|
| 130 |
+
current_tempo = 120.0
|
| 131 |
+
if len(tempos[1]) > 0:
|
| 132 |
+
current_tempo = tempos[1][0]
|
| 133 |
+
tokens.append(self.tempo_token(current_tempo))
|
| 134 |
+
|
| 135 |
+
# Compute bar duration
|
| 136 |
+
bar_duration = 60.0 / current_tempo * 4 # Assume 4/4
|
| 137 |
+
current_bar = 0
|
| 138 |
+
tokens.append(self.bar_token())
|
| 139 |
+
|
| 140 |
+
prev_time = 0.0
|
| 141 |
+
for note in all_notes:
|
| 142 |
+
# Bar tracking
|
| 143 |
+
note_bar = int(note.start / bar_duration)
|
| 144 |
+
while current_bar < note_bar:
|
| 145 |
+
current_bar += 1
|
| 146 |
+
tokens.append(self.bar_token())
|
| 147 |
+
|
| 148 |
+
# Time shift from previous event
|
| 149 |
+
dt = note.start - prev_time
|
| 150 |
+
if dt > 0:
|
| 151 |
+
# Break into chunks of max 1000ms
|
| 152 |
+
while dt > 1.0:
|
| 153 |
+
tokens.append(self.timeshift_token(1000))
|
| 154 |
+
dt -= 1.0
|
| 155 |
+
if dt > 0.005: # Ignore < 5ms
|
| 156 |
+
tokens.append(self.timeshift_token(dt * 1000))
|
| 157 |
+
|
| 158 |
+
# Position within bar
|
| 159 |
+
pos_in_bar = (note.start % bar_duration) / bar_duration
|
| 160 |
+
pos_idx = int(pos_in_bar * 32)
|
| 161 |
+
tokens.append(self.position_token(pos_idx))
|
| 162 |
+
|
| 163 |
+
# Velocity then NoteOn
|
| 164 |
+
tokens.append(self.velocity_token(note.velocity))
|
| 165 |
+
tokens.append(self.note_on_token(note.pitch))
|
| 166 |
+
|
| 167 |
+
# Note duration as timeshift + NoteOff
|
| 168 |
+
dur = note.end - note.start
|
| 169 |
+
if dur > 0:
|
| 170 |
+
while dur > 1.0:
|
| 171 |
+
tokens.append(self.timeshift_token(1000))
|
| 172 |
+
dur -= 1.0
|
| 173 |
+
if dur > 0.005:
|
| 174 |
+
tokens.append(self.timeshift_token(dur * 1000))
|
| 175 |
+
tokens.append(self.note_off_token(note.pitch))
|
| 176 |
+
|
| 177 |
+
prev_time = note.start
|
| 178 |
+
|
| 179 |
+
if max_len and len(tokens) >= max_len - 1:
|
| 180 |
+
break
|
| 181 |
+
|
| 182 |
+
tokens.append(self.eos_id)
|
| 183 |
+
|
| 184 |
+
if max_len:
|
| 185 |
+
tokens = tokens[:max_len]
|
| 186 |
+
|
| 187 |
+
return tokens
|
| 188 |
+
|
| 189 |
+
def tokens_to_midi(self, tokens: list[int]):
|
| 190 |
+
"""Convert REMI tokens back to a PrettyMIDI object."""
|
| 191 |
+
import pretty_midi
|
| 192 |
+
|
| 193 |
+
midi = pretty_midi.PrettyMIDI(initial_tempo=120.0)
|
| 194 |
+
inst = pretty_midi.Instrument(program=0, name="Piano")
|
| 195 |
+
|
| 196 |
+
current_time = 0.0
|
| 197 |
+
current_velocity = 80
|
| 198 |
+
active_notes = {} # pitch -> (start_time, velocity)
|
| 199 |
+
|
| 200 |
+
for token_id in tokens:
|
| 201 |
+
event = self.decode_token(token_id)
|
| 202 |
+
etype = event["type"]
|
| 203 |
+
val = event["value"]
|
| 204 |
+
|
| 205 |
+
if etype in ("PAD", "BOS", "EOS", "SEP", "Bar", "Position"):
|
| 206 |
+
continue
|
| 207 |
+
elif etype == "Tempo":
|
| 208 |
+
pass # Could adjust timing but simpler to ignore
|
| 209 |
+
elif etype == "TimeShift":
|
| 210 |
+
current_time += val / 1000.0
|
| 211 |
+
elif etype == "Velocity":
|
| 212 |
+
current_velocity = max(1, min(127, val))
|
| 213 |
+
elif etype == "NoteOn":
|
| 214 |
+
active_notes[val] = (current_time, current_velocity)
|
| 215 |
+
elif etype == "NoteOff":
|
| 216 |
+
if val in active_notes:
|
| 217 |
+
start, vel = active_notes.pop(val)
|
| 218 |
+
if current_time > start:
|
| 219 |
+
note = pretty_midi.Note(
|
| 220 |
+
velocity=vel,
|
| 221 |
+
pitch=val,
|
| 222 |
+
start=start,
|
| 223 |
+
end=current_time,
|
| 224 |
+
)
|
| 225 |
+
inst.notes.append(note)
|
| 226 |
+
|
| 227 |
+
# Close any remaining active notes
|
| 228 |
+
for pitch, (start, vel) in active_notes.items():
|
| 229 |
+
note = pretty_midi.Note(
|
| 230 |
+
velocity=vel, pitch=pitch, start=start, end=current_time + 0.5
|
| 231 |
+
)
|
| 232 |
+
inst.notes.append(note)
|
| 233 |
+
|
| 234 |
+
midi.instruments.append(inst)
|
| 235 |
+
return midi
|
| 236 |
+
|
| 237 |
+
def save(self, path: Path):
|
| 238 |
+
data = {"vocab_size": self.vocab_size}
|
| 239 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 240 |
+
with open(path, "w") as f:
|
| 241 |
+
json.dump(data, f)
|
| 242 |
+
|
| 243 |
+
@classmethod
|
| 244 |
+
def load(cls, path: Path) -> "MusicTokenizer":
|
| 245 |
+
tok = cls()
|
| 246 |
+
if path.exists():
|
| 247 |
+
with open(path) as f:
|
| 248 |
+
data = json.load(f)
|
| 249 |
+
tok.vocab_size = data.get("vocab_size", VOCAB_SIZE)
|
| 250 |
+
return tok
|
src/s03_dataset.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data pipeline: downloads MIDI dataset, tokenizes, creates PyTorch DataLoaders.
|
| 3 |
+
Uses HuggingFace datasets for efficient streaming + caching.
|
| 4 |
+
Memory-efficient: processes files lazily, doesn't hold entire dataset in RAM.
|
| 5 |
+
"""
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
import pickle
|
| 9 |
+
import signal
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
from torch.utils.data import Dataset, DataLoader
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class _TimeoutError(Exception):
|
| 19 |
+
pass
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _timeout_handler(signum, frame):
|
| 23 |
+
raise _TimeoutError("File processing timed out")
|
| 24 |
+
|
| 25 |
+
from src.s01_config import DataConfig, PathConfig, TrainConfig
|
| 26 |
+
from src.s02_tokenizer import MusicTokenizer
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class MidiTokenDataset(Dataset):
|
| 32 |
+
"""
|
| 33 |
+
PyTorch dataset of pre-tokenized MIDI sequences.
|
| 34 |
+
Stores token IDs as memory-mapped numpy arrays for RAM efficiency.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
def __init__(self, token_sequences: list[list[int]], max_seq_len: int, pad_id: int = 0):
|
| 38 |
+
self.max_seq_len = max_seq_len
|
| 39 |
+
self.pad_id = pad_id
|
| 40 |
+
# Filter out empty or too-short sequences
|
| 41 |
+
self.sequences = [s for s in token_sequences if len(s) >= 10]
|
| 42 |
+
logger.info(f"Dataset: {len(self.sequences)} sequences, max_len={max_seq_len}")
|
| 43 |
+
|
| 44 |
+
def __len__(self):
|
| 45 |
+
return len(self.sequences)
|
| 46 |
+
|
| 47 |
+
def __getitem__(self, idx):
|
| 48 |
+
seq = self.sequences[idx]
|
| 49 |
+
|
| 50 |
+
# For training: input = seq[:-1], target = seq[1:]
|
| 51 |
+
if len(seq) > self.max_seq_len + 1:
|
| 52 |
+
# Random crop for data augmentation
|
| 53 |
+
start = np.random.randint(0, len(seq) - self.max_seq_len)
|
| 54 |
+
seq = seq[start : start + self.max_seq_len + 1]
|
| 55 |
+
|
| 56 |
+
input_ids = seq[:-1]
|
| 57 |
+
target_ids = seq[1:]
|
| 58 |
+
|
| 59 |
+
# Pad to max_seq_len
|
| 60 |
+
pad_len = self.max_seq_len - len(input_ids)
|
| 61 |
+
if pad_len > 0:
|
| 62 |
+
input_ids = input_ids + [self.pad_id] * pad_len
|
| 63 |
+
target_ids = target_ids + [self.pad_id] * pad_len
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
torch.tensor(input_ids, dtype=torch.long),
|
| 67 |
+
torch.tensor(target_ids, dtype=torch.long),
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def download_and_tokenize(
|
| 72 |
+
data_config: DataConfig,
|
| 73 |
+
path_config: PathConfig,
|
| 74 |
+
tokenizer: MusicTokenizer,
|
| 75 |
+
) -> tuple[list[list[int]], list[list[int]]]:
|
| 76 |
+
"""
|
| 77 |
+
Download MIDI dataset from HuggingFace and tokenize all files.
|
| 78 |
+
Returns (train_sequences, val_sequences).
|
| 79 |
+
Caches tokenized data to disk for fast reload.
|
| 80 |
+
"""
|
| 81 |
+
cache_path = path_config.data_dir / "tokenized_cache.pkl"
|
| 82 |
+
|
| 83 |
+
if cache_path.exists():
|
| 84 |
+
logger.info("Loading tokenized data from cache...")
|
| 85 |
+
with open(cache_path, "rb") as f:
|
| 86 |
+
data = pickle.load(f)
|
| 87 |
+
return data["train"], data["val"]
|
| 88 |
+
|
| 89 |
+
logger.info(f"Downloading dataset: {data_config.dataset_name}")
|
| 90 |
+
import pretty_midi
|
| 91 |
+
import subprocess
|
| 92 |
+
import glob
|
| 93 |
+
|
| 94 |
+
# Fast git clone instead of slow per-file snapshot_download
|
| 95 |
+
midi_dir = path_config.data_dir / "midi_files"
|
| 96 |
+
if not midi_dir.exists():
|
| 97 |
+
logger.info("Cloning MIDI dataset repo (faster than per-file download)...")
|
| 98 |
+
subprocess.run(
|
| 99 |
+
["git", "clone", "--depth", "1",
|
| 100 |
+
f"https://huggingface.co/datasets/{data_config.dataset_name}",
|
| 101 |
+
str(midi_dir)],
|
| 102 |
+
check=True,
|
| 103 |
+
)
|
| 104 |
+
else:
|
| 105 |
+
logger.info(f"Using cached MIDI files from {midi_dir}")
|
| 106 |
+
|
| 107 |
+
# Find all MIDI files
|
| 108 |
+
midi_files = sorted(
|
| 109 |
+
glob.glob(f"{midi_dir}/**/*.mid", recursive=True)
|
| 110 |
+
+ glob.glob(f"{midi_dir}/**/*.midi", recursive=True)
|
| 111 |
+
+ glob.glob(f"{midi_dir}/**/*.MID", recursive=True)
|
| 112 |
+
)
|
| 113 |
+
logger.info(f"Found {len(midi_files)} MIDI files")
|
| 114 |
+
|
| 115 |
+
all_sequences = []
|
| 116 |
+
errors = 0
|
| 117 |
+
|
| 118 |
+
for i, midi_path in enumerate(midi_files):
|
| 119 |
+
try:
|
| 120 |
+
# Skip files > 100KB (large orchestral pieces cause slow processing)
|
| 121 |
+
if os.path.getsize(midi_path) > 100_000:
|
| 122 |
+
errors += 1
|
| 123 |
+
continue
|
| 124 |
+
# Skip git LFS pointer files
|
| 125 |
+
with open(midi_path, "rb") as f:
|
| 126 |
+
header = f.read(20)
|
| 127 |
+
if header.startswith(b"version https://git"):
|
| 128 |
+
errors += 1
|
| 129 |
+
continue
|
| 130 |
+
# 30s timeout per file to avoid hangs on complex MIDI
|
| 131 |
+
old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
|
| 132 |
+
signal.alarm(30)
|
| 133 |
+
try:
|
| 134 |
+
midi = pretty_midi.PrettyMIDI(midi_path)
|
| 135 |
+
tokens = tokenizer.midi_to_tokens(midi, max_len=data_config.max_seq_len + 1)
|
| 136 |
+
if len(tokens) >= 20:
|
| 137 |
+
all_sequences.append(tokens)
|
| 138 |
+
finally:
|
| 139 |
+
signal.alarm(0)
|
| 140 |
+
signal.signal(signal.SIGALRM, old_handler)
|
| 141 |
+
except (_TimeoutError, Exception) as e:
|
| 142 |
+
errors += 1
|
| 143 |
+
if errors <= 10:
|
| 144 |
+
logger.warning(f"Error processing {midi_path}: {e}")
|
| 145 |
+
|
| 146 |
+
if (i + 1) % 200 == 0:
|
| 147 |
+
logger.info(f" Processed {i+1}/{len(midi_files)}, valid={len(all_sequences)}, errors={errors}")
|
| 148 |
+
|
| 149 |
+
logger.info(f"Tokenization complete: {len(all_sequences)} sequences, {errors} errors")
|
| 150 |
+
|
| 151 |
+
# Split into train/val
|
| 152 |
+
np.random.seed(42)
|
| 153 |
+
indices = np.random.permutation(len(all_sequences))
|
| 154 |
+
split = int(len(all_sequences) * data_config.train_split)
|
| 155 |
+
|
| 156 |
+
train_seqs = [all_sequences[i] for i in indices[:split]]
|
| 157 |
+
val_seqs = [all_sequences[i] for i in indices[split:]]
|
| 158 |
+
|
| 159 |
+
# Cache to disk
|
| 160 |
+
with open(cache_path, "wb") as f:
|
| 161 |
+
pickle.dump({"train": train_seqs, "val": val_seqs}, f)
|
| 162 |
+
logger.info(f"Cached tokenized data: train={len(train_seqs)}, val={len(val_seqs)}")
|
| 163 |
+
|
| 164 |
+
return train_seqs, val_seqs
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def create_dataloaders(
|
| 168 |
+
data_config: DataConfig,
|
| 169 |
+
train_config: TrainConfig,
|
| 170 |
+
path_config: PathConfig,
|
| 171 |
+
tokenizer: MusicTokenizer,
|
| 172 |
+
) -> tuple[DataLoader, DataLoader]:
|
| 173 |
+
"""Create train and validation DataLoaders."""
|
| 174 |
+
train_seqs, val_seqs = download_and_tokenize(data_config, path_config, tokenizer)
|
| 175 |
+
|
| 176 |
+
train_ds = MidiTokenDataset(train_seqs, data_config.max_seq_len, tokenizer.pad_id)
|
| 177 |
+
val_ds = MidiTokenDataset(val_seqs, data_config.max_seq_len, tokenizer.pad_id)
|
| 178 |
+
|
| 179 |
+
train_loader = DataLoader(
|
| 180 |
+
train_ds,
|
| 181 |
+
batch_size=train_config.batch_size,
|
| 182 |
+
shuffle=True,
|
| 183 |
+
num_workers=train_config.num_workers,
|
| 184 |
+
pin_memory=train_config.pin_memory,
|
| 185 |
+
prefetch_factor=train_config.prefetch_factor,
|
| 186 |
+
drop_last=True,
|
| 187 |
+
)
|
| 188 |
+
val_loader = DataLoader(
|
| 189 |
+
val_ds,
|
| 190 |
+
batch_size=train_config.batch_size,
|
| 191 |
+
shuffle=False,
|
| 192 |
+
num_workers=train_config.num_workers,
|
| 193 |
+
pin_memory=train_config.pin_memory,
|
| 194 |
+
prefetch_factor=train_config.prefetch_factor,
|
| 195 |
+
drop_last=False,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
return train_loader, val_loader
|
src/s04_model.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Music Transformer Model — LLaMA-style architecture for symbolic music generation.
|
| 3 |
+
|
| 4 |
+
Key innovations combined:
|
| 5 |
+
- Rotary Position Embeddings (RoPE) — better long-range modeling than sinusoidal
|
| 6 |
+
- RMSNorm — faster than LayerNorm, used in LLaMA/Mistral
|
| 7 |
+
- SwiGLU activation — better than GELU/ReLU, used in LLaMA
|
| 8 |
+
- Grouped Query Attention (GQA) — reduces KV-cache memory by sharing KV heads
|
| 9 |
+
- Gradient checkpointing — cuts memory usage ~50% with ~20% speed cost
|
| 10 |
+
- KV-cache — O(1) per-token inference instead of O(n)
|
| 11 |
+
"""
|
| 12 |
+
import math
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn as nn
|
| 17 |
+
import torch.nn.functional as F
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class RMSNorm(nn.Module):
|
| 21 |
+
"""Root Mean Square Layer Normalization (faster than LayerNorm)."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, dim: int, eps: float = 1e-6):
|
| 24 |
+
super().__init__()
|
| 25 |
+
self.eps = eps
|
| 26 |
+
self.weight = nn.Parameter(torch.ones(dim))
|
| 27 |
+
|
| 28 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 29 |
+
norm = x.float().pow(2).mean(-1, keepdim=True).add(self.eps).rsqrt()
|
| 30 |
+
return (x.float() * norm).type_as(x) * self.weight
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def precompute_rope_freqs(dim: int, max_seq_len: int, theta: float = 10000.0) -> torch.Tensor:
|
| 34 |
+
"""Precompute RoPE frequency tensor for complex exponentials."""
|
| 35 |
+
freqs = 1.0 / (theta ** (torch.arange(0, dim, 2).float() / dim))
|
| 36 |
+
t = torch.arange(max_seq_len, dtype=torch.float32)
|
| 37 |
+
freqs = torch.outer(t, freqs)
|
| 38 |
+
return torch.polar(torch.ones_like(freqs), freqs) # complex64
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def apply_rope(xq: torch.Tensor, xk: torch.Tensor, freqs: torch.Tensor):
|
| 42 |
+
"""Apply rotary embeddings to query and key tensors."""
|
| 43 |
+
# Reshape to complex
|
| 44 |
+
xq_c = torch.view_as_complex(xq.float().reshape(*xq.shape[:-1], -1, 2))
|
| 45 |
+
xk_c = torch.view_as_complex(xk.float().reshape(*xk.shape[:-1], -1, 2))
|
| 46 |
+
|
| 47 |
+
# Reshape freqs for broadcasting: (seq_len,) -> (1, seq_len, 1, head_dim//2)
|
| 48 |
+
freqs = freqs.unsqueeze(0).unsqueeze(2)
|
| 49 |
+
|
| 50 |
+
xq_out = torch.view_as_real(xq_c * freqs).flatten(-2)
|
| 51 |
+
xk_out = torch.view_as_real(xk_c * freqs).flatten(-2)
|
| 52 |
+
return xq_out.type_as(xq), xk_out.type_as(xk)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def repeat_kv(x: torch.Tensor, n_rep: int) -> torch.Tensor:
|
| 56 |
+
"""Repeat KV heads to match query head count for GQA."""
|
| 57 |
+
if n_rep == 1:
|
| 58 |
+
return x
|
| 59 |
+
bs, seq_len, n_kv_heads, head_dim = x.shape
|
| 60 |
+
return (
|
| 61 |
+
x[:, :, :, None, :]
|
| 62 |
+
.expand(bs, seq_len, n_kv_heads, n_rep, head_dim)
|
| 63 |
+
.reshape(bs, seq_len, n_kv_heads * n_rep, head_dim)
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class GroupedQueryAttention(nn.Module):
|
| 68 |
+
"""
|
| 69 |
+
Multi-head attention with Grouped Query Attention (GQA).
|
| 70 |
+
Uses fewer KV heads than Q heads to reduce memory.
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
def __init__(self, dim: int, n_heads: int, n_kv_heads: int, dropout: float = 0.1):
|
| 74 |
+
super().__init__()
|
| 75 |
+
self.n_heads = n_heads
|
| 76 |
+
self.n_kv_heads = n_kv_heads
|
| 77 |
+
self.n_rep = n_heads // n_kv_heads
|
| 78 |
+
self.head_dim = dim // n_heads
|
| 79 |
+
|
| 80 |
+
self.wq = nn.Linear(dim, n_heads * self.head_dim, bias=False)
|
| 81 |
+
self.wk = nn.Linear(dim, n_kv_heads * self.head_dim, bias=False)
|
| 82 |
+
self.wv = nn.Linear(dim, n_kv_heads * self.head_dim, bias=False)
|
| 83 |
+
self.wo = nn.Linear(n_heads * self.head_dim, dim, bias=False)
|
| 84 |
+
self.attn_dropout = nn.Dropout(dropout)
|
| 85 |
+
self.resid_dropout = nn.Dropout(dropout)
|
| 86 |
+
|
| 87 |
+
# KV-cache for inference
|
| 88 |
+
self.cache_k: Optional[torch.Tensor] = None
|
| 89 |
+
self.cache_v: Optional[torch.Tensor] = None
|
| 90 |
+
|
| 91 |
+
def forward(
|
| 92 |
+
self,
|
| 93 |
+
x: torch.Tensor,
|
| 94 |
+
freqs: torch.Tensor,
|
| 95 |
+
mask: Optional[torch.Tensor] = None,
|
| 96 |
+
use_cache: bool = False,
|
| 97 |
+
) -> torch.Tensor:
|
| 98 |
+
bs, seq_len, _ = x.shape
|
| 99 |
+
|
| 100 |
+
q = self.wq(x).view(bs, seq_len, self.n_heads, self.head_dim)
|
| 101 |
+
k = self.wk(x).view(bs, seq_len, self.n_kv_heads, self.head_dim)
|
| 102 |
+
v = self.wv(x).view(bs, seq_len, self.n_kv_heads, self.head_dim)
|
| 103 |
+
|
| 104 |
+
# Apply RoPE to Q and K
|
| 105 |
+
q_rope = q.view(bs, seq_len, self.n_heads, self.head_dim)
|
| 106 |
+
k_rope = k.view(bs, seq_len, self.n_kv_heads, self.head_dim)
|
| 107 |
+
|
| 108 |
+
# RoPE needs (bs, seq_len, heads, head_dim) but freqs is (seq_len, head_dim//2)
|
| 109 |
+
# Apply per-head
|
| 110 |
+
q_for_rope = q_rope.reshape(bs * self.n_heads, seq_len, self.head_dim)
|
| 111 |
+
k_for_rope = k_rope.reshape(bs * self.n_kv_heads, seq_len, self.head_dim)
|
| 112 |
+
|
| 113 |
+
# Simpler RoPE application
|
| 114 |
+
q = q.transpose(1, 2) # (bs, n_heads, seq_len, head_dim)
|
| 115 |
+
k = k.transpose(1, 2)
|
| 116 |
+
v = v.transpose(1, 2)
|
| 117 |
+
|
| 118 |
+
# Apply RoPE via cos/sin (more compatible than complex)
|
| 119 |
+
q, k = self._apply_rope_real(q, k, freqs)
|
| 120 |
+
|
| 121 |
+
# KV-cache for generation
|
| 122 |
+
if use_cache:
|
| 123 |
+
if self.cache_k is not None:
|
| 124 |
+
k = torch.cat([self.cache_k, k], dim=2)
|
| 125 |
+
v = torch.cat([self.cache_v, v], dim=2)
|
| 126 |
+
self.cache_k = k.detach()
|
| 127 |
+
self.cache_v = v.detach()
|
| 128 |
+
|
| 129 |
+
# GQA: repeat KV heads
|
| 130 |
+
k = repeat_kv(k.transpose(1, 2), self.n_rep).transpose(1, 2)
|
| 131 |
+
v = repeat_kv(v.transpose(1, 2), self.n_rep).transpose(1, 2)
|
| 132 |
+
|
| 133 |
+
# Scaled dot-product attention (uses Flash Attention when available)
|
| 134 |
+
scale = 1.0 / math.sqrt(self.head_dim)
|
| 135 |
+
try:
|
| 136 |
+
# PyTorch 2.0+ SDPA with memory-efficient backend
|
| 137 |
+
out = F.scaled_dot_product_attention(
|
| 138 |
+
q, k, v,
|
| 139 |
+
attn_mask=mask,
|
| 140 |
+
dropout_p=self.attn_dropout.p if self.training else 0.0,
|
| 141 |
+
is_causal=(mask is None and not use_cache),
|
| 142 |
+
)
|
| 143 |
+
except RuntimeError:
|
| 144 |
+
# Fallback for older PyTorch
|
| 145 |
+
scores = torch.matmul(q, k.transpose(-2, -1)) * scale
|
| 146 |
+
if mask is not None:
|
| 147 |
+
scores = scores + mask
|
| 148 |
+
elif not use_cache:
|
| 149 |
+
causal = torch.triu(
|
| 150 |
+
torch.full((seq_len, seq_len), float("-inf"), device=x.device), diagonal=1
|
| 151 |
+
)
|
| 152 |
+
scores = scores + causal
|
| 153 |
+
scores = F.softmax(scores, dim=-1)
|
| 154 |
+
scores = self.attn_dropout(scores)
|
| 155 |
+
out = torch.matmul(scores, v)
|
| 156 |
+
|
| 157 |
+
out = out.transpose(1, 2).contiguous().view(bs, seq_len, -1)
|
| 158 |
+
return self.resid_dropout(self.wo(out))
|
| 159 |
+
|
| 160 |
+
def _apply_rope_real(self, q, k, freqs):
|
| 161 |
+
"""Apply RoPE using real-valued sin/cos (more device-compatible)."""
|
| 162 |
+
# freqs shape: (seq_len, head_dim//2)
|
| 163 |
+
seq_len = q.shape[2]
|
| 164 |
+
freqs = freqs[:seq_len]
|
| 165 |
+
|
| 166 |
+
cos_f = freqs.cos().unsqueeze(0).unsqueeze(0) # (1, 1, seq_len, head_dim//2)
|
| 167 |
+
sin_f = freqs.sin().unsqueeze(0).unsqueeze(0)
|
| 168 |
+
|
| 169 |
+
def rotate_half(x):
|
| 170 |
+
x1, x2 = x.chunk(2, dim=-1)
|
| 171 |
+
return torch.cat((-x2, x1), dim=-1)
|
| 172 |
+
|
| 173 |
+
q = q * cos_f.repeat(1, 1, 1, 2) + rotate_half(q) * sin_f.repeat(1, 1, 1, 2)
|
| 174 |
+
k = k * cos_f.repeat(1, 1, 1, 2) + rotate_half(k) * sin_f.repeat(1, 1, 1, 2)
|
| 175 |
+
return q, k
|
| 176 |
+
|
| 177 |
+
def reset_cache(self):
|
| 178 |
+
self.cache_k = None
|
| 179 |
+
self.cache_v = None
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
class SwiGLU(nn.Module):
|
| 183 |
+
"""SwiGLU activation — superior to GELU/ReLU, used in LLaMA."""
|
| 184 |
+
|
| 185 |
+
def __init__(self, dim: int, hidden_dim: int, dropout: float = 0.1):
|
| 186 |
+
super().__init__()
|
| 187 |
+
self.w1 = nn.Linear(dim, hidden_dim, bias=False)
|
| 188 |
+
self.w2 = nn.Linear(hidden_dim, dim, bias=False)
|
| 189 |
+
self.w3 = nn.Linear(dim, hidden_dim, bias=False)
|
| 190 |
+
self.dropout = nn.Dropout(dropout)
|
| 191 |
+
|
| 192 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 193 |
+
return self.dropout(self.w2(F.silu(self.w1(x)) * self.w3(x)))
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
class TransformerBlock(nn.Module):
|
| 197 |
+
"""Single transformer block with pre-norm architecture."""
|
| 198 |
+
|
| 199 |
+
def __init__(self, dim: int, n_heads: int, n_kv_heads: int, hidden_dim: int, dropout: float):
|
| 200 |
+
super().__init__()
|
| 201 |
+
self.attention = GroupedQueryAttention(dim, n_heads, n_kv_heads, dropout)
|
| 202 |
+
self.feed_forward = SwiGLU(dim, hidden_dim, dropout)
|
| 203 |
+
self.norm1 = RMSNorm(dim)
|
| 204 |
+
self.norm2 = RMSNorm(dim)
|
| 205 |
+
|
| 206 |
+
def forward(
|
| 207 |
+
self,
|
| 208 |
+
x: torch.Tensor,
|
| 209 |
+
freqs: torch.Tensor,
|
| 210 |
+
mask: Optional[torch.Tensor] = None,
|
| 211 |
+
use_cache: bool = False,
|
| 212 |
+
) -> torch.Tensor:
|
| 213 |
+
# Pre-norm residual connections
|
| 214 |
+
x = x + self.attention(self.norm1(x), freqs, mask, use_cache)
|
| 215 |
+
x = x + self.feed_forward(self.norm2(x))
|
| 216 |
+
return x
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
class MusicTransformer(nn.Module):
|
| 220 |
+
"""
|
| 221 |
+
LLaMA-style Transformer for music generation.
|
| 222 |
+
Combines: RoPE + GQA + SwiGLU + RMSNorm + gradient checkpointing.
|
| 223 |
+
~5M parameters with default config — suitable for training on consumer GPUs.
|
| 224 |
+
"""
|
| 225 |
+
|
| 226 |
+
def __init__(self, config):
|
| 227 |
+
super().__init__()
|
| 228 |
+
self.config = config
|
| 229 |
+
self.token_emb = nn.Embedding(config.vocab_size, config.dim)
|
| 230 |
+
self.dropout = nn.Dropout(config.dropout)
|
| 231 |
+
|
| 232 |
+
self.layers = nn.ModuleList([
|
| 233 |
+
TransformerBlock(
|
| 234 |
+
config.dim, config.n_heads, config.n_kv_heads,
|
| 235 |
+
config.hidden_dim, config.dropout,
|
| 236 |
+
)
|
| 237 |
+
for _ in range(config.n_layers)
|
| 238 |
+
])
|
| 239 |
+
|
| 240 |
+
self.norm = RMSNorm(config.dim)
|
| 241 |
+
self.output = nn.Linear(config.dim, config.vocab_size, bias=False)
|
| 242 |
+
|
| 243 |
+
# Weight tying — reduces params and improves generalization
|
| 244 |
+
self.token_emb.weight = self.output.weight
|
| 245 |
+
|
| 246 |
+
# Precompute RoPE frequencies
|
| 247 |
+
head_dim = config.dim // config.n_heads
|
| 248 |
+
freqs = self._precompute_freqs(head_dim, config.max_seq_len, config.rope_theta)
|
| 249 |
+
self.register_buffer("freqs", freqs, persistent=False)
|
| 250 |
+
|
| 251 |
+
self.grad_checkpoint = False
|
| 252 |
+
self._init_weights()
|
| 253 |
+
|
| 254 |
+
def _precompute_freqs(self, dim: int, max_seq_len: int, theta: float) -> torch.Tensor:
|
| 255 |
+
freqs = 1.0 / (theta ** (torch.arange(0, dim, 2).float() / dim))
|
| 256 |
+
t = torch.arange(max_seq_len, dtype=torch.float32)
|
| 257 |
+
return torch.outer(t, freqs)
|
| 258 |
+
|
| 259 |
+
def _init_weights(self):
|
| 260 |
+
"""Xavier-style initialization for stable training."""
|
| 261 |
+
for module in self.modules():
|
| 262 |
+
if isinstance(module, nn.Linear):
|
| 263 |
+
nn.init.normal_(module.weight, mean=0.0, std=0.02)
|
| 264 |
+
if module.bias is not None:
|
| 265 |
+
nn.init.zeros_(module.bias)
|
| 266 |
+
elif isinstance(module, nn.Embedding):
|
| 267 |
+
nn.init.normal_(module.weight, mean=0.0, std=0.02)
|
| 268 |
+
|
| 269 |
+
def forward(
|
| 270 |
+
self,
|
| 271 |
+
input_ids: torch.Tensor,
|
| 272 |
+
targets: Optional[torch.Tensor] = None,
|
| 273 |
+
use_cache: bool = False,
|
| 274 |
+
) -> tuple[torch.Tensor, Optional[torch.Tensor]]:
|
| 275 |
+
bs, seq_len = input_ids.shape
|
| 276 |
+
h = self.dropout(self.token_emb(input_ids))
|
| 277 |
+
|
| 278 |
+
freqs = self.freqs[:seq_len].to(h.device)
|
| 279 |
+
|
| 280 |
+
for layer in self.layers:
|
| 281 |
+
if self.grad_checkpoint and self.training:
|
| 282 |
+
h = torch.utils.checkpoint.checkpoint(
|
| 283 |
+
layer, h, freqs, None, use_cache, use_reentrant=False
|
| 284 |
+
)
|
| 285 |
+
else:
|
| 286 |
+
h = layer(h, freqs, use_cache=use_cache)
|
| 287 |
+
|
| 288 |
+
h = self.norm(h)
|
| 289 |
+
logits = self.output(h)
|
| 290 |
+
|
| 291 |
+
loss = None
|
| 292 |
+
if targets is not None:
|
| 293 |
+
loss = F.cross_entropy(
|
| 294 |
+
logits.view(-1, logits.size(-1)),
|
| 295 |
+
targets.view(-1),
|
| 296 |
+
ignore_index=0, # Ignore padding
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
return logits, loss
|
| 300 |
+
|
| 301 |
+
def reset_caches(self):
|
| 302 |
+
for layer in self.layers:
|
| 303 |
+
layer.attention.reset_cache()
|
| 304 |
+
|
| 305 |
+
def count_parameters(self) -> int:
|
| 306 |
+
return sum(p.numel() for p in self.parameters() if p.requires_grad)
|
| 307 |
+
|
| 308 |
+
@classmethod
|
| 309 |
+
def from_config(cls, model_config) -> "MusicTransformer":
|
| 310 |
+
return cls(model_config)
|
src/s05_trainer.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Training loop with:
|
| 3 |
+
- Mixed precision (AMP) for memory savings
|
| 4 |
+
- Gradient checkpointing for OOM prevention
|
| 5 |
+
- Cosine annealing with warmup
|
| 6 |
+
- Early stopping
|
| 7 |
+
- Gradient accumulation for larger effective batch
|
| 8 |
+
- TensorBoard logging
|
| 9 |
+
"""
|
| 10 |
+
import logging
|
| 11 |
+
import math
|
| 12 |
+
import time
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn as nn
|
| 18 |
+
from torch.utils.data import DataLoader
|
| 19 |
+
from torch.utils.tensorboard import SummaryWriter
|
| 20 |
+
|
| 21 |
+
from src.s01_config import TrainConfig, PathConfig, get_device
|
| 22 |
+
from src.s04_model import MusicTransformer
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class CosineWarmupScheduler:
|
| 28 |
+
"""Cosine annealing LR with linear warmup."""
|
| 29 |
+
|
| 30 |
+
def __init__(self, optimizer, warmup_steps: int, total_steps: int, min_lr: float = 1e-6):
|
| 31 |
+
self.optimizer = optimizer
|
| 32 |
+
self.warmup_steps = warmup_steps
|
| 33 |
+
self.total_steps = total_steps
|
| 34 |
+
self.min_lr = min_lr
|
| 35 |
+
self.base_lrs = [pg["lr"] for pg in optimizer.param_groups]
|
| 36 |
+
self.step_count = 0
|
| 37 |
+
|
| 38 |
+
def step(self):
|
| 39 |
+
self.step_count += 1
|
| 40 |
+
for pg, base_lr in zip(self.optimizer.param_groups, self.base_lrs):
|
| 41 |
+
if self.step_count < self.warmup_steps:
|
| 42 |
+
lr = base_lr * self.step_count / max(1, self.warmup_steps)
|
| 43 |
+
else:
|
| 44 |
+
progress = (self.step_count - self.warmup_steps) / max(
|
| 45 |
+
1, self.total_steps - self.warmup_steps
|
| 46 |
+
)
|
| 47 |
+
lr = self.min_lr + (base_lr - self.min_lr) * 0.5 * (1 + math.cos(math.pi * progress))
|
| 48 |
+
pg["lr"] = lr
|
| 49 |
+
|
| 50 |
+
def get_lr(self) -> float:
|
| 51 |
+
return self.optimizer.param_groups[0]["lr"]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class Trainer:
|
| 55 |
+
"""Handles the full training pipeline with memory-efficient techniques."""
|
| 56 |
+
|
| 57 |
+
def __init__(
|
| 58 |
+
self,
|
| 59 |
+
model: MusicTransformer,
|
| 60 |
+
train_loader: DataLoader,
|
| 61 |
+
val_loader: DataLoader,
|
| 62 |
+
train_config: TrainConfig,
|
| 63 |
+
path_config: PathConfig,
|
| 64 |
+
):
|
| 65 |
+
self.model = model
|
| 66 |
+
self.train_loader = train_loader
|
| 67 |
+
self.val_loader = val_loader
|
| 68 |
+
self.config = train_config
|
| 69 |
+
self.paths = path_config
|
| 70 |
+
self.device = get_device()
|
| 71 |
+
|
| 72 |
+
# Enable gradient checkpointing
|
| 73 |
+
if train_config.grad_checkpoint:
|
| 74 |
+
self.model.grad_checkpoint = True
|
| 75 |
+
logger.info("Gradient checkpointing ENABLED")
|
| 76 |
+
|
| 77 |
+
self.model.to(self.device)
|
| 78 |
+
|
| 79 |
+
# Optimizer: AdamW with weight decay (decoupled)
|
| 80 |
+
self.optimizer = torch.optim.AdamW(
|
| 81 |
+
self.model.parameters(),
|
| 82 |
+
lr=train_config.learning_rate,
|
| 83 |
+
weight_decay=train_config.weight_decay,
|
| 84 |
+
betas=(0.9, 0.95),
|
| 85 |
+
fused=torch.cuda.is_available(), # Fused optimizer on CUDA
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# LR scheduler
|
| 89 |
+
total_steps = len(train_loader) * train_config.max_epochs // train_config.grad_accum_steps
|
| 90 |
+
self.scheduler = CosineWarmupScheduler(
|
| 91 |
+
self.optimizer, train_config.warmup_steps, total_steps
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# Mixed precision scaler
|
| 95 |
+
use_amp = train_config.use_amp and torch.cuda.is_available()
|
| 96 |
+
self.scaler = torch.amp.GradScaler("cuda", enabled=use_amp)
|
| 97 |
+
if use_amp:
|
| 98 |
+
self.amp_dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
| 99 |
+
else:
|
| 100 |
+
self.amp_dtype = torch.float32
|
| 101 |
+
self.use_amp = use_amp
|
| 102 |
+
|
| 103 |
+
# TensorBoard
|
| 104 |
+
self.writer = SummaryWriter(log_dir=str(path_config.log_dir))
|
| 105 |
+
|
| 106 |
+
# Tracking
|
| 107 |
+
self.global_step = 0
|
| 108 |
+
self.best_val_loss = float("inf")
|
| 109 |
+
self.patience_counter = 0
|
| 110 |
+
|
| 111 |
+
def train(self):
|
| 112 |
+
"""Main training loop."""
|
| 113 |
+
logger.info(f"Starting training on {self.device}")
|
| 114 |
+
logger.info(f"Model params: {self.model.count_parameters():,}")
|
| 115 |
+
logger.info(f"Train batches: {len(self.train_loader)}, Val batches: {len(self.val_loader)}")
|
| 116 |
+
|
| 117 |
+
for epoch in range(1, self.config.max_epochs + 1):
|
| 118 |
+
t0 = time.time()
|
| 119 |
+
train_loss = self._train_epoch(epoch)
|
| 120 |
+
val_loss = self._validate()
|
| 121 |
+
elapsed = time.time() - t0
|
| 122 |
+
|
| 123 |
+
logger.info(
|
| 124 |
+
f"Epoch {epoch}/{self.config.max_epochs} | "
|
| 125 |
+
f"Train Loss: {train_loss:.4f} | Val Loss: {val_loss:.4f} | "
|
| 126 |
+
f"LR: {self.scheduler.get_lr():.2e} | Time: {elapsed:.1f}s"
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
self.writer.add_scalars("loss", {"train": train_loss, "val": val_loss}, epoch)
|
| 130 |
+
self.writer.add_scalar("lr", self.scheduler.get_lr(), epoch)
|
| 131 |
+
|
| 132 |
+
# Early stopping check
|
| 133 |
+
if val_loss < self.best_val_loss - self.config.min_delta:
|
| 134 |
+
self.best_val_loss = val_loss
|
| 135 |
+
self.patience_counter = 0
|
| 136 |
+
self._save_checkpoint("best.pt", epoch, val_loss)
|
| 137 |
+
logger.info(f" New best model saved (val_loss={val_loss:.4f})")
|
| 138 |
+
else:
|
| 139 |
+
self.patience_counter += 1
|
| 140 |
+
if self.patience_counter >= self.config.patience:
|
| 141 |
+
logger.info(f"Early stopping at epoch {epoch} (patience={self.config.patience})")
|
| 142 |
+
break
|
| 143 |
+
|
| 144 |
+
# Periodic checkpoint
|
| 145 |
+
if epoch % 5 == 0:
|
| 146 |
+
self._save_checkpoint(f"epoch_{epoch}.pt", epoch, val_loss)
|
| 147 |
+
|
| 148 |
+
self.writer.close()
|
| 149 |
+
logger.info("Training complete!")
|
| 150 |
+
|
| 151 |
+
def _train_epoch(self, epoch: int) -> float:
|
| 152 |
+
self.model.train()
|
| 153 |
+
total_loss = 0.0
|
| 154 |
+
n_batches = 0
|
| 155 |
+
self.optimizer.zero_grad(set_to_none=True)
|
| 156 |
+
|
| 157 |
+
for batch_idx, (input_ids, targets) in enumerate(self.train_loader):
|
| 158 |
+
input_ids = input_ids.to(self.device, non_blocking=True)
|
| 159 |
+
targets = targets.to(self.device, non_blocking=True)
|
| 160 |
+
|
| 161 |
+
# Mixed precision forward
|
| 162 |
+
with torch.amp.autocast(
|
| 163 |
+
device_type=self.device.type,
|
| 164 |
+
dtype=self.amp_dtype,
|
| 165 |
+
enabled=self.use_amp,
|
| 166 |
+
):
|
| 167 |
+
_, loss = self.model(input_ids, targets)
|
| 168 |
+
loss = loss / self.config.grad_accum_steps
|
| 169 |
+
|
| 170 |
+
# Backward with gradient scaling
|
| 171 |
+
self.scaler.scale(loss).backward()
|
| 172 |
+
|
| 173 |
+
if (batch_idx + 1) % self.config.grad_accum_steps == 0:
|
| 174 |
+
self.scaler.unscale_(self.optimizer)
|
| 175 |
+
nn.utils.clip_grad_norm_(self.model.parameters(), self.config.max_grad_norm)
|
| 176 |
+
self.scaler.step(self.optimizer)
|
| 177 |
+
self.scaler.update()
|
| 178 |
+
self.optimizer.zero_grad(set_to_none=True)
|
| 179 |
+
self.scheduler.step()
|
| 180 |
+
self.global_step += 1
|
| 181 |
+
|
| 182 |
+
total_loss += loss.item() * self.config.grad_accum_steps
|
| 183 |
+
n_batches += 1
|
| 184 |
+
|
| 185 |
+
if (batch_idx + 1) % self.config.log_interval == 0:
|
| 186 |
+
avg = total_loss / n_batches
|
| 187 |
+
logger.info(
|
| 188 |
+
f" Epoch {epoch} [{batch_idx+1}/{len(self.train_loader)}] "
|
| 189 |
+
f"loss={avg:.4f} lr={self.scheduler.get_lr():.2e}"
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
return total_loss / max(1, n_batches)
|
| 193 |
+
|
| 194 |
+
@torch.no_grad()
|
| 195 |
+
def _validate(self) -> float:
|
| 196 |
+
self.model.eval()
|
| 197 |
+
total_loss = 0.0
|
| 198 |
+
n_batches = 0
|
| 199 |
+
|
| 200 |
+
for input_ids, targets in self.val_loader:
|
| 201 |
+
input_ids = input_ids.to(self.device, non_blocking=True)
|
| 202 |
+
targets = targets.to(self.device, non_blocking=True)
|
| 203 |
+
|
| 204 |
+
with torch.amp.autocast(
|
| 205 |
+
device_type=self.device.type,
|
| 206 |
+
dtype=self.amp_dtype,
|
| 207 |
+
enabled=self.use_amp,
|
| 208 |
+
):
|
| 209 |
+
_, loss = self.model(input_ids, targets)
|
| 210 |
+
|
| 211 |
+
total_loss += loss.item()
|
| 212 |
+
n_batches += 1
|
| 213 |
+
|
| 214 |
+
return total_loss / max(1, n_batches)
|
| 215 |
+
|
| 216 |
+
def _save_checkpoint(self, name: str, epoch: int, val_loss: float):
|
| 217 |
+
path = self.paths.checkpoint_dir / name
|
| 218 |
+
torch.save(
|
| 219 |
+
{
|
| 220 |
+
"epoch": epoch,
|
| 221 |
+
"model_state_dict": self.model.state_dict(),
|
| 222 |
+
"optimizer_state_dict": self.optimizer.state_dict(),
|
| 223 |
+
"val_loss": val_loss,
|
| 224 |
+
"global_step": self.global_step,
|
| 225 |
+
"config": self.model.config,
|
| 226 |
+
},
|
| 227 |
+
path,
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
def load_checkpoint(self, path: Path):
|
| 231 |
+
ckpt = torch.load(path, map_location=self.device, weights_only=False)
|
| 232 |
+
self.model.load_state_dict(ckpt["model_state_dict"])
|
| 233 |
+
self.optimizer.load_state_dict(ckpt["optimizer_state_dict"])
|
| 234 |
+
self.global_step = ckpt.get("global_step", 0)
|
| 235 |
+
logger.info(f"Loaded checkpoint: {path} (epoch={ckpt['epoch']}, val_loss={ckpt['val_loss']:.4f})")
|
src/s06_generator.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Music generation / inference with:
|
| 3 |
+
- Top-k / Top-p (nucleus) sampling
|
| 4 |
+
- Temperature scaling
|
| 5 |
+
- Repetition penalty
|
| 6 |
+
- KV-cache for efficient autoregressive generation
|
| 7 |
+
"""
|
| 8 |
+
import logging
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
import torch.nn.functional as F
|
| 13 |
+
|
| 14 |
+
from src.s01_config import GenConfig, get_device
|
| 15 |
+
from src.s02_tokenizer import MusicTokenizer, BOS_TOKEN, EOS_TOKEN
|
| 16 |
+
from src.s04_model import MusicTransformer
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def top_k_top_p_filter(logits: torch.Tensor, top_k: int, top_p: float) -> torch.Tensor:
|
| 22 |
+
"""Filter logits using top-k and nucleus (top-p) sampling."""
|
| 23 |
+
if top_k > 0:
|
| 24 |
+
top_k = min(top_k, logits.size(-1))
|
| 25 |
+
indices_to_remove = logits < torch.topk(logits, top_k)[0][..., -1, None]
|
| 26 |
+
logits[indices_to_remove] = float("-inf")
|
| 27 |
+
|
| 28 |
+
if top_p < 1.0:
|
| 29 |
+
sorted_logits, sorted_indices = torch.sort(logits, descending=True)
|
| 30 |
+
cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
|
| 31 |
+
sorted_indices_to_remove = cumulative_probs > top_p
|
| 32 |
+
sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
|
| 33 |
+
sorted_indices_to_remove[..., 0] = 0
|
| 34 |
+
indices_to_remove = sorted_indices_to_remove.scatter(
|
| 35 |
+
dim=-1, index=sorted_indices, src=sorted_indices_to_remove
|
| 36 |
+
)
|
| 37 |
+
logits[indices_to_remove] = float("-inf")
|
| 38 |
+
|
| 39 |
+
return logits
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def apply_repetition_penalty(logits: torch.Tensor, past_tokens: list[int], penalty: float):
|
| 43 |
+
"""Penalize tokens that have appeared recently."""
|
| 44 |
+
if penalty == 1.0 or not past_tokens:
|
| 45 |
+
return logits
|
| 46 |
+
# Only penalize last 64 tokens to avoid over-suppression
|
| 47 |
+
recent = past_tokens[-64:]
|
| 48 |
+
unique_tokens = set(recent)
|
| 49 |
+
for token_id in unique_tokens:
|
| 50 |
+
if logits[0, token_id] > 0:
|
| 51 |
+
logits[0, token_id] /= penalty
|
| 52 |
+
else:
|
| 53 |
+
logits[0, token_id] *= penalty
|
| 54 |
+
return logits
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@torch.no_grad()
|
| 58 |
+
def generate(
|
| 59 |
+
model: MusicTransformer,
|
| 60 |
+
tokenizer: MusicTokenizer,
|
| 61 |
+
config: GenConfig,
|
| 62 |
+
prompt_tokens: list[int] | None = None,
|
| 63 |
+
device: torch.device | None = None,
|
| 64 |
+
) -> list[int]:
|
| 65 |
+
"""
|
| 66 |
+
Generate music tokens autoregressively using KV-cache.
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
model: Trained MusicTransformer
|
| 70 |
+
tokenizer: MusicTokenizer instance
|
| 71 |
+
config: Generation config (temperature, top_k, etc.)
|
| 72 |
+
prompt_tokens: Optional seed tokens (if None, starts with BOS)
|
| 73 |
+
device: Target device
|
| 74 |
+
Returns:
|
| 75 |
+
List of generated token IDs
|
| 76 |
+
"""
|
| 77 |
+
if device is None:
|
| 78 |
+
device = get_device()
|
| 79 |
+
|
| 80 |
+
model.eval()
|
| 81 |
+
model.reset_caches()
|
| 82 |
+
model.to(device)
|
| 83 |
+
|
| 84 |
+
if prompt_tokens is None:
|
| 85 |
+
prompt_tokens = [BOS_TOKEN]
|
| 86 |
+
|
| 87 |
+
generated = list(prompt_tokens)
|
| 88 |
+
|
| 89 |
+
# Set seed for reproducibility
|
| 90 |
+
if config.seed is not None:
|
| 91 |
+
torch.manual_seed(config.seed)
|
| 92 |
+
|
| 93 |
+
# Process prompt in one shot (prefill)
|
| 94 |
+
input_tensor = torch.tensor([generated], dtype=torch.long, device=device)
|
| 95 |
+
logits, _ = model(input_tensor, use_cache=True)
|
| 96 |
+
|
| 97 |
+
for step in range(config.max_tokens - len(generated)):
|
| 98 |
+
# Get logits for last position
|
| 99 |
+
next_logits = logits[:, -1, :] / max(config.temperature, 1e-8)
|
| 100 |
+
|
| 101 |
+
# Apply repetition penalty
|
| 102 |
+
next_logits = apply_repetition_penalty(
|
| 103 |
+
next_logits, generated, config.repetition_penalty
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Top-k / Top-p filtering
|
| 107 |
+
next_logits = top_k_top_p_filter(next_logits, config.top_k, config.top_p)
|
| 108 |
+
|
| 109 |
+
# Sample
|
| 110 |
+
probs = F.softmax(next_logits, dim=-1)
|
| 111 |
+
next_token = torch.multinomial(probs, num_samples=1).item()
|
| 112 |
+
|
| 113 |
+
generated.append(next_token)
|
| 114 |
+
|
| 115 |
+
# Stop on EOS
|
| 116 |
+
if next_token == EOS_TOKEN:
|
| 117 |
+
break
|
| 118 |
+
|
| 119 |
+
# Next step: single token with KV-cache
|
| 120 |
+
input_tensor = torch.tensor([[next_token]], dtype=torch.long, device=device)
|
| 121 |
+
logits, _ = model(input_tensor, use_cache=True)
|
| 122 |
+
|
| 123 |
+
model.reset_caches()
|
| 124 |
+
return generated
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def generate_midi_file(
|
| 128 |
+
model: MusicTransformer,
|
| 129 |
+
tokenizer: MusicTokenizer,
|
| 130 |
+
config: GenConfig,
|
| 131 |
+
output_path: Path,
|
| 132 |
+
prompt_tokens: list[int] | None = None,
|
| 133 |
+
):
|
| 134 |
+
"""Generate a MIDI file and save to disk."""
|
| 135 |
+
logger.info(f"Generating music (max_tokens={config.max_tokens}, temp={config.temperature})...")
|
| 136 |
+
|
| 137 |
+
tokens = generate(model, tokenizer, config, prompt_tokens)
|
| 138 |
+
logger.info(f"Generated {len(tokens)} tokens")
|
| 139 |
+
|
| 140 |
+
midi = tokenizer.tokens_to_midi(tokens)
|
| 141 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 142 |
+
midi.write(str(output_path))
|
| 143 |
+
logger.info(f"Saved MIDI to {output_path}")
|
| 144 |
+
return tokens
|
src/s07_utils.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility helpers for logging, memory monitoring, and reproducibility.
|
| 3 |
+
"""
|
| 4 |
+
import gc
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import random
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def setup_logging(level: str = "INFO"):
|
| 15 |
+
logging.basicConfig(
|
| 16 |
+
level=getattr(logging, level.upper(), logging.INFO),
|
| 17 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 18 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 19 |
+
handlers=[logging.StreamHandler(sys.stdout)],
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def set_seed(seed: int = 42):
|
| 24 |
+
random.seed(seed)
|
| 25 |
+
np.random.seed(seed)
|
| 26 |
+
torch.manual_seed(seed)
|
| 27 |
+
if torch.cuda.is_available():
|
| 28 |
+
torch.cuda.manual_seed_all(seed)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def log_memory_usage(tag: str = ""):
|
| 32 |
+
"""Log current memory usage — critical for constrained hardware."""
|
| 33 |
+
prefix = f"[{tag}] " if tag else ""
|
| 34 |
+
if torch.cuda.is_available():
|
| 35 |
+
allocated = torch.cuda.memory_allocated() / 1024**2
|
| 36 |
+
reserved = torch.cuda.memory_reserved() / 1024**2
|
| 37 |
+
logging.info(f"{prefix}GPU Memory: {allocated:.0f}MB allocated, {reserved:.0f}MB reserved")
|
| 38 |
+
import psutil
|
| 39 |
+
proc = psutil.Process(os.getpid())
|
| 40 |
+
ram = proc.memory_info().rss / 1024**2
|
| 41 |
+
logging.info(f"{prefix}RAM Usage: {ram:.0f}MB")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def clear_memory():
|
| 45 |
+
"""Aggressively free memory — call between major operations."""
|
| 46 |
+
gc.collect()
|
| 47 |
+
if torch.cuda.is_available():
|
| 48 |
+
torch.cuda.empty_cache()
|
| 49 |
+
torch.cuda.synchronize()
|
src/s08_xml_generator.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
XML Configuration-driven Inference for AriaLM.
|
| 3 |
+
Allows specifying prompt notes, generation length, and sampling settings in an XML file.
|
| 4 |
+
"""
|
| 5 |
+
import logging
|
| 6 |
+
import xml.etree.ElementTree as ET
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import pretty_midi
|
| 9 |
+
import torch
|
| 10 |
+
|
| 11 |
+
from src.s01_config import GenConfig, ModelConfig, PathConfig
|
| 12 |
+
from src.s02_tokenizer import MusicTokenizer
|
| 13 |
+
from src.s04_model import MusicTransformer
|
| 14 |
+
from src.s06_generator import generate
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def parse_xml_config(xml_path: Path) -> tuple[GenConfig, list[int]]:
|
| 20 |
+
"""
|
| 21 |
+
Parse generation settings and starting prompt notes from an XML file.
|
| 22 |
+
"""
|
| 23 |
+
tree = ET.parse(xml_path)
|
| 24 |
+
root = tree.getroot()
|
| 25 |
+
|
| 26 |
+
# 1. Parse Settings
|
| 27 |
+
settings = root.find("settings")
|
| 28 |
+
temp = float(settings.find("temperature").text or 0.85)
|
| 29 |
+
top_k = int(settings.find("top_k").text or 40)
|
| 30 |
+
top_p = float(settings.find("top_p").text or 0.92)
|
| 31 |
+
max_tokens = int(settings.find("max_tokens").text or 512)
|
| 32 |
+
rep_penalty = float(settings.find("repetition_penalty").text or 1.15)
|
| 33 |
+
seed = int(settings.find("seed").text or 42)
|
| 34 |
+
|
| 35 |
+
gen_config = GenConfig(
|
| 36 |
+
temperature=temp,
|
| 37 |
+
top_k=top_k,
|
| 38 |
+
top_p=top_p,
|
| 39 |
+
max_tokens=max_tokens,
|
| 40 |
+
repetition_penalty=rep_penalty,
|
| 41 |
+
seed=seed,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# 2. Parse Prompt Notes
|
| 45 |
+
prompt_node = root.find("prompt")
|
| 46 |
+
prompt_tokens = []
|
| 47 |
+
if prompt_node is not None:
|
| 48 |
+
tokenizer = MusicTokenizer()
|
| 49 |
+
pm = pretty_midi.PrettyMIDI()
|
| 50 |
+
instrument = pretty_midi.Instrument(program=0) # Default piano
|
| 51 |
+
|
| 52 |
+
current_time = 0.0
|
| 53 |
+
for note_el in prompt_node.findall("note"):
|
| 54 |
+
pitch = int(note_el.attrib["pitch"])
|
| 55 |
+
velocity = int(note_el.attrib["velocity"])
|
| 56 |
+
duration = float(note_el.attrib["duration_ms"]) / 1000.0
|
| 57 |
+
delay = float(note_el.attrib.get("delay_ms", 0)) / 1000.0
|
| 58 |
+
|
| 59 |
+
current_time += delay
|
| 60 |
+
note = pretty_midi.Note(
|
| 61 |
+
velocity=velocity,
|
| 62 |
+
pitch=pitch,
|
| 63 |
+
start=current_time,
|
| 64 |
+
end=current_time + duration,
|
| 65 |
+
)
|
| 66 |
+
instrument.notes.append(note)
|
| 67 |
+
current_time += duration
|
| 68 |
+
|
| 69 |
+
pm.instruments.append(instrument)
|
| 70 |
+
# Convert prompt MIDI to token IDs
|
| 71 |
+
prompt_tokens = tokenizer.midi_to_tokens(pm)
|
| 72 |
+
|
| 73 |
+
return gen_config, prompt_tokens
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def generate_from_xml(xml_path: Path, model_path: Path, output_path: Path):
|
| 77 |
+
"""Run generation using parameters specified in an XML file."""
|
| 78 |
+
# Load config and prompt
|
| 79 |
+
gen_config, prompt_tokens = parse_xml_config(xml_path)
|
| 80 |
+
|
| 81 |
+
# Load Model
|
| 82 |
+
tokenizer = MusicTokenizer()
|
| 83 |
+
model_config = ModelConfig(vocab_size=tokenizer.vocab_size)
|
| 84 |
+
model = MusicTransformer.from_config(model_config)
|
| 85 |
+
ckpt = torch.load(model_path, map_location="cpu", weights_only=False)
|
| 86 |
+
model.load_state_dict(ckpt["model_state_dict"])
|
| 87 |
+
|
| 88 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 89 |
+
model.to(device)
|
| 90 |
+
|
| 91 |
+
print(f"Generating from XML Config: {xml_path}")
|
| 92 |
+
print(f"Sampling details: Temp={gen_config.temperature}, Seed={gen_config.seed}, Length={gen_config.max_tokens}")
|
| 93 |
+
|
| 94 |
+
# Generate
|
| 95 |
+
tokens = generate(model, tokenizer, gen_config, prompt_tokens, device=device)
|
| 96 |
+
|
| 97 |
+
# Save output
|
| 98 |
+
midi = tokenizer.tokens_to_midi(tokens)
|
| 99 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 100 |
+
midi.write(str(output_path))
|
| 101 |
+
print(f"Saved generated MIDI to: {output_path}")
|
tests/__init__.py
ADDED
|
File without changes
|
tests/test_pipeline.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
End-to-end tests for the music generation pipeline.
|
| 3 |
+
Tests: tokenizer, model, generation, full pipeline.
|
| 4 |
+
"""
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
import numpy as np
|
| 12 |
+
from src.s01_config import ModelConfig, GenConfig
|
| 13 |
+
from src.s02_tokenizer import MusicTokenizer, BOS_TOKEN, EOS_TOKEN, VOCAB_SIZE
|
| 14 |
+
from src.s04_model import MusicTransformer
|
| 15 |
+
from src.s06_generator import generate
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_tokenizer_roundtrip():
|
| 19 |
+
"""Test tokenizer encode/decode produces valid events."""
|
| 20 |
+
tok = MusicTokenizer()
|
| 21 |
+
assert tok.vocab_size == VOCAB_SIZE
|
| 22 |
+
assert tok.bos_id == BOS_TOKEN
|
| 23 |
+
assert tok.eos_id == EOS_TOKEN
|
| 24 |
+
|
| 25 |
+
# Test individual token encoding
|
| 26 |
+
note_on = tok.note_on_token(60) # Middle C
|
| 27 |
+
event = tok.decode_token(note_on)
|
| 28 |
+
assert event["type"] == "NoteOn"
|
| 29 |
+
assert event["value"] == 60
|
| 30 |
+
|
| 31 |
+
vel = tok.velocity_token(100)
|
| 32 |
+
event = tok.decode_token(vel)
|
| 33 |
+
assert event["type"] == "Velocity"
|
| 34 |
+
|
| 35 |
+
ts = tok.timeshift_token(500) # 500ms
|
| 36 |
+
event = tok.decode_token(ts)
|
| 37 |
+
assert event["type"] == "TimeShift"
|
| 38 |
+
assert event["value"] == 500
|
| 39 |
+
|
| 40 |
+
print("PASS: test_tokenizer_roundtrip")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_tokenizer_midi_conversion():
|
| 44 |
+
"""Test MIDI to tokens and back."""
|
| 45 |
+
try:
|
| 46 |
+
import pretty_midi
|
| 47 |
+
except ImportError:
|
| 48 |
+
print("SKIP: test_tokenizer_midi_conversion (pretty_midi not installed)")
|
| 49 |
+
return
|
| 50 |
+
|
| 51 |
+
tok = MusicTokenizer()
|
| 52 |
+
|
| 53 |
+
# Create a simple test MIDI
|
| 54 |
+
midi = pretty_midi.PrettyMIDI(initial_tempo=120)
|
| 55 |
+
inst = pretty_midi.Instrument(program=0)
|
| 56 |
+
# C major chord
|
| 57 |
+
for pitch in [60, 64, 67]:
|
| 58 |
+
note = pretty_midi.Note(velocity=80, pitch=pitch, start=0.0, end=1.0)
|
| 59 |
+
inst.notes.append(note)
|
| 60 |
+
# Second chord at 1.0s
|
| 61 |
+
for pitch in [65, 69, 72]:
|
| 62 |
+
note = pretty_midi.Note(velocity=90, pitch=pitch, start=1.0, end=2.0)
|
| 63 |
+
inst.notes.append(note)
|
| 64 |
+
midi.instruments.append(inst)
|
| 65 |
+
|
| 66 |
+
# Tokenize
|
| 67 |
+
tokens = tok.midi_to_tokens(midi)
|
| 68 |
+
assert len(tokens) > 5
|
| 69 |
+
assert tokens[0] == BOS_TOKEN
|
| 70 |
+
assert tokens[-1] == EOS_TOKEN
|
| 71 |
+
|
| 72 |
+
# Decode back to MIDI
|
| 73 |
+
midi_out = tok.tokens_to_midi(tokens)
|
| 74 |
+
assert len(midi_out.instruments) == 1
|
| 75 |
+
assert len(midi_out.instruments[0].notes) > 0
|
| 76 |
+
|
| 77 |
+
print(f"PASS: test_tokenizer_midi_conversion ({len(tokens)} tokens, "
|
| 78 |
+
f"{len(midi_out.instruments[0].notes)} notes)")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_model_forward():
|
| 82 |
+
"""Test model forward pass and loss computation."""
|
| 83 |
+
config = ModelConfig(
|
| 84 |
+
vocab_size=VOCAB_SIZE,
|
| 85 |
+
dim=64,
|
| 86 |
+
n_layers=2,
|
| 87 |
+
n_heads=4,
|
| 88 |
+
n_kv_heads=2,
|
| 89 |
+
max_seq_len=128,
|
| 90 |
+
dropout=0.0,
|
| 91 |
+
)
|
| 92 |
+
model = MusicTransformer.from_config(config)
|
| 93 |
+
|
| 94 |
+
# Check parameter count is reasonable
|
| 95 |
+
n_params = model.count_parameters()
|
| 96 |
+
assert n_params > 0
|
| 97 |
+
print(f" Model params: {n_params:,}")
|
| 98 |
+
|
| 99 |
+
# Forward pass
|
| 100 |
+
batch_size = 2
|
| 101 |
+
seq_len = 32
|
| 102 |
+
input_ids = torch.randint(0, config.vocab_size, (batch_size, seq_len))
|
| 103 |
+
targets = torch.randint(0, config.vocab_size, (batch_size, seq_len))
|
| 104 |
+
|
| 105 |
+
logits, loss = model(input_ids, targets)
|
| 106 |
+
assert logits.shape == (batch_size, seq_len, config.vocab_size)
|
| 107 |
+
assert loss is not None
|
| 108 |
+
assert loss.item() > 0
|
| 109 |
+
|
| 110 |
+
# Backward pass (check gradients flow)
|
| 111 |
+
loss.backward()
|
| 112 |
+
grad_norms = [p.grad.norm().item() for p in model.parameters() if p.grad is not None]
|
| 113 |
+
assert len(grad_norms) > 0
|
| 114 |
+
assert all(not np.isnan(g) for g in grad_norms)
|
| 115 |
+
|
| 116 |
+
print(f"PASS: test_model_forward (loss={loss.item():.4f})")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_model_gradient_checkpoint():
|
| 120 |
+
"""Test that gradient checkpointing works and reduces memory."""
|
| 121 |
+
config = ModelConfig(
|
| 122 |
+
vocab_size=VOCAB_SIZE,
|
| 123 |
+
dim=64,
|
| 124 |
+
n_layers=4,
|
| 125 |
+
n_heads=4,
|
| 126 |
+
n_kv_heads=2,
|
| 127 |
+
max_seq_len=128,
|
| 128 |
+
dropout=0.0,
|
| 129 |
+
)
|
| 130 |
+
model = MusicTransformer.from_config(config)
|
| 131 |
+
model.grad_checkpoint = True
|
| 132 |
+
|
| 133 |
+
input_ids = torch.randint(0, config.vocab_size, (2, 64))
|
| 134 |
+
targets = torch.randint(0, config.vocab_size, (2, 64))
|
| 135 |
+
|
| 136 |
+
logits, loss = model(input_ids, targets)
|
| 137 |
+
loss.backward()
|
| 138 |
+
|
| 139 |
+
assert loss.item() > 0
|
| 140 |
+
print(f"PASS: test_model_gradient_checkpoint (loss={loss.item():.4f})")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def test_generation():
|
| 144 |
+
"""Test autoregressive generation."""
|
| 145 |
+
config = ModelConfig(
|
| 146 |
+
vocab_size=VOCAB_SIZE,
|
| 147 |
+
dim=64,
|
| 148 |
+
n_layers=2,
|
| 149 |
+
n_heads=4,
|
| 150 |
+
n_kv_heads=2,
|
| 151 |
+
max_seq_len=128,
|
| 152 |
+
dropout=0.0,
|
| 153 |
+
)
|
| 154 |
+
model = MusicTransformer.from_config(config)
|
| 155 |
+
tokenizer = MusicTokenizer()
|
| 156 |
+
|
| 157 |
+
gen_config = GenConfig(
|
| 158 |
+
temperature=0.8,
|
| 159 |
+
top_k=20,
|
| 160 |
+
top_p=0.9,
|
| 161 |
+
max_tokens=50,
|
| 162 |
+
repetition_penalty=1.1,
|
| 163 |
+
seed=42,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
tokens = generate(model, tokenizer, gen_config, device=torch.device("cpu"))
|
| 167 |
+
assert len(tokens) > 1
|
| 168 |
+
assert tokens[0] == BOS_TOKEN
|
| 169 |
+
|
| 170 |
+
print(f"PASS: test_generation ({len(tokens)} tokens generated)")
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_generation_to_midi():
|
| 174 |
+
"""Test full pipeline: generate tokens → convert to MIDI."""
|
| 175 |
+
try:
|
| 176 |
+
import pretty_midi
|
| 177 |
+
except ImportError:
|
| 178 |
+
print("SKIP: test_generation_to_midi (pretty_midi not installed)")
|
| 179 |
+
return
|
| 180 |
+
|
| 181 |
+
config = ModelConfig(
|
| 182 |
+
vocab_size=VOCAB_SIZE,
|
| 183 |
+
dim=64,
|
| 184 |
+
n_layers=2,
|
| 185 |
+
n_heads=4,
|
| 186 |
+
n_kv_heads=2,
|
| 187 |
+
max_seq_len=128,
|
| 188 |
+
dropout=0.0,
|
| 189 |
+
)
|
| 190 |
+
model = MusicTransformer.from_config(config)
|
| 191 |
+
tokenizer = MusicTokenizer()
|
| 192 |
+
|
| 193 |
+
gen_config = GenConfig(
|
| 194 |
+
temperature=1.0,
|
| 195 |
+
top_k=50,
|
| 196 |
+
top_p=0.95,
|
| 197 |
+
max_tokens=100,
|
| 198 |
+
repetition_penalty=1.1,
|
| 199 |
+
seed=123,
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
tokens = generate(model, tokenizer, gen_config, device=torch.device("cpu"))
|
| 203 |
+
midi = tokenizer.tokens_to_midi(tokens)
|
| 204 |
+
|
| 205 |
+
assert midi is not None
|
| 206 |
+
assert len(midi.instruments) == 1
|
| 207 |
+
|
| 208 |
+
print(f"PASS: test_generation_to_midi ({len(tokens)} tokens → "
|
| 209 |
+
f"{len(midi.instruments[0].notes)} notes)")
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def test_dataset_creation():
|
| 213 |
+
"""Test MidiTokenDataset with synthetic data."""
|
| 214 |
+
from src.s03_dataset import MidiTokenDataset
|
| 215 |
+
|
| 216 |
+
# Synthetic token sequences
|
| 217 |
+
sequences = [
|
| 218 |
+
[BOS_TOKEN] + list(np.random.randint(4, VOCAB_SIZE, size=100)) + [EOS_TOKEN]
|
| 219 |
+
for _ in range(20)
|
| 220 |
+
]
|
| 221 |
+
|
| 222 |
+
ds = MidiTokenDataset(sequences, max_seq_len=64, pad_id=0)
|
| 223 |
+
assert len(ds) == 20
|
| 224 |
+
|
| 225 |
+
input_ids, targets = ds[0]
|
| 226 |
+
assert input_ids.shape == (64,)
|
| 227 |
+
assert targets.shape == (64,)
|
| 228 |
+
assert input_ids.dtype == torch.long
|
| 229 |
+
|
| 230 |
+
print(f"PASS: test_dataset_creation ({len(ds)} sequences)")
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
if __name__ == "__main__":
|
| 234 |
+
print("=" * 60)
|
| 235 |
+
print("MUSIC GENERATION LLM — TESTS")
|
| 236 |
+
print("=" * 60)
|
| 237 |
+
|
| 238 |
+
tests = [
|
| 239 |
+
test_tokenizer_roundtrip,
|
| 240 |
+
test_tokenizer_midi_conversion,
|
| 241 |
+
test_model_forward,
|
| 242 |
+
test_model_gradient_checkpoint,
|
| 243 |
+
test_generation,
|
| 244 |
+
test_generation_to_midi,
|
| 245 |
+
test_dataset_creation,
|
| 246 |
+
]
|
| 247 |
+
|
| 248 |
+
passed = 0
|
| 249 |
+
failed = 0
|
| 250 |
+
skipped = 0
|
| 251 |
+
|
| 252 |
+
for test in tests:
|
| 253 |
+
try:
|
| 254 |
+
test()
|
| 255 |
+
passed += 1
|
| 256 |
+
except Exception as e:
|
| 257 |
+
if "SKIP" in str(e):
|
| 258 |
+
skipped += 1
|
| 259 |
+
else:
|
| 260 |
+
print(f"FAIL: {test.__name__}: {e}")
|
| 261 |
+
import traceback
|
| 262 |
+
traceback.print_exc()
|
| 263 |
+
failed += 1
|
| 264 |
+
|
| 265 |
+
print("=" * 60)
|
| 266 |
+
print(f"Results: {passed} passed, {failed} failed, {skipped} skipped")
|
| 267 |
+
print("=" * 60)
|
| 268 |
+
sys.exit(1 if failed > 0 else 0)
|