krishnah27 commited on
Commit
30e9297
·
verified ·
1 Parent(s): bd86a4d

Upload folder using huggingface_hub

Browse files
.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
- license: mit
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&#10;Entry Point&#10;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&#10;ModelConfig, TrainConfig&#10;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&#10;REMI Tokenizer&#10;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&#10;HF Download → Parse&#10;→ 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&#10;MusicTransformer&#10;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&#10;AMP + Grad Ckpt&#10;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&#10;KV-Cache Decoding&#10;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/&#10;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/&#10;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&#10;• Logging&#10;• Memory Monitor&#10;• 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&#10;[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&#10;(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&#10;8Q / 4KV heads + RoPE&#10;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&#10;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&#10;─────────────&#10;- vocab_size: int&#10;- pad_id: int&#10;- bos_id: int&#10;- eos_id: int&#10;─────────────&#10;+ midi_to_tokens(midi, max_len)&#10;+ tokens_to_midi(tokens)&#10;+ decode_token(token_id)&#10;+ 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&#10;─────────────&#10;- config: ModelConfig&#10;- token_emb: Embedding&#10;- layers: ModuleList[TransformerBlock]&#10;- norm: RMSNorm&#10;- output: Linear&#10;- freqs: Tensor (RoPE)&#10;─────────────&#10;+ forward(input_ids, targets, use_cache)&#10;+ reset_caches()&#10;+ count_parameters()&#10;+ 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&#10;─────────────&#10;- attention: GroupedQueryAttention&#10;- feed_forward: SwiGLU&#10;- norm1, norm2: RMSNorm&#10;─────────────&#10;+ 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&#10;─────────────&#10;- model: MusicTransformer&#10;- optimizer: AdamW&#10;- scheduler: CosineWarmup&#10;- scaler: GradScaler&#10;─────────────&#10;+ train()&#10;+ _train_epoch(epoch)&#10;+ _validate()&#10;+ _save_checkpoint(name)&#10;+ 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)&#10;─────────────&#10;+ generate(model, tokenizer, config)&#10;+ generate_midi_file(model, tok, config, path)&#10;+ top_k_top_p_filter(logits, k, p)&#10;+ 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)