marcos Claude Opus 4.5 commited on
Commit ·
41d51d1
0
Parent(s):
Initial commit: Speech-to-Speech dataset generation pipeline
Browse files- passo0_setup.py: Environment setup for GPU training
- passo1_convertdataset.py: Convert raw data to InstructS2S format
- passo2_finetune_stage1.py: Stage 1 training (adapter only)
- passo3_finetune_stage2.py: Stage 2 training (adapter + LoRA)
- datasets/generate_dataset.py: Generate Q&A dataset with TTS/STT
- services/: Modular services for Groq, WhisperX, Kokoro TTS, Llama
All API keys use environment variables for security.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- .gitattributes +35 -0
- .gitignore +27 -0
- claude.md +122 -0
- datasets/generate_dataset.py +848 -0
- passo0_setup.py +434 -0
- passo2_finetune_stage1.py +817 -0
- passo3_finetune_stage2.py +876 -0
- passo4_inference.py +239 -0
- services/__init__.py +9 -0
- services/groq_service.py +106 -0
- services/kokoro_service.py +238 -0
- services/llama_service.py +212 -0
- services/whisperx_service.py +210 -0
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OS
|
| 2 |
+
.DS_Store
|
| 3 |
+
|
| 4 |
+
# Python
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*.egg-info/
|
| 8 |
+
.eggs/
|
| 9 |
+
dist/
|
| 10 |
+
build/
|
| 11 |
+
|
| 12 |
+
# Data and checkpoints
|
| 13 |
+
data/
|
| 14 |
+
checkpoints/
|
| 15 |
+
logs/
|
| 16 |
+
*.pt
|
| 17 |
+
*.gguf
|
| 18 |
+
*.onnx
|
| 19 |
+
*.bin
|
| 20 |
+
|
| 21 |
+
# Temp
|
| 22 |
+
*.tmp
|
| 23 |
+
temp_*/
|
| 24 |
+
|
| 25 |
+
# IDE
|
| 26 |
+
.vscode/
|
| 27 |
+
.idea/
|
claude.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# claude.md - Contexto do Projeto
|
| 2 |
+
|
| 3 |
+
## Objetivo
|
| 4 |
+
Modelo **Speech-to-Speech** end-to-end: Áudio entrada → LLM → Áudio resposta (sem texto intermediário).
|
| 5 |
+
|
| 6 |
+
## Arquitetura
|
| 7 |
+
|
| 8 |
+
```
|
| 9 |
+
Áudio Usuário → Whisper → SpeechAdapter → Orpheus+LoRA → Interleaved Output → SNAC → Áudio
|
| 10 |
+
(Stage 1+2) (Stage 2 only)
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
## Componentes
|
| 14 |
+
|
| 15 |
+
| Componente | Estrutura | Parâmetros | Treina em |
|
| 16 |
+
|------------|-----------|------------|-----------|
|
| 17 |
+
| **Whisper** | whisper-large-v3 (1280 dim) | Frozen | - |
|
| 18 |
+
| **SpeechAdapter** | 5× downsample + FFN(6400→2048→3072) + LayerNorm | ~19M | Stage 1, Stage 2 |
|
| 19 |
+
| **Orpheus** | LLaMA 3B (orpheus-3b-es-it) | Frozen (Stage 1), LoRA (Stage 2) | Stage 2 |
|
| 20 |
+
| **LoRA** | r=16, alpha=32, all projections | ~50M | Stage 2 |
|
| 21 |
+
|
| 22 |
+
## SNAC Token Offsets (CRÍTICO)
|
| 23 |
+
|
| 24 |
+
Tokens SNAC usam offsets position-based para Orpheus:
|
| 25 |
+
```python
|
| 26 |
+
SNAC_BASE = 128266
|
| 27 |
+
EOS_TOKEN = 128009
|
| 28 |
+
# 7 tokens por frame: pos 0-6 têm offsets diferentes
|
| 29 |
+
# Offset = 128266 + (pos % 7) * 4096
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
## Pipeline de Training (IST-LM + LLaMA-Omni 2)
|
| 33 |
+
|
| 34 |
+
**IMPORTANTE: NÃO crie arquivos temporários, checkpoints, logs ou datasets neste diretório.**
|
| 35 |
+
Use subdiretórios como `./checkpoints`, `./data`, `./logs` para manter este diretório limpo.
|
| 36 |
+
|
| 37 |
+
Pipeline de 4 passos com **Scheduled Interleaved Speech-Text**:
|
| 38 |
+
- Começa com 90% texto, diminui 0.1 a cada 300 steps até 100% áudio
|
| 39 |
+
|
| 40 |
+
### Passo 0: Setup do Ambiente
|
| 41 |
+
```bash
|
| 42 |
+
python passo0_setup.py
|
| 43 |
+
```
|
| 44 |
+
- Instala todas as dependências (torch, transformers, snac, etc.)
|
| 45 |
+
- Verifica CUDA/GPU
|
| 46 |
+
- Cria diretórios necessários
|
| 47 |
+
- Otimiza automaticamente batch size com base na VRAM
|
| 48 |
+
|
| 49 |
+
### Passo 1: ConvertDataset
|
| 50 |
+
```bash
|
| 51 |
+
python passo1_convertdataset.py --input_dir ./data/raw --output ./data/processed/data.pt
|
| 52 |
+
```
|
| 53 |
+
- Converte datasets brutos para formato InstructS2S
|
| 54 |
+
- Extrai Whisper features e SNAC tokens
|
| 55 |
+
- Gerenciamento automático de memória (evita OOM)
|
| 56 |
+
- Output: `data.pt`
|
| 57 |
+
|
| 58 |
+
### Passo 2: Stage 1 - Adapter Only (LLM Frozen)
|
| 59 |
+
```bash
|
| 60 |
+
python passo2_finetune_stage1.py --data ./data/processed/data.pt --epochs 2 --lr 5e-5 --output_dir ./checkpoints
|
| 61 |
+
```
|
| 62 |
+
- Treina: SpeechAdapter only
|
| 63 |
+
- Frozen: Orpheus (completamente)
|
| 64 |
+
- Output: Interleaved text+audio tokens
|
| 65 |
+
- Epochs: 1-2 (warmup)
|
| 66 |
+
- Output: `checkpoints/stage1_best.pt`
|
| 67 |
+
|
| 68 |
+
### Passo 3: Stage 2 - Adapter + LoRA Together
|
| 69 |
+
```bash
|
| 70 |
+
python passo3_finetune_stage2.py --data ./data/processed/data.pt --stage1_ckpt ./checkpoints/stage1_best.pt --epochs 3 --lr 5e-5 --output_dir ./checkpoints
|
| 71 |
+
```
|
| 72 |
+
- Treina: SpeechAdapter + LoRA (juntos)
|
| 73 |
+
- Output: Interleaved text+audio tokens
|
| 74 |
+
- Epochs: 3+
|
| 75 |
+
- Output: `checkpoints/stage2_best.pt`
|
| 76 |
+
|
| 77 |
+
## Scheduled Interleaving (IST-LM Paper)
|
| 78 |
+
|
| 79 |
+
```python
|
| 80 |
+
def get_text_ratio(global_step, decay_steps=300, initial_ratio=0.9):
|
| 81 |
+
num_decays = global_step // decay_steps
|
| 82 |
+
text_ratio = initial_ratio - (num_decays * 0.1)
|
| 83 |
+
return max(0.0, text_ratio)
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
Pattern por text_ratio:
|
| 87 |
+
- 0.9: 1 text token + 3 audio frames (21 tokens)
|
| 88 |
+
- 0.7: 1 text token + 5 audio frames
|
| 89 |
+
- 0.5: 1 text token + 7 audio frames
|
| 90 |
+
- 0.3: 1 text token + 10 audio frames
|
| 91 |
+
- 0.0: Pure audio
|
| 92 |
+
|
| 93 |
+
## Otimizações Comuns
|
| 94 |
+
|
| 95 |
+
- **GPU Auto-Detection**: B200/A100/RTX4090 → batch/dtype automático
|
| 96 |
+
- **Label Smoothing**: `--label_smoothing 0.1`
|
| 97 |
+
- **Dynamic Loading**: Datasets carregam em background via mmap
|
| 98 |
+
- **Async Checkpoints**: Saves não bloqueiam training
|
| 99 |
+
|
| 100 |
+
## Dataset
|
| 101 |
+
|
| 102 |
+
**ATENÇÃO: Dataset InstructS2S-200K localizado em `/workspace/dataset/InstructS2S-200K/`**
|
| 103 |
+
- Arquivos de origem (en_part_00 a en_part_32): 33 arquivos de ~10GB cada
|
| 104 |
+
- **NÃO DELETE** os arquivos em `/workspace/dataset/InstructS2S-200K/`
|
| 105 |
+
- Arquivos extraídos serão salvos em `/root/InstructS2S-200K/extracted_data/`
|
| 106 |
+
|
| 107 |
+
Formato InstructS2S:
|
| 108 |
+
```python
|
| 109 |
+
{
|
| 110 |
+
"whisper_features": [seq_len, 1280], # Whisper do áudio USUÁRIO
|
| 111 |
+
"snac_tokens": [num_tokens], # SNAC do áudio RESPOSTA
|
| 112 |
+
"text": str, # Texto da pergunta
|
| 113 |
+
"answer": str # Texto da resposta (para interleaving)
|
| 114 |
+
}
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
## Regras
|
| 118 |
+
|
| 119 |
+
- **Whisper**: Usar `whisper-large-v3` (1280 dim) - NUNCA whisper-small
|
| 120 |
+
- **HF Token**: `$HF_TOKEN`
|
| 121 |
+
- **SNAC**: Truncar para múltiplo de 7 (frames completos)
|
| 122 |
+
- **Interleaved**: Sempre usar scheduled interleaving, nunca pure text ou pure audio fixo
|
datasets/generate_dataset.py
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Full GPU pipeline with Kokoro TTS - MULTI-GPU VERSION.
|
| 4 |
+
|
| 5 |
+
Features:
|
| 6 |
+
- Auto-detects all available GPUs
|
| 7 |
+
- Distributes workload across GPUs
|
| 8 |
+
- Each GPU runs independent pipeline
|
| 9 |
+
- Merges results at the end
|
| 10 |
+
- Single WhisperX model for BOTH alignment AND feature extraction (no duplicate Whisper!)
|
| 11 |
+
- SNAC with torch.compile optimization
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
python generate_kokoro_gpu.py --count 60000 --output-dir ./qa_dataset
|
| 15 |
+
|
| 16 |
+
With 1 GPU at ~5 items/s = 60k in ~3.3 hours
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
import sys
|
| 21 |
+
|
| 22 |
+
# CRITICAL: Set LD_LIBRARY_PATH for ONNX CUDA before any imports
|
| 23 |
+
# This must happen before importing onnxruntime or kokoro_onnx
|
| 24 |
+
nvidia_libs = []
|
| 25 |
+
for pyver in ['python3.12', 'python3.11', 'python3.10']:
|
| 26 |
+
nvidia_base = f'/usr/local/lib/{pyver}/dist-packages/nvidia'
|
| 27 |
+
for subdir in ['cublas', 'cudnn', 'cufft', 'curand', 'cusolver', 'cusparse',
|
| 28 |
+
'cuda_runtime', 'cuda_nvrtc', 'cuda_cupti', 'nvjitlink', 'nccl', 'nvtx', 'cusparselt', 'cufile']:
|
| 29 |
+
lib_path = f"{nvidia_base}/{subdir}/lib"
|
| 30 |
+
if os.path.exists(lib_path):
|
| 31 |
+
nvidia_libs.append(lib_path)
|
| 32 |
+
|
| 33 |
+
os.environ['LD_LIBRARY_PATH'] = ':'.join(nvidia_libs) + ':' + os.environ.get('LD_LIBRARY_PATH', '')
|
| 34 |
+
|
| 35 |
+
# CRITICAL: Force Kokoro TTS to use CUDA provider (not CPU)
|
| 36 |
+
os.environ['ONNX_PROVIDER'] = 'CUDAExecutionProvider'
|
| 37 |
+
|
| 38 |
+
import json
|
| 39 |
+
import time
|
| 40 |
+
import argparse
|
| 41 |
+
import threading
|
| 42 |
+
import queue
|
| 43 |
+
import multiprocessing as mp
|
| 44 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 45 |
+
from pathlib import Path
|
| 46 |
+
from typing import List, Dict, Tuple, Optional
|
| 47 |
+
import numpy as np
|
| 48 |
+
import torch
|
| 49 |
+
import torchaudio
|
| 50 |
+
import soundfile as sf
|
| 51 |
+
|
| 52 |
+
# Configuration
|
| 53 |
+
GROQ_API_KEY = os.environ.get('GROQ_API_KEY', '')
|
| 54 |
+
GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"
|
| 55 |
+
MODEL = "llama-3.1-8b-instant"
|
| 56 |
+
API_BATCH_SIZE = 20 # QA pairs per LLM request (15-20 works best)
|
| 57 |
+
|
| 58 |
+
# Local Llama configuration
|
| 59 |
+
LLAMA_MODEL_PATH = "/tmp/llama-3.2-3b-instruct-q4_k_m.gguf"
|
| 60 |
+
USE_LOCAL_LLAMA = False # Set via --use-llama flag
|
| 61 |
+
LLAMA_GPU_ID = 1 # Use GPU 1 for Llama (GPU 0 for TTS/WhisperX)
|
| 62 |
+
|
| 63 |
+
KOKORO_MODEL_PATH = '/tmp/kokoro-v1.0.onnx'
|
| 64 |
+
KOKORO_VOICES_PATH = '/tmp/voices-v1.0.bin'
|
| 65 |
+
SAMPLE_RATE = 24000
|
| 66 |
+
|
| 67 |
+
WHISPERX_MODEL = "large-v3-turbo" # Single model for alignment AND feature extraction
|
| 68 |
+
WHISPERX_COMPUTE_TYPE = "float16" # float16 for RTX 50xx
|
| 69 |
+
SNAC_MODEL = "hubertsiuzdak/snac_24khz"
|
| 70 |
+
|
| 71 |
+
BATCH_SIZE = 50
|
| 72 |
+
NUM_TTS_WORKERS = 1 # Single TTS model to avoid GPU contention
|
| 73 |
+
SILENCE_BETWEEN_PAIRS = 0.5
|
| 74 |
+
IO_WORKERS = 4
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class PipelineStats:
|
| 78 |
+
def __init__(self, target_count: int, gpu_id: int = 0):
|
| 79 |
+
self.lock = threading.Lock()
|
| 80 |
+
self.target_count = target_count
|
| 81 |
+
self.gpu_id = gpu_id
|
| 82 |
+
self.api_pairs_sent = 0
|
| 83 |
+
self.tts_batches = 0
|
| 84 |
+
self.feature_batches = 0
|
| 85 |
+
self.total_items = 0
|
| 86 |
+
self.start_time = time.time()
|
| 87 |
+
self.stop_event = threading.Event()
|
| 88 |
+
|
| 89 |
+
def add_api_pairs(self, count: int):
|
| 90 |
+
with self.lock:
|
| 91 |
+
self.api_pairs_sent += count
|
| 92 |
+
|
| 93 |
+
def get_api_pairs_sent(self):
|
| 94 |
+
with self.lock:
|
| 95 |
+
return self.api_pairs_sent
|
| 96 |
+
|
| 97 |
+
def update(self, stage: str, count: int = 0):
|
| 98 |
+
with self.lock:
|
| 99 |
+
if stage == 'tts':
|
| 100 |
+
self.tts_batches += 1
|
| 101 |
+
elif stage == 'features':
|
| 102 |
+
self.feature_batches += 1
|
| 103 |
+
self.total_items += count
|
| 104 |
+
if self.total_items >= self.target_count:
|
| 105 |
+
self.stop_event.set()
|
| 106 |
+
|
| 107 |
+
def should_stop(self):
|
| 108 |
+
return self.stop_event.is_set()
|
| 109 |
+
|
| 110 |
+
def get_total_items(self):
|
| 111 |
+
with self.lock:
|
| 112 |
+
return self.total_items
|
| 113 |
+
|
| 114 |
+
def print_status(self):
|
| 115 |
+
with self.lock:
|
| 116 |
+
elapsed = time.time() - self.start_time
|
| 117 |
+
rate = self.total_items / elapsed if elapsed > 0 else 0
|
| 118 |
+
remaining = self.target_count - self.total_items
|
| 119 |
+
eta_seconds = remaining / rate if rate > 0 else 0
|
| 120 |
+
eta_min = int(eta_seconds // 60)
|
| 121 |
+
eta_sec = int(eta_seconds % 60)
|
| 122 |
+
print(f"\r[GPU {self.gpu_id}] {self.total_items}/{self.target_count} | {rate:.1f}/s | "
|
| 123 |
+
f"ETA: {eta_min}m{eta_sec:02d}s", end='', flush=True)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def generate_qa_batch_groq(batch_size: int) -> List[Dict[str, str]]:
|
| 127 |
+
"""Generate Q&A pairs using Groq API."""
|
| 128 |
+
import requests
|
| 129 |
+
headers = {
|
| 130 |
+
"Authorization": f"Bearer {GROQ_API_KEY}",
|
| 131 |
+
"Content-Type": "application/json"
|
| 132 |
+
}
|
| 133 |
+
prompt = f"""Generate {batch_size} unique question-answer pairs about general knowledge.
|
| 134 |
+
REQUIREMENTS:
|
| 135 |
+
- Questions: 5 to 10 words
|
| 136 |
+
- Answers: 5 to 10 words
|
| 137 |
+
Format: Q: [question]
|
| 138 |
+
A: [answer]
|
| 139 |
+
Return exactly {batch_size} pairs."""
|
| 140 |
+
|
| 141 |
+
payload = {
|
| 142 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 143 |
+
"model": MODEL,
|
| 144 |
+
"temperature": 0.7,
|
| 145 |
+
"max_tokens": 2000
|
| 146 |
+
}
|
| 147 |
+
try:
|
| 148 |
+
response = requests.post(GROQ_API_URL, headers=headers, json=payload, timeout=30)
|
| 149 |
+
response.raise_for_status()
|
| 150 |
+
content = response.json()['choices'][0]['message']['content']
|
| 151 |
+
return parse_qa_pairs(content)
|
| 152 |
+
except Exception as e:
|
| 153 |
+
return []
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def generate_qa_batch_llama(batch_size: int, llm) -> List[Dict[str, str]]:
|
| 157 |
+
"""Generate Q&A pairs using local Llama model."""
|
| 158 |
+
prompt = f"""<|start_header_id|>system<|end_header_id|>
|
| 159 |
+
Generate concise Q&A pairs. Keep answers SHORT (5-10 words max).<|eot_id|><|start_header_id|>user<|end_header_id|>
|
| 160 |
+
Generate {batch_size} Q&A pairs about general knowledge.
|
| 161 |
+
STRICT FORMAT - each answer must be 5-10 words only:
|
| 162 |
+
Q: What is the capital of France?
|
| 163 |
+
A: Paris is the capital of France.
|
| 164 |
+
Q: Who invented the telephone?
|
| 165 |
+
A: Alexander Graham Bell invented the telephone.<|eot_id|><|start_header_id|>assistant<|end_header_id|>
|
| 166 |
+
"""
|
| 167 |
+
try:
|
| 168 |
+
# ~35 tokens per QA pair (Q + A + formatting)
|
| 169 |
+
response = llm(prompt, max_tokens=batch_size * 35, temperature=0.8, stop=["<|eot_id|>"])
|
| 170 |
+
content = response['choices'][0]['text']
|
| 171 |
+
return parse_qa_pairs(content)
|
| 172 |
+
except Exception as e:
|
| 173 |
+
print(f"[LLM] Error: {e}", flush=True)
|
| 174 |
+
return []
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def generate_qa_batch(batch_size: int, llm=None) -> List[Dict[str, str]]:
|
| 178 |
+
"""Generate Q&A pairs using either Groq API or local Llama."""
|
| 179 |
+
if USE_LOCAL_LLAMA and llm is not None:
|
| 180 |
+
return generate_qa_batch_llama(batch_size, llm)
|
| 181 |
+
else:
|
| 182 |
+
return generate_qa_batch_groq(batch_size)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def parse_qa_pairs(content: str) -> List[Dict[str, str]]:
|
| 186 |
+
pairs = []
|
| 187 |
+
lines = content.split('\n')
|
| 188 |
+
current_q, current_a = None, None
|
| 189 |
+
for line in lines:
|
| 190 |
+
line = line.strip()
|
| 191 |
+
if line.lower().startswith('q:'):
|
| 192 |
+
current_q = line[2:].strip()
|
| 193 |
+
elif line.lower().startswith('a:'):
|
| 194 |
+
current_a = line[2:].strip()
|
| 195 |
+
if current_q and current_a:
|
| 196 |
+
# Accept 3-20 words for both Q and A (more lenient for local LLM)
|
| 197 |
+
q_words = len(current_q.split())
|
| 198 |
+
a_words = len(current_a.split())
|
| 199 |
+
if 3 <= q_words <= 20 and 3 <= a_words <= 20:
|
| 200 |
+
pairs.append({'question': current_q, 'answer': current_a})
|
| 201 |
+
current_q, current_a = None, None
|
| 202 |
+
return pairs
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def api_worker(output_queue: queue.Queue, total_needed: int, stats: PipelineStats):
|
| 206 |
+
collected = []
|
| 207 |
+
batches_sent = 0
|
| 208 |
+
total_batches_needed = (total_needed + BATCH_SIZE - 1) // BATCH_SIZE + 1
|
| 209 |
+
|
| 210 |
+
# Load Llama model if using local LLM
|
| 211 |
+
llm = None
|
| 212 |
+
if USE_LOCAL_LLAMA:
|
| 213 |
+
try:
|
| 214 |
+
from llama_cpp import Llama
|
| 215 |
+
print(f"[LLM] Loading Llama model on GPU {LLAMA_GPU_ID}...", flush=True)
|
| 216 |
+
|
| 217 |
+
# Set CUDA device for Llama
|
| 218 |
+
os.environ['CUDA_VISIBLE_DEVICES'] = str(LLAMA_GPU_ID)
|
| 219 |
+
|
| 220 |
+
llm = Llama(
|
| 221 |
+
model_path=LLAMA_MODEL_PATH,
|
| 222 |
+
n_gpu_layers=-1,
|
| 223 |
+
n_ctx=2048,
|
| 224 |
+
n_batch=512,
|
| 225 |
+
verbose=False
|
| 226 |
+
)
|
| 227 |
+
# Warmup
|
| 228 |
+
llm("Hello", max_tokens=10)
|
| 229 |
+
print(f"[LLM] Llama loaded on GPU {LLAMA_GPU_ID}", flush=True)
|
| 230 |
+
except Exception as e:
|
| 231 |
+
print(f"[LLM] Failed to load Llama: {e}, falling back to Groq API", flush=True)
|
| 232 |
+
llm = None
|
| 233 |
+
|
| 234 |
+
source = "LLM" if llm else "API"
|
| 235 |
+
print(f"[{source}] Starting, need {total_batches_needed} batches", flush=True)
|
| 236 |
+
|
| 237 |
+
while batches_sent < total_batches_needed and not stats.should_stop():
|
| 238 |
+
pairs_in_queue = stats.get_api_pairs_sent() - stats.get_total_items()
|
| 239 |
+
if pairs_in_queue > total_needed * 0.3:
|
| 240 |
+
time.sleep(0.5)
|
| 241 |
+
continue
|
| 242 |
+
|
| 243 |
+
needed = min(API_BATCH_SIZE, total_needed - len(collected) - stats.get_api_pairs_sent() + batches_sent * BATCH_SIZE)
|
| 244 |
+
if needed <= 0:
|
| 245 |
+
break
|
| 246 |
+
|
| 247 |
+
print(f"[{source}] Generating {needed} QA pairs...", flush=True)
|
| 248 |
+
pairs = generate_qa_batch(needed, llm)
|
| 249 |
+
print(f"[{source}] Got {len(pairs)} pairs", flush=True)
|
| 250 |
+
collected.extend(pairs)
|
| 251 |
+
|
| 252 |
+
while len(collected) >= BATCH_SIZE and batches_sent < total_batches_needed:
|
| 253 |
+
batch = collected[:BATCH_SIZE]
|
| 254 |
+
collected = collected[BATCH_SIZE:]
|
| 255 |
+
output_queue.put(batch)
|
| 256 |
+
stats.add_api_pairs(len(batch))
|
| 257 |
+
batches_sent += 1
|
| 258 |
+
|
| 259 |
+
if stats.should_stop():
|
| 260 |
+
break
|
| 261 |
+
|
| 262 |
+
if collected and not stats.should_stop():
|
| 263 |
+
remaining_needed = total_needed - stats.get_api_pairs_sent()
|
| 264 |
+
if remaining_needed > 0:
|
| 265 |
+
batch = collected[:remaining_needed]
|
| 266 |
+
output_queue.put(batch)
|
| 267 |
+
stats.add_api_pairs(len(batch))
|
| 268 |
+
|
| 269 |
+
output_queue.put(None)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def tts_worker(input_queue: queue.Queue, output_queue: queue.Queue, stats: PipelineStats):
|
| 273 |
+
from kokoro_onnx import Kokoro
|
| 274 |
+
|
| 275 |
+
print(f"[TTS] Loading {NUM_TTS_WORKERS} Kokoro models...", flush=True)
|
| 276 |
+
|
| 277 |
+
# Kokoro will use CUDA via ONNX_PROVIDER env var set at top of script
|
| 278 |
+
tts_models = [Kokoro(KOKORO_MODEL_PATH, KOKORO_VOICES_PATH) for _ in range(NUM_TTS_WORKERS)]
|
| 279 |
+
|
| 280 |
+
# Log providers to confirm CUDA is being used
|
| 281 |
+
providers = tts_models[0].sess.get_providers()
|
| 282 |
+
print(f"[TTS] ONNX Providers: {providers}", flush=True)
|
| 283 |
+
if 'CUDAExecutionProvider' not in providers:
|
| 284 |
+
print("[TTS] WARNING: CUDA not available, running on CPU (will be slow!)", flush=True)
|
| 285 |
+
|
| 286 |
+
for i, tts in enumerate(tts_models):
|
| 287 |
+
tts.create("warmup", voice='af_heart', speed=1.0)
|
| 288 |
+
print(f"[TTS] Models loaded and warmed up", flush=True)
|
| 289 |
+
|
| 290 |
+
def process_single_pair(args):
|
| 291 |
+
idx, pair, tts_model = args
|
| 292 |
+
try:
|
| 293 |
+
q_samples, _ = tts_model.create(pair['question'], voice='af_heart', speed=1.0)
|
| 294 |
+
a_samples, _ = tts_model.create(pair['answer'], voice='af_heart', speed=1.0)
|
| 295 |
+
return {
|
| 296 |
+
'question': pair['question'],
|
| 297 |
+
'answer': pair['answer'],
|
| 298 |
+
'q_audio': q_samples,
|
| 299 |
+
'a_audio': a_samples
|
| 300 |
+
}
|
| 301 |
+
except Exception as e:
|
| 302 |
+
print(f"[TTS] Error: {e}", flush=True)
|
| 303 |
+
return None
|
| 304 |
+
|
| 305 |
+
batch_idx = 0
|
| 306 |
+
|
| 307 |
+
while not stats.should_stop():
|
| 308 |
+
try:
|
| 309 |
+
batch = input_queue.get(timeout=1)
|
| 310 |
+
except queue.Empty:
|
| 311 |
+
continue
|
| 312 |
+
|
| 313 |
+
if batch is None:
|
| 314 |
+
output_queue.put(None)
|
| 315 |
+
break
|
| 316 |
+
|
| 317 |
+
batch_idx += 1
|
| 318 |
+
print(f"[TTS] Processing batch {batch_idx} with {len(batch)} items...", flush=True)
|
| 319 |
+
audio_data = []
|
| 320 |
+
|
| 321 |
+
with ThreadPoolExecutor(max_workers=NUM_TTS_WORKERS) as executor:
|
| 322 |
+
tasks = [(i, pair, tts_models[i % NUM_TTS_WORKERS]) for i, pair in enumerate(batch)]
|
| 323 |
+
futures = {executor.submit(process_single_pair, task): task for task in tasks}
|
| 324 |
+
|
| 325 |
+
for future in as_completed(futures):
|
| 326 |
+
if stats.should_stop():
|
| 327 |
+
break
|
| 328 |
+
result = future.result()
|
| 329 |
+
if result:
|
| 330 |
+
audio_data.append(result)
|
| 331 |
+
|
| 332 |
+
if stats.should_stop():
|
| 333 |
+
output_queue.put(None)
|
| 334 |
+
break
|
| 335 |
+
|
| 336 |
+
print(f"[TTS] Batch {batch_idx} done: {len(audio_data)} items", flush=True)
|
| 337 |
+
stats.update('tts')
|
| 338 |
+
output_queue.put((batch_idx, audio_data))
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def batch_extract_whisper_features(
|
| 342 |
+
audios: List[np.ndarray],
|
| 343 |
+
whisper_encoder, # WhisperX internal model (FasterWhisper)
|
| 344 |
+
device: str
|
| 345 |
+
) -> List[torch.Tensor]:
|
| 346 |
+
"""Extract encoder features using WhisperX's internal faster-whisper model."""
|
| 347 |
+
import ctranslate2
|
| 348 |
+
|
| 349 |
+
if not audios:
|
| 350 |
+
return []
|
| 351 |
+
|
| 352 |
+
all_features = []
|
| 353 |
+
|
| 354 |
+
for audio in audios:
|
| 355 |
+
# Resample to 16kHz for Whisper
|
| 356 |
+
audio_16k = torchaudio.functional.resample(
|
| 357 |
+
torch.from_numpy(audio), 24000, 16000
|
| 358 |
+
).numpy().astype(np.float32)
|
| 359 |
+
|
| 360 |
+
# Use WhisperX's internal feature extractor and encoder
|
| 361 |
+
mel_features = whisper_encoder.feature_extractor(audio_16k)
|
| 362 |
+
encoded = whisper_encoder.encode(mel_features) # Returns ctranslate2 StorageView
|
| 363 |
+
|
| 364 |
+
# Convert ctranslate2 StorageView to numpy (must move to CPU first)
|
| 365 |
+
cpu_view = encoded.to_device(ctranslate2.Device.cpu)
|
| 366 |
+
features_np = np.array(cpu_view, copy=True) # [1, seq_len, 1280]
|
| 367 |
+
features = torch.from_numpy(features_np).squeeze(0).float() # [seq_len, 1280]
|
| 368 |
+
all_features.append(features)
|
| 369 |
+
|
| 370 |
+
return all_features
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def batch_extract_snac_tokens(audios: List[np.ndarray], snac_model, device: str) -> List[List[int]]:
|
| 374 |
+
if not audios:
|
| 375 |
+
return []
|
| 376 |
+
|
| 377 |
+
all_tokens = []
|
| 378 |
+
|
| 379 |
+
with torch.no_grad():
|
| 380 |
+
for audio in audios:
|
| 381 |
+
audio_tensor = torch.from_numpy(audio).unsqueeze(0).unsqueeze(0).to(device)
|
| 382 |
+
snac_codes = snac_model.encode(audio_tensor)
|
| 383 |
+
|
| 384 |
+
min_len = snac_codes[0].shape[-1]
|
| 385 |
+
snac_tokens = []
|
| 386 |
+
|
| 387 |
+
for i in range(min_len):
|
| 388 |
+
snac_tokens.append(snac_codes[0][0, i].item() + 128266)
|
| 389 |
+
if i * 2 + 1 < snac_codes[1].shape[-1]:
|
| 390 |
+
snac_tokens.append(snac_codes[1][0, i*2].item() + 128266 + 4096)
|
| 391 |
+
snac_tokens.append(snac_codes[1][0, i*2+1].item() + 128266 + 4096)
|
| 392 |
+
for k in range(4):
|
| 393 |
+
if i*4+k < snac_codes[2].shape[-1]:
|
| 394 |
+
snac_tokens.append(snac_codes[2][0, i*4+k].item() + 128266 + 2*4096)
|
| 395 |
+
|
| 396 |
+
all_tokens.append(snac_tokens)
|
| 397 |
+
|
| 398 |
+
return all_tokens
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def feature_worker(
|
| 402 |
+
input_queue: queue.Queue,
|
| 403 |
+
output_queue: queue.Queue,
|
| 404 |
+
whisperx_model,
|
| 405 |
+
align_model,
|
| 406 |
+
align_metadata,
|
| 407 |
+
whisper_encoder, # WhisperX internal model for feature extraction
|
| 408 |
+
snac_model,
|
| 409 |
+
device: str,
|
| 410 |
+
output_dir: Path,
|
| 411 |
+
stats: PipelineStats
|
| 412 |
+
):
|
| 413 |
+
import whisperx
|
| 414 |
+
|
| 415 |
+
while not stats.should_stop():
|
| 416 |
+
try:
|
| 417 |
+
data = input_queue.get(timeout=1)
|
| 418 |
+
except queue.Empty:
|
| 419 |
+
continue
|
| 420 |
+
|
| 421 |
+
if data is None:
|
| 422 |
+
break
|
| 423 |
+
|
| 424 |
+
batch_idx, audio_data = data
|
| 425 |
+
|
| 426 |
+
items_needed = stats.target_count - stats.get_total_items()
|
| 427 |
+
if items_needed <= 0:
|
| 428 |
+
break
|
| 429 |
+
|
| 430 |
+
audio_data = audio_data[:items_needed]
|
| 431 |
+
if not audio_data:
|
| 432 |
+
continue
|
| 433 |
+
|
| 434 |
+
silence = np.zeros(int(SILENCE_BETWEEN_PAIRS * SAMPLE_RATE), dtype=np.float32)
|
| 435 |
+
all_answer_audio = []
|
| 436 |
+
segment_boundaries = []
|
| 437 |
+
current_pos = 0
|
| 438 |
+
|
| 439 |
+
for item in audio_data:
|
| 440 |
+
a_audio = item['a_audio']
|
| 441 |
+
start_pos = current_pos
|
| 442 |
+
all_answer_audio.append(a_audio)
|
| 443 |
+
all_answer_audio.append(silence)
|
| 444 |
+
current_pos += len(a_audio) + len(silence)
|
| 445 |
+
segment_boundaries.append((start_pos, start_pos + len(a_audio), item))
|
| 446 |
+
|
| 447 |
+
if not all_answer_audio:
|
| 448 |
+
continue
|
| 449 |
+
|
| 450 |
+
concatenated = np.concatenate(all_answer_audio)
|
| 451 |
+
temp_path = output_dir / f'temp_batch_{batch_idx}_{stats.gpu_id}.wav'
|
| 452 |
+
sf.write(temp_path, concatenated, SAMPLE_RATE)
|
| 453 |
+
|
| 454 |
+
try:
|
| 455 |
+
result = whisperx_model.transcribe(str(temp_path), batch_size=16)
|
| 456 |
+
result = whisperx.align(result["segments"], align_model, align_metadata,
|
| 457 |
+
str(temp_path), device)
|
| 458 |
+
words = result.get("word_segments", [])
|
| 459 |
+
except Exception as e:
|
| 460 |
+
temp_path.unlink(missing_ok=True)
|
| 461 |
+
continue
|
| 462 |
+
|
| 463 |
+
temp_path.unlink(missing_ok=True)
|
| 464 |
+
|
| 465 |
+
processed_items = []
|
| 466 |
+
|
| 467 |
+
for start_sample, end_sample, item in segment_boundaries:
|
| 468 |
+
if stats.get_total_items() + len(processed_items) >= stats.target_count:
|
| 469 |
+
break
|
| 470 |
+
|
| 471 |
+
start_time_s = start_sample / SAMPLE_RATE
|
| 472 |
+
end_time_s = end_sample / SAMPLE_RATE
|
| 473 |
+
|
| 474 |
+
segment_words = [w for w in words
|
| 475 |
+
if w.get('start', 0) >= start_time_s - 0.1
|
| 476 |
+
and w.get('end', 0) <= end_time_s + 0.1]
|
| 477 |
+
|
| 478 |
+
adjusted_words = []
|
| 479 |
+
for w in segment_words:
|
| 480 |
+
adjusted_words.append({
|
| 481 |
+
'word': w.get('word', ''),
|
| 482 |
+
'start': w.get('start', 0) - start_time_s,
|
| 483 |
+
'end': w.get('end', 0) - start_time_s,
|
| 484 |
+
'start_frame': int((w.get('start', 0) - start_time_s) * 75),
|
| 485 |
+
'end_frame': int((w.get('end', 0) - start_time_s) * 75)
|
| 486 |
+
})
|
| 487 |
+
|
| 488 |
+
processed_items.append({
|
| 489 |
+
'item': item,
|
| 490 |
+
'word_alignments': adjusted_words
|
| 491 |
+
})
|
| 492 |
+
|
| 493 |
+
if not processed_items:
|
| 494 |
+
continue
|
| 495 |
+
|
| 496 |
+
q_audios = [pi['item']['q_audio'] for pi in processed_items]
|
| 497 |
+
batch_features = batch_extract_whisper_features(
|
| 498 |
+
q_audios, whisper_encoder, device
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
a_audios = [pi['item']['a_audio'] for pi in processed_items]
|
| 502 |
+
snac_results = batch_extract_snac_tokens(a_audios, snac_model, device)
|
| 503 |
+
|
| 504 |
+
final_items = []
|
| 505 |
+
for i, pi in enumerate(processed_items):
|
| 506 |
+
final_items.append({
|
| 507 |
+
'whisper_features': batch_features[i],
|
| 508 |
+
'snac_tokens': torch.tensor(snac_results[i], dtype=torch.long),
|
| 509 |
+
'text': pi['item']['question'],
|
| 510 |
+
'answer': pi['item']['answer'],
|
| 511 |
+
'word_alignments': pi['word_alignments'],
|
| 512 |
+
'q_audio': pi['item']['q_audio'],
|
| 513 |
+
'a_audio': pi['item']['a_audio']
|
| 514 |
+
})
|
| 515 |
+
|
| 516 |
+
if final_items:
|
| 517 |
+
output_queue.put(final_items)
|
| 518 |
+
stats.update('features', len(final_items))
|
| 519 |
+
stats.print_status()
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
def saver_worker(
|
| 523 |
+
input_queue: queue.Queue,
|
| 524 |
+
output_dir: Path,
|
| 525 |
+
stats: PipelineStats,
|
| 526 |
+
start_idx: int = 0
|
| 527 |
+
):
|
| 528 |
+
all_items = []
|
| 529 |
+
questions_dir = output_dir / 'questions'
|
| 530 |
+
answers_dir = output_dir / 'answers'
|
| 531 |
+
questions_dir.mkdir(parents=True, exist_ok=True)
|
| 532 |
+
answers_dir.mkdir(parents=True, exist_ok=True)
|
| 533 |
+
|
| 534 |
+
global_idx = start_idx
|
| 535 |
+
last_save = 0
|
| 536 |
+
save_interval = 5000
|
| 537 |
+
|
| 538 |
+
io_executor = ThreadPoolExecutor(max_workers=IO_WORKERS)
|
| 539 |
+
pending_futures = []
|
| 540 |
+
|
| 541 |
+
def save_wav(path, audio, sr):
|
| 542 |
+
sf.write(path, audio, sr)
|
| 543 |
+
|
| 544 |
+
while True:
|
| 545 |
+
try:
|
| 546 |
+
data = input_queue.get(timeout=2)
|
| 547 |
+
except queue.Empty:
|
| 548 |
+
if stats.should_stop() or stats.get_total_items() >= stats.target_count:
|
| 549 |
+
break
|
| 550 |
+
continue
|
| 551 |
+
|
| 552 |
+
if data is None:
|
| 553 |
+
break
|
| 554 |
+
|
| 555 |
+
for item in data:
|
| 556 |
+
global_idx += 1
|
| 557 |
+
|
| 558 |
+
q_path = questions_dir / f'q_{global_idx:06d}.wav'
|
| 559 |
+
a_path = answers_dir / f'a_{global_idx:06d}.wav'
|
| 560 |
+
|
| 561 |
+
pending_futures.append(io_executor.submit(save_wav, q_path, item['q_audio'], SAMPLE_RATE))
|
| 562 |
+
pending_futures.append(io_executor.submit(save_wav, a_path, item['a_audio'], SAMPLE_RATE))
|
| 563 |
+
|
| 564 |
+
all_items.append({
|
| 565 |
+
'whisper_features': item['whisper_features'],
|
| 566 |
+
'snac_tokens': item['snac_tokens'],
|
| 567 |
+
'text': item['text'],
|
| 568 |
+
'answer': item['answer'],
|
| 569 |
+
'word_alignments': item['word_alignments']
|
| 570 |
+
})
|
| 571 |
+
|
| 572 |
+
if len(pending_futures) > 100:
|
| 573 |
+
pending_futures = [f for f in pending_futures if not f.done()]
|
| 574 |
+
|
| 575 |
+
if len(all_items) - last_save >= save_interval:
|
| 576 |
+
for f in pending_futures:
|
| 577 |
+
f.result()
|
| 578 |
+
pending_futures = []
|
| 579 |
+
|
| 580 |
+
checkpoint_path = output_dir / f'checkpoint_{len(all_items)}.pt'
|
| 581 |
+
torch.save(all_items, checkpoint_path)
|
| 582 |
+
last_save = len(all_items)
|
| 583 |
+
print(f"\n[GPU {stats.gpu_id}] Checkpoint: {len(all_items)} items")
|
| 584 |
+
|
| 585 |
+
for f in pending_futures:
|
| 586 |
+
try:
|
| 587 |
+
f.result()
|
| 588 |
+
except:
|
| 589 |
+
pass
|
| 590 |
+
|
| 591 |
+
io_executor.shutdown(wait=True)
|
| 592 |
+
|
| 593 |
+
# Save partial dataset for this GPU
|
| 594 |
+
partial_path = output_dir / f'dataset_gpu{stats.gpu_id}.pt'
|
| 595 |
+
torch.save(all_items, partial_path)
|
| 596 |
+
print(f"\n[GPU {stats.gpu_id}] Saved {len(all_items)} items to {partial_path}")
|
| 597 |
+
|
| 598 |
+
return len(all_items)
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
def run_gpu_pipeline(gpu_id: int, count: int, output_dir: Path, start_idx: int = 0) -> int:
|
| 602 |
+
"""Run complete pipeline on a single GPU. Returns number of items generated."""
|
| 603 |
+
|
| 604 |
+
# Set GPU - use "cuda" as device since CUDA_VISIBLE_DEVICES makes only one GPU visible
|
| 605 |
+
os.environ['CUDA_VISIBLE_DEVICES'] = str(gpu_id)
|
| 606 |
+
device = "cuda" # Not "cuda:0" - WhisperX/ctranslate2 expects just "cuda"
|
| 607 |
+
|
| 608 |
+
torch.cuda.set_device(0)
|
| 609 |
+
|
| 610 |
+
print(f"\n[GPU {gpu_id}] Starting pipeline for {count} items...")
|
| 611 |
+
print(f"[GPU {gpu_id}] Device: {torch.cuda.get_device_name(0)}")
|
| 612 |
+
|
| 613 |
+
# Load WhisperX - single model for BOTH alignment AND feature extraction
|
| 614 |
+
import whisperx
|
| 615 |
+
whisperx_model = whisperx.load_model(WHISPERX_MODEL, device, compute_type=WHISPERX_COMPUTE_TYPE,
|
| 616 |
+
language="en", vad_method="silero")
|
| 617 |
+
align_model, align_metadata = whisperx.load_align_model(language_code="en", device=device)
|
| 618 |
+
|
| 619 |
+
# Get internal model for feature extraction (no need for separate HuggingFace model!)
|
| 620 |
+
whisper_encoder = whisperx_model.model # FasterWhisper model with encode() method
|
| 621 |
+
|
| 622 |
+
import snac
|
| 623 |
+
snac_model = snac.SNAC.from_pretrained(SNAC_MODEL).to(device)
|
| 624 |
+
snac_model.eval()
|
| 625 |
+
|
| 626 |
+
# Compile SNAC for faster inference (requires PyTorch 2.0+)
|
| 627 |
+
try:
|
| 628 |
+
snac_model = torch.compile(snac_model, mode="reduce-overhead")
|
| 629 |
+
print(f"[GPU {gpu_id}] SNAC compiled with torch.compile")
|
| 630 |
+
except Exception as e:
|
| 631 |
+
print(f"[GPU {gpu_id}] torch.compile not available: {e}")
|
| 632 |
+
|
| 633 |
+
print(f"[GPU {gpu_id}] Models loaded. VRAM: {torch.cuda.memory_allocated() / 1e9:.1f}GB")
|
| 634 |
+
|
| 635 |
+
# Queues
|
| 636 |
+
api_to_tts = queue.Queue(maxsize=3)
|
| 637 |
+
tts_to_features = queue.Queue(maxsize=3)
|
| 638 |
+
features_to_saver = queue.Queue(maxsize=5)
|
| 639 |
+
|
| 640 |
+
stats = PipelineStats(target_count=count, gpu_id=gpu_id)
|
| 641 |
+
|
| 642 |
+
# Create output directory for this GPU
|
| 643 |
+
gpu_output_dir = output_dir / f'gpu{gpu_id}'
|
| 644 |
+
gpu_output_dir.mkdir(parents=True, exist_ok=True)
|
| 645 |
+
|
| 646 |
+
# Start workers
|
| 647 |
+
api_thread = threading.Thread(target=api_worker, args=(api_to_tts, count, stats))
|
| 648 |
+
tts_thread = threading.Thread(target=tts_worker, args=(api_to_tts, tts_to_features, stats))
|
| 649 |
+
feature_thread = threading.Thread(
|
| 650 |
+
target=feature_worker,
|
| 651 |
+
args=(tts_to_features, features_to_saver, whisperx_model,
|
| 652 |
+
align_model, align_metadata, whisper_encoder,
|
| 653 |
+
snac_model, device, gpu_output_dir, stats)
|
| 654 |
+
)
|
| 655 |
+
saver_thread = threading.Thread(
|
| 656 |
+
target=saver_worker,
|
| 657 |
+
args=(features_to_saver, gpu_output_dir, stats, start_idx)
|
| 658 |
+
)
|
| 659 |
+
|
| 660 |
+
start_time = time.time()
|
| 661 |
+
|
| 662 |
+
api_thread.start()
|
| 663 |
+
tts_thread.start()
|
| 664 |
+
feature_thread.start()
|
| 665 |
+
saver_thread.start()
|
| 666 |
+
|
| 667 |
+
api_thread.join()
|
| 668 |
+
tts_thread.join()
|
| 669 |
+
tts_to_features.put(None)
|
| 670 |
+
feature_thread.join()
|
| 671 |
+
features_to_saver.put(None)
|
| 672 |
+
saver_thread.join()
|
| 673 |
+
|
| 674 |
+
total_time = time.time() - start_time
|
| 675 |
+
print(f"\n[GPU {gpu_id}] Complete: {stats.total_items} items in {total_time:.1f}s ({stats.total_items/total_time:.2f}/s)")
|
| 676 |
+
|
| 677 |
+
return stats.total_items
|
| 678 |
+
|
| 679 |
+
|
| 680 |
+
def gpu_worker_process(gpu_id: int, count: int, output_dir: str, start_idx: int, result_queue: mp.Queue):
|
| 681 |
+
"""Wrapper for multiprocessing."""
|
| 682 |
+
try:
|
| 683 |
+
output_path = Path(output_dir)
|
| 684 |
+
items_generated = run_gpu_pipeline(gpu_id, count, output_path, start_idx)
|
| 685 |
+
result_queue.put((gpu_id, items_generated, None))
|
| 686 |
+
except Exception as e:
|
| 687 |
+
result_queue.put((gpu_id, 0, str(e)))
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
def merge_datasets(output_dir: Path, num_gpus: int) -> int:
|
| 691 |
+
"""Merge partial datasets from all GPUs into one."""
|
| 692 |
+
print("\n" + "="*60)
|
| 693 |
+
print("MERGING DATASETS")
|
| 694 |
+
print("="*60)
|
| 695 |
+
|
| 696 |
+
all_items = []
|
| 697 |
+
|
| 698 |
+
for gpu_id in range(num_gpus):
|
| 699 |
+
partial_path = output_dir / f'gpu{gpu_id}' / f'dataset_gpu{gpu_id}.pt'
|
| 700 |
+
if partial_path.exists():
|
| 701 |
+
items = torch.load(partial_path, weights_only=False)
|
| 702 |
+
print(f" GPU {gpu_id}: {len(items)} items")
|
| 703 |
+
all_items.extend(items)
|
| 704 |
+
|
| 705 |
+
# Save merged dataset
|
| 706 |
+
merged_path = output_dir / 'dataset.pt'
|
| 707 |
+
torch.save(all_items, merged_path)
|
| 708 |
+
print(f"\nMerged: {len(all_items)} total items -> {merged_path}")
|
| 709 |
+
|
| 710 |
+
# Clean up partial files
|
| 711 |
+
for gpu_id in range(num_gpus):
|
| 712 |
+
partial_path = output_dir / f'gpu{gpu_id}' / f'dataset_gpu{gpu_id}.pt'
|
| 713 |
+
if partial_path.exists():
|
| 714 |
+
partial_path.unlink()
|
| 715 |
+
# Remove checkpoints
|
| 716 |
+
gpu_dir = output_dir / f'gpu{gpu_id}'
|
| 717 |
+
for ckpt in gpu_dir.glob('checkpoint_*.pt'):
|
| 718 |
+
ckpt.unlink()
|
| 719 |
+
|
| 720 |
+
return len(all_items)
|
| 721 |
+
|
| 722 |
+
|
| 723 |
+
def main():
|
| 724 |
+
parser = argparse.ArgumentParser(description="Multi-GPU Dataset Generator")
|
| 725 |
+
parser.add_argument('--count', type=int, default=1000, help='Total items to generate')
|
| 726 |
+
parser.add_argument('--batch-size', type=int, default=50, help='TTS batch size')
|
| 727 |
+
parser.add_argument('--output-dir', type=str, default='./qa_dataset', help='Output directory')
|
| 728 |
+
parser.add_argument('--tts-workers', type=int, default=4, help='TTS workers per GPU')
|
| 729 |
+
parser.add_argument('--io-workers', type=int, default=4, help='I/O workers per GPU')
|
| 730 |
+
parser.add_argument('--gpus', type=str, default=None, help='Comma-separated GPU IDs (default: all)')
|
| 731 |
+
parser.add_argument('--use-llama', action='store_true', help='Use local Llama 3.2 instead of Groq API')
|
| 732 |
+
parser.add_argument('--llama-gpu', type=int, default=1, help='GPU ID for Llama model (default: 1)')
|
| 733 |
+
args = parser.parse_args()
|
| 734 |
+
|
| 735 |
+
global BATCH_SIZE, NUM_TTS_WORKERS, IO_WORKERS, USE_LOCAL_LLAMA, LLAMA_GPU_ID
|
| 736 |
+
BATCH_SIZE = args.batch_size
|
| 737 |
+
NUM_TTS_WORKERS = args.tts_workers
|
| 738 |
+
IO_WORKERS = args.io_workers
|
| 739 |
+
USE_LOCAL_LLAMA = args.use_llama
|
| 740 |
+
LLAMA_GPU_ID = args.llama_gpu
|
| 741 |
+
|
| 742 |
+
output_dir = Path(args.output_dir)
|
| 743 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 744 |
+
|
| 745 |
+
# Detect GPUs
|
| 746 |
+
if args.gpus:
|
| 747 |
+
gpu_ids = [int(g.strip()) for g in args.gpus.split(',')]
|
| 748 |
+
else:
|
| 749 |
+
num_gpus = torch.cuda.device_count()
|
| 750 |
+
gpu_ids = list(range(num_gpus))
|
| 751 |
+
|
| 752 |
+
num_gpus = len(gpu_ids)
|
| 753 |
+
|
| 754 |
+
if num_gpus == 0:
|
| 755 |
+
print("ERROR: No GPUs detected!")
|
| 756 |
+
print("This script requires CUDA GPUs to run.")
|
| 757 |
+
print("Please run on a machine with NVIDIA GPUs.")
|
| 758 |
+
sys.exit(1)
|
| 759 |
+
|
| 760 |
+
print("="*60)
|
| 761 |
+
print("MULTI-GPU DATASET GENERATOR")
|
| 762 |
+
print("="*60)
|
| 763 |
+
print(f"Total items: {args.count}")
|
| 764 |
+
print(f"GPUs detected: {num_gpus}")
|
| 765 |
+
for gpu_id in gpu_ids:
|
| 766 |
+
torch.cuda.set_device(gpu_id)
|
| 767 |
+
print(f" GPU {gpu_id}: {torch.cuda.get_device_name(gpu_id)}")
|
| 768 |
+
print(f"Items per GPU: ~{args.count // num_gpus}")
|
| 769 |
+
print(f"Q&A source: {'Local Llama 3.2 (GPU ' + str(LLAMA_GPU_ID) + ')' if USE_LOCAL_LLAMA else 'Groq API'}")
|
| 770 |
+
print(f"Output: {output_dir}")
|
| 771 |
+
print("="*60)
|
| 772 |
+
|
| 773 |
+
if num_gpus == 1:
|
| 774 |
+
# Single GPU - run directly
|
| 775 |
+
print("\nSingle GPU mode")
|
| 776 |
+
start_time = time.time()
|
| 777 |
+
total_items = run_gpu_pipeline(gpu_ids[0], args.count, output_dir)
|
| 778 |
+
|
| 779 |
+
# Rename partial to final
|
| 780 |
+
partial = output_dir / f'gpu{gpu_ids[0]}' / f'dataset_gpu{gpu_ids[0]}.pt'
|
| 781 |
+
if partial.exists():
|
| 782 |
+
items = torch.load(partial, weights_only=False)
|
| 783 |
+
torch.save(items, output_dir / 'dataset.pt')
|
| 784 |
+
partial.unlink()
|
| 785 |
+
|
| 786 |
+
total_time = time.time() - start_time
|
| 787 |
+
print(f"\n{'='*60}")
|
| 788 |
+
print(f"COMPLETE: {total_items} items in {total_time:.1f}s")
|
| 789 |
+
print(f"Rate: {total_items/total_time:.2f} items/s")
|
| 790 |
+
print(f"Output: {output_dir / 'dataset.pt'}")
|
| 791 |
+
print("="*60)
|
| 792 |
+
else:
|
| 793 |
+
# Multi-GPU - spawn processes
|
| 794 |
+
print(f"\nMulti-GPU mode: {num_gpus} GPUs")
|
| 795 |
+
|
| 796 |
+
# Split work across GPUs
|
| 797 |
+
items_per_gpu = args.count // num_gpus
|
| 798 |
+
remainder = args.count % num_gpus
|
| 799 |
+
|
| 800 |
+
result_queue = mp.Queue()
|
| 801 |
+
processes = []
|
| 802 |
+
|
| 803 |
+
start_time = time.time()
|
| 804 |
+
start_idx = 0
|
| 805 |
+
|
| 806 |
+
for i, gpu_id in enumerate(gpu_ids):
|
| 807 |
+
# Last GPU gets remainder
|
| 808 |
+
gpu_count = items_per_gpu + (remainder if i == len(gpu_ids) - 1 else 0)
|
| 809 |
+
|
| 810 |
+
p = mp.Process(
|
| 811 |
+
target=gpu_worker_process,
|
| 812 |
+
args=(gpu_id, gpu_count, str(output_dir), start_idx, result_queue)
|
| 813 |
+
)
|
| 814 |
+
p.start()
|
| 815 |
+
processes.append(p)
|
| 816 |
+
start_idx += gpu_count
|
| 817 |
+
|
| 818 |
+
# Wait for all processes
|
| 819 |
+
results = []
|
| 820 |
+
for _ in processes:
|
| 821 |
+
result = result_queue.get()
|
| 822 |
+
results.append(result)
|
| 823 |
+
|
| 824 |
+
for p in processes:
|
| 825 |
+
p.join()
|
| 826 |
+
|
| 827 |
+
# Check results
|
| 828 |
+
total_items = 0
|
| 829 |
+
for gpu_id, items, error in results:
|
| 830 |
+
if error:
|
| 831 |
+
print(f"[GPU {gpu_id}] ERROR: {error}")
|
| 832 |
+
else:
|
| 833 |
+
total_items += items
|
| 834 |
+
|
| 835 |
+
# Merge datasets
|
| 836 |
+
total_items = merge_datasets(output_dir, num_gpus)
|
| 837 |
+
|
| 838 |
+
total_time = time.time() - start_time
|
| 839 |
+
print(f"\n{'='*60}")
|
| 840 |
+
print(f"COMPLETE: {total_items} items in {total_time:.1f}s")
|
| 841 |
+
print(f"Rate: {total_items/total_time:.2f} items/s")
|
| 842 |
+
print(f"Output: {output_dir / 'dataset.pt'}")
|
| 843 |
+
print("="*60)
|
| 844 |
+
|
| 845 |
+
|
| 846 |
+
if __name__ == '__main__':
|
| 847 |
+
mp.set_start_method('spawn', force=True)
|
| 848 |
+
main()
|
passo0_setup.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Passo 0: Setup do Ambiente
|
| 4 |
+
Instala todas as dependências e configura o ambiente para:
|
| 5 |
+
- Geração de dataset (Kokoro TTS GPU, WhisperX, SNAC)
|
| 6 |
+
- Treinamento do modelo Speech-to-Speech
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python passo0_setup.py [--dataset_only] [--training_only]
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
import subprocess
|
| 15 |
+
import argparse
|
| 16 |
+
import shutil
|
| 17 |
+
|
| 18 |
+
def log(msg):
|
| 19 |
+
print(f"[SETUP] {msg}")
|
| 20 |
+
sys.stdout.flush()
|
| 21 |
+
|
| 22 |
+
def run_command(cmd, check=True, capture=True):
|
| 23 |
+
"""Execute command and return output."""
|
| 24 |
+
log(f"Running: {cmd}")
|
| 25 |
+
if capture:
|
| 26 |
+
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
| 27 |
+
if check and result.returncode != 0:
|
| 28 |
+
log(f"ERROR: {result.stderr}")
|
| 29 |
+
return False
|
| 30 |
+
return True
|
| 31 |
+
else:
|
| 32 |
+
return subprocess.run(cmd, shell=True).returncode == 0
|
| 33 |
+
|
| 34 |
+
def check_python_version():
|
| 35 |
+
"""Check Python version."""
|
| 36 |
+
version = sys.version_info
|
| 37 |
+
log(f"Python version: {version.major}.{version.minor}.{version.micro}")
|
| 38 |
+
|
| 39 |
+
if version.major < 3 or (version.major == 3 and version.minor < 10):
|
| 40 |
+
log("ERROR: Python 3.10+ required")
|
| 41 |
+
sys.exit(1)
|
| 42 |
+
|
| 43 |
+
if version.minor > 12:
|
| 44 |
+
log("WARNING: Python 3.13+ may have compatibility issues")
|
| 45 |
+
|
| 46 |
+
log("Python version OK")
|
| 47 |
+
return True
|
| 48 |
+
|
| 49 |
+
def check_nvidia_smi():
|
| 50 |
+
"""Check if nvidia-smi is available."""
|
| 51 |
+
if shutil.which('nvidia-smi') is None:
|
| 52 |
+
log("ERROR: nvidia-smi not found. NVIDIA GPU required.")
|
| 53 |
+
return False
|
| 54 |
+
|
| 55 |
+
result = subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total', '--format=csv,noheader'],
|
| 56 |
+
capture_output=True, text=True)
|
| 57 |
+
if result.returncode == 0:
|
| 58 |
+
log(f"GPU: {result.stdout.strip()}")
|
| 59 |
+
return True
|
| 60 |
+
return False
|
| 61 |
+
|
| 62 |
+
def check_cuda():
|
| 63 |
+
"""Check CUDA availability via PyTorch."""
|
| 64 |
+
try:
|
| 65 |
+
import torch
|
| 66 |
+
cuda_available = torch.cuda.is_available()
|
| 67 |
+
if cuda_available:
|
| 68 |
+
gpu_count = torch.cuda.device_count()
|
| 69 |
+
gpu_name = torch.cuda.get_device_name(0)
|
| 70 |
+
vram = torch.cuda.get_device_properties(0).total_memory / 1024**3
|
| 71 |
+
log(f"CUDA available: {gpu_count}x {gpu_name} ({vram:.1f}GB)")
|
| 72 |
+
else:
|
| 73 |
+
log("WARNING: CUDA not available - will be very slow!")
|
| 74 |
+
return cuda_available
|
| 75 |
+
except Exception as e:
|
| 76 |
+
log(f"Could not check CUDA: {e}")
|
| 77 |
+
return False
|
| 78 |
+
|
| 79 |
+
def upgrade_pip():
|
| 80 |
+
"""Upgrade pip to latest version."""
|
| 81 |
+
log("\nUpgrading pip...")
|
| 82 |
+
run_command("pip install --upgrade pip -q")
|
| 83 |
+
log("pip upgraded")
|
| 84 |
+
|
| 85 |
+
def install_pytorch():
|
| 86 |
+
"""Install PyTorch with CUDA 12.8 (latest, supports H100/H200)."""
|
| 87 |
+
log("\nInstalling PyTorch with CUDA 12.8...")
|
| 88 |
+
|
| 89 |
+
# Always reinstall to ensure correct CUDA version
|
| 90 |
+
# PyTorch 2.8+ with CUDA 12.8 for best compatibility with modern GPUs
|
| 91 |
+
cmd = "pip install --force-reinstall torch==2.8.0 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128"
|
| 92 |
+
if run_command(cmd, capture=False):
|
| 93 |
+
log("PyTorch 2.8.0 installed with CUDA 12.8")
|
| 94 |
+
# Verify
|
| 95 |
+
try:
|
| 96 |
+
import importlib
|
| 97 |
+
import torch
|
| 98 |
+
importlib.reload(torch)
|
| 99 |
+
if torch.cuda.is_available():
|
| 100 |
+
log(f"Verified: PyTorch {torch.__version__} with CUDA {torch.version.cuda}")
|
| 101 |
+
return True
|
| 102 |
+
except:
|
| 103 |
+
pass
|
| 104 |
+
return True
|
| 105 |
+
else:
|
| 106 |
+
log("Failed to install PyTorch")
|
| 107 |
+
return False
|
| 108 |
+
|
| 109 |
+
def install_onnxruntime_gpu():
|
| 110 |
+
"""Install onnxruntime-gpu for Kokoro TTS with CUDA support."""
|
| 111 |
+
log("\nInstalling onnxruntime-gpu with CUDA support...")
|
| 112 |
+
|
| 113 |
+
# Remove any existing onnxruntime
|
| 114 |
+
run_command("pip uninstall onnxruntime onnxruntime-gpu -y -q", check=False)
|
| 115 |
+
|
| 116 |
+
# Install onnxruntime-gpu 1.20.1 - known to work with CUDA 12.x
|
| 117 |
+
# Newer versions (1.23+) may not include CUDA provider
|
| 118 |
+
cmd = "pip install onnxruntime-gpu==1.20.1"
|
| 119 |
+
if run_command(cmd, capture=False):
|
| 120 |
+
# Verify - need to restart Python to pick up new module
|
| 121 |
+
import subprocess
|
| 122 |
+
result = subprocess.run(
|
| 123 |
+
['python3', '-c', 'import onnxruntime as ort; print(ort.get_available_providers())'],
|
| 124 |
+
capture_output=True, text=True
|
| 125 |
+
)
|
| 126 |
+
providers = result.stdout.strip()
|
| 127 |
+
log(f"onnxruntime providers: {providers}")
|
| 128 |
+
if 'CUDAExecutionProvider' in providers:
|
| 129 |
+
log("onnxruntime-gpu OK with CUDA")
|
| 130 |
+
return True
|
| 131 |
+
else:
|
| 132 |
+
log("WARNING: CUDAExecutionProvider not available")
|
| 133 |
+
return False
|
| 134 |
+
return False
|
| 135 |
+
|
| 136 |
+
def install_dataset_packages():
|
| 137 |
+
"""Install packages for dataset generation."""
|
| 138 |
+
log("\nInstalling dataset generation packages...")
|
| 139 |
+
|
| 140 |
+
packages = [
|
| 141 |
+
"numpy", # Required by many packages
|
| 142 |
+
"kokoro-onnx", # TTS (GPU via ONNX CUDA)
|
| 143 |
+
"whisperx", # Speech alignment (GPU)
|
| 144 |
+
"ctranslate2", # Required by WhisperX for encoder features
|
| 145 |
+
"snac", # Audio codec (GPU)
|
| 146 |
+
"transformers>=4.40.0", # Whisper encoder
|
| 147 |
+
"soundfile", # Audio I/O
|
| 148 |
+
"groq", # Q&A generation API
|
| 149 |
+
"requests", # HTTP requests
|
| 150 |
+
"tqdm", # Progress bars
|
| 151 |
+
]
|
| 152 |
+
|
| 153 |
+
for pkg in packages:
|
| 154 |
+
log(f" Installing {pkg}...")
|
| 155 |
+
run_command(f"pip install {pkg} -q", check=False)
|
| 156 |
+
|
| 157 |
+
log("Dataset packages installed")
|
| 158 |
+
|
| 159 |
+
def install_training_packages():
|
| 160 |
+
"""Install packages for model training."""
|
| 161 |
+
log("\nInstalling training packages...")
|
| 162 |
+
|
| 163 |
+
packages = [
|
| 164 |
+
"transformers>=4.40.0",
|
| 165 |
+
"peft>=0.6.0",
|
| 166 |
+
"accelerate>=0.24.0",
|
| 167 |
+
"pandas>=1.5.0",
|
| 168 |
+
"huggingface-hub>=0.17.0",
|
| 169 |
+
"psutil>=5.9.0",
|
| 170 |
+
"tensorboard",
|
| 171 |
+
]
|
| 172 |
+
|
| 173 |
+
for pkg in packages:
|
| 174 |
+
log(f" Installing {pkg}...")
|
| 175 |
+
run_command(f"pip install {pkg} -q", check=False)
|
| 176 |
+
|
| 177 |
+
log("Training packages installed")
|
| 178 |
+
|
| 179 |
+
def download_kokoro_models():
|
| 180 |
+
"""Download Kokoro TTS model files."""
|
| 181 |
+
log("\nDownloading Kokoro TTS models...")
|
| 182 |
+
|
| 183 |
+
kokoro_dir = "/tmp"
|
| 184 |
+
model_url = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx"
|
| 185 |
+
voices_url = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin"
|
| 186 |
+
|
| 187 |
+
model_path = os.path.join(kokoro_dir, "kokoro-v1.0.onnx")
|
| 188 |
+
voices_path = os.path.join(kokoro_dir, "voices-v1.0.bin")
|
| 189 |
+
|
| 190 |
+
if not os.path.exists(model_path):
|
| 191 |
+
log(" Downloading kokoro-v1.0.onnx (310MB)...")
|
| 192 |
+
run_command(f"wget -q --show-progress -O {model_path} {model_url}", capture=False)
|
| 193 |
+
else:
|
| 194 |
+
log(" kokoro-v1.0.onnx already exists")
|
| 195 |
+
|
| 196 |
+
if not os.path.exists(voices_path):
|
| 197 |
+
log(" Downloading voices-v1.0.bin (27MB)...")
|
| 198 |
+
run_command(f"wget -q --show-progress -O {voices_path} {voices_url}", capture=False)
|
| 199 |
+
else:
|
| 200 |
+
log(" voices-v1.0.bin already exists")
|
| 201 |
+
|
| 202 |
+
log("Kokoro models ready")
|
| 203 |
+
|
| 204 |
+
def setup_ld_library_path():
|
| 205 |
+
"""Create script to set LD_LIBRARY_PATH for ONNX CUDA."""
|
| 206 |
+
log("\nCreating environment setup script...")
|
| 207 |
+
|
| 208 |
+
# Critical libraries for onnxruntime-gpu CUDA provider
|
| 209 |
+
cuda_libs = [
|
| 210 |
+
'cublas', 'cudnn', 'cufft', 'curand', 'cusolver', 'cusparse',
|
| 211 |
+
'cuda_runtime', 'cuda_nvrtc', 'cuda_cupti', 'nvjitlink', 'nccl',
|
| 212 |
+
'nvtx', 'cusparselt', 'cufile'
|
| 213 |
+
]
|
| 214 |
+
|
| 215 |
+
script_content = f'''#!/bin/bash
|
| 216 |
+
# Source this before running dataset generation
|
| 217 |
+
# Usage: source /tmp/setup_env.sh
|
| 218 |
+
|
| 219 |
+
# Set LD_LIBRARY_PATH for ONNX CUDA - CRITICAL for GPU inference
|
| 220 |
+
NVIDIA_LIBS=""
|
| 221 |
+
|
| 222 |
+
# Check all Python versions
|
| 223 |
+
for pyver in python3.12 python3.11 python3.10; do
|
| 224 |
+
NVIDIA_BASE="/usr/local/lib/$pyver/dist-packages/nvidia"
|
| 225 |
+
for subdir in {' '.join(cuda_libs)}; do
|
| 226 |
+
lib_path="$NVIDIA_BASE/$subdir/lib"
|
| 227 |
+
if [ -d "$lib_path" ]; then
|
| 228 |
+
NVIDIA_LIBS="$NVIDIA_LIBS:$lib_path"
|
| 229 |
+
fi
|
| 230 |
+
done
|
| 231 |
+
done
|
| 232 |
+
|
| 233 |
+
export LD_LIBRARY_PATH="${{NVIDIA_LIBS:1}}:$LD_LIBRARY_PATH"
|
| 234 |
+
|
| 235 |
+
# CRITICAL: Force Kokoro to use CUDA provider
|
| 236 |
+
export ONNX_PROVIDER="CUDAExecutionProvider"
|
| 237 |
+
|
| 238 |
+
echo "Environment configured for GPU inference"
|
| 239 |
+
echo "LD_LIBRARY_PATH set with NVIDIA CUDA libraries"
|
| 240 |
+
echo "ONNX_PROVIDER=$ONNX_PROVIDER"
|
| 241 |
+
'''
|
| 242 |
+
|
| 243 |
+
with open("/tmp/setup_env.sh", "w") as f:
|
| 244 |
+
f.write(script_content)
|
| 245 |
+
os.chmod("/tmp/setup_env.sh", 0o755)
|
| 246 |
+
|
| 247 |
+
log("Created /tmp/setup_env.sh")
|
| 248 |
+
|
| 249 |
+
def create_directories():
|
| 250 |
+
"""Create necessary directories."""
|
| 251 |
+
log("\nCreating directories...")
|
| 252 |
+
|
| 253 |
+
dirs = [
|
| 254 |
+
"./data",
|
| 255 |
+
"./data/raw",
|
| 256 |
+
"./data/processed",
|
| 257 |
+
"./checkpoints",
|
| 258 |
+
"./logs",
|
| 259 |
+
"./datasets",
|
| 260 |
+
]
|
| 261 |
+
|
| 262 |
+
for d in dirs:
|
| 263 |
+
os.makedirs(d, exist_ok=True)
|
| 264 |
+
|
| 265 |
+
log("Directories created")
|
| 266 |
+
|
| 267 |
+
def login_huggingface():
|
| 268 |
+
"""Login to HuggingFace."""
|
| 269 |
+
log("\nHuggingFace login...")
|
| 270 |
+
|
| 271 |
+
token = os.environ.get("HF_TOKEN")
|
| 272 |
+
if not token:
|
| 273 |
+
log("WARNING: HF_TOKEN not set. Set it with: export HF_TOKEN=your_token")
|
| 274 |
+
return False
|
| 275 |
+
|
| 276 |
+
try:
|
| 277 |
+
from huggingface_hub import login
|
| 278 |
+
login(token=token)
|
| 279 |
+
log("HuggingFace login successful")
|
| 280 |
+
return True
|
| 281 |
+
except Exception as e:
|
| 282 |
+
log(f"HuggingFace login failed: {e}")
|
| 283 |
+
return False
|
| 284 |
+
|
| 285 |
+
def test_kokoro_gpu():
|
| 286 |
+
"""Test Kokoro TTS on GPU."""
|
| 287 |
+
log("\nTesting Kokoro TTS on GPU...")
|
| 288 |
+
log("NOTE: LD_LIBRARY_PATH must be set BEFORE Python starts for CUDA to work.")
|
| 289 |
+
log("Run this test via: source /tmp/setup_env.sh && python3 -c 'from passo0_setup import test_kokoro_gpu; test_kokoro_gpu()'")
|
| 290 |
+
|
| 291 |
+
# Check if ONNX_PROVIDER is set
|
| 292 |
+
if os.environ.get('ONNX_PROVIDER') != 'CUDAExecutionProvider':
|
| 293 |
+
log("Setting ONNX_PROVIDER=CUDAExecutionProvider")
|
| 294 |
+
os.environ['ONNX_PROVIDER'] = 'CUDAExecutionProvider'
|
| 295 |
+
|
| 296 |
+
try:
|
| 297 |
+
import time
|
| 298 |
+
from kokoro_onnx import Kokoro
|
| 299 |
+
|
| 300 |
+
k = Kokoro('/tmp/kokoro-v1.0.onnx', '/tmp/voices-v1.0.bin')
|
| 301 |
+
providers = k.sess.get_providers()
|
| 302 |
+
log(f" Providers: {providers}")
|
| 303 |
+
|
| 304 |
+
if 'CUDAExecutionProvider' in providers:
|
| 305 |
+
# Warmup
|
| 306 |
+
k.create("warmup", voice='af_heart', speed=1.0)
|
| 307 |
+
|
| 308 |
+
# Benchmark
|
| 309 |
+
start = time.time()
|
| 310 |
+
for _ in range(5):
|
| 311 |
+
samples, sr = k.create("Hello, testing GPU acceleration.", voice='af_heart', speed=1.0)
|
| 312 |
+
elapsed = time.time() - start
|
| 313 |
+
calls_per_sec = 5 / elapsed
|
| 314 |
+
|
| 315 |
+
log(f" Speed: {calls_per_sec:.1f} calls/s")
|
| 316 |
+
if calls_per_sec > 5:
|
| 317 |
+
log("Kokoro GPU test PASSED")
|
| 318 |
+
return True
|
| 319 |
+
else:
|
| 320 |
+
log("WARNING: GPU detected but speed is slow")
|
| 321 |
+
return True
|
| 322 |
+
else:
|
| 323 |
+
log("WARNING: Kokoro running on CPU, not GPU")
|
| 324 |
+
log("Make sure to run: source /tmp/setup_env.sh before running Python")
|
| 325 |
+
return False
|
| 326 |
+
except Exception as e:
|
| 327 |
+
log(f"Kokoro test failed: {e}")
|
| 328 |
+
return False
|
| 329 |
+
|
| 330 |
+
def verify_installation():
|
| 331 |
+
"""Verify all packages are installed."""
|
| 332 |
+
log("\nVerifying installation...")
|
| 333 |
+
|
| 334 |
+
packages = {
|
| 335 |
+
"torch": "PyTorch",
|
| 336 |
+
"torchaudio": "TorchAudio",
|
| 337 |
+
"onnxruntime": "ONNX Runtime",
|
| 338 |
+
"kokoro_onnx": "Kokoro TTS",
|
| 339 |
+
"whisperx": "WhisperX",
|
| 340 |
+
"ctranslate2": "CTranslate2",
|
| 341 |
+
"snac": "SNAC",
|
| 342 |
+
"transformers": "Transformers",
|
| 343 |
+
"peft": "PEFT",
|
| 344 |
+
"soundfile": "SoundFile",
|
| 345 |
+
"groq": "Groq API",
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
all_ok = True
|
| 349 |
+
for pkg, name in packages.items():
|
| 350 |
+
try:
|
| 351 |
+
__import__(pkg)
|
| 352 |
+
log(f" {name}: OK")
|
| 353 |
+
except ImportError:
|
| 354 |
+
log(f" {name}: MISSING")
|
| 355 |
+
all_ok = False
|
| 356 |
+
|
| 357 |
+
return all_ok
|
| 358 |
+
|
| 359 |
+
def main():
|
| 360 |
+
parser = argparse.ArgumentParser(description="Setup environment for Speech-to-Speech")
|
| 361 |
+
parser.add_argument("--dataset_only", action="store_true", help="Only install dataset generation packages")
|
| 362 |
+
parser.add_argument("--training_only", action="store_true", help="Only install training packages")
|
| 363 |
+
parser.add_argument("--skip_kokoro_download", action="store_true", help="Skip Kokoro model download")
|
| 364 |
+
parser.add_argument("--skip_test", action="store_true", help="Skip Kokoro GPU test")
|
| 365 |
+
|
| 366 |
+
args = parser.parse_args()
|
| 367 |
+
|
| 368 |
+
log("="*60)
|
| 369 |
+
log("PASSO 0: SETUP DO AMBIENTE")
|
| 370 |
+
log("="*60)
|
| 371 |
+
|
| 372 |
+
# Check prerequisites
|
| 373 |
+
check_python_version()
|
| 374 |
+
|
| 375 |
+
if not check_nvidia_smi():
|
| 376 |
+
log("ERROR: NVIDIA GPU required")
|
| 377 |
+
sys.exit(1)
|
| 378 |
+
|
| 379 |
+
# Upgrade pip
|
| 380 |
+
upgrade_pip()
|
| 381 |
+
|
| 382 |
+
# Install PyTorch
|
| 383 |
+
install_pytorch()
|
| 384 |
+
check_cuda()
|
| 385 |
+
|
| 386 |
+
# Install packages based on mode
|
| 387 |
+
if args.training_only:
|
| 388 |
+
install_training_packages()
|
| 389 |
+
elif args.dataset_only:
|
| 390 |
+
install_onnxruntime_gpu()
|
| 391 |
+
install_dataset_packages()
|
| 392 |
+
else:
|
| 393 |
+
# Install everything
|
| 394 |
+
install_onnxruntime_gpu()
|
| 395 |
+
install_dataset_packages()
|
| 396 |
+
install_training_packages()
|
| 397 |
+
|
| 398 |
+
# Download Kokoro models
|
| 399 |
+
if not args.training_only and not args.skip_kokoro_download:
|
| 400 |
+
download_kokoro_models()
|
| 401 |
+
|
| 402 |
+
# Setup environment
|
| 403 |
+
setup_ld_library_path()
|
| 404 |
+
|
| 405 |
+
# Create directories
|
| 406 |
+
create_directories()
|
| 407 |
+
|
| 408 |
+
# HuggingFace login
|
| 409 |
+
login_huggingface()
|
| 410 |
+
|
| 411 |
+
# Verify
|
| 412 |
+
if not verify_installation():
|
| 413 |
+
log("\nWARNING: Some packages missing")
|
| 414 |
+
|
| 415 |
+
# Test Kokoro GPU
|
| 416 |
+
if not args.training_only and not args.skip_test:
|
| 417 |
+
test_kokoro_gpu()
|
| 418 |
+
|
| 419 |
+
log("\n" + "="*60)
|
| 420 |
+
log("SETUP COMPLETO!")
|
| 421 |
+
log("="*60)
|
| 422 |
+
|
| 423 |
+
log("\nPara gerar dataset:")
|
| 424 |
+
log(" 1. source /tmp/setup_env.sh")
|
| 425 |
+
log(" 2. python datasets/generate_dataset.py --count 1000 --output-dir ./data/processed")
|
| 426 |
+
log("")
|
| 427 |
+
log("Para treinar:")
|
| 428 |
+
log(" 1. python passo2_finetune_stage1.py --data ./data/processed/dataset.pt")
|
| 429 |
+
log(" 2. python passo3_finetune_stage2.py --data ./data/processed/dataset.pt --stage1_ckpt ./checkpoints/stage1_best.pt")
|
| 430 |
+
|
| 431 |
+
return 0
|
| 432 |
+
|
| 433 |
+
if __name__ == "__main__":
|
| 434 |
+
sys.exit(main())
|
passo2_finetune_stage1.py
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Stage 1: Train Adapter Only (LLM Frozen) with Scheduled Interleaved Output
|
| 4 |
+
|
| 5 |
+
Based on IST-LM paper combined with LLaMA-Omni 2 staging:
|
| 6 |
+
- Adapter learns to map audio → LLM embedding space
|
| 7 |
+
- Output is interleaved text+audio (90% text initially)
|
| 8 |
+
- LLM is completely frozen (adapter gets a "head start")
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python finetune_stage1.py --data data.pt --epochs 2
|
| 12 |
+
|
| 13 |
+
Next: Stage 2 trains Adapter + LoRA together
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
import argparse
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn as nn
|
| 21 |
+
import torch.nn.functional as F
|
| 22 |
+
import torch.distributed as dist
|
| 23 |
+
from torch.utils.data import Dataset, DataLoader, DistributedSampler, ConcatDataset
|
| 24 |
+
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 25 |
+
from torch.optim.lr_scheduler import CosineAnnealingLR
|
| 26 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 27 |
+
from huggingface_hub import login
|
| 28 |
+
from tqdm import tqdm
|
| 29 |
+
import threading
|
| 30 |
+
|
| 31 |
+
# ============================================================
|
| 32 |
+
# Config
|
| 33 |
+
# ============================================================
|
| 34 |
+
torch.backends.cuda.matmul.allow_tf32 = True
|
| 35 |
+
torch.backends.cudnn.allow_tf32 = True
|
| 36 |
+
torch.backends.cudnn.benchmark = True
|
| 37 |
+
torch.set_float32_matmul_precision('high')
|
| 38 |
+
|
| 39 |
+
# SNAC token offsets for Orpheus
|
| 40 |
+
SNAC_BASE_OFFSET = 128266
|
| 41 |
+
EOS_TOKEN = 128009
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def log(msg):
|
| 45 |
+
print(msg)
|
| 46 |
+
sys.stdout.flush()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def apply_snac_offset(token_idx, position):
|
| 50 |
+
"""Apply position-based offset to SNAC token.
|
| 51 |
+
If token is already offset (>= SNAC_BASE_OFFSET), return as-is.
|
| 52 |
+
"""
|
| 53 |
+
if int(token_idx) >= SNAC_BASE_OFFSET:
|
| 54 |
+
# Already has offset applied
|
| 55 |
+
return int(token_idx)
|
| 56 |
+
offset = SNAC_BASE_OFFSET + (position % 7) * 4096
|
| 57 |
+
return int(token_idx) + offset
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def get_text_ratio(global_step, decay_steps=300, initial_ratio=0.9, min_ratio=0.0):
|
| 61 |
+
"""
|
| 62 |
+
IST-LM: Start with 90% text, decrease by 0.1 every 300 steps.
|
| 63 |
+
"""
|
| 64 |
+
num_decays = global_step // decay_steps
|
| 65 |
+
text_ratio = initial_ratio - (num_decays * 0.1)
|
| 66 |
+
return max(min_ratio, text_ratio)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ============================================================
|
| 70 |
+
# Async Checkpoint Saving
|
| 71 |
+
# ============================================================
|
| 72 |
+
_save_threads = []
|
| 73 |
+
|
| 74 |
+
def save_checkpoint_async(state_dict, path, is_main=True):
|
| 75 |
+
global _save_threads
|
| 76 |
+
_save_threads = [t for t in _save_threads if t.is_alive()]
|
| 77 |
+
|
| 78 |
+
def copy_to_cpu(obj):
|
| 79 |
+
if isinstance(obj, torch.Tensor):
|
| 80 |
+
return obj.detach().cpu().clone()
|
| 81 |
+
elif isinstance(obj, dict):
|
| 82 |
+
return {k: copy_to_cpu(v) for k, v in obj.items()}
|
| 83 |
+
return obj
|
| 84 |
+
|
| 85 |
+
state_copy = copy_to_cpu(state_dict)
|
| 86 |
+
|
| 87 |
+
def _save():
|
| 88 |
+
try:
|
| 89 |
+
torch.save(state_copy, path)
|
| 90 |
+
if is_main:
|
| 91 |
+
log(f"[ASYNC] Saved: {path}")
|
| 92 |
+
except Exception as e:
|
| 93 |
+
if is_main:
|
| 94 |
+
log(f"[ASYNC] Error: {e}")
|
| 95 |
+
|
| 96 |
+
thread = threading.Thread(target=_save, daemon=True)
|
| 97 |
+
thread.start()
|
| 98 |
+
_save_threads.append(thread)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def wait_for_checkpoints():
|
| 102 |
+
global _save_threads
|
| 103 |
+
for t in _save_threads:
|
| 104 |
+
t.join()
|
| 105 |
+
_save_threads = []
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ============================================================
|
| 109 |
+
# GPU Auto-Detection
|
| 110 |
+
# ============================================================
|
| 111 |
+
def auto_detect_gpu_config():
|
| 112 |
+
try:
|
| 113 |
+
import subprocess
|
| 114 |
+
result = subprocess.run(
|
| 115 |
+
['nvidia-smi', '--query-gpu=name,memory.total', '--format=csv,noheader,nounits'],
|
| 116 |
+
capture_output=True, text=True
|
| 117 |
+
)
|
| 118 |
+
lines = result.stdout.strip().split('\n')
|
| 119 |
+
gpu_name, vram_mb = lines[0].split(', ')
|
| 120 |
+
vram_gb = int(vram_mb) // 1024
|
| 121 |
+
|
| 122 |
+
if vram_gb >= 150:
|
| 123 |
+
return {"name": "B200", "batch_size": 8, "grad_accum": 4, "vram_gb": vram_gb}
|
| 124 |
+
elif vram_gb >= 80:
|
| 125 |
+
return {"name": "A100-80GB", "batch_size": 6, "grad_accum": 5, "vram_gb": vram_gb}
|
| 126 |
+
elif vram_gb >= 35:
|
| 127 |
+
return {"name": "A100-40GB", "batch_size": 4, "grad_accum": 8, "vram_gb": vram_gb}
|
| 128 |
+
else:
|
| 129 |
+
return {"name": "RTX4090", "batch_size": 2, "grad_accum": 16, "vram_gb": vram_gb}
|
| 130 |
+
except:
|
| 131 |
+
return {"name": "Unknown", "batch_size": 2, "grad_accum": 16, "vram_gb": 24}
|
| 132 |
+
|
| 133 |
+
def get_ram_info():
|
| 134 |
+
"""Get RAM info in GB."""
|
| 135 |
+
try:
|
| 136 |
+
import psutil
|
| 137 |
+
total = psutil.virtual_memory().total / 1024**3
|
| 138 |
+
available = psutil.virtual_memory().available / 1024**3
|
| 139 |
+
return total, available
|
| 140 |
+
except ImportError:
|
| 141 |
+
try:
|
| 142 |
+
import subprocess
|
| 143 |
+
result = subprocess.run(
|
| 144 |
+
['free', '-g', '--output=SIZE,AVAILABLE'],
|
| 145 |
+
capture_output=True, text=True
|
| 146 |
+
)
|
| 147 |
+
lines = result.stdout.strip().split('\n')
|
| 148 |
+
if len(lines) >= 2:
|
| 149 |
+
total, available = map(float, lines[1].split())
|
| 150 |
+
return total, available
|
| 151 |
+
except:
|
| 152 |
+
pass
|
| 153 |
+
except:
|
| 154 |
+
pass
|
| 155 |
+
return 0, 0
|
| 156 |
+
|
| 157 |
+
def limit_ram_usage(max_ram_gb):
|
| 158 |
+
"""Limit RAM usage by setting resource limits."""
|
| 159 |
+
try:
|
| 160 |
+
import resource
|
| 161 |
+
max_bytes = int(max_ram_gb * 1024**3)
|
| 162 |
+
resource.setrlimit(resource.RLIMIT_AS, (max_bytes, max_bytes))
|
| 163 |
+
except:
|
| 164 |
+
pass
|
| 165 |
+
|
| 166 |
+
def log_memory_usage():
|
| 167 |
+
"""Log current memory usage."""
|
| 168 |
+
msg = []
|
| 169 |
+
if torch.cuda.is_available():
|
| 170 |
+
used = torch.cuda.memory_allocated() / 1024**3
|
| 171 |
+
reserved = torch.cuda.memory_reserved() / 1024**3
|
| 172 |
+
msg.append(f"GPU: {used:.2f}GB / {reserved:.2f}GB")
|
| 173 |
+
try:
|
| 174 |
+
import psutil
|
| 175 |
+
ram_used = psutil.virtual_memory().used / 1024**3
|
| 176 |
+
ram_total = psutil.virtual_memory().total / 1024**3
|
| 177 |
+
msg.append(f"RAM: {ram_used:.1f}GB / {ram_total:.1f}GB")
|
| 178 |
+
except:
|
| 179 |
+
pass
|
| 180 |
+
return " | ".join(msg)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ============================================================
|
| 184 |
+
# Speech Adapter (LLaMA-Omni 2 Style)
|
| 185 |
+
# ============================================================
|
| 186 |
+
class SpeechAdapter(nn.Module):
|
| 187 |
+
"""
|
| 188 |
+
5× downsampling + FFN with intermediate dim 2048
|
| 189 |
+
"""
|
| 190 |
+
def __init__(self, whisper_dim=1280, llm_dim=3072, downsample=5, intermediate_dim=2048):
|
| 191 |
+
super().__init__()
|
| 192 |
+
self.downsample = downsample
|
| 193 |
+
concat_dim = whisper_dim * downsample
|
| 194 |
+
|
| 195 |
+
self.ffn = nn.Sequential(
|
| 196 |
+
nn.Linear(concat_dim, intermediate_dim),
|
| 197 |
+
nn.GELU(),
|
| 198 |
+
nn.Linear(intermediate_dim, llm_dim),
|
| 199 |
+
nn.LayerNorm(llm_dim)
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
def forward(self, x):
|
| 203 |
+
B, T, D = x.shape
|
| 204 |
+
T_new = (T // self.downsample) * self.downsample
|
| 205 |
+
x = x[:, :T_new]
|
| 206 |
+
x = x.reshape(B, T_new // self.downsample, D * self.downsample)
|
| 207 |
+
return self.ffn(x)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# ============================================================
|
| 211 |
+
# Scheduled Interleaved Sequence Creation with Word Alignment
|
| 212 |
+
# ============================================================
|
| 213 |
+
def create_interleaved_sequence(text_tokens, snac_tokens, text_ratio=0.9, word_alignments=None, tokenizer=None, answer_text=None):
|
| 214 |
+
"""
|
| 215 |
+
Create interleaved sequence based on text_ratio with word-level alignment.
|
| 216 |
+
- text_ratio=0.9 means 90% of words are replaced by text tokens
|
| 217 |
+
- text_ratio=0.0 means 100% audio (no text replacement)
|
| 218 |
+
|
| 219 |
+
With word_alignments: replaces aligned audio spans with corresponding text tokens
|
| 220 |
+
Without word_alignments: falls back to positional interleaving
|
| 221 |
+
"""
|
| 222 |
+
interleaved = []
|
| 223 |
+
is_audio_mask = []
|
| 224 |
+
|
| 225 |
+
if len(snac_tokens) == 0:
|
| 226 |
+
return text_tokens + [EOS_TOKEN], [False] * (len(text_tokens) + 1)
|
| 227 |
+
|
| 228 |
+
# Group SNAC into frames of 7
|
| 229 |
+
frames = []
|
| 230 |
+
for i in range(0, len(snac_tokens), 7):
|
| 231 |
+
frame = snac_tokens[i:i+7]
|
| 232 |
+
if len(frame) == 7:
|
| 233 |
+
frames.append(frame)
|
| 234 |
+
|
| 235 |
+
if len(frames) == 0:
|
| 236 |
+
return text_tokens + [EOS_TOKEN], [False] * (len(text_tokens) + 1)
|
| 237 |
+
|
| 238 |
+
total_frames = len(frames)
|
| 239 |
+
|
| 240 |
+
# If we have word alignments, use semantic interleaving
|
| 241 |
+
if word_alignments and tokenizer and answer_text and text_ratio > 0:
|
| 242 |
+
import random
|
| 243 |
+
|
| 244 |
+
# Decide which words to replace with text based on text_ratio
|
| 245 |
+
num_words = len(word_alignments)
|
| 246 |
+
num_text_words = int(num_words * text_ratio)
|
| 247 |
+
|
| 248 |
+
# Randomly select which word indices to replace with text
|
| 249 |
+
word_indices = list(range(num_words))
|
| 250 |
+
random.shuffle(word_indices)
|
| 251 |
+
text_word_indices = set(word_indices[:num_text_words])
|
| 252 |
+
|
| 253 |
+
# Build interleaved sequence frame by frame
|
| 254 |
+
frame_idx = 0
|
| 255 |
+
snac_position = 0
|
| 256 |
+
|
| 257 |
+
for word_idx, alignment in enumerate(word_alignments):
|
| 258 |
+
word = alignment['word']
|
| 259 |
+
start_frame = alignment['start_frame']
|
| 260 |
+
end_frame = min(alignment['end_frame'], total_frames)
|
| 261 |
+
|
| 262 |
+
if word_idx in text_word_indices:
|
| 263 |
+
# Replace this word's audio with text tokens
|
| 264 |
+
word_tokens = tokenizer.encode(word, add_special_tokens=False)
|
| 265 |
+
for tok in word_tokens:
|
| 266 |
+
interleaved.append(tok)
|
| 267 |
+
is_audio_mask.append(False)
|
| 268 |
+
# Skip the audio frames for this word
|
| 269 |
+
snac_position = end_frame * 7
|
| 270 |
+
else:
|
| 271 |
+
# Keep audio for this word
|
| 272 |
+
for f_idx in range(start_frame, end_frame):
|
| 273 |
+
if f_idx < total_frames:
|
| 274 |
+
frame = frames[f_idx]
|
| 275 |
+
for tok in frame:
|
| 276 |
+
interleaved.append(apply_snac_offset(tok, snac_position))
|
| 277 |
+
is_audio_mask.append(True)
|
| 278 |
+
snac_position += 1
|
| 279 |
+
|
| 280 |
+
frame_idx = end_frame
|
| 281 |
+
|
| 282 |
+
# Add any remaining frames after the last word
|
| 283 |
+
while frame_idx < total_frames:
|
| 284 |
+
frame = frames[frame_idx]
|
| 285 |
+
for tok in frame:
|
| 286 |
+
interleaved.append(apply_snac_offset(tok, snac_position))
|
| 287 |
+
is_audio_mask.append(True)
|
| 288 |
+
snac_position += 1
|
| 289 |
+
frame_idx += 1
|
| 290 |
+
|
| 291 |
+
else:
|
| 292 |
+
# Fallback: positional interleaving (original behavior)
|
| 293 |
+
total_text = len(text_tokens)
|
| 294 |
+
|
| 295 |
+
# Determine interleaving pattern based on text_ratio
|
| 296 |
+
if text_ratio >= 0.9:
|
| 297 |
+
text_per_chunk, frames_per_chunk = 1, 3
|
| 298 |
+
elif text_ratio >= 0.7:
|
| 299 |
+
text_per_chunk, frames_per_chunk = 1, 5
|
| 300 |
+
elif text_ratio >= 0.5:
|
| 301 |
+
text_per_chunk, frames_per_chunk = 1, 7
|
| 302 |
+
elif text_ratio >= 0.3:
|
| 303 |
+
text_per_chunk, frames_per_chunk = 1, 10
|
| 304 |
+
else:
|
| 305 |
+
text_per_chunk, frames_per_chunk = 0, 1
|
| 306 |
+
|
| 307 |
+
text_idx = 0
|
| 308 |
+
frame_idx = 0
|
| 309 |
+
snac_position = 0
|
| 310 |
+
|
| 311 |
+
while frame_idx < total_frames:
|
| 312 |
+
if text_per_chunk > 0 and text_idx < total_text:
|
| 313 |
+
for _ in range(text_per_chunk):
|
| 314 |
+
if text_idx < total_text:
|
| 315 |
+
interleaved.append(text_tokens[text_idx])
|
| 316 |
+
is_audio_mask.append(False)
|
| 317 |
+
text_idx += 1
|
| 318 |
+
|
| 319 |
+
for _ in range(frames_per_chunk):
|
| 320 |
+
if frame_idx < total_frames:
|
| 321 |
+
frame = frames[frame_idx]
|
| 322 |
+
for tok in frame:
|
| 323 |
+
interleaved.append(apply_snac_offset(tok, snac_position))
|
| 324 |
+
is_audio_mask.append(True)
|
| 325 |
+
snac_position += 1
|
| 326 |
+
frame_idx += 1
|
| 327 |
+
|
| 328 |
+
while text_idx < total_text:
|
| 329 |
+
interleaved.append(text_tokens[text_idx])
|
| 330 |
+
is_audio_mask.append(False)
|
| 331 |
+
text_idx += 1
|
| 332 |
+
|
| 333 |
+
# Add EOS
|
| 334 |
+
interleaved.append(EOS_TOKEN)
|
| 335 |
+
is_audio_mask.append(False)
|
| 336 |
+
|
| 337 |
+
return interleaved, is_audio_mask
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
# ============================================================
|
| 341 |
+
# Dataset
|
| 342 |
+
# ============================================================
|
| 343 |
+
class InterleavedDataset(Dataset):
|
| 344 |
+
def __init__(self, data, tokenizer, max_audio_len=500, max_seq_len=2048):
|
| 345 |
+
self.data = data
|
| 346 |
+
self.tokenizer = tokenizer
|
| 347 |
+
self.max_audio = max_audio_len * 5
|
| 348 |
+
self.max_seq_len = max_seq_len
|
| 349 |
+
|
| 350 |
+
def __len__(self):
|
| 351 |
+
return len(self.data)
|
| 352 |
+
|
| 353 |
+
def __getitem__(self, idx):
|
| 354 |
+
item = self.data[idx]
|
| 355 |
+
|
| 356 |
+
# Whisper features
|
| 357 |
+
whisper = item["whisper_features"][:self.max_audio]
|
| 358 |
+
|
| 359 |
+
# Text tokens - use pre-computed if available, otherwise tokenize
|
| 360 |
+
if "text_tokens" in item and len(item["text_tokens"]) > 0:
|
| 361 |
+
tt = item["text_tokens"]
|
| 362 |
+
text_tokens = tt.tolist() if hasattr(tt, 'tolist') else list(tt)
|
| 363 |
+
else:
|
| 364 |
+
text = item.get("answer", item.get("text", ""))
|
| 365 |
+
if isinstance(text, str) and len(text) > 0:
|
| 366 |
+
text_tokens = self.tokenizer.encode(text, add_special_tokens=False)
|
| 367 |
+
else:
|
| 368 |
+
text_tokens = []
|
| 369 |
+
|
| 370 |
+
# SNAC tokens
|
| 371 |
+
snac = item["snac_tokens"]
|
| 372 |
+
snac_len = (len(snac) // 7) * 7
|
| 373 |
+
snac = snac[:snac_len] if snac_len > 0 else snac[:7]
|
| 374 |
+
snac_list = snac.tolist() if hasattr(snac, 'tolist') else list(snac)
|
| 375 |
+
|
| 376 |
+
# Word alignments (if available)
|
| 377 |
+
word_alignments = item.get("word_alignments", None)
|
| 378 |
+
answer_text = item.get("answer", "")
|
| 379 |
+
|
| 380 |
+
return {
|
| 381 |
+
"whisper": whisper,
|
| 382 |
+
"text_tokens": text_tokens,
|
| 383 |
+
"snac_tokens": snac_list,
|
| 384 |
+
"word_alignments": word_alignments,
|
| 385 |
+
"answer_text": answer_text
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def collate_fn(batch, text_ratio=0.9, tokenizer=None):
|
| 390 |
+
"""Collate with dynamic interleaving based on text_ratio and word alignments."""
|
| 391 |
+
max_w = max(b["whisper"].shape[0] for b in batch)
|
| 392 |
+
max_w = ((max_w + 4) // 5) * 5
|
| 393 |
+
|
| 394 |
+
whisper_batch = []
|
| 395 |
+
interleaved_batch = []
|
| 396 |
+
is_audio_batch = []
|
| 397 |
+
|
| 398 |
+
max_seq = 0
|
| 399 |
+
sequences = []
|
| 400 |
+
|
| 401 |
+
for b in batch:
|
| 402 |
+
interleaved, is_audio = create_interleaved_sequence(
|
| 403 |
+
b["text_tokens"],
|
| 404 |
+
b["snac_tokens"],
|
| 405 |
+
text_ratio,
|
| 406 |
+
word_alignments=b.get("word_alignments"),
|
| 407 |
+
tokenizer=tokenizer,
|
| 408 |
+
answer_text=b.get("answer_text")
|
| 409 |
+
)
|
| 410 |
+
sequences.append((interleaved, is_audio))
|
| 411 |
+
max_seq = max(max_seq, len(interleaved))
|
| 412 |
+
|
| 413 |
+
for i, b in enumerate(batch):
|
| 414 |
+
w = b["whisper"]
|
| 415 |
+
w_pad = F.pad(w, (0, 0, 0, max_w - w.shape[0]))
|
| 416 |
+
whisper_batch.append(w_pad)
|
| 417 |
+
|
| 418 |
+
interleaved, is_audio = sequences[i]
|
| 419 |
+
seq_tensor = torch.tensor(interleaved, dtype=torch.long)
|
| 420 |
+
mask_tensor = torch.tensor(is_audio, dtype=torch.bool)
|
| 421 |
+
|
| 422 |
+
seq_pad = F.pad(seq_tensor, (0, max_seq - len(interleaved)), value=-100)
|
| 423 |
+
mask_pad = F.pad(mask_tensor, (0, max_seq - len(is_audio)), value=False)
|
| 424 |
+
|
| 425 |
+
interleaved_batch.append(seq_pad)
|
| 426 |
+
is_audio_batch.append(mask_pad)
|
| 427 |
+
|
| 428 |
+
return {
|
| 429 |
+
"whisper": torch.stack(whisper_batch),
|
| 430 |
+
"interleaved": torch.stack(interleaved_batch),
|
| 431 |
+
"is_audio_mask": torch.stack(is_audio_batch)
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
# ============================================================
|
| 436 |
+
# Arguments
|
| 437 |
+
# ============================================================
|
| 438 |
+
def parse_args():
|
| 439 |
+
parser = argparse.ArgumentParser(description="Stage 1: Adapter Only with Interleaved Output")
|
| 440 |
+
parser.add_argument("--data", type=str, required=True)
|
| 441 |
+
parser.add_argument("--output_dir", type=str, default="./checkpoints")
|
| 442 |
+
parser.add_argument("--lr", type=float, default=5e-5)
|
| 443 |
+
parser.add_argument("--epochs", type=int, default=2, help="1-2 epochs for adapter warmup")
|
| 444 |
+
parser.add_argument("--batch_size", type=int, default=None)
|
| 445 |
+
parser.add_argument("--grad_accum", type=int, default=None)
|
| 446 |
+
parser.add_argument("--warmup_ratio", type=float, default=0.03)
|
| 447 |
+
parser.add_argument("--max_grad_norm", type=float, default=1.0)
|
| 448 |
+
parser.add_argument("--save_steps", type=int, default=200)
|
| 449 |
+
parser.add_argument("--label_smoothing", type=float, default=0.1)
|
| 450 |
+
# Scheduled interleaving
|
| 451 |
+
parser.add_argument("--initial_text_ratio", type=float, default=0.9)
|
| 452 |
+
parser.add_argument("--decay_steps", type=int, default=300)
|
| 453 |
+
# Model
|
| 454 |
+
parser.add_argument("--model_path", type=str, default="canopylabs/3b-es_it-ft-research_release")
|
| 455 |
+
parser.add_argument("--resume", type=str, default=None)
|
| 456 |
+
# Memory limits
|
| 457 |
+
parser.add_argument("--vram_fraction", type=float, default=0.80, help="VRAM fraction to use (default 0.80)")
|
| 458 |
+
parser.add_argument("--ram_limit_gb", type=float, default=None, help="RAM limit in GB (auto if not specified)")
|
| 459 |
+
# Modes
|
| 460 |
+
parser.add_argument("--demo", action="store_true")
|
| 461 |
+
parser.add_argument("--test", action="store_true")
|
| 462 |
+
return parser.parse_args()
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
# ============================================================
|
| 466 |
+
# Main
|
| 467 |
+
# ============================================================
|
| 468 |
+
def main():
|
| 469 |
+
args = parse_args()
|
| 470 |
+
|
| 471 |
+
# DDP setup
|
| 472 |
+
local_rank = int(os.environ.get("LOCAL_RANK", 0))
|
| 473 |
+
world_size = int(os.environ.get("WORLD_SIZE", 1))
|
| 474 |
+
|
| 475 |
+
if world_size > 1:
|
| 476 |
+
dist.init_process_group("nccl")
|
| 477 |
+
torch.cuda.set_device(local_rank)
|
| 478 |
+
|
| 479 |
+
device = torch.device(f"cuda:{local_rank}")
|
| 480 |
+
is_main = local_rank == 0
|
| 481 |
+
|
| 482 |
+
# GPU config
|
| 483 |
+
gpu_config = auto_detect_gpu_config()
|
| 484 |
+
if args.batch_size is None:
|
| 485 |
+
args.batch_size = gpu_config["batch_size"]
|
| 486 |
+
if args.grad_accum is None:
|
| 487 |
+
args.grad_accum = gpu_config["grad_accum"]
|
| 488 |
+
|
| 489 |
+
torch_dtype = torch.bfloat16
|
| 490 |
+
|
| 491 |
+
# Get RAM info and set limits
|
| 492 |
+
ram_total, ram_available = get_ram_info()
|
| 493 |
+
if args.ram_limit_gb is None:
|
| 494 |
+
args.ram_limit_gb = ram_total * 0.80 # Default to 80% of RAM
|
| 495 |
+
limit_ram_usage(args.ram_limit_gb)
|
| 496 |
+
|
| 497 |
+
if is_main:
|
| 498 |
+
log("=" * 60)
|
| 499 |
+
log("STAGE 1: Adapter Only (LLM Frozen) + Interleaved Output")
|
| 500 |
+
log("=" * 60)
|
| 501 |
+
log(f"GPU: {gpu_config['name']} ({gpu_config['vram_gb']}GB)")
|
| 502 |
+
log(f"RAM: {ram_total:.1f}GB total, {ram_available:.1f}GB available")
|
| 503 |
+
log(f"Batch: {args.batch_size}, Grad accum: {args.grad_accum}")
|
| 504 |
+
log(f"LR: {args.lr}, Epochs: {args.epochs}")
|
| 505 |
+
log(f"VRAM limit: {args.vram_fraction*100:.0f}%")
|
| 506 |
+
log(f"RAM limit: {args.ram_limit_gb:.1f}GB (80% of total)")
|
| 507 |
+
log(f"Initial text ratio: {args.initial_text_ratio}")
|
| 508 |
+
log(f"Decay steps: {args.decay_steps}")
|
| 509 |
+
|
| 510 |
+
# Apply VRAM and RAM limits
|
| 511 |
+
if str(device).startswith('cuda'):
|
| 512 |
+
torch.cuda.set_per_process_memory_fraction(args.vram_fraction)
|
| 513 |
+
torch.cuda.empty_cache()
|
| 514 |
+
torch.backends.cudnn.benchmark = True
|
| 515 |
+
torch.set_float32_matmul_precision('high')
|
| 516 |
+
if is_main:
|
| 517 |
+
log(f"[MEMORY] VRAM limited to {args.vram_fraction*100:.0f}%")
|
| 518 |
+
log(f"[MEMORY] RAM limited to {args.ram_limit_gb:.1f}GB")
|
| 519 |
+
|
| 520 |
+
# HuggingFace login
|
| 521 |
+
hf_token = os.environ.get("HF_TOKEN")
|
| 522 |
+
if hf_token:
|
| 523 |
+
login(token=hf_token)
|
| 524 |
+
|
| 525 |
+
# Load tokenizer
|
| 526 |
+
tokenizer = AutoTokenizer.from_pretrained(args.model_path)
|
| 527 |
+
if tokenizer.pad_token is None:
|
| 528 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 529 |
+
|
| 530 |
+
# Load datasets
|
| 531 |
+
data_paths = [p.strip() for p in args.data.split(",")]
|
| 532 |
+
all_datasets = []
|
| 533 |
+
|
| 534 |
+
if is_main:
|
| 535 |
+
log("\nLoading datasets...")
|
| 536 |
+
|
| 537 |
+
for path in data_paths:
|
| 538 |
+
if os.path.exists(path):
|
| 539 |
+
data = torch.load(path, weights_only=False, mmap=True)
|
| 540 |
+
dataset = InterleavedDataset(data, tokenizer)
|
| 541 |
+
all_datasets.append(dataset)
|
| 542 |
+
if is_main:
|
| 543 |
+
log(f" {os.path.basename(path)}: {len(data):,} samples")
|
| 544 |
+
|
| 545 |
+
if len(all_datasets) == 0:
|
| 546 |
+
raise ValueError("No datasets loaded!")
|
| 547 |
+
|
| 548 |
+
combined_dataset = ConcatDataset(all_datasets) if len(all_datasets) > 1 else all_datasets[0]
|
| 549 |
+
|
| 550 |
+
# Demo/Test mode
|
| 551 |
+
if args.test:
|
| 552 |
+
combined_dataset = torch.utils.data.Subset(combined_dataset, range(min(5, len(combined_dataset))))
|
| 553 |
+
args.batch_size = min(args.batch_size, len(combined_dataset))
|
| 554 |
+
args.grad_accum = 1
|
| 555 |
+
elif args.demo:
|
| 556 |
+
combined_dataset = torch.utils.data.Subset(combined_dataset, range(min(1000, len(combined_dataset))))
|
| 557 |
+
args.batch_size = min(4, args.batch_size)
|
| 558 |
+
args.grad_accum = max(8, args.grad_accum)
|
| 559 |
+
|
| 560 |
+
if is_main:
|
| 561 |
+
log(f"Total samples: {len(combined_dataset):,}")
|
| 562 |
+
|
| 563 |
+
# Load LLM (FROZEN)
|
| 564 |
+
if is_main:
|
| 565 |
+
log(f"\nLoading LLM (FROZEN): {args.model_path}")
|
| 566 |
+
|
| 567 |
+
llm = AutoModelForCausalLM.from_pretrained(
|
| 568 |
+
args.model_path,
|
| 569 |
+
torch_dtype=torch_dtype,
|
| 570 |
+
attn_implementation="sdpa",
|
| 571 |
+
device_map={"": device}
|
| 572 |
+
)
|
| 573 |
+
|
| 574 |
+
# Freeze LLM completely
|
| 575 |
+
for p in llm.parameters():
|
| 576 |
+
p.requires_grad = False
|
| 577 |
+
llm.eval()
|
| 578 |
+
|
| 579 |
+
# Create adapter (TRAINABLE)
|
| 580 |
+
adapter = SpeechAdapter(
|
| 581 |
+
whisper_dim=1280,
|
| 582 |
+
llm_dim=3072,
|
| 583 |
+
downsample=5,
|
| 584 |
+
intermediate_dim=2048
|
| 585 |
+
).to(device, dtype=torch_dtype)
|
| 586 |
+
|
| 587 |
+
adapter_params = sum(p.numel() for p in adapter.parameters())
|
| 588 |
+
if is_main:
|
| 589 |
+
log(f"\nTrainable: Adapter only ({adapter_params:,} = {adapter_params/1e6:.1f}M params)")
|
| 590 |
+
log("LLM: FROZEN")
|
| 591 |
+
|
| 592 |
+
# DDP
|
| 593 |
+
if world_size > 1:
|
| 594 |
+
adapter = DDP(adapter, device_ids=[local_rank])
|
| 595 |
+
dist.barrier()
|
| 596 |
+
|
| 597 |
+
# Optimizer (only adapter)
|
| 598 |
+
optimizer = torch.optim.AdamW(adapter.parameters(), lr=args.lr, weight_decay=0.01)
|
| 599 |
+
|
| 600 |
+
# We need to create dataloader with dynamic collate
|
| 601 |
+
# For simplicity, we'll update text_ratio per epoch
|
| 602 |
+
global_step = 0
|
| 603 |
+
start_epoch = 0
|
| 604 |
+
best_loss = float("inf")
|
| 605 |
+
current_text_ratio = args.initial_text_ratio
|
| 606 |
+
|
| 607 |
+
# Resume
|
| 608 |
+
if args.resume and os.path.exists(args.resume):
|
| 609 |
+
if is_main:
|
| 610 |
+
log(f"\nResuming from: {args.resume}")
|
| 611 |
+
ckpt = torch.load(args.resume, map_location=device, weights_only=False)
|
| 612 |
+
adapter_module = adapter.module if hasattr(adapter, "module") else adapter
|
| 613 |
+
if "adapter" in ckpt:
|
| 614 |
+
adapter_module.load_state_dict(ckpt["adapter"])
|
| 615 |
+
if "optimizer" in ckpt:
|
| 616 |
+
optimizer.load_state_dict(ckpt["optimizer"])
|
| 617 |
+
if "step" in ckpt:
|
| 618 |
+
global_step = ckpt["step"]
|
| 619 |
+
if "epoch" in ckpt:
|
| 620 |
+
start_epoch = ckpt["epoch"]
|
| 621 |
+
if "text_ratio" in ckpt:
|
| 622 |
+
current_text_ratio = ckpt["text_ratio"]
|
| 623 |
+
|
| 624 |
+
# Training
|
| 625 |
+
os.makedirs(args.output_dir, exist_ok=True)
|
| 626 |
+
|
| 627 |
+
# Create dataloader first to calculate steps
|
| 628 |
+
def collate_with_ratio(batch):
|
| 629 |
+
current_ratio = get_text_ratio(global_step, args.decay_steps, args.initial_text_ratio)
|
| 630 |
+
return collate_fn(batch, current_ratio, tokenizer=tokenizer)
|
| 631 |
+
|
| 632 |
+
if world_size > 1:
|
| 633 |
+
sampler = DistributedSampler(combined_dataset, shuffle=True)
|
| 634 |
+
sampler.set_epoch(0)
|
| 635 |
+
else:
|
| 636 |
+
sampler = None
|
| 637 |
+
|
| 638 |
+
train_loader = DataLoader(
|
| 639 |
+
combined_dataset,
|
| 640 |
+
batch_size=args.batch_size,
|
| 641 |
+
shuffle=(sampler is None),
|
| 642 |
+
sampler=sampler,
|
| 643 |
+
collate_fn=collate_with_ratio,
|
| 644 |
+
num_workers=4,
|
| 645 |
+
pin_memory=True
|
| 646 |
+
)
|
| 647 |
+
|
| 648 |
+
steps_per_epoch = max(1, len(train_loader) // args.grad_accum)
|
| 649 |
+
total_steps = max(1, steps_per_epoch * args.epochs)
|
| 650 |
+
warmup_steps = int(total_steps * args.warmup_ratio)
|
| 651 |
+
|
| 652 |
+
scheduler = CosineAnnealingLR(optimizer, T_max=max(1, total_steps - warmup_steps), eta_min=1e-6)
|
| 653 |
+
|
| 654 |
+
if is_main:
|
| 655 |
+
log(f"Steps per epoch: {steps_per_epoch}, Total: {total_steps}, Warmup: {warmup_steps}")
|
| 656 |
+
|
| 657 |
+
if is_main:
|
| 658 |
+
log("\n" + "=" * 60)
|
| 659 |
+
log("STARTING STAGE 1 TRAINING")
|
| 660 |
+
log("=" * 60)
|
| 661 |
+
|
| 662 |
+
for epoch in range(start_epoch, args.epochs):
|
| 663 |
+
# Update text ratio based on global step
|
| 664 |
+
current_text_ratio = get_text_ratio(global_step, args.decay_steps, args.initial_text_ratio)
|
| 665 |
+
|
| 666 |
+
# Update sampler for this epoch
|
| 667 |
+
if world_size > 1:
|
| 668 |
+
sampler.set_epoch(epoch)
|
| 669 |
+
|
| 670 |
+
# Recreate dataloader with current text_ratio
|
| 671 |
+
def collate_with_ratio(batch):
|
| 672 |
+
return collate_fn(batch, current_text_ratio, tokenizer=tokenizer)
|
| 673 |
+
|
| 674 |
+
train_loader = DataLoader(
|
| 675 |
+
combined_dataset,
|
| 676 |
+
batch_size=args.batch_size,
|
| 677 |
+
shuffle=(sampler is None),
|
| 678 |
+
sampler=sampler,
|
| 679 |
+
collate_fn=collate_with_ratio,
|
| 680 |
+
num_workers=4,
|
| 681 |
+
pin_memory=True
|
| 682 |
+
)
|
| 683 |
+
|
| 684 |
+
adapter.train()
|
| 685 |
+
epoch_loss = 0
|
| 686 |
+
accum_loss = 0
|
| 687 |
+
|
| 688 |
+
pbar = tqdm(train_loader, desc=f"Epoch {epoch+1}/{args.epochs}", disable=not is_main)
|
| 689 |
+
|
| 690 |
+
for batch_idx, batch in enumerate(pbar):
|
| 691 |
+
# Update text ratio dynamically
|
| 692 |
+
current_text_ratio = get_text_ratio(global_step, args.decay_steps, args.initial_text_ratio)
|
| 693 |
+
|
| 694 |
+
whisper = batch["whisper"].to(device, dtype=torch_dtype, non_blocking=True)
|
| 695 |
+
interleaved = batch["interleaved"].to(device, non_blocking=True)
|
| 696 |
+
|
| 697 |
+
# Forward through adapter
|
| 698 |
+
adapter_module = adapter.module if hasattr(adapter, "module") else adapter
|
| 699 |
+
audio_embeds = adapter_module(whisper)
|
| 700 |
+
|
| 701 |
+
# Get token embeddings for interleaved sequence (teacher forcing)
|
| 702 |
+
input_tokens = interleaved[:, :-1].clamp(min=0)
|
| 703 |
+
with torch.no_grad():
|
| 704 |
+
token_embeds = llm.model.embed_tokens(input_tokens)
|
| 705 |
+
|
| 706 |
+
# Combine: [audio_embeds] + [token_embeds]
|
| 707 |
+
combined = torch.cat([audio_embeds, token_embeds], dim=1)
|
| 708 |
+
|
| 709 |
+
# Forward through frozen LLM (no torch.no_grad - need gradients to flow back to adapter)
|
| 710 |
+
# LLM params are frozen (requires_grad=False), so they won't be updated
|
| 711 |
+
outputs = llm(inputs_embeds=combined, use_cache=False)
|
| 712 |
+
logits = outputs.logits
|
| 713 |
+
|
| 714 |
+
# Loss: predict interleaved tokens after audio prefix
|
| 715 |
+
# logits[:, audio_len-1] predicts first token of interleaved
|
| 716 |
+
# logits[:, audio_len-1+i] predicts interleaved[i]
|
| 717 |
+
audio_len = audio_embeds.shape[1]
|
| 718 |
+
seq_len = interleaved.shape[1]
|
| 719 |
+
|
| 720 |
+
# Get logits that predict the interleaved sequence
|
| 721 |
+
# Position audio_len-1 predicts interleaved[0], etc.
|
| 722 |
+
seq_logits = logits[:, audio_len-1:audio_len-1+seq_len]
|
| 723 |
+
targets = interleaved
|
| 724 |
+
|
| 725 |
+
loss = F.cross_entropy(
|
| 726 |
+
seq_logits.reshape(-1, logits.size(-1)),
|
| 727 |
+
targets.reshape(-1),
|
| 728 |
+
ignore_index=-100,
|
| 729 |
+
label_smoothing=args.label_smoothing
|
| 730 |
+
)
|
| 731 |
+
|
| 732 |
+
loss = loss / args.grad_accum
|
| 733 |
+
loss.backward()
|
| 734 |
+
accum_loss += loss.item() * args.grad_accum
|
| 735 |
+
|
| 736 |
+
# Update
|
| 737 |
+
if (batch_idx + 1) % args.grad_accum == 0 or (batch_idx + 1) == len(train_loader):
|
| 738 |
+
torch.nn.utils.clip_grad_norm_(adapter.parameters(), args.max_grad_norm)
|
| 739 |
+
optimizer.step()
|
| 740 |
+
optimizer.zero_grad(set_to_none=True)
|
| 741 |
+
|
| 742 |
+
if global_step < warmup_steps:
|
| 743 |
+
lr_scale = (global_step + 1) / warmup_steps
|
| 744 |
+
for pg in optimizer.param_groups:
|
| 745 |
+
pg["lr"] = args.lr * lr_scale
|
| 746 |
+
else:
|
| 747 |
+
scheduler.step()
|
| 748 |
+
|
| 749 |
+
global_step += 1
|
| 750 |
+
epoch_loss += accum_loss
|
| 751 |
+
|
| 752 |
+
pbar.set_postfix(
|
| 753 |
+
loss=f"{accum_loss:.4f}",
|
| 754 |
+
text_ratio=f"{current_text_ratio:.1f}",
|
| 755 |
+
lr=f"{optimizer.param_groups[0]['lr']:.2e}"
|
| 756 |
+
)
|
| 757 |
+
|
| 758 |
+
# Save checkpoint
|
| 759 |
+
if global_step % args.save_steps == 0 and is_main:
|
| 760 |
+
adapter_to_save = adapter.module if hasattr(adapter, "module") else adapter
|
| 761 |
+
ckpt_path = os.path.join(args.output_dir, f"stage1_step{global_step}.pt")
|
| 762 |
+
save_checkpoint_async({
|
| 763 |
+
"adapter": adapter_to_save.state_dict(),
|
| 764 |
+
"optimizer": optimizer.state_dict(),
|
| 765 |
+
"step": global_step,
|
| 766 |
+
"epoch": epoch,
|
| 767 |
+
"loss": accum_loss,
|
| 768 |
+
"text_ratio": current_text_ratio
|
| 769 |
+
}, ckpt_path, is_main)
|
| 770 |
+
|
| 771 |
+
accum_loss = 0
|
| 772 |
+
|
| 773 |
+
# Epoch end
|
| 774 |
+
avg_loss = epoch_loss / max(1, steps_per_epoch)
|
| 775 |
+
|
| 776 |
+
if is_main:
|
| 777 |
+
log(f"Epoch {epoch+1} avg loss: {avg_loss:.4f}, text_ratio: {current_text_ratio:.1f}")
|
| 778 |
+
|
| 779 |
+
adapter_to_save = adapter.module if hasattr(adapter, "module") else adapter
|
| 780 |
+
ckpt_path = os.path.join(args.output_dir, f"stage1_epoch{epoch+1}.pt")
|
| 781 |
+
save_checkpoint_async({
|
| 782 |
+
"adapter": adapter_to_save.state_dict(),
|
| 783 |
+
"optimizer": optimizer.state_dict(),
|
| 784 |
+
"step": global_step,
|
| 785 |
+
"epoch": epoch + 1,
|
| 786 |
+
"loss": avg_loss,
|
| 787 |
+
"text_ratio": current_text_ratio
|
| 788 |
+
}, ckpt_path, is_main)
|
| 789 |
+
|
| 790 |
+
if avg_loss < best_loss:
|
| 791 |
+
best_loss = avg_loss
|
| 792 |
+
best_path = os.path.join(args.output_dir, "stage1_best.pt")
|
| 793 |
+
save_checkpoint_async({
|
| 794 |
+
"adapter": adapter_to_save.state_dict(),
|
| 795 |
+
"step": global_step,
|
| 796 |
+
"epoch": epoch + 1,
|
| 797 |
+
"loss": best_loss,
|
| 798 |
+
"text_ratio": current_text_ratio
|
| 799 |
+
}, best_path, is_main)
|
| 800 |
+
log(f"Best model saved! Loss: {best_loss:.4f}")
|
| 801 |
+
|
| 802 |
+
# Finish
|
| 803 |
+
if is_main:
|
| 804 |
+
wait_for_checkpoints()
|
| 805 |
+
log("\n" + "=" * 60)
|
| 806 |
+
log("STAGE 1 COMPLETE!")
|
| 807 |
+
log(f"Best loss: {best_loss:.4f}")
|
| 808 |
+
log(f"Final text_ratio: {current_text_ratio:.1f}")
|
| 809 |
+
log("Next: Stage 2 (Adapter + LoRA)")
|
| 810 |
+
log("=" * 60)
|
| 811 |
+
|
| 812 |
+
if world_size > 1:
|
| 813 |
+
dist.destroy_process_group()
|
| 814 |
+
|
| 815 |
+
|
| 816 |
+
if __name__ == "__main__":
|
| 817 |
+
main()
|
passo3_finetune_stage2.py
ADDED
|
@@ -0,0 +1,876 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Stage 2: Train Adapter + LoRA Together with Scheduled Interleaved Output
|
| 4 |
+
|
| 5 |
+
Continues from Stage 1 checkpoint:
|
| 6 |
+
- Loads pre-trained adapter from Stage 1
|
| 7 |
+
- Adds LoRA to LLM
|
| 8 |
+
- Trains both Adapter + LoRA together
|
| 9 |
+
- Continues scheduled interleaving (90% text -> 0% text)
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
python finetune_stage2.py --data data.pt --stage1_ckpt checkpoints/stage1_best.pt --epochs 3
|
| 13 |
+
|
| 14 |
+
Based on IST-LM paper + LLaMA-Omni 2 staging approach.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import sys
|
| 19 |
+
import argparse
|
| 20 |
+
import torch
|
| 21 |
+
import torch.nn as nn
|
| 22 |
+
import torch.nn.functional as F
|
| 23 |
+
import torch.distributed as dist
|
| 24 |
+
from torch.utils.data import Dataset, DataLoader, DistributedSampler, ConcatDataset
|
| 25 |
+
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 26 |
+
from torch.optim.lr_scheduler import CosineAnnealingLR
|
| 27 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 28 |
+
from peft import LoraConfig, get_peft_model, TaskType
|
| 29 |
+
from huggingface_hub import login
|
| 30 |
+
from tqdm import tqdm
|
| 31 |
+
import threading
|
| 32 |
+
|
| 33 |
+
# ============================================================
|
| 34 |
+
# Config
|
| 35 |
+
# ============================================================
|
| 36 |
+
torch.backends.cuda.matmul.allow_tf32 = True
|
| 37 |
+
torch.backends.cudnn.allow_tf32 = True
|
| 38 |
+
torch.backends.cudnn.benchmark = True
|
| 39 |
+
torch.set_float32_matmul_precision('high')
|
| 40 |
+
|
| 41 |
+
# SNAC token offsets for Orpheus
|
| 42 |
+
SNAC_BASE_OFFSET = 128266
|
| 43 |
+
EOS_TOKEN = 128009
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def log(msg):
|
| 47 |
+
print(msg)
|
| 48 |
+
sys.stdout.flush()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def apply_snac_offset(token_idx, position):
|
| 52 |
+
"""Apply position-based offset to SNAC token.
|
| 53 |
+
If token is already offset (>= SNAC_BASE_OFFSET), return as-is.
|
| 54 |
+
"""
|
| 55 |
+
if int(token_idx) >= SNAC_BASE_OFFSET:
|
| 56 |
+
# Already has offset applied
|
| 57 |
+
return int(token_idx)
|
| 58 |
+
offset = SNAC_BASE_OFFSET + (position % 7) * 4096
|
| 59 |
+
return int(token_idx) + offset
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def get_text_ratio(global_step, decay_steps=300, initial_ratio=0.9, min_ratio=0.0):
|
| 63 |
+
"""
|
| 64 |
+
IST-LM: Start with 90% text, decrease by 0.1 every 300 steps.
|
| 65 |
+
"""
|
| 66 |
+
num_decays = global_step // decay_steps
|
| 67 |
+
text_ratio = initial_ratio - (num_decays * 0.1)
|
| 68 |
+
return max(min_ratio, text_ratio)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ============================================================
|
| 72 |
+
# Async Checkpoint Saving
|
| 73 |
+
# ============================================================
|
| 74 |
+
_save_threads = []
|
| 75 |
+
|
| 76 |
+
def save_checkpoint_async(state_dict, path, is_main=True):
|
| 77 |
+
global _save_threads
|
| 78 |
+
_save_threads = [t for t in _save_threads if t.is_alive()]
|
| 79 |
+
|
| 80 |
+
def copy_to_cpu(obj):
|
| 81 |
+
if isinstance(obj, torch.Tensor):
|
| 82 |
+
return obj.detach().cpu().clone()
|
| 83 |
+
elif isinstance(obj, dict):
|
| 84 |
+
return {k: copy_to_cpu(v) for k, v in obj.items()}
|
| 85 |
+
return obj
|
| 86 |
+
|
| 87 |
+
state_copy = copy_to_cpu(state_dict)
|
| 88 |
+
|
| 89 |
+
def _save():
|
| 90 |
+
try:
|
| 91 |
+
torch.save(state_copy, path)
|
| 92 |
+
if is_main:
|
| 93 |
+
log(f"[ASYNC] Saved: {path}")
|
| 94 |
+
except Exception as e:
|
| 95 |
+
if is_main:
|
| 96 |
+
log(f"[ASYNC] Error: {e}")
|
| 97 |
+
|
| 98 |
+
thread = threading.Thread(target=_save, daemon=True)
|
| 99 |
+
thread.start()
|
| 100 |
+
_save_threads.append(thread)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def wait_for_checkpoints():
|
| 104 |
+
global _save_threads
|
| 105 |
+
for t in _save_threads:
|
| 106 |
+
t.join()
|
| 107 |
+
_save_threads = []
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ============================================================
|
| 111 |
+
# GPU Auto-Detection
|
| 112 |
+
# ============================================================
|
| 113 |
+
def auto_detect_gpu_config():
|
| 114 |
+
try:
|
| 115 |
+
import subprocess
|
| 116 |
+
result = subprocess.run(
|
| 117 |
+
['nvidia-smi', '--query-gpu=name,memory.total', '--format=csv,noheader,nounits'],
|
| 118 |
+
capture_output=True, text=True
|
| 119 |
+
)
|
| 120 |
+
lines = result.stdout.strip().split('\n')
|
| 121 |
+
gpu_name, vram_mb = lines[0].split(', ')
|
| 122 |
+
vram_gb = int(vram_mb) // 1024
|
| 123 |
+
|
| 124 |
+
if vram_gb >= 150:
|
| 125 |
+
return {"name": "B200", "batch_size": 8, "grad_accum": 4, "vram_gb": vram_gb}
|
| 126 |
+
elif vram_gb >= 80:
|
| 127 |
+
return {"name": "A100-80GB", "batch_size": 6, "grad_accum": 5, "vram_gb": vram_gb}
|
| 128 |
+
elif vram_gb >= 35:
|
| 129 |
+
return {"name": "A100-40GB", "batch_size": 4, "grad_accum": 8, "vram_gb": vram_gb}
|
| 130 |
+
else:
|
| 131 |
+
return {"name": "RTX4090", "batch_size": 2, "grad_accum": 16, "vram_gb": vram_gb}
|
| 132 |
+
except:
|
| 133 |
+
return {"name": "Unknown", "batch_size": 2, "grad_accum": 16, "vram_gb": 24}
|
| 134 |
+
|
| 135 |
+
def get_ram_info():
|
| 136 |
+
"""Get RAM info in GB."""
|
| 137 |
+
try:
|
| 138 |
+
import psutil
|
| 139 |
+
total = psutil.virtual_memory().total / 1024**3
|
| 140 |
+
available = psutil.virtual_memory().available / 1024**3
|
| 141 |
+
return total, available
|
| 142 |
+
except ImportError:
|
| 143 |
+
try:
|
| 144 |
+
import subprocess
|
| 145 |
+
result = subprocess.run(
|
| 146 |
+
['free', '-g', '--output=SIZE,AVAILABLE'],
|
| 147 |
+
capture_output=True, text=True
|
| 148 |
+
)
|
| 149 |
+
lines = result.stdout.strip().split('\n')
|
| 150 |
+
if len(lines) >= 2:
|
| 151 |
+
total, available = map(float, lines[1].split())
|
| 152 |
+
return total, available
|
| 153 |
+
except:
|
| 154 |
+
pass
|
| 155 |
+
except:
|
| 156 |
+
pass
|
| 157 |
+
return 0, 0
|
| 158 |
+
|
| 159 |
+
def limit_ram_usage(max_ram_gb):
|
| 160 |
+
"""Limit RAM usage by setting resource limits."""
|
| 161 |
+
try:
|
| 162 |
+
import resource
|
| 163 |
+
max_bytes = int(max_ram_gb * 1024**3)
|
| 164 |
+
resource.setrlimit(resource.RLIMIT_AS, (max_bytes, max_bytes))
|
| 165 |
+
except:
|
| 166 |
+
pass
|
| 167 |
+
|
| 168 |
+
def log_memory_usage():
|
| 169 |
+
"""Log current memory usage."""
|
| 170 |
+
msg = []
|
| 171 |
+
if torch.cuda.is_available():
|
| 172 |
+
used = torch.cuda.memory_allocated() / 1024**3
|
| 173 |
+
reserved = torch.cuda.memory_reserved() / 1024**3
|
| 174 |
+
msg.append(f"GPU: {used:.2f}GB / {reserved:.2f}GB")
|
| 175 |
+
try:
|
| 176 |
+
import psutil
|
| 177 |
+
ram_used = psutil.virtual_memory().used / 1024**3
|
| 178 |
+
ram_total = psutil.virtual_memory().total / 1024**3
|
| 179 |
+
msg.append(f"RAM: {ram_used:.1f}GB / {ram_total:.1f}GB")
|
| 180 |
+
except:
|
| 181 |
+
pass
|
| 182 |
+
return " | ".join(msg)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ============================================================
|
| 186 |
+
# Speech Adapter (Same as Stage 1 - LLaMA-Omni 2 Style)
|
| 187 |
+
# ============================================================
|
| 188 |
+
class SpeechAdapter(nn.Module):
|
| 189 |
+
"""
|
| 190 |
+
5x downsampling + FFN with intermediate dim 2048
|
| 191 |
+
MUST match Stage 1 architecture exactly for checkpoint loading.
|
| 192 |
+
"""
|
| 193 |
+
def __init__(self, whisper_dim=1280, llm_dim=3072, downsample=5, intermediate_dim=2048):
|
| 194 |
+
super().__init__()
|
| 195 |
+
self.downsample = downsample
|
| 196 |
+
concat_dim = whisper_dim * downsample
|
| 197 |
+
|
| 198 |
+
self.ffn = nn.Sequential(
|
| 199 |
+
nn.Linear(concat_dim, intermediate_dim),
|
| 200 |
+
nn.GELU(),
|
| 201 |
+
nn.Linear(intermediate_dim, llm_dim),
|
| 202 |
+
nn.LayerNorm(llm_dim)
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
def forward(self, x):
|
| 206 |
+
B, T, D = x.shape
|
| 207 |
+
T_new = (T // self.downsample) * self.downsample
|
| 208 |
+
x = x[:, :T_new]
|
| 209 |
+
x = x.reshape(B, T_new // self.downsample, D * self.downsample)
|
| 210 |
+
return self.ffn(x)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ============================================================
|
| 214 |
+
# Scheduled Interleaved Sequence Creation with Word Alignment
|
| 215 |
+
# ============================================================
|
| 216 |
+
def create_interleaved_sequence(text_tokens, snac_tokens, text_ratio=0.9, word_alignments=None, tokenizer=None, answer_text=None):
|
| 217 |
+
"""
|
| 218 |
+
Create interleaved sequence based on text_ratio with word-level alignment.
|
| 219 |
+
- text_ratio=0.9 means 90% of words are replaced by text tokens
|
| 220 |
+
- text_ratio=0.0 means 100% audio (no text replacement)
|
| 221 |
+
|
| 222 |
+
With word_alignments: replaces aligned audio spans with corresponding text tokens
|
| 223 |
+
Without word_alignments: falls back to positional interleaving
|
| 224 |
+
"""
|
| 225 |
+
interleaved = []
|
| 226 |
+
is_audio_mask = []
|
| 227 |
+
|
| 228 |
+
if len(snac_tokens) == 0:
|
| 229 |
+
return text_tokens + [EOS_TOKEN], [False] * (len(text_tokens) + 1)
|
| 230 |
+
|
| 231 |
+
# Group SNAC into frames of 7
|
| 232 |
+
frames = []
|
| 233 |
+
for i in range(0, len(snac_tokens), 7):
|
| 234 |
+
frame = snac_tokens[i:i+7]
|
| 235 |
+
if len(frame) == 7:
|
| 236 |
+
frames.append(frame)
|
| 237 |
+
|
| 238 |
+
if len(frames) == 0:
|
| 239 |
+
return text_tokens + [EOS_TOKEN], [False] * (len(text_tokens) + 1)
|
| 240 |
+
|
| 241 |
+
total_frames = len(frames)
|
| 242 |
+
|
| 243 |
+
# If we have word alignments, use semantic interleaving
|
| 244 |
+
if word_alignments and tokenizer and answer_text and text_ratio > 0:
|
| 245 |
+
import random
|
| 246 |
+
|
| 247 |
+
# Decide which words to replace with text based on text_ratio
|
| 248 |
+
num_words = len(word_alignments)
|
| 249 |
+
num_text_words = int(num_words * text_ratio)
|
| 250 |
+
|
| 251 |
+
# Randomly select which word indices to replace with text
|
| 252 |
+
word_indices = list(range(num_words))
|
| 253 |
+
random.shuffle(word_indices)
|
| 254 |
+
text_word_indices = set(word_indices[:num_text_words])
|
| 255 |
+
|
| 256 |
+
# Build interleaved sequence frame by frame
|
| 257 |
+
frame_idx = 0
|
| 258 |
+
snac_position = 0
|
| 259 |
+
|
| 260 |
+
for word_idx, alignment in enumerate(word_alignments):
|
| 261 |
+
word = alignment['word']
|
| 262 |
+
start_frame = alignment['start_frame']
|
| 263 |
+
end_frame = min(alignment['end_frame'], total_frames)
|
| 264 |
+
|
| 265 |
+
if word_idx in text_word_indices:
|
| 266 |
+
# Replace this word's audio with text tokens
|
| 267 |
+
word_tokens = tokenizer.encode(word, add_special_tokens=False)
|
| 268 |
+
for tok in word_tokens:
|
| 269 |
+
interleaved.append(tok)
|
| 270 |
+
is_audio_mask.append(False)
|
| 271 |
+
# Skip the audio frames for this word
|
| 272 |
+
snac_position = end_frame * 7
|
| 273 |
+
else:
|
| 274 |
+
# Keep audio for this word
|
| 275 |
+
for f_idx in range(start_frame, end_frame):
|
| 276 |
+
if f_idx < total_frames:
|
| 277 |
+
frame = frames[f_idx]
|
| 278 |
+
for tok in frame:
|
| 279 |
+
interleaved.append(apply_snac_offset(tok, snac_position))
|
| 280 |
+
is_audio_mask.append(True)
|
| 281 |
+
snac_position += 1
|
| 282 |
+
|
| 283 |
+
frame_idx = end_frame
|
| 284 |
+
|
| 285 |
+
# Add any remaining frames after the last word
|
| 286 |
+
while frame_idx < total_frames:
|
| 287 |
+
frame = frames[frame_idx]
|
| 288 |
+
for tok in frame:
|
| 289 |
+
interleaved.append(apply_snac_offset(tok, snac_position))
|
| 290 |
+
is_audio_mask.append(True)
|
| 291 |
+
snac_position += 1
|
| 292 |
+
frame_idx += 1
|
| 293 |
+
|
| 294 |
+
else:
|
| 295 |
+
# Fallback: positional interleaving (original behavior)
|
| 296 |
+
total_text = len(text_tokens)
|
| 297 |
+
|
| 298 |
+
# Determine interleaving pattern based on text_ratio
|
| 299 |
+
if text_ratio >= 0.9:
|
| 300 |
+
text_per_chunk, frames_per_chunk = 1, 3
|
| 301 |
+
elif text_ratio >= 0.7:
|
| 302 |
+
text_per_chunk, frames_per_chunk = 1, 5
|
| 303 |
+
elif text_ratio >= 0.5:
|
| 304 |
+
text_per_chunk, frames_per_chunk = 1, 7
|
| 305 |
+
elif text_ratio >= 0.3:
|
| 306 |
+
text_per_chunk, frames_per_chunk = 1, 10
|
| 307 |
+
else:
|
| 308 |
+
text_per_chunk, frames_per_chunk = 0, 1
|
| 309 |
+
|
| 310 |
+
text_idx = 0
|
| 311 |
+
frame_idx = 0
|
| 312 |
+
snac_position = 0
|
| 313 |
+
|
| 314 |
+
while frame_idx < total_frames:
|
| 315 |
+
if text_per_chunk > 0 and text_idx < total_text:
|
| 316 |
+
for _ in range(text_per_chunk):
|
| 317 |
+
if text_idx < total_text:
|
| 318 |
+
interleaved.append(text_tokens[text_idx])
|
| 319 |
+
is_audio_mask.append(False)
|
| 320 |
+
text_idx += 1
|
| 321 |
+
|
| 322 |
+
for _ in range(frames_per_chunk):
|
| 323 |
+
if frame_idx < total_frames:
|
| 324 |
+
frame = frames[frame_idx]
|
| 325 |
+
for tok in frame:
|
| 326 |
+
interleaved.append(apply_snac_offset(tok, snac_position))
|
| 327 |
+
is_audio_mask.append(True)
|
| 328 |
+
snac_position += 1
|
| 329 |
+
frame_idx += 1
|
| 330 |
+
|
| 331 |
+
while text_idx < total_text:
|
| 332 |
+
interleaved.append(text_tokens[text_idx])
|
| 333 |
+
is_audio_mask.append(False)
|
| 334 |
+
text_idx += 1
|
| 335 |
+
|
| 336 |
+
# Add EOS
|
| 337 |
+
interleaved.append(EOS_TOKEN)
|
| 338 |
+
is_audio_mask.append(False)
|
| 339 |
+
|
| 340 |
+
return interleaved, is_audio_mask
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
# ============================================================
|
| 344 |
+
# Dataset
|
| 345 |
+
# ============================================================
|
| 346 |
+
class InterleavedDataset(Dataset):
|
| 347 |
+
def __init__(self, data, tokenizer, max_audio_len=500, max_seq_len=2048):
|
| 348 |
+
self.data = data
|
| 349 |
+
self.tokenizer = tokenizer
|
| 350 |
+
self.max_audio = max_audio_len * 5
|
| 351 |
+
self.max_seq_len = max_seq_len
|
| 352 |
+
|
| 353 |
+
def __len__(self):
|
| 354 |
+
return len(self.data)
|
| 355 |
+
|
| 356 |
+
def __getitem__(self, idx):
|
| 357 |
+
item = self.data[idx]
|
| 358 |
+
|
| 359 |
+
# Whisper features
|
| 360 |
+
whisper = item["whisper_features"][:self.max_audio]
|
| 361 |
+
|
| 362 |
+
# Text tokens - use pre-computed if available, otherwise tokenize
|
| 363 |
+
if "text_tokens" in item and len(item["text_tokens"]) > 0:
|
| 364 |
+
tt = item["text_tokens"]
|
| 365 |
+
text_tokens = tt.tolist() if hasattr(tt, 'tolist') else list(tt)
|
| 366 |
+
else:
|
| 367 |
+
text = item.get("answer", item.get("text", ""))
|
| 368 |
+
if isinstance(text, str) and len(text) > 0:
|
| 369 |
+
text_tokens = self.tokenizer.encode(text, add_special_tokens=False)
|
| 370 |
+
else:
|
| 371 |
+
text_tokens = []
|
| 372 |
+
|
| 373 |
+
# SNAC tokens
|
| 374 |
+
snac = item["snac_tokens"]
|
| 375 |
+
snac_len = (len(snac) // 7) * 7
|
| 376 |
+
snac = snac[:snac_len] if snac_len > 0 else snac[:7]
|
| 377 |
+
snac_list = snac.tolist() if hasattr(snac, 'tolist') else list(snac)
|
| 378 |
+
|
| 379 |
+
# Word alignments (if available)
|
| 380 |
+
word_alignments = item.get("word_alignments", None)
|
| 381 |
+
answer_text = item.get("answer", "")
|
| 382 |
+
|
| 383 |
+
return {
|
| 384 |
+
"whisper": whisper,
|
| 385 |
+
"text_tokens": text_tokens,
|
| 386 |
+
"snac_tokens": snac_list,
|
| 387 |
+
"word_alignments": word_alignments,
|
| 388 |
+
"answer_text": answer_text
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def collate_fn(batch, text_ratio=0.9, tokenizer=None):
|
| 393 |
+
"""Collate with dynamic interleaving based on text_ratio and word alignments."""
|
| 394 |
+
max_w = max(b["whisper"].shape[0] for b in batch)
|
| 395 |
+
max_w = ((max_w + 4) // 5) * 5
|
| 396 |
+
|
| 397 |
+
whisper_batch = []
|
| 398 |
+
interleaved_batch = []
|
| 399 |
+
is_audio_batch = []
|
| 400 |
+
|
| 401 |
+
max_seq = 0
|
| 402 |
+
sequences = []
|
| 403 |
+
|
| 404 |
+
for b in batch:
|
| 405 |
+
interleaved, is_audio = create_interleaved_sequence(
|
| 406 |
+
b["text_tokens"],
|
| 407 |
+
b["snac_tokens"],
|
| 408 |
+
text_ratio,
|
| 409 |
+
word_alignments=b.get("word_alignments"),
|
| 410 |
+
tokenizer=tokenizer,
|
| 411 |
+
answer_text=b.get("answer_text")
|
| 412 |
+
)
|
| 413 |
+
sequences.append((interleaved, is_audio))
|
| 414 |
+
max_seq = max(max_seq, len(interleaved))
|
| 415 |
+
|
| 416 |
+
for i, b in enumerate(batch):
|
| 417 |
+
w = b["whisper"]
|
| 418 |
+
w_pad = F.pad(w, (0, 0, 0, max_w - w.shape[0]))
|
| 419 |
+
whisper_batch.append(w_pad)
|
| 420 |
+
|
| 421 |
+
interleaved, is_audio = sequences[i]
|
| 422 |
+
seq_tensor = torch.tensor(interleaved, dtype=torch.long)
|
| 423 |
+
mask_tensor = torch.tensor(is_audio, dtype=torch.bool)
|
| 424 |
+
|
| 425 |
+
seq_pad = F.pad(seq_tensor, (0, max_seq - len(interleaved)), value=-100)
|
| 426 |
+
mask_pad = F.pad(mask_tensor, (0, max_seq - len(is_audio)), value=False)
|
| 427 |
+
|
| 428 |
+
interleaved_batch.append(seq_pad)
|
| 429 |
+
is_audio_batch.append(mask_pad)
|
| 430 |
+
|
| 431 |
+
return {
|
| 432 |
+
"whisper": torch.stack(whisper_batch),
|
| 433 |
+
"interleaved": torch.stack(interleaved_batch),
|
| 434 |
+
"is_audio_mask": torch.stack(is_audio_batch)
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
# ============================================================
|
| 439 |
+
# Arguments
|
| 440 |
+
# ============================================================
|
| 441 |
+
def parse_args():
|
| 442 |
+
parser = argparse.ArgumentParser(description="Stage 2: Adapter + LoRA with Interleaved Output")
|
| 443 |
+
parser.add_argument("--data", type=str, required=True)
|
| 444 |
+
parser.add_argument("--output_dir", type=str, default="./checkpoints")
|
| 445 |
+
parser.add_argument("--stage1_ckpt", type=str, default=None,
|
| 446 |
+
help="Path to Stage 1 adapter checkpoint (required for proper init)")
|
| 447 |
+
parser.add_argument("--lr", type=float, default=5e-5)
|
| 448 |
+
parser.add_argument("--epochs", type=int, default=3, help="3+ epochs recommended")
|
| 449 |
+
parser.add_argument("--batch_size", type=int, default=None)
|
| 450 |
+
parser.add_argument("--grad_accum", type=int, default=None)
|
| 451 |
+
parser.add_argument("--warmup_ratio", type=float, default=0.03)
|
| 452 |
+
parser.add_argument("--max_grad_norm", type=float, default=1.0)
|
| 453 |
+
parser.add_argument("--save_steps", type=int, default=200)
|
| 454 |
+
parser.add_argument("--label_smoothing", type=float, default=0.1)
|
| 455 |
+
# Scheduled interleaving
|
| 456 |
+
parser.add_argument("--initial_text_ratio", type=float, default=0.9)
|
| 457 |
+
parser.add_argument("--decay_steps", type=int, default=300)
|
| 458 |
+
# LoRA config
|
| 459 |
+
parser.add_argument("--lora_r", type=int, default=16)
|
| 460 |
+
parser.add_argument("--lora_alpha", type=int, default=32)
|
| 461 |
+
parser.add_argument("--lora_dropout", type=float, default=0.05)
|
| 462 |
+
# Model
|
| 463 |
+
parser.add_argument("--model_path", type=str, default="canopylabs/3b-es_it-ft-research_release")
|
| 464 |
+
parser.add_argument("--resume", type=str, default=None)
|
| 465 |
+
# Memory limits
|
| 466 |
+
parser.add_argument("--vram_fraction", type=float, default=0.80, help="VRAM fraction to use (default 0.80)")
|
| 467 |
+
parser.add_argument("--ram_limit_gb", type=float, default=None, help="RAM limit in GB (auto if not specified)")
|
| 468 |
+
# Modes
|
| 469 |
+
parser.add_argument("--demo", action="store_true")
|
| 470 |
+
parser.add_argument("--test", action="store_true")
|
| 471 |
+
return parser.parse_args()
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
# ============================================================
|
| 475 |
+
# Main
|
| 476 |
+
# ============================================================
|
| 477 |
+
def main():
|
| 478 |
+
args = parse_args()
|
| 479 |
+
|
| 480 |
+
# DDP setup
|
| 481 |
+
local_rank = int(os.environ.get("LOCAL_RANK", 0))
|
| 482 |
+
world_size = int(os.environ.get("WORLD_SIZE", 1))
|
| 483 |
+
|
| 484 |
+
if world_size > 1:
|
| 485 |
+
dist.init_process_group("nccl")
|
| 486 |
+
torch.cuda.set_device(local_rank)
|
| 487 |
+
|
| 488 |
+
device = torch.device(f"cuda:{local_rank}")
|
| 489 |
+
is_main = local_rank == 0
|
| 490 |
+
|
| 491 |
+
# GPU config
|
| 492 |
+
gpu_config = auto_detect_gpu_config()
|
| 493 |
+
if args.batch_size is None:
|
| 494 |
+
args.batch_size = gpu_config["batch_size"]
|
| 495 |
+
if args.grad_accum is None:
|
| 496 |
+
args.grad_accum = gpu_config["grad_accum"]
|
| 497 |
+
|
| 498 |
+
torch_dtype = torch.bfloat16
|
| 499 |
+
|
| 500 |
+
# Get RAM info and set limits
|
| 501 |
+
ram_total, ram_available = get_ram_info()
|
| 502 |
+
if args.ram_limit_gb is None:
|
| 503 |
+
args.ram_limit_gb = ram_total * 0.80 # Default to 80% of RAM
|
| 504 |
+
limit_ram_usage(args.ram_limit_gb)
|
| 505 |
+
|
| 506 |
+
if is_main:
|
| 507 |
+
log("=" * 60)
|
| 508 |
+
log("STAGE 2: Adapter + LoRA (Both Trainable) + Interleaved Output")
|
| 509 |
+
log("=" * 60)
|
| 510 |
+
log(f"GPU: {gpu_config['name']} ({gpu_config['vram_gb']}GB)")
|
| 511 |
+
log(f"RAM: {ram_total:.1f}GB total, {ram_available:.1f}GB available")
|
| 512 |
+
log(f"Batch: {args.batch_size}, Grad accum: {args.grad_accum}")
|
| 513 |
+
log(f"LR: {args.lr}, Epochs: {args.epochs}")
|
| 514 |
+
log(f"LoRA: r={args.lora_r}, alpha={args.lora_alpha}")
|
| 515 |
+
log(f"VRAM limit: {args.vram_fraction*100:.0f}%")
|
| 516 |
+
log(f"RAM limit: {args.ram_limit_gb:.1f}GB (80% of total)")
|
| 517 |
+
log(f"Initial text ratio: {args.initial_text_ratio}")
|
| 518 |
+
log(f"Decay steps: {args.decay_steps}")
|
| 519 |
+
|
| 520 |
+
# Apply VRAM and RAM limits
|
| 521 |
+
if str(device).startswith('cuda'):
|
| 522 |
+
torch.cuda.set_per_process_memory_fraction(args.vram_fraction)
|
| 523 |
+
torch.cuda.empty_cache()
|
| 524 |
+
torch.backends.cudnn.benchmark = True
|
| 525 |
+
torch.set_float32_matmul_precision('high')
|
| 526 |
+
if is_main:
|
| 527 |
+
log(f"[MEMORY] VRAM limited to {args.vram_fraction*100:.0f}%")
|
| 528 |
+
log(f"[MEMORY] RAM limited to {args.ram_limit_gb:.1f}GB")
|
| 529 |
+
|
| 530 |
+
# HuggingFace login
|
| 531 |
+
hf_token = os.environ.get("HF_TOKEN")
|
| 532 |
+
if hf_token:
|
| 533 |
+
login(token=hf_token)
|
| 534 |
+
|
| 535 |
+
# Load tokenizer
|
| 536 |
+
tokenizer = AutoTokenizer.from_pretrained(args.model_path)
|
| 537 |
+
if tokenizer.pad_token is None:
|
| 538 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 539 |
+
|
| 540 |
+
# Load datasets
|
| 541 |
+
data_paths = [p.strip() for p in args.data.split(",")]
|
| 542 |
+
all_datasets = []
|
| 543 |
+
|
| 544 |
+
if is_main:
|
| 545 |
+
log("\nLoading datasets...")
|
| 546 |
+
|
| 547 |
+
for path in data_paths:
|
| 548 |
+
if os.path.exists(path):
|
| 549 |
+
data = torch.load(path, weights_only=False, mmap=True)
|
| 550 |
+
dataset = InterleavedDataset(data, tokenizer)
|
| 551 |
+
all_datasets.append(dataset)
|
| 552 |
+
if is_main:
|
| 553 |
+
log(f" {os.path.basename(path)}: {len(data):,} samples")
|
| 554 |
+
|
| 555 |
+
if len(all_datasets) == 0:
|
| 556 |
+
raise ValueError("No datasets loaded!")
|
| 557 |
+
|
| 558 |
+
combined_dataset = ConcatDataset(all_datasets) if len(all_datasets) > 1 else all_datasets[0]
|
| 559 |
+
|
| 560 |
+
# Demo/Test mode
|
| 561 |
+
if args.test:
|
| 562 |
+
combined_dataset = torch.utils.data.Subset(combined_dataset, range(min(5, len(combined_dataset))))
|
| 563 |
+
args.batch_size = min(args.batch_size, len(combined_dataset))
|
| 564 |
+
args.grad_accum = 1
|
| 565 |
+
elif args.demo:
|
| 566 |
+
combined_dataset = torch.utils.data.Subset(combined_dataset, range(min(1000, len(combined_dataset))))
|
| 567 |
+
args.batch_size = min(4, args.batch_size)
|
| 568 |
+
args.grad_accum = max(8, args.grad_accum)
|
| 569 |
+
|
| 570 |
+
if is_main:
|
| 571 |
+
log(f"Total samples: {len(combined_dataset):,}")
|
| 572 |
+
|
| 573 |
+
# Load LLM with LoRA
|
| 574 |
+
if is_main:
|
| 575 |
+
log(f"\nLoading LLM with LoRA: {args.model_path}")
|
| 576 |
+
|
| 577 |
+
llm = AutoModelForCausalLM.from_pretrained(
|
| 578 |
+
args.model_path,
|
| 579 |
+
torch_dtype=torch_dtype,
|
| 580 |
+
attn_implementation="sdpa",
|
| 581 |
+
device_map={"": device}
|
| 582 |
+
)
|
| 583 |
+
|
| 584 |
+
# Apply LoRA
|
| 585 |
+
lora_config = LoraConfig(
|
| 586 |
+
r=args.lora_r,
|
| 587 |
+
lora_alpha=args.lora_alpha,
|
| 588 |
+
target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
|
| 589 |
+
lora_dropout=args.lora_dropout,
|
| 590 |
+
bias="none",
|
| 591 |
+
task_type=TaskType.CAUSAL_LM
|
| 592 |
+
)
|
| 593 |
+
llm = get_peft_model(llm, lora_config)
|
| 594 |
+
|
| 595 |
+
if is_main:
|
| 596 |
+
llm.print_trainable_parameters()
|
| 597 |
+
|
| 598 |
+
# Create adapter (TRAINABLE - same architecture as Stage 1)
|
| 599 |
+
adapter = SpeechAdapter(
|
| 600 |
+
whisper_dim=1280,
|
| 601 |
+
llm_dim=3072,
|
| 602 |
+
downsample=5,
|
| 603 |
+
intermediate_dim=2048
|
| 604 |
+
).to(device, dtype=torch_dtype)
|
| 605 |
+
|
| 606 |
+
# Load Stage 1 adapter checkpoint
|
| 607 |
+
if args.stage1_ckpt and os.path.exists(args.stage1_ckpt):
|
| 608 |
+
if is_main:
|
| 609 |
+
log(f"\nLoading Stage 1 adapter from: {args.stage1_ckpt}")
|
| 610 |
+
ckpt = torch.load(args.stage1_ckpt, map_location=device, weights_only=False)
|
| 611 |
+
if "adapter" in ckpt:
|
| 612 |
+
adapter.load_state_dict(ckpt["adapter"])
|
| 613 |
+
if is_main:
|
| 614 |
+
log(" Adapter weights loaded successfully!")
|
| 615 |
+
if "loss" in ckpt:
|
| 616 |
+
log(f" Stage 1 final loss: {ckpt['loss']:.4f}")
|
| 617 |
+
if "text_ratio" in ckpt:
|
| 618 |
+
log(f" Stage 1 final text_ratio: {ckpt['text_ratio']:.1f}")
|
| 619 |
+
else:
|
| 620 |
+
if is_main:
|
| 621 |
+
log(" WARNING: No 'adapter' key in checkpoint, using random init")
|
| 622 |
+
else:
|
| 623 |
+
if is_main:
|
| 624 |
+
log("\nWARNING: No Stage 1 checkpoint provided, adapter starting from random init")
|
| 625 |
+
log(" Recommended: --stage1_ckpt checkpoints/stage1_best.pt")
|
| 626 |
+
|
| 627 |
+
adapter_params = sum(p.numel() for p in adapter.parameters())
|
| 628 |
+
lora_params = sum(p.numel() for p in llm.parameters() if p.requires_grad)
|
| 629 |
+
|
| 630 |
+
if is_main:
|
| 631 |
+
log(f"\nTrainable parameters:")
|
| 632 |
+
log(f" Adapter: {adapter_params:,} ({adapter_params/1e6:.1f}M)")
|
| 633 |
+
log(f" LoRA: {lora_params:,} ({lora_params/1e6:.1f}M)")
|
| 634 |
+
log(f" Total: {adapter_params + lora_params:,} ({(adapter_params + lora_params)/1e6:.1f}M)")
|
| 635 |
+
|
| 636 |
+
# DDP
|
| 637 |
+
if world_size > 1:
|
| 638 |
+
adapter = DDP(adapter, device_ids=[local_rank])
|
| 639 |
+
llm = DDP(llm, device_ids=[local_rank])
|
| 640 |
+
dist.barrier()
|
| 641 |
+
|
| 642 |
+
# Optimizer (Adapter + LoRA together)
|
| 643 |
+
all_params = list(adapter.parameters()) + [p for p in llm.parameters() if p.requires_grad]
|
| 644 |
+
optimizer = torch.optim.AdamW(all_params, lr=args.lr, weight_decay=0.01)
|
| 645 |
+
|
| 646 |
+
# Training state
|
| 647 |
+
global_step = 0
|
| 648 |
+
start_epoch = 0
|
| 649 |
+
best_loss = float("inf")
|
| 650 |
+
current_text_ratio = args.initial_text_ratio
|
| 651 |
+
|
| 652 |
+
# Resume from Stage 2 checkpoint
|
| 653 |
+
if args.resume and os.path.exists(args.resume):
|
| 654 |
+
if is_main:
|
| 655 |
+
log(f"\nResuming from: {args.resume}")
|
| 656 |
+
ckpt = torch.load(args.resume, map_location=device, weights_only=False)
|
| 657 |
+
adapter_module = adapter.module if hasattr(adapter, "module") else adapter
|
| 658 |
+
llm_module = llm.module if hasattr(llm, "module") else llm
|
| 659 |
+
if "adapter" in ckpt:
|
| 660 |
+
adapter_module.load_state_dict(ckpt["adapter"])
|
| 661 |
+
if "lora" in ckpt:
|
| 662 |
+
llm_module.load_state_dict(ckpt["lora"], strict=False)
|
| 663 |
+
if "optimizer" in ckpt:
|
| 664 |
+
optimizer.load_state_dict(ckpt["optimizer"])
|
| 665 |
+
if "step" in ckpt:
|
| 666 |
+
global_step = ckpt["step"]
|
| 667 |
+
if "epoch" in ckpt:
|
| 668 |
+
start_epoch = ckpt["epoch"]
|
| 669 |
+
if "text_ratio" in ckpt:
|
| 670 |
+
current_text_ratio = ckpt["text_ratio"]
|
| 671 |
+
if "loss" in ckpt:
|
| 672 |
+
best_loss = ckpt["loss"]
|
| 673 |
+
|
| 674 |
+
# Training
|
| 675 |
+
os.makedirs(args.output_dir, exist_ok=True)
|
| 676 |
+
|
| 677 |
+
# Create dataloader first to calculate steps
|
| 678 |
+
def collate_with_ratio(batch):
|
| 679 |
+
current_ratio = get_text_ratio(global_step, args.decay_steps, args.initial_text_ratio)
|
| 680 |
+
return collate_fn(batch, current_ratio, tokenizer=tokenizer)
|
| 681 |
+
|
| 682 |
+
if world_size > 1:
|
| 683 |
+
sampler = DistributedSampler(combined_dataset, shuffle=True)
|
| 684 |
+
sampler.set_epoch(0)
|
| 685 |
+
else:
|
| 686 |
+
sampler = None
|
| 687 |
+
|
| 688 |
+
train_loader = DataLoader(
|
| 689 |
+
combined_dataset,
|
| 690 |
+
batch_size=args.batch_size,
|
| 691 |
+
shuffle=(sampler is None),
|
| 692 |
+
sampler=sampler,
|
| 693 |
+
collate_fn=collate_with_ratio,
|
| 694 |
+
num_workers=4,
|
| 695 |
+
pin_memory=True
|
| 696 |
+
)
|
| 697 |
+
|
| 698 |
+
steps_per_epoch = max(1, len(train_loader) // args.grad_accum)
|
| 699 |
+
total_steps = max(1, steps_per_epoch * args.epochs)
|
| 700 |
+
warmup_steps = int(total_steps * args.warmup_ratio)
|
| 701 |
+
|
| 702 |
+
scheduler = CosineAnnealingLR(optimizer, T_max=max(1, total_steps - warmup_steps), eta_min=1e-6)
|
| 703 |
+
|
| 704 |
+
if is_main:
|
| 705 |
+
log(f"Steps per epoch: {steps_per_epoch}, Total: {total_steps}, Warmup: {warmup_steps}")
|
| 706 |
+
|
| 707 |
+
if is_main:
|
| 708 |
+
log("\n" + "=" * 60)
|
| 709 |
+
log("STARTING STAGE 2 TRAINING")
|
| 710 |
+
log("=" * 60)
|
| 711 |
+
|
| 712 |
+
for epoch in range(start_epoch, args.epochs):
|
| 713 |
+
# Update text ratio based on global step
|
| 714 |
+
current_text_ratio = get_text_ratio(global_step, args.decay_steps, args.initial_text_ratio)
|
| 715 |
+
|
| 716 |
+
# Update sampler for this epoch
|
| 717 |
+
if world_size > 1:
|
| 718 |
+
sampler.set_epoch(epoch)
|
| 719 |
+
|
| 720 |
+
# Recreate dataloader with current text_ratio
|
| 721 |
+
def collate_with_ratio(batch):
|
| 722 |
+
return collate_fn(batch, current_text_ratio, tokenizer=tokenizer)
|
| 723 |
+
|
| 724 |
+
train_loader = DataLoader(
|
| 725 |
+
combined_dataset,
|
| 726 |
+
batch_size=args.batch_size,
|
| 727 |
+
shuffle=(sampler is None),
|
| 728 |
+
sampler=sampler,
|
| 729 |
+
collate_fn=collate_with_ratio,
|
| 730 |
+
num_workers=4,
|
| 731 |
+
pin_memory=True
|
| 732 |
+
)
|
| 733 |
+
|
| 734 |
+
adapter.train()
|
| 735 |
+
llm.train()
|
| 736 |
+
epoch_loss = 0
|
| 737 |
+
accum_loss = 0
|
| 738 |
+
|
| 739 |
+
pbar = tqdm(train_loader, desc=f"Epoch {epoch+1}/{args.epochs}", disable=not is_main)
|
| 740 |
+
|
| 741 |
+
for batch_idx, batch in enumerate(pbar):
|
| 742 |
+
# Update text ratio dynamically
|
| 743 |
+
current_text_ratio = get_text_ratio(global_step, args.decay_steps, args.initial_text_ratio)
|
| 744 |
+
|
| 745 |
+
whisper = batch["whisper"].to(device, dtype=torch_dtype, non_blocking=True)
|
| 746 |
+
interleaved = batch["interleaved"].to(device, non_blocking=True)
|
| 747 |
+
|
| 748 |
+
# Forward through adapter
|
| 749 |
+
adapter_module = adapter.module if hasattr(adapter, "module") else adapter
|
| 750 |
+
llm_module = llm.module if hasattr(llm, "module") else llm
|
| 751 |
+
audio_embeds = adapter_module(whisper)
|
| 752 |
+
|
| 753 |
+
# Get token embeddings for interleaved sequence (teacher forcing)
|
| 754 |
+
input_tokens = interleaved[:, :-1].clamp(min=0)
|
| 755 |
+
with torch.no_grad():
|
| 756 |
+
# Access base model's embed_tokens through PEFT wrapper
|
| 757 |
+
token_embeds = llm_module.get_base_model().model.embed_tokens(input_tokens)
|
| 758 |
+
|
| 759 |
+
# Combine: [audio_embeds] + [token_embeds]
|
| 760 |
+
combined = torch.cat([audio_embeds, token_embeds], dim=1)
|
| 761 |
+
|
| 762 |
+
# Forward through LLM with LoRA (trainable)
|
| 763 |
+
outputs = llm(inputs_embeds=combined, use_cache=False)
|
| 764 |
+
logits = outputs.logits
|
| 765 |
+
|
| 766 |
+
# Loss: predict interleaved tokens after audio prefix
|
| 767 |
+
# logits[:, audio_len-1] predicts first token of interleaved
|
| 768 |
+
# logits[:, audio_len-1+i] predicts interleaved[i]
|
| 769 |
+
audio_len = audio_embeds.shape[1]
|
| 770 |
+
seq_len = interleaved.shape[1]
|
| 771 |
+
|
| 772 |
+
# Get logits that predict the interleaved sequence
|
| 773 |
+
# Position audio_len-1 predicts interleaved[0], etc.
|
| 774 |
+
seq_logits = logits[:, audio_len-1:audio_len-1+seq_len]
|
| 775 |
+
targets = interleaved
|
| 776 |
+
|
| 777 |
+
loss = F.cross_entropy(
|
| 778 |
+
seq_logits.reshape(-1, logits.size(-1)),
|
| 779 |
+
targets.reshape(-1),
|
| 780 |
+
ignore_index=-100,
|
| 781 |
+
label_smoothing=args.label_smoothing
|
| 782 |
+
)
|
| 783 |
+
|
| 784 |
+
loss = loss / args.grad_accum
|
| 785 |
+
loss.backward()
|
| 786 |
+
accum_loss += loss.item() * args.grad_accum
|
| 787 |
+
|
| 788 |
+
# Update
|
| 789 |
+
if (batch_idx + 1) % args.grad_accum == 0 or (batch_idx + 1) == len(train_loader):
|
| 790 |
+
torch.nn.utils.clip_grad_norm_(all_params, args.max_grad_norm)
|
| 791 |
+
optimizer.step()
|
| 792 |
+
optimizer.zero_grad(set_to_none=True)
|
| 793 |
+
|
| 794 |
+
if global_step < warmup_steps:
|
| 795 |
+
lr_scale = (global_step + 1) / warmup_steps
|
| 796 |
+
for pg in optimizer.param_groups:
|
| 797 |
+
pg["lr"] = args.lr * lr_scale
|
| 798 |
+
else:
|
| 799 |
+
scheduler.step()
|
| 800 |
+
|
| 801 |
+
global_step += 1
|
| 802 |
+
epoch_loss += accum_loss
|
| 803 |
+
|
| 804 |
+
pbar.set_postfix(
|
| 805 |
+
loss=f"{accum_loss:.4f}",
|
| 806 |
+
text_ratio=f"{current_text_ratio:.1f}",
|
| 807 |
+
lr=f"{optimizer.param_groups[0]['lr']:.2e}"
|
| 808 |
+
)
|
| 809 |
+
|
| 810 |
+
# Save checkpoint
|
| 811 |
+
if global_step % args.save_steps == 0 and is_main:
|
| 812 |
+
adapter_to_save = adapter.module if hasattr(adapter, "module") else adapter
|
| 813 |
+
llm_to_save = llm.module if hasattr(llm, "module") else llm
|
| 814 |
+
ckpt_path = os.path.join(args.output_dir, f"stage2_step{global_step}.pt")
|
| 815 |
+
save_checkpoint_async({
|
| 816 |
+
"adapter": adapter_to_save.state_dict(),
|
| 817 |
+
"lora": llm_to_save.state_dict(),
|
| 818 |
+
"optimizer": optimizer.state_dict(),
|
| 819 |
+
"step": global_step,
|
| 820 |
+
"epoch": epoch,
|
| 821 |
+
"loss": accum_loss,
|
| 822 |
+
"text_ratio": current_text_ratio
|
| 823 |
+
}, ckpt_path, is_main)
|
| 824 |
+
|
| 825 |
+
accum_loss = 0
|
| 826 |
+
|
| 827 |
+
# Epoch end
|
| 828 |
+
avg_loss = epoch_loss / max(1, steps_per_epoch)
|
| 829 |
+
|
| 830 |
+
if is_main:
|
| 831 |
+
log(f"Epoch {epoch+1} avg loss: {avg_loss:.4f}, text_ratio: {current_text_ratio:.1f}")
|
| 832 |
+
|
| 833 |
+
adapter_to_save = adapter.module if hasattr(adapter, "module") else adapter
|
| 834 |
+
llm_to_save = llm.module if hasattr(llm, "module") else llm
|
| 835 |
+
ckpt_path = os.path.join(args.output_dir, f"stage2_epoch{epoch+1}.pt")
|
| 836 |
+
save_checkpoint_async({
|
| 837 |
+
"adapter": adapter_to_save.state_dict(),
|
| 838 |
+
"lora": llm_to_save.state_dict(),
|
| 839 |
+
"optimizer": optimizer.state_dict(),
|
| 840 |
+
"step": global_step,
|
| 841 |
+
"epoch": epoch + 1,
|
| 842 |
+
"loss": avg_loss,
|
| 843 |
+
"text_ratio": current_text_ratio
|
| 844 |
+
}, ckpt_path, is_main)
|
| 845 |
+
|
| 846 |
+
if avg_loss < best_loss:
|
| 847 |
+
best_loss = avg_loss
|
| 848 |
+
best_path = os.path.join(args.output_dir, "stage2_best.pt")
|
| 849 |
+
save_checkpoint_async({
|
| 850 |
+
"adapter": adapter_to_save.state_dict(),
|
| 851 |
+
"lora": llm_to_save.state_dict(),
|
| 852 |
+
"step": global_step,
|
| 853 |
+
"epoch": epoch + 1,
|
| 854 |
+
"loss": best_loss,
|
| 855 |
+
"text_ratio": current_text_ratio
|
| 856 |
+
}, best_path, is_main)
|
| 857 |
+
log(f"Best model saved! Loss: {best_loss:.4f}")
|
| 858 |
+
|
| 859 |
+
# Finish
|
| 860 |
+
if is_main:
|
| 861 |
+
wait_for_checkpoints()
|
| 862 |
+
log("\n" + "=" * 60)
|
| 863 |
+
log("STAGE 2 COMPLETE!")
|
| 864 |
+
log(f"Best loss: {best_loss:.4f}")
|
| 865 |
+
log(f"Final text_ratio: {current_text_ratio:.1f}")
|
| 866 |
+
log("=" * 60)
|
| 867 |
+
log("\nCheckpoints saved:")
|
| 868 |
+
log(f" Best: {args.output_dir}/stage2_best.pt")
|
| 869 |
+
log(f" Last: {args.output_dir}/stage2_epoch{args.epochs}.pt")
|
| 870 |
+
|
| 871 |
+
if world_size > 1:
|
| 872 |
+
dist.destroy_process_group()
|
| 873 |
+
|
| 874 |
+
|
| 875 |
+
if __name__ == "__main__":
|
| 876 |
+
main()
|
passo4_inference.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Passo 4: Inference - Speech-to-Speech
|
| 4 |
+
Load trained model and generate audio responses from audio input.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
import argparse
|
| 10 |
+
import torch
|
| 11 |
+
import torchaudio
|
| 12 |
+
import numpy as np
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
# SNAC token configuration
|
| 16 |
+
SNAC_BASE = 128266
|
| 17 |
+
EOS_TOKEN = 128009
|
| 18 |
+
|
| 19 |
+
def load_models(checkpoint_path: str, device: str = "cuda"):
|
| 20 |
+
"""Load all models for inference."""
|
| 21 |
+
from transformers import WhisperModel, WhisperFeatureExtractor, AutoTokenizer
|
| 22 |
+
from peft import PeftModel
|
| 23 |
+
import snac
|
| 24 |
+
|
| 25 |
+
print("Loading models...")
|
| 26 |
+
|
| 27 |
+
# Load Whisper encoder
|
| 28 |
+
print(" Whisper encoder...")
|
| 29 |
+
whisper_model = WhisperModel.from_pretrained("openai/whisper-large-v3").to(device)
|
| 30 |
+
whisper_model.eval()
|
| 31 |
+
feature_extractor = WhisperFeatureExtractor.from_pretrained("openai/whisper-large-v3")
|
| 32 |
+
|
| 33 |
+
# Load checkpoint
|
| 34 |
+
print(f" Loading checkpoint: {checkpoint_path}")
|
| 35 |
+
checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
|
| 36 |
+
|
| 37 |
+
# Load LLM with LoRA
|
| 38 |
+
print(" Loading LLM with LoRA...")
|
| 39 |
+
from transformers import AutoModelForCausalLM
|
| 40 |
+
|
| 41 |
+
llm = AutoModelForCausalLM.from_pretrained(
|
| 42 |
+
"canopylabs/3b-es_it-ft-research_release",
|
| 43 |
+
torch_dtype=torch.bfloat16,
|
| 44 |
+
device_map=device
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Load LoRA weights if present
|
| 48 |
+
if 'lora' in checkpoint or 'lora_state_dict' in checkpoint:
|
| 49 |
+
print(" Loading LoRA weights...")
|
| 50 |
+
from peft import LoraConfig, get_peft_model
|
| 51 |
+
lora_config = LoraConfig(
|
| 52 |
+
r=16,
|
| 53 |
+
lora_alpha=32,
|
| 54 |
+
target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
|
| 55 |
+
lora_dropout=0.05,
|
| 56 |
+
bias="none",
|
| 57 |
+
task_type="CAUSAL_LM"
|
| 58 |
+
)
|
| 59 |
+
llm = get_peft_model(llm, lora_config)
|
| 60 |
+
lora_key = 'lora' if 'lora' in checkpoint else 'lora_state_dict'
|
| 61 |
+
llm.load_state_dict(checkpoint[lora_key], strict=False)
|
| 62 |
+
|
| 63 |
+
llm.eval()
|
| 64 |
+
|
| 65 |
+
# Load Speech Adapter
|
| 66 |
+
print(" Loading Speech Adapter...")
|
| 67 |
+
from passo2_finetune_stage1 import SpeechAdapter
|
| 68 |
+
adapter = SpeechAdapter(
|
| 69 |
+
whisper_dim=1280,
|
| 70 |
+
llm_dim=3072,
|
| 71 |
+
downsample=5
|
| 72 |
+
).to(device)
|
| 73 |
+
|
| 74 |
+
adapter_key = 'adapter' if 'adapter' in checkpoint else 'adapter_state_dict'
|
| 75 |
+
if adapter_key in checkpoint:
|
| 76 |
+
adapter.load_state_dict(checkpoint[adapter_key])
|
| 77 |
+
adapter.eval()
|
| 78 |
+
|
| 79 |
+
# Load SNAC decoder
|
| 80 |
+
print(" SNAC decoder...")
|
| 81 |
+
snac_model = snac.SNAC.from_pretrained("hubertsiuzdak/snac_24khz").to(device)
|
| 82 |
+
snac_model.eval()
|
| 83 |
+
|
| 84 |
+
# Load tokenizer
|
| 85 |
+
tokenizer = AutoTokenizer.from_pretrained("canopylabs/3b-es_it-ft-research_release")
|
| 86 |
+
|
| 87 |
+
print("Models loaded!")
|
| 88 |
+
return whisper_model, feature_extractor, llm, adapter, snac_model, tokenizer
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def encode_audio(audio_path: str, whisper_model, feature_extractor, adapter, device: str):
|
| 92 |
+
"""Encode input audio to embeddings."""
|
| 93 |
+
# Load audio
|
| 94 |
+
waveform, sr = torchaudio.load(audio_path)
|
| 95 |
+
if sr != 16000:
|
| 96 |
+
waveform = torchaudio.functional.resample(waveform, sr, 16000)
|
| 97 |
+
|
| 98 |
+
# Extract Whisper features
|
| 99 |
+
with torch.no_grad():
|
| 100 |
+
inputs = feature_extractor(waveform.squeeze().numpy(), sampling_rate=16000, return_tensors="pt")
|
| 101 |
+
whisper_features = whisper_model.encoder(
|
| 102 |
+
inputs.input_features.to(device)
|
| 103 |
+
).last_hidden_state
|
| 104 |
+
|
| 105 |
+
# Adapt to LLM space
|
| 106 |
+
adapted = adapter(whisper_features)
|
| 107 |
+
|
| 108 |
+
return adapted.to(torch.bfloat16)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def decode_snac_tokens(tokens: list, snac_model, device: str):
|
| 112 |
+
"""Decode SNAC tokens to audio waveform."""
|
| 113 |
+
# Remove offsets and reshape
|
| 114 |
+
# SNAC has 3 layers with 1:2:4 ratio
|
| 115 |
+
layer0_tokens = []
|
| 116 |
+
layer1_tokens = []
|
| 117 |
+
layer2_tokens = []
|
| 118 |
+
|
| 119 |
+
# Parse interleaved tokens: 1 from layer0, 2 from layer1, 4 from layer2 per frame
|
| 120 |
+
i = 0
|
| 121 |
+
while i < len(tokens):
|
| 122 |
+
if i < len(tokens):
|
| 123 |
+
layer0_tokens.append(tokens[i] - SNAC_BASE)
|
| 124 |
+
i += 1
|
| 125 |
+
if i < len(tokens):
|
| 126 |
+
layer1_tokens.append(tokens[i] - SNAC_BASE - 4096)
|
| 127 |
+
i += 1
|
| 128 |
+
if i < len(tokens):
|
| 129 |
+
layer1_tokens.append(tokens[i] - SNAC_BASE - 4096)
|
| 130 |
+
i += 1
|
| 131 |
+
for _ in range(4):
|
| 132 |
+
if i < len(tokens):
|
| 133 |
+
layer2_tokens.append(tokens[i] - SNAC_BASE - 8192)
|
| 134 |
+
i += 1
|
| 135 |
+
|
| 136 |
+
# Create tensors
|
| 137 |
+
min_len = min(len(layer0_tokens), len(layer1_tokens) // 2, len(layer2_tokens) // 4)
|
| 138 |
+
if min_len == 0:
|
| 139 |
+
return np.zeros(24000, dtype=np.float32) # 1 second of silence
|
| 140 |
+
|
| 141 |
+
codes = [
|
| 142 |
+
torch.tensor([layer0_tokens[:min_len]], dtype=torch.long, device=device),
|
| 143 |
+
torch.tensor([layer1_tokens[:min_len * 2]], dtype=torch.long, device=device),
|
| 144 |
+
torch.tensor([layer2_tokens[:min_len * 4]], dtype=torch.long, device=device)
|
| 145 |
+
]
|
| 146 |
+
|
| 147 |
+
# Decode
|
| 148 |
+
with torch.no_grad():
|
| 149 |
+
audio = snac_model.decode(codes)
|
| 150 |
+
|
| 151 |
+
return audio.squeeze().cpu().numpy()
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def generate_response(
|
| 155 |
+
audio_input: str,
|
| 156 |
+
whisper_model, feature_extractor, llm, adapter, snac_model, tokenizer,
|
| 157 |
+
device: str,
|
| 158 |
+
max_new_tokens: int = 500,
|
| 159 |
+
temperature: float = 0.7
|
| 160 |
+
):
|
| 161 |
+
"""Generate speech response from audio input."""
|
| 162 |
+
|
| 163 |
+
# Encode input audio
|
| 164 |
+
print("Encoding input audio...")
|
| 165 |
+
audio_embeddings = encode_audio(audio_input, whisper_model, feature_extractor, adapter, device)
|
| 166 |
+
|
| 167 |
+
# Generate with LLM
|
| 168 |
+
print("Generating response...")
|
| 169 |
+
|
| 170 |
+
# Create input embeds by concatenating audio embeddings with start tokens
|
| 171 |
+
with torch.no_grad():
|
| 172 |
+
# Get embeddings layer
|
| 173 |
+
embed_layer = llm.get_input_embeddings()
|
| 174 |
+
|
| 175 |
+
# Add start token
|
| 176 |
+
start_tokens = tokenizer.encode("<|start|>", add_special_tokens=False, return_tensors="pt").to(device)
|
| 177 |
+
start_embeds = embed_layer(start_tokens)
|
| 178 |
+
|
| 179 |
+
# Concatenate: audio + start (ensure same dtype)
|
| 180 |
+
input_embeds = torch.cat([audio_embeddings.to(torch.bfloat16), start_embeds.to(torch.bfloat16)], dim=1)
|
| 181 |
+
|
| 182 |
+
# Generate
|
| 183 |
+
outputs = llm.generate(
|
| 184 |
+
inputs_embeds=input_embeds,
|
| 185 |
+
max_new_tokens=max_new_tokens,
|
| 186 |
+
temperature=temperature,
|
| 187 |
+
do_sample=True,
|
| 188 |
+
top_p=0.9,
|
| 189 |
+
pad_token_id=tokenizer.pad_token_id,
|
| 190 |
+
eos_token_id=EOS_TOKEN
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
# Extract SNAC tokens from output
|
| 194 |
+
generated_tokens = outputs[0].tolist()
|
| 195 |
+
snac_tokens = [t for t in generated_tokens if SNAC_BASE <= t < SNAC_BASE + 3 * 4096]
|
| 196 |
+
|
| 197 |
+
print(f"Generated {len(snac_tokens)} SNAC tokens")
|
| 198 |
+
|
| 199 |
+
# Decode to audio
|
| 200 |
+
print("Decoding to audio...")
|
| 201 |
+
audio_output = decode_snac_tokens(snac_tokens, snac_model, device)
|
| 202 |
+
|
| 203 |
+
return audio_output
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def main():
|
| 207 |
+
parser = argparse.ArgumentParser(description="Speech-to-Speech Inference")
|
| 208 |
+
parser.add_argument("--checkpoint", type=str, required=True, help="Path to model checkpoint")
|
| 209 |
+
parser.add_argument("--input", type=str, required=True, help="Input audio file")
|
| 210 |
+
parser.add_argument("--output", type=str, default="output.wav", help="Output audio file")
|
| 211 |
+
parser.add_argument("--max_tokens", type=int, default=500, help="Max tokens to generate")
|
| 212 |
+
parser.add_argument("--temperature", type=float, default=0.7, help="Sampling temperature")
|
| 213 |
+
args = parser.parse_args()
|
| 214 |
+
|
| 215 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 216 |
+
print(f"Device: {device}")
|
| 217 |
+
|
| 218 |
+
# Load models
|
| 219 |
+
whisper_model, feature_extractor, llm, adapter, snac_model, tokenizer = load_models(
|
| 220 |
+
args.checkpoint, device
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
# Generate
|
| 224 |
+
audio_output = generate_response(
|
| 225 |
+
args.input,
|
| 226 |
+
whisper_model, feature_extractor, llm, adapter, snac_model, tokenizer,
|
| 227 |
+
device,
|
| 228 |
+
max_new_tokens=args.max_tokens,
|
| 229 |
+
temperature=args.temperature
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
# Save output
|
| 233 |
+
import soundfile as sf
|
| 234 |
+
sf.write(args.output, audio_output, 24000)
|
| 235 |
+
print(f"Saved output to: {args.output}")
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
if __name__ == "__main__":
|
| 239 |
+
main()
|
services/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Services for dataset generation.
|
| 3 |
+
"""
|
| 4 |
+
from .groq_service import GroqService
|
| 5 |
+
from .whisperx_service import WhisperXService
|
| 6 |
+
from .kokoro_service import KokoroService
|
| 7 |
+
from .llama_service import LlamaService
|
| 8 |
+
|
| 9 |
+
__all__ = ['GroqService', 'WhisperXService', 'KokoroService', 'LlamaService']
|
services/groq_service.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Groq API Service for Q&A generation.
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
import requests
|
| 6 |
+
from typing import List, Dict, Optional
|
| 7 |
+
|
| 8 |
+
GROQ_API_KEY = os.environ.get('GROQ_API_KEY', '')
|
| 9 |
+
GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"
|
| 10 |
+
DEFAULT_MODEL = "llama-3.1-8b-instant"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class GroqService:
|
| 14 |
+
"""Service for generating Q&A pairs using Groq API."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, api_key: Optional[str] = None, model: str = DEFAULT_MODEL):
|
| 17 |
+
self.api_key = api_key or GROQ_API_KEY
|
| 18 |
+
self.model = model
|
| 19 |
+
self.headers = {
|
| 20 |
+
"Authorization": f"Bearer {self.api_key}",
|
| 21 |
+
"Content-Type": "application/json"
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
def generate_qa_pairs(self, count: int, timeout: int = 30) -> List[Dict[str, str]]:
|
| 25 |
+
"""Generate Q&A pairs.
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
count: Number of Q&A pairs to generate.
|
| 29 |
+
timeout: Request timeout in seconds.
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
List of dicts with 'question' and 'answer' keys.
|
| 33 |
+
"""
|
| 34 |
+
prompt = f"""Generate {count} unique question-answer pairs about general knowledge.
|
| 35 |
+
REQUIREMENTS:
|
| 36 |
+
- Questions: 5 to 10 words
|
| 37 |
+
- Answers: 5 to 10 words
|
| 38 |
+
Format: Q: [question]
|
| 39 |
+
A: [answer]
|
| 40 |
+
Return exactly {count} pairs."""
|
| 41 |
+
|
| 42 |
+
payload = {
|
| 43 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 44 |
+
"model": self.model,
|
| 45 |
+
"temperature": 0.7,
|
| 46 |
+
"max_tokens": 2000
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
response = requests.post(
|
| 51 |
+
GROQ_API_URL,
|
| 52 |
+
headers=self.headers,
|
| 53 |
+
json=payload,
|
| 54 |
+
timeout=timeout
|
| 55 |
+
)
|
| 56 |
+
response.raise_for_status()
|
| 57 |
+
content = response.json()['choices'][0]['message']['content']
|
| 58 |
+
return self._parse_qa_pairs(content)
|
| 59 |
+
except Exception as e:
|
| 60 |
+
print(f"[GroqService] Error: {e}")
|
| 61 |
+
return []
|
| 62 |
+
|
| 63 |
+
def _parse_qa_pairs(self, content: str) -> List[Dict[str, str]]:
|
| 64 |
+
"""Parse Q&A pairs from API response."""
|
| 65 |
+
pairs = []
|
| 66 |
+
lines = content.split('\n')
|
| 67 |
+
current_q, current_a = None, None
|
| 68 |
+
|
| 69 |
+
for line in lines:
|
| 70 |
+
line = line.strip()
|
| 71 |
+
if line.lower().startswith('q:'):
|
| 72 |
+
current_q = line[2:].strip()
|
| 73 |
+
elif line.lower().startswith('a:'):
|
| 74 |
+
current_a = line[2:].strip()
|
| 75 |
+
if current_q and current_a:
|
| 76 |
+
word_count_q = len(current_q.split())
|
| 77 |
+
word_count_a = len(current_a.split())
|
| 78 |
+
if 3 <= word_count_q <= 15 and 3 <= word_count_a <= 15:
|
| 79 |
+
pairs.append({'question': current_q, 'answer': current_a})
|
| 80 |
+
current_q, current_a = None, None
|
| 81 |
+
|
| 82 |
+
return pairs
|
| 83 |
+
|
| 84 |
+
def generate_batch(self, total_count: int, batch_size: int = 15) -> List[Dict[str, str]]:
|
| 85 |
+
"""Generate multiple batches of Q&A pairs.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
total_count: Total number of pairs needed.
|
| 89 |
+
batch_size: Pairs per API call.
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
List of all generated Q&A pairs.
|
| 93 |
+
"""
|
| 94 |
+
all_pairs = []
|
| 95 |
+
remaining = total_count
|
| 96 |
+
|
| 97 |
+
while remaining > 0:
|
| 98 |
+
count = min(batch_size, remaining)
|
| 99 |
+
pairs = self.generate_qa_pairs(count)
|
| 100 |
+
all_pairs.extend(pairs)
|
| 101 |
+
remaining -= len(pairs)
|
| 102 |
+
|
| 103 |
+
if not pairs:
|
| 104 |
+
break
|
| 105 |
+
|
| 106 |
+
return all_pairs[:total_count]
|
services/kokoro_service.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Kokoro TTS Service for text-to-speech synthesis.
|
| 3 |
+
|
| 4 |
+
IMPORTANT: For GPU acceleration, set these environment variables BEFORE importing:
|
| 5 |
+
export LD_LIBRARY_PATH="/usr/local/lib/python3.12/dist-packages/nvidia/cublas/lib:..."
|
| 6 |
+
export ONNX_PROVIDER=CUDAExecutionProvider
|
| 7 |
+
|
| 8 |
+
Or use: source /tmp/setup_env.sh
|
| 9 |
+
"""
|
| 10 |
+
import os
|
| 11 |
+
import numpy as np
|
| 12 |
+
from typing import Tuple, List, Optional
|
| 13 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 14 |
+
|
| 15 |
+
# Default paths
|
| 16 |
+
DEFAULT_MODEL_PATH = '/tmp/kokoro-v1.0.onnx'
|
| 17 |
+
DEFAULT_VOICES_PATH = '/tmp/voices-v1.0.bin'
|
| 18 |
+
DEFAULT_SAMPLE_RATE = 24000
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class KokoroService:
|
| 22 |
+
"""Service for Kokoro TTS synthesis."""
|
| 23 |
+
|
| 24 |
+
def __init__(
|
| 25 |
+
self,
|
| 26 |
+
model_path: str = DEFAULT_MODEL_PATH,
|
| 27 |
+
voices_path: str = DEFAULT_VOICES_PATH,
|
| 28 |
+
num_workers: int = 1
|
| 29 |
+
):
|
| 30 |
+
self.model_path = model_path
|
| 31 |
+
self.voices_path = voices_path
|
| 32 |
+
self.num_workers = num_workers
|
| 33 |
+
self.sample_rate = DEFAULT_SAMPLE_RATE
|
| 34 |
+
|
| 35 |
+
self._models = None
|
| 36 |
+
|
| 37 |
+
def load(self) -> 'KokoroService':
|
| 38 |
+
"""Load Kokoro TTS models."""
|
| 39 |
+
from kokoro_onnx import Kokoro
|
| 40 |
+
|
| 41 |
+
print(f"[KokoroTTS] Loading {self.num_workers} model(s)...")
|
| 42 |
+
|
| 43 |
+
# Load models
|
| 44 |
+
self._models = [
|
| 45 |
+
Kokoro(self.model_path, self.voices_path)
|
| 46 |
+
for _ in range(self.num_workers)
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
# Log provider info
|
| 50 |
+
providers = self._models[0].sess.get_providers()
|
| 51 |
+
print(f"[KokoroTTS] ONNX Providers: {providers}")
|
| 52 |
+
|
| 53 |
+
if 'CUDAExecutionProvider' not in providers:
|
| 54 |
+
print("[KokoroTTS] WARNING: Running on CPU! Set ONNX_PROVIDER=CUDAExecutionProvider")
|
| 55 |
+
|
| 56 |
+
# Warmup
|
| 57 |
+
for model in self._models:
|
| 58 |
+
model.create("warmup", voice='af_heart', speed=1.0)
|
| 59 |
+
|
| 60 |
+
print(f"[KokoroTTS] Models loaded and warmed up")
|
| 61 |
+
return self
|
| 62 |
+
|
| 63 |
+
def synthesize(
|
| 64 |
+
self,
|
| 65 |
+
text: str,
|
| 66 |
+
voice: str = 'af_heart',
|
| 67 |
+
speed: float = 1.0
|
| 68 |
+
) -> Tuple[np.ndarray, int]:
|
| 69 |
+
"""Synthesize speech from text.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
text: Text to synthesize.
|
| 73 |
+
voice: Voice ID to use.
|
| 74 |
+
speed: Speech speed multiplier.
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
Tuple of (audio samples, sample rate).
|
| 78 |
+
"""
|
| 79 |
+
if self._models is None:
|
| 80 |
+
self.load()
|
| 81 |
+
|
| 82 |
+
samples, sr = self._models[0].create(text, voice=voice, speed=speed)
|
| 83 |
+
return samples, sr
|
| 84 |
+
|
| 85 |
+
def synthesize_batch(
|
| 86 |
+
self,
|
| 87 |
+
texts: List[str],
|
| 88 |
+
voice: str = 'af_heart',
|
| 89 |
+
speed: float = 1.0
|
| 90 |
+
) -> List[Tuple[np.ndarray, int]]:
|
| 91 |
+
"""Synthesize multiple texts in parallel.
|
| 92 |
+
|
| 93 |
+
Args:
|
| 94 |
+
texts: List of texts to synthesize.
|
| 95 |
+
voice: Voice ID to use.
|
| 96 |
+
speed: Speech speed multiplier.
|
| 97 |
+
|
| 98 |
+
Returns:
|
| 99 |
+
List of (audio samples, sample rate) tuples.
|
| 100 |
+
"""
|
| 101 |
+
if self._models is None:
|
| 102 |
+
self.load()
|
| 103 |
+
|
| 104 |
+
def process_text(args):
|
| 105 |
+
idx, text, model = args
|
| 106 |
+
try:
|
| 107 |
+
samples, sr = model.create(text, voice=voice, speed=speed)
|
| 108 |
+
return idx, samples, sr
|
| 109 |
+
except Exception as e:
|
| 110 |
+
print(f"[KokoroTTS] Error synthesizing: {e}")
|
| 111 |
+
return idx, None, None
|
| 112 |
+
|
| 113 |
+
results = [None] * len(texts)
|
| 114 |
+
|
| 115 |
+
with ThreadPoolExecutor(max_workers=self.num_workers) as executor:
|
| 116 |
+
tasks = [
|
| 117 |
+
(i, text, self._models[i % self.num_workers])
|
| 118 |
+
for i, text in enumerate(texts)
|
| 119 |
+
]
|
| 120 |
+
futures = {executor.submit(process_text, task): task for task in tasks}
|
| 121 |
+
|
| 122 |
+
for future in as_completed(futures):
|
| 123 |
+
idx, samples, sr = future.result()
|
| 124 |
+
if samples is not None:
|
| 125 |
+
results[idx] = (samples, sr)
|
| 126 |
+
|
| 127 |
+
return [r for r in results if r is not None]
|
| 128 |
+
|
| 129 |
+
def synthesize_qa_pair(
|
| 130 |
+
self,
|
| 131 |
+
question: str,
|
| 132 |
+
answer: str,
|
| 133 |
+
voice: str = 'af_heart',
|
| 134 |
+
speed: float = 1.0
|
| 135 |
+
) -> dict:
|
| 136 |
+
"""Synthesize a Q&A pair.
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
question: Question text.
|
| 140 |
+
answer: Answer text.
|
| 141 |
+
voice: Voice ID to use.
|
| 142 |
+
speed: Speech speed multiplier.
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
Dict with 'q_audio', 'a_audio', 'question', 'answer' keys.
|
| 146 |
+
"""
|
| 147 |
+
if self._models is None:
|
| 148 |
+
self.load()
|
| 149 |
+
|
| 150 |
+
model = self._models[0]
|
| 151 |
+
q_samples, _ = model.create(question, voice=voice, speed=speed)
|
| 152 |
+
a_samples, _ = model.create(answer, voice=voice, speed=speed)
|
| 153 |
+
|
| 154 |
+
return {
|
| 155 |
+
'question': question,
|
| 156 |
+
'answer': answer,
|
| 157 |
+
'q_audio': q_samples,
|
| 158 |
+
'a_audio': a_samples
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
def synthesize_qa_batch(
|
| 162 |
+
self,
|
| 163 |
+
qa_pairs: List[dict],
|
| 164 |
+
voice: str = 'af_heart',
|
| 165 |
+
speed: float = 1.0
|
| 166 |
+
) -> List[dict]:
|
| 167 |
+
"""Synthesize multiple Q&A pairs.
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
qa_pairs: List of dicts with 'question' and 'answer' keys.
|
| 171 |
+
voice: Voice ID to use.
|
| 172 |
+
speed: Speech speed multiplier.
|
| 173 |
+
|
| 174 |
+
Returns:
|
| 175 |
+
List of dicts with audio added.
|
| 176 |
+
"""
|
| 177 |
+
if self._models is None:
|
| 178 |
+
self.load()
|
| 179 |
+
|
| 180 |
+
def process_pair(args):
|
| 181 |
+
idx, pair, model = args
|
| 182 |
+
try:
|
| 183 |
+
q_samples, _ = model.create(pair['question'], voice=voice, speed=speed)
|
| 184 |
+
a_samples, _ = model.create(pair['answer'], voice=voice, speed=speed)
|
| 185 |
+
return {
|
| 186 |
+
'question': pair['question'],
|
| 187 |
+
'answer': pair['answer'],
|
| 188 |
+
'q_audio': q_samples,
|
| 189 |
+
'a_audio': a_samples
|
| 190 |
+
}
|
| 191 |
+
except Exception as e:
|
| 192 |
+
print(f"[KokoroTTS] Error: {e}")
|
| 193 |
+
return None
|
| 194 |
+
|
| 195 |
+
results = []
|
| 196 |
+
|
| 197 |
+
with ThreadPoolExecutor(max_workers=self.num_workers) as executor:
|
| 198 |
+
tasks = [
|
| 199 |
+
(i, pair, self._models[i % self.num_workers])
|
| 200 |
+
for i, pair in enumerate(qa_pairs)
|
| 201 |
+
]
|
| 202 |
+
futures = {executor.submit(process_pair, task): task for task in tasks}
|
| 203 |
+
|
| 204 |
+
for future in as_completed(futures):
|
| 205 |
+
result = future.result()
|
| 206 |
+
if result:
|
| 207 |
+
results.append(result)
|
| 208 |
+
|
| 209 |
+
return results
|
| 210 |
+
|
| 211 |
+
@property
|
| 212 |
+
def is_gpu_enabled(self) -> bool:
|
| 213 |
+
"""Check if GPU acceleration is enabled."""
|
| 214 |
+
if self._models is None:
|
| 215 |
+
self.load()
|
| 216 |
+
providers = self._models[0].sess.get_providers()
|
| 217 |
+
return 'CUDAExecutionProvider' in providers
|
| 218 |
+
|
| 219 |
+
@staticmethod
|
| 220 |
+
def setup_gpu_environment():
|
| 221 |
+
"""Set up environment variables for GPU acceleration.
|
| 222 |
+
|
| 223 |
+
Call this BEFORE importing kokoro_onnx.
|
| 224 |
+
"""
|
| 225 |
+
nvidia_libs = []
|
| 226 |
+
for pyver in ['python3.12', 'python3.11', 'python3.10']:
|
| 227 |
+
nvidia_base = f'/usr/local/lib/{pyver}/dist-packages/nvidia'
|
| 228 |
+
for subdir in ['cublas', 'cudnn', 'cufft', 'curand', 'cusolver', 'cusparse',
|
| 229 |
+
'cuda_runtime', 'cuda_nvrtc', 'cuda_cupti', 'nvjitlink']:
|
| 230 |
+
lib_path = f"{nvidia_base}/{subdir}/lib"
|
| 231 |
+
if os.path.exists(lib_path):
|
| 232 |
+
nvidia_libs.append(lib_path)
|
| 233 |
+
|
| 234 |
+
if nvidia_libs:
|
| 235 |
+
os.environ['LD_LIBRARY_PATH'] = ':'.join(nvidia_libs) + ':' + os.environ.get('LD_LIBRARY_PATH', '')
|
| 236 |
+
os.environ['ONNX_PROVIDER'] = 'CUDAExecutionProvider'
|
| 237 |
+
return True
|
| 238 |
+
return False
|
services/llama_service.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Llama 3.2 Service for Q&A generation using llama.cpp.
|
| 3 |
+
|
| 4 |
+
Requires: pip install llama-cpp-python
|
| 5 |
+
For GPU: CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python --force-reinstall --no-cache-dir
|
| 6 |
+
"""
|
| 7 |
+
import os
|
| 8 |
+
from typing import List, Dict, Optional
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
# Default model path
|
| 12 |
+
DEFAULT_MODEL_PATH = "/tmp/llama-3.2-3b-instruct-q4_k_m.gguf"
|
| 13 |
+
DEFAULT_MODEL_URL = "https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class LlamaService:
|
| 17 |
+
"""Service for generating Q&A pairs using Llama 3.2 via llama.cpp."""
|
| 18 |
+
|
| 19 |
+
def __init__(
|
| 20 |
+
self,
|
| 21 |
+
model_path: str = DEFAULT_MODEL_PATH,
|
| 22 |
+
n_gpu_layers: int = -1, # -1 = all layers on GPU
|
| 23 |
+
n_ctx: int = 2048,
|
| 24 |
+
n_batch: int = 512,
|
| 25 |
+
verbose: bool = False
|
| 26 |
+
):
|
| 27 |
+
self.model_path = model_path
|
| 28 |
+
self.n_gpu_layers = n_gpu_layers
|
| 29 |
+
self.n_ctx = n_ctx
|
| 30 |
+
self.n_batch = n_batch
|
| 31 |
+
self.verbose = verbose
|
| 32 |
+
self._llm = None
|
| 33 |
+
|
| 34 |
+
def download_model(self) -> bool:
|
| 35 |
+
"""Download the model if not present."""
|
| 36 |
+
if os.path.exists(self.model_path):
|
| 37 |
+
print(f"[LlamaService] Model already exists: {self.model_path}")
|
| 38 |
+
return True
|
| 39 |
+
|
| 40 |
+
print(f"[LlamaService] Downloading model to {self.model_path}...")
|
| 41 |
+
import subprocess
|
| 42 |
+
result = subprocess.run(
|
| 43 |
+
["wget", "-q", "--show-progress", "-O", self.model_path, DEFAULT_MODEL_URL],
|
| 44 |
+
capture_output=False
|
| 45 |
+
)
|
| 46 |
+
return result.returncode == 0
|
| 47 |
+
|
| 48 |
+
def load(self) -> 'LlamaService':
|
| 49 |
+
"""Load the Llama model."""
|
| 50 |
+
from llama_cpp import Llama
|
| 51 |
+
|
| 52 |
+
if not os.path.exists(self.model_path):
|
| 53 |
+
self.download_model()
|
| 54 |
+
|
| 55 |
+
print(f"[LlamaService] Loading model from {self.model_path}...")
|
| 56 |
+
print(f"[LlamaService] GPU layers: {self.n_gpu_layers}, Context: {self.n_ctx}")
|
| 57 |
+
|
| 58 |
+
self._llm = Llama(
|
| 59 |
+
model_path=self.model_path,
|
| 60 |
+
n_gpu_layers=self.n_gpu_layers,
|
| 61 |
+
n_ctx=self.n_ctx,
|
| 62 |
+
n_batch=self.n_batch,
|
| 63 |
+
verbose=self.verbose
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
print(f"[LlamaService] Model loaded successfully")
|
| 67 |
+
return self
|
| 68 |
+
|
| 69 |
+
def generate(
|
| 70 |
+
self,
|
| 71 |
+
prompt: str,
|
| 72 |
+
max_tokens: int = 512,
|
| 73 |
+
temperature: float = 0.7,
|
| 74 |
+
stop: Optional[List[str]] = None
|
| 75 |
+
) -> str:
|
| 76 |
+
"""Generate text from prompt.
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
prompt: The prompt text.
|
| 80 |
+
max_tokens: Maximum tokens to generate.
|
| 81 |
+
temperature: Sampling temperature.
|
| 82 |
+
stop: Stop sequences.
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
Generated text.
|
| 86 |
+
"""
|
| 87 |
+
if self._llm is None:
|
| 88 |
+
self.load()
|
| 89 |
+
|
| 90 |
+
response = self._llm(
|
| 91 |
+
prompt,
|
| 92 |
+
max_tokens=max_tokens,
|
| 93 |
+
temperature=temperature,
|
| 94 |
+
stop=stop or [],
|
| 95 |
+
echo=False
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
return response['choices'][0]['text'].strip()
|
| 99 |
+
|
| 100 |
+
def generate_qa_pairs(self, count: int) -> List[Dict[str, str]]:
|
| 101 |
+
"""Generate Q&A pairs.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
count: Number of Q&A pairs to generate.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
List of dicts with 'question' and 'answer' keys.
|
| 108 |
+
"""
|
| 109 |
+
if self._llm is None:
|
| 110 |
+
self.load()
|
| 111 |
+
|
| 112 |
+
prompt = f"""<|begin_of_text|><|start_header_id|>system<|end_header_id|>
|
| 113 |
+
You are a helpful assistant that generates question-answer pairs about general knowledge.<|eot_id|><|start_header_id|>user<|end_header_id|>
|
| 114 |
+
Generate {count} unique question-answer pairs about general knowledge.
|
| 115 |
+
REQUIREMENTS:
|
| 116 |
+
- Questions: 5 to 10 words
|
| 117 |
+
- Answers: 5 to 10 words
|
| 118 |
+
Format each pair as:
|
| 119 |
+
Q: [question]
|
| 120 |
+
A: [answer]
|
| 121 |
+
|
| 122 |
+
Generate exactly {count} pairs now:<|eot_id|><|start_header_id|>assistant<|end_header_id|>
|
| 123 |
+
"""
|
| 124 |
+
|
| 125 |
+
response = self._llm(
|
| 126 |
+
prompt,
|
| 127 |
+
max_tokens=count * 50, # ~50 tokens per QA pair
|
| 128 |
+
temperature=0.7,
|
| 129 |
+
stop=["<|eot_id|>"],
|
| 130 |
+
echo=False
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
content = response['choices'][0]['text']
|
| 134 |
+
return self._parse_qa_pairs(content)
|
| 135 |
+
|
| 136 |
+
def _parse_qa_pairs(self, content: str) -> List[Dict[str, str]]:
|
| 137 |
+
"""Parse Q&A pairs from generated text."""
|
| 138 |
+
pairs = []
|
| 139 |
+
lines = content.split('\n')
|
| 140 |
+
current_q, current_a = None, None
|
| 141 |
+
|
| 142 |
+
for line in lines:
|
| 143 |
+
line = line.strip()
|
| 144 |
+
if line.lower().startswith('q:'):
|
| 145 |
+
current_q = line[2:].strip()
|
| 146 |
+
elif line.lower().startswith('a:'):
|
| 147 |
+
current_a = line[2:].strip()
|
| 148 |
+
if current_q and current_a:
|
| 149 |
+
word_count_q = len(current_q.split())
|
| 150 |
+
word_count_a = len(current_a.split())
|
| 151 |
+
if 3 <= word_count_q <= 15 and 3 <= word_count_a <= 15:
|
| 152 |
+
pairs.append({'question': current_q, 'answer': current_a})
|
| 153 |
+
current_q, current_a = None, None
|
| 154 |
+
|
| 155 |
+
return pairs
|
| 156 |
+
|
| 157 |
+
def generate_batch(self, total_count: int, batch_size: int = 15) -> List[Dict[str, str]]:
|
| 158 |
+
"""Generate multiple batches of Q&A pairs.
|
| 159 |
+
|
| 160 |
+
Args:
|
| 161 |
+
total_count: Total number of pairs needed.
|
| 162 |
+
batch_size: Pairs per generation call.
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
List of all generated Q&A pairs.
|
| 166 |
+
"""
|
| 167 |
+
all_pairs = []
|
| 168 |
+
remaining = total_count
|
| 169 |
+
|
| 170 |
+
while remaining > 0:
|
| 171 |
+
count = min(batch_size, remaining)
|
| 172 |
+
pairs = self.generate_qa_pairs(count)
|
| 173 |
+
all_pairs.extend(pairs)
|
| 174 |
+
remaining -= len(pairs)
|
| 175 |
+
|
| 176 |
+
if not pairs:
|
| 177 |
+
# Retry once if no pairs generated
|
| 178 |
+
pairs = self.generate_qa_pairs(count)
|
| 179 |
+
all_pairs.extend(pairs)
|
| 180 |
+
remaining -= len(pairs)
|
| 181 |
+
if not pairs:
|
| 182 |
+
break
|
| 183 |
+
|
| 184 |
+
return all_pairs[:total_count]
|
| 185 |
+
|
| 186 |
+
@staticmethod
|
| 187 |
+
def install_with_cuda():
|
| 188 |
+
"""Install llama-cpp-python with CUDA support."""
|
| 189 |
+
import subprocess
|
| 190 |
+
print("[LlamaService] Installing llama-cpp-python with CUDA...")
|
| 191 |
+
cmd = 'CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python --force-reinstall --no-cache-dir'
|
| 192 |
+
return subprocess.run(cmd, shell=True).returncode == 0
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# Convenience function for quick testing
|
| 196 |
+
def test_llama_service():
|
| 197 |
+
"""Test the Llama service."""
|
| 198 |
+
service = LlamaService()
|
| 199 |
+
service.load()
|
| 200 |
+
|
| 201 |
+
print("\nGenerating 5 Q&A pairs...")
|
| 202 |
+
pairs = service.generate_qa_pairs(5)
|
| 203 |
+
|
| 204 |
+
for i, pair in enumerate(pairs, 1):
|
| 205 |
+
print(f"\n{i}. Q: {pair['question']}")
|
| 206 |
+
print(f" A: {pair['answer']}")
|
| 207 |
+
|
| 208 |
+
return pairs
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
if __name__ == "__main__":
|
| 212 |
+
test_llama_service()
|
services/whisperx_service.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WhisperX Service for speech recognition, alignment, and feature extraction.
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
import torchaudio
|
| 8 |
+
from typing import List, Dict, Optional, Tuple, Any
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
# Default configuration
|
| 12 |
+
DEFAULT_MODEL = "large-v3-turbo"
|
| 13 |
+
DEFAULT_COMPUTE_TYPE = "float16"
|
| 14 |
+
DEFAULT_LANGUAGE = "en"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class WhisperXService:
|
| 18 |
+
"""Service for WhisperX speech recognition and feature extraction."""
|
| 19 |
+
|
| 20 |
+
def __init__(
|
| 21 |
+
self,
|
| 22 |
+
model_name: str = DEFAULT_MODEL,
|
| 23 |
+
device: str = "cuda",
|
| 24 |
+
compute_type: str = DEFAULT_COMPUTE_TYPE,
|
| 25 |
+
language: str = DEFAULT_LANGUAGE
|
| 26 |
+
):
|
| 27 |
+
self.model_name = model_name
|
| 28 |
+
self.device = device
|
| 29 |
+
self.compute_type = compute_type
|
| 30 |
+
self.language = language
|
| 31 |
+
|
| 32 |
+
self._model = None
|
| 33 |
+
self._align_model = None
|
| 34 |
+
self._align_metadata = None
|
| 35 |
+
self._encoder = None
|
| 36 |
+
|
| 37 |
+
def load(self) -> 'WhisperXService':
|
| 38 |
+
"""Load WhisperX models."""
|
| 39 |
+
import whisperx
|
| 40 |
+
|
| 41 |
+
print(f"[WhisperX] Loading model {self.model_name}...")
|
| 42 |
+
|
| 43 |
+
# Load main model
|
| 44 |
+
self._model = whisperx.load_model(
|
| 45 |
+
self.model_name,
|
| 46 |
+
self.device,
|
| 47 |
+
compute_type=self.compute_type,
|
| 48 |
+
language=self.language,
|
| 49 |
+
vad_method="silero"
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Load alignment model
|
| 53 |
+
self._align_model, self._align_metadata = whisperx.load_align_model(
|
| 54 |
+
language_code=self.language,
|
| 55 |
+
device=self.device
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# Get internal encoder for feature extraction
|
| 59 |
+
self._encoder = self._model.model
|
| 60 |
+
|
| 61 |
+
print(f"[WhisperX] Models loaded on {self.device}")
|
| 62 |
+
return self
|
| 63 |
+
|
| 64 |
+
def transcribe(self, audio_path: str, batch_size: int = 16) -> Dict:
|
| 65 |
+
"""Transcribe audio file.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
audio_path: Path to audio file.
|
| 69 |
+
batch_size: Batch size for transcription.
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
Transcription result with segments.
|
| 73 |
+
"""
|
| 74 |
+
if self._model is None:
|
| 75 |
+
self.load()
|
| 76 |
+
|
| 77 |
+
return self._model.transcribe(audio_path, batch_size=batch_size)
|
| 78 |
+
|
| 79 |
+
def align(self, segments: List[Dict], audio_path: str) -> Dict:
|
| 80 |
+
"""Align transcription to audio.
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
segments: Transcription segments.
|
| 84 |
+
audio_path: Path to audio file.
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
Aligned result with word-level timestamps.
|
| 88 |
+
"""
|
| 89 |
+
import whisperx
|
| 90 |
+
|
| 91 |
+
if self._align_model is None:
|
| 92 |
+
self.load()
|
| 93 |
+
|
| 94 |
+
result = {"segments": segments}
|
| 95 |
+
return whisperx.align(
|
| 96 |
+
result["segments"],
|
| 97 |
+
self._align_model,
|
| 98 |
+
self._align_metadata,
|
| 99 |
+
audio_path,
|
| 100 |
+
self.device
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
def transcribe_and_align(self, audio_path: str) -> Dict:
|
| 104 |
+
"""Transcribe and align audio file.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
Result with word-level alignments.
|
| 108 |
+
"""
|
| 109 |
+
result = self.transcribe(audio_path)
|
| 110 |
+
return self.align(result["segments"], audio_path)
|
| 111 |
+
|
| 112 |
+
def extract_features(self, audio: np.ndarray, sample_rate: int = 24000) -> torch.Tensor:
|
| 113 |
+
"""Extract encoder features from audio.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
audio: Audio samples as numpy array.
|
| 117 |
+
sample_rate: Audio sample rate.
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
Encoder features tensor [seq_len, 1280].
|
| 121 |
+
"""
|
| 122 |
+
import ctranslate2
|
| 123 |
+
|
| 124 |
+
if self._encoder is None:
|
| 125 |
+
self.load()
|
| 126 |
+
|
| 127 |
+
# Resample to 16kHz for Whisper
|
| 128 |
+
if sample_rate != 16000:
|
| 129 |
+
audio_tensor = torch.from_numpy(audio)
|
| 130 |
+
audio_16k = torchaudio.functional.resample(audio_tensor, sample_rate, 16000)
|
| 131 |
+
audio = audio_16k.numpy()
|
| 132 |
+
|
| 133 |
+
audio = audio.astype(np.float32)
|
| 134 |
+
|
| 135 |
+
# Extract mel features
|
| 136 |
+
mel_features = self._encoder.feature_extractor(audio)
|
| 137 |
+
|
| 138 |
+
# Encode
|
| 139 |
+
encoded = self._encoder.encode(mel_features)
|
| 140 |
+
|
| 141 |
+
# Convert ctranslate2 StorageView to numpy
|
| 142 |
+
cpu_view = encoded.to_device(ctranslate2.Device.cpu)
|
| 143 |
+
features_np = np.array(cpu_view, copy=True) # [1, seq_len, 1280]
|
| 144 |
+
features = torch.from_numpy(features_np).squeeze(0).float() # [seq_len, 1280]
|
| 145 |
+
|
| 146 |
+
return features
|
| 147 |
+
|
| 148 |
+
def batch_extract_features(
|
| 149 |
+
self,
|
| 150 |
+
audios: List[np.ndarray],
|
| 151 |
+
sample_rate: int = 24000
|
| 152 |
+
) -> List[torch.Tensor]:
|
| 153 |
+
"""Extract features from multiple audios.
|
| 154 |
+
|
| 155 |
+
Args:
|
| 156 |
+
audios: List of audio samples.
|
| 157 |
+
sample_rate: Audio sample rate.
|
| 158 |
+
|
| 159 |
+
Returns:
|
| 160 |
+
List of feature tensors.
|
| 161 |
+
"""
|
| 162 |
+
return [self.extract_features(audio, sample_rate) for audio in audios]
|
| 163 |
+
|
| 164 |
+
def get_word_alignments(
|
| 165 |
+
self,
|
| 166 |
+
audio_path: str,
|
| 167 |
+
start_time: float,
|
| 168 |
+
end_time: float
|
| 169 |
+
) -> List[Dict]:
|
| 170 |
+
"""Get word alignments for a segment of audio.
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
audio_path: Path to audio file.
|
| 174 |
+
start_time: Segment start time in seconds.
|
| 175 |
+
end_time: Segment end time in seconds.
|
| 176 |
+
|
| 177 |
+
Returns:
|
| 178 |
+
List of word alignment dicts.
|
| 179 |
+
"""
|
| 180 |
+
result = self.transcribe_and_align(audio_path)
|
| 181 |
+
words = result.get("word_segments", [])
|
| 182 |
+
|
| 183 |
+
segment_words = []
|
| 184 |
+
for w in words:
|
| 185 |
+
word_start = w.get('start', 0)
|
| 186 |
+
word_end = w.get('end', 0)
|
| 187 |
+
if word_start >= start_time - 0.1 and word_end <= end_time + 0.1:
|
| 188 |
+
segment_words.append({
|
| 189 |
+
'word': w.get('word', ''),
|
| 190 |
+
'start': word_start - start_time,
|
| 191 |
+
'end': word_end - start_time,
|
| 192 |
+
'start_frame': int((word_start - start_time) * 75),
|
| 193 |
+
'end_frame': int((word_end - start_time) * 75)
|
| 194 |
+
})
|
| 195 |
+
|
| 196 |
+
return segment_words
|
| 197 |
+
|
| 198 |
+
@property
|
| 199 |
+
def model(self):
|
| 200 |
+
"""Get the underlying WhisperX model."""
|
| 201 |
+
if self._model is None:
|
| 202 |
+
self.load()
|
| 203 |
+
return self._model
|
| 204 |
+
|
| 205 |
+
@property
|
| 206 |
+
def encoder(self):
|
| 207 |
+
"""Get the internal encoder for feature extraction."""
|
| 208 |
+
if self._encoder is None:
|
| 209 |
+
self.load()
|
| 210 |
+
return self._encoder
|