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 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