multimodalart HF Staff commited on
Commit
7b592f7
·
verified ·
1 Parent(s): 57124a4

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* 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
 
 
 
 
 
 
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
36
+ sample/01_count_bark/sample01_02.wav filter=lfs diff=lfs merge=lfs -text
37
+ sample/02_translate/sample02_02.wav filter=lfs diff=lfs merge=lfs -text
38
+ sample/02_translate/sample02_04.wav filter=lfs diff=lfs merge=lfs -text
39
+ sample/03_cough_music/sample03_01.wav filter=lfs diff=lfs merge=lfs -text
40
+ sample/03_cough_music/sample03_03.wav filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,28 @@
1
  ---
2
  title: Audio Interaction Model
3
- emoji: 🏢
4
- colorFrom: purple
5
- colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.19.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Audio Interaction Model
3
+ emoji: 🎧
4
+ colorFrom: blue
5
+ colorTo: red
6
  sdk: gradio
7
  sdk_version: 6.19.0
 
8
  app_file: app.py
9
+ short_description: Streaming audio-language model that listens and responds
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 30m
12
  ---
13
 
14
+ # Audio Interaction Model
15
+
16
+ This Space demos the **Audio Interaction Model** from the paper
17
+ [Audio Interaction Model](https://arxiv.org/abs/2606.05121).
18
+
19
+ The model is an always-on streaming audio language model that listens to
20
+ audio and decides for itself when to speak. Upload an audio clip or record
21
+ from microphone, and the model will listen, understand, and respond with text.
22
+
23
+ ## Model
24
+
25
+ - **Weights**: [zhifeixie/AudioInteraction](https://huggingface.co/zhifeixie/AudioInteraction)
26
+ - **Architecture**: 3B parameter GPT based on Qwen2.5-Omni
27
+ - **Paper**: [arXiv:2606.05121](https://arxiv.org/abs/2606.05121)
28
+ - **Code**: [GitHub](https://github.com/xzf-thu/Audio-Interaction)
app.py ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio Interaction Model — Gradio demo on ZeroGPU.
2
+
3
+ Upload one or more audio clips (or record from microphone) and the model
4
+ will listen, decide whether to respond, and generate a text answer.
5
+
6
+ Based on the paper: "Audio Interaction Model" (arXiv:2606.05121)
7
+ Model weights: https://huggingface.co/zhifeixie/AudioInteraction
8
+ """
9
+
10
+ import os
11
+
12
+ # Avoid allocator fragmentation under memory spikes
13
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
14
+ # Prevent numba from initializing CUDA before spaces can patch torch
15
+ os.environ.setdefault("NUMBA_DISABLE_CUDA", "1")
16
+
17
+ # Install openai-whisper with --no-deps to avoid it downgrading torch.
18
+ # Its real runtime deps (tiktoken, more-itertools, tqdm, scipy) are in requirements.txt.
19
+ import subprocess, sys
20
+ try:
21
+ import whisper
22
+ except ImportError:
23
+ subprocess.run(
24
+ [sys.executable, "-m", "pip", "install", "--no-deps", "openai-whisper"],
25
+ check=True,
26
+ )
27
+
28
+ import spaces # MUST come before any CUDA-touching import
29
+ import torch
30
+
31
+ # Patch torch.load to disable weights_only (the audio encoder checkpoint
32
+ # was saved with older torch that pickles numpy/object globals).
33
+ _orig_torch_load = torch.load
34
+ torch.load = lambda *a, **k: _orig_torch_load(*a, **{**k, "weights_only": k.get("weights_only", False)})
35
+
36
+ import json
37
+ import sys as _sys
38
+ import time
39
+ from pathlib import Path
40
+ from typing import List, Optional, Tuple
41
+
42
+ import gradio as gr
43
+ import numpy as np
44
+ import lightning as L
45
+ from huggingface_hub import snapshot_download
46
+ from transformers import AutoConfig
47
+ from transformers.models.qwen2_5_omni.modeling_qwen2_5_omni import Qwen2_5OmniAudioEncoder
48
+ from safetensors.torch import load_file
49
+
50
+ # Add the app root to PYTHONPATH so `src.*` and `utils` imports resolve
51
+ _sys.path.insert(0, str(Path(__file__).resolve().parent))
52
+
53
+ from src.audiointeraction.dataset.TOKENS import (
54
+ ASSISTANT, AUDIO_BEGIN, ENGLISH, KEEP_SILENCE, ONLINE, PAD,
55
+ SYSTEM, TEXT_BEGIN, TEXT_END,
56
+ HAPPY, SAD, ANGRY, SURPRISE, NORMAL, URGENT,
57
+ )
58
+ from src.audiointeraction.generate.base import (
59
+ AUDIO_TOKENS_PER_CHUNK,
60
+ sample,
61
+ encode_audio_chunks,
62
+ _encode_audio_samples,
63
+ )
64
+ from src.audiointeraction.model import GPT
65
+ from src.audiointeraction.config import Config
66
+ from src.audiointeraction.tokenizer import Tokenizer
67
+ from src.audiointeraction.utils import get_default_supported_precision
68
+
69
+ MODEL_REPO = "zhifeixie/AudioInteraction"
70
+ EMOTION_EMOJI = {
71
+ HAPPY: "😊", SAD: "😢", ANGRY: "😠", SURPRISE: "😲",
72
+ NORMAL: "😐", URGENT: "⚠️",
73
+ }
74
+
75
+ SYSTEM_PROMPT = (
76
+ "You are a helpful assistant. When there is no user text, if the audio contains a question, "
77
+ "please answer it. If it is a sound effect, determine based on the sound whether help is needed."
78
+ )
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Model loading
83
+ # ---------------------------------------------------------------------------
84
+
85
+ def _download_checkpoints() -> str:
86
+ """Download model weights from HuggingFace Hub and return local path."""
87
+ local_dir = "/home/user/checkpoints"
88
+ os.makedirs(local_dir, exist_ok=True)
89
+ snapshot_download(
90
+ repo_id=MODEL_REPO,
91
+ repo_type="model",
92
+ local_dir=local_dir,
93
+ resume_download=True,
94
+ )
95
+ return local_dir
96
+
97
+
98
+ CHECKPOINT_DIR = _download_checkpoints()
99
+
100
+ ckpt = Path(CHECKPOINT_DIR)
101
+ MODEL_CONFIG_DIR = str(ckpt)
102
+ TRAINED_CHECKPOINT = str(ckpt)
103
+ QWEN_OMNI_CKPT = str(ckpt / "qwen25OmniConfig")
104
+ AUDIO_TOWER_CKPT = str(ckpt / "audiointeraction_ChunkwisedEncoder.pth")
105
+
106
+
107
+ def _load_model():
108
+ """Load the GPT model from sharded safetensors."""
109
+ config = Config.from_file(Path(MODEL_CONFIG_DIR) / "model_config.yaml")
110
+ # Force SDPA attention (flash_attn not available / sm_120 incompatible with FA3)
111
+ config.use_flash_attention = False
112
+
113
+ fabric = L.Fabric(
114
+ devices=1, num_nodes=1, strategy="auto",
115
+ precision=get_default_supported_precision(training=False),
116
+ loggers="tensorboard",
117
+ )
118
+
119
+ with fabric.init_module(empty_init=False):
120
+ model = GPT(config)
121
+ model = fabric.setup(model)
122
+
123
+ index_path = Path(TRAINED_CHECKPOINT) / "model.safetensors.index.json"
124
+ with open(index_path) as f:
125
+ index = json.load(f)
126
+ shard_files = sorted(set(index["weight_map"].values()))
127
+ state_dict = {}
128
+ for shard in shard_files:
129
+ state_dict.update(load_file(str(Path(TRAINED_CHECKPOINT) / shard), device="cpu"))
130
+ missing, unexpected = model.load_state_dict(state_dict, strict=True)
131
+ if missing or unexpected:
132
+ print(f"[load_model] missing={missing[:3]}... unexpected={unexpected[:3]}...")
133
+
134
+ return fabric, model, config
135
+
136
+
137
+ def _load_audio_encoder(device):
138
+ """Load the Qwen2.5-Omni audio encoder."""
139
+ cfg = AutoConfig.from_pretrained(QWEN_OMNI_CKPT)
140
+ audio_cfg = cfg.thinker_config.audio_config
141
+ encoder = Qwen2_5OmniAudioEncoder._from_config(audio_cfg)
142
+ state_dict = torch.load(AUDIO_TOWER_CKPT, map_location=device)
143
+ encoder.load_state_dict(state_dict)
144
+ encoder.to(device).requires_grad_(False).eval()
145
+ return encoder
146
+
147
+
148
+ print("Loading model and audio encoder...")
149
+ fabric, model, model_config = _load_model()
150
+ model.to("cuda")
151
+ model.eval()
152
+
153
+ audio_encoder = _load_audio_encoder("cuda")
154
+ tokenizer = Tokenizer(MODEL_CONFIG_DIR)
155
+
156
+ system_ids = tokenizer.encode(SYSTEM_PROMPT).cpu().tolist()
157
+ prefix_ids = torch.LongTensor(
158
+ [ONLINE, ENGLISH, SYSTEM, TEXT_BEGIN] + system_ids + [TEXT_END]
159
+ ).to(model.device)
160
+
161
+ with fabric.init_tensor():
162
+ model.set_kv_cache(batch_size=1)
163
+ model.eval()
164
+
165
+ print("Model and audio encoder loaded successfully!")
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Inference
170
+ # ---------------------------------------------------------------------------
171
+
172
+ @spaces.GPU(duration=120)
173
+ def interact_with_audio(
174
+ audio_file: str,
175
+ text_instruction: str = "",
176
+ ) -> str:
177
+ """Listen to an audio clip and generate a text response.
178
+
179
+ Upload an audio file (wav/mp3/m4a/flac/ogg) and optionally add a text
180
+ instruction. The model will listen to the audio and decide whether to
181
+ respond — it may answer a question, transcribe speech, translate,
182
+ describe a sound, or stay silent if the audio doesn't need a response.
183
+ """
184
+ if audio_file is None:
185
+ return "Please provide an audio file."
186
+
187
+ device = model.device
188
+
189
+ # Reset KV cache for a fresh session
190
+ model.clear_kv_cache()
191
+ with fabric.init_tensor():
192
+ model.set_kv_cache(batch_size=1)
193
+
194
+ token = prefix_ids
195
+ input_pos = torch.arange(0, prefix_ids.size(0), device=device, dtype=torch.int64)
196
+ prompt_size = prefix_ids.size(0)
197
+
198
+ # Encode the audio file into chunks
199
+ audio_chunks = encode_audio_chunks(audio_file, audio_encoder, device)
200
+
201
+ # If there's a text instruction, prepend it as text tokens
202
+ if text_instruction.strip():
203
+ # Insert text instruction after the system prompt
204
+ instruction_ids = tokenizer.encode(text_instruction.strip()).cpu().tolist()
205
+ text_tokens = [TEXT_BEGIN] + instruction_ids + [TEXT_END]
206
+ text_tensor = torch.LongTensor(text_tokens).to(device)
207
+ token = torch.cat([token, text_tensor])
208
+
209
+ turns: List[List[int]] = []
210
+ listening = True
211
+ audio_idx = -1
212
+ current_turn: List[int] = []
213
+ text_started = False
214
+ emotion_prefix = ""
215
+
216
+ max_steps = 4096
217
+
218
+ for _ in range(max_steps - input_pos.numel()):
219
+ if listening:
220
+ audio_idx += 1
221
+ if audio_idx >= len(audio_chunks):
222
+ break
223
+ # Append listening block: [AUDIO_BEGIN, PAD*N, ASSISTANT]
224
+ new_tokens = torch.LongTensor(
225
+ [AUDIO_BEGIN] + [PAD] * AUDIO_TOKENS_PER_CHUNK + [ASSISTANT]
226
+ ).to(device)
227
+ new_positions = input_pos[-1] + torch.arange(1, len(new_tokens) + 1, device=device)
228
+ token = torch.cat([token, new_tokens])
229
+ input_pos = torch.cat([input_pos, new_positions])
230
+
231
+ logits = model(
232
+ token.view(1, -1), None, 1,
233
+ audio_chunks[audio_idx].to(device),
234
+ input_pos,
235
+ input_pos_maxp1=None,
236
+ audio_tokens_per_chunk=AUDIO_TOKENS_PER_CHUNK,
237
+ )
238
+ else:
239
+ logits = model(
240
+ token.view(1, -1), None, 1,
241
+ None,
242
+ input_pos,
243
+ input_pos_maxp1=None,
244
+ audio_tokens_per_chunk=AUDIO_TOKENS_PER_CHUNK,
245
+ )
246
+
247
+ token = sample(logits, temperature=0.0, top_p=0.0).to(torch.int64)
248
+ int_token = token.item()
249
+ # Advance input_pos by one
250
+ new_pos = input_pos[-1].unsqueeze(0).add_(1)
251
+ input_pos = torch.cat([input_pos, new_pos])
252
+
253
+ if listening:
254
+ if int_token == TEXT_BEGIN:
255
+ listening = False
256
+ current_turn = [int_token]
257
+ text_started = False
258
+ emotion_prefix = ""
259
+ elif int_token == KEEP_SILENCE:
260
+ turns.append([int_token])
261
+ else:
262
+ # Unexpected token
263
+ break
264
+ else:
265
+ current_turn.append(int_token)
266
+ if int_token == TEXT_END:
267
+ turns.append(current_turn)
268
+ current_turn = []
269
+ text_started = False
270
+ listening = True
271
+ else:
272
+ n = len(current_turn)
273
+ if n == 2:
274
+ text_started = True
275
+ if int_token in EMOTION_EMOJI:
276
+ emotion_prefix = EMOTION_EMOJI[int_token] + " "
277
+ else:
278
+ piece = tokenizer.decode(torch.tensor([int_token]))
279
+ if piece:
280
+ # Start output
281
+ pass
282
+ elif n >= 3:
283
+ pass
284
+
285
+ # Decode all turns into text
286
+ responses = []
287
+ for turn in turns:
288
+ if turn[0] == KEEP_SILENCE:
289
+ continue
290
+ # Extract text tokens: skip TEXT_BEGIN and optional emotion tag
291
+ text_tokens_list = turn[1:] # skip TEXT_BEGIN
292
+ if text_tokens_list and text_tokens_list[-1] == TEXT_END:
293
+ text_tokens_list = text_tokens_list[:-1]
294
+ # Check if first token is an emotion tag
295
+ emotion = ""
296
+ if text_tokens_list and text_tokens_list[0] in EMOTION_EMOJI:
297
+ emotion = EMOTION_EMOJI[text_tokens_list[0]] + " "
298
+ text_tokens_list = text_tokens_list[1:]
299
+
300
+ if text_tokens_list:
301
+ decoded = tokenizer.decode(torch.tensor(text_tokens_list))
302
+ if decoded:
303
+ responses.append(emotion + decoded)
304
+
305
+ model.clear_kv_cache()
306
+
307
+ if not responses:
308
+ return "🔇 The model listened to the audio and chose to stay silent. (This is expected for non-question sounds — the model decides for itself when to speak.)"
309
+
310
+ return "\n\n".join(responses)
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # Gradio UI
315
+ # ---------------------------------------------------------------------------
316
+
317
+ DESCRIPTION = """
318
+ # Audio Interaction Model
319
+
320
+ This is a demo of the **Audio Interaction Model** from the paper
321
+ [Audio Interaction Model](https://arxiv.org/abs/2606.05121).
322
+
323
+ The model is an always-on streaming audio language model that listens to
324
+ audio and **decides for itself when to speak**. Upload an audio clip
325
+ (or record from microphone) and the model will:
326
+
327
+ - 🎧 **Listen** to the audio and understand its content
328
+ - 🧠 **Decide** whether a response is needed
329
+ - 💬 **Respond** with text (transcription, answer, translation, description, etc.)
330
+
331
+ The model may also choose to stay silent (⟨Silent⟩) if the audio doesn't
332
+ warrant a response — this is a core feature of the Audio Interaction Model,
333
+ not a bug!
334
+ """
335
+
336
+ with gr.Blocks(title="Audio Interaction Model") as demo:
337
+ gr.Markdown(DESCRIPTION)
338
+
339
+ with gr.Row():
340
+ with gr.Column():
341
+ audio_input = gr.Audio(
342
+ label="Audio Input",
343
+ type="filepath",
344
+ sources=["upload", "microphone"],
345
+ )
346
+ text_instruction = gr.Textbox(
347
+ label="Text Instruction (optional)",
348
+ placeholder="e.g. 'Translate this to English' or leave empty",
349
+ lines=2,
350
+ )
351
+ submit_btn = gr.Button("Interact", variant="primary")
352
+
353
+ with gr.Column():
354
+ output_text = gr.Textbox(
355
+ label="Model Response",
356
+ lines=10,
357
+ interactive=False,
358
+ )
359
+
360
+ submit_btn.click(
361
+ fn=interact_with_audio,
362
+ inputs=[audio_input, text_instruction],
363
+ outputs=output_text,
364
+ )
365
+
366
+ gr.Examples(
367
+ examples=[
368
+ ["sample/01_count_bark/sample01_01.mp3", ""],
369
+ ["sample/01_count_bark/sample01_02.wav", ""],
370
+ ["sample/02_translate/sample02_01.mp3", "Translate to English"],
371
+ ["sample/03_cough_music/sample03_01.wav", ""],
372
+ ],
373
+ inputs=[audio_input, text_instruction],
374
+ outputs=output_text,
375
+ fn=interact_with_audio,
376
+ cache_examples=True,
377
+ cache_mode="lazy",
378
+ )
379
+
380
+ gr.Markdown("""
381
+ ---
382
+ **Model**: [zhifeixie/AudioInteraction](https://huggingface.co/zhifeixie/AudioInteraction)
383
+ | **Paper**: [arXiv:2606.05121](https://arxiv.org/abs/2606.05121)
384
+ | **Code**: [GitHub](https://github.com/xzf-thu/Audio-Interaction)
385
+
386
+ The model is a 3B parameter streaming audio language model based on
387
+ Qwen2.5-Omni. It processes audio in 400ms chunks and generates text
388
+ responses when it decides intervention is needed.
389
+ """)
390
+
391
+ demo.launch(mcp_server=True)
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ lightning
2
+ litgpt==0.5.8
3
+ transformers==4.57.6
4
+ safetensors
5
+ tokenizers
6
+ sentencepiece
7
+ torchaudio
8
+ librosa
9
+ numpy
10
+ pyyaml
11
+ einops
12
+ tiktoken
13
+ more-itertools
14
+ tqdm
15
+ scipy
16
+ numba
sample/01_count_bark/sample01_01.mp3 ADDED
Binary file (89 kB). View file
 
sample/01_count_bark/sample01_02.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3ce892a13032de23728b8614091c1323114362f290a4599cb77195ccebc328de
3
+ size 115278
sample/01_count_bark/sequence.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "sample01_01.mp3",
3
+ "sample01_02.wav",
4
+ "sample01_02.wav",
5
+ "sample01_02.wav",
6
+ "sample01_02.wav",
7
+ "sample01_02.wav"
8
+ ]
sample/02_translate/sample02_01.mp3 ADDED
Binary file (73.9 kB). View file
 
sample/02_translate/sample02_02.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:aaf85760891e017ee5925641407cabdc3f122c9730dbd3b4e88d062b6ba2bb63
3
+ size 345998
sample/02_translate/sample02_03.mp3 ADDED
Binary file (74.8 kB). View file
 
sample/02_translate/sample02_04.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:40687cad8ef0b483391eadbecca4b2ae7af7e85ec287e7ae3e943717037e458c
3
+ size 102478
sample/02_translate/sequence.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ [
2
+ "sample02_01.mp3",
3
+ "sample02_02.wav",
4
+ "sample02_03.mp3",
5
+ "sample02_04.wav"
6
+ ]
sample/03_cough_music/sample03_01.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2451bf4cae9cfeeab9de667a1c0163dbe158e5ce687bf55077cbcbee4047297b
3
+ size 460878
sample/03_cough_music/sample03_02.mp3 ADDED
Binary file (90.2 kB). View file
 
sample/03_cough_music/sample03_03.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a7012f2fe10d4219a3699f85b0ca11037dfaab8ef10f8b49599e0070c166484b
3
+ size 160044
sample/03_cough_music/sequence.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ [
2
+ "sample03_01.wav",
3
+ "sample03_02.mp3",
4
+ "sample03_03.wav"
5
+ ]
src/__init__.py ADDED
File without changes
src/audiointeraction/TOKENS.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Single source of truth for special token IDs used by the audio-enhanced GPT.
3
+
4
+ Both `cons_online_data.py` and `cons_offline_data.py` import from here so
5
+ that any change to a token id propagates everywhere.
6
+ """
7
+
8
+ # === Vocabulary layout ===
9
+ VOCAB_SHIFT = 151600
10
+
11
+ # === Role / segment markers ===
12
+ USER = VOCAB_SHIFT + 1
13
+ ASSISTANT = VOCAB_SHIFT + 2
14
+
15
+ TEXT_BEGIN = 151644
16
+ TEXT_END = 151643
17
+ SYSTEM = TEXT_BEGIN # 151644 — same id used as both turn separator and system marker
18
+
19
+ # === Audio markers ===
20
+ AUDIO_BEGIN = 151647
21
+ AUDIO_END = 151648
22
+ AUDIO_PAD = 151646
23
+
24
+ # === Control / padding ===
25
+ PAD = VOCAB_SHIFT + 8
26
+ MASK = -100
27
+ KEEP_SILENCE = VOCAB_SHIFT + 5
28
+
29
+ # === Task mode (offline batch vs online streaming) ===
30
+ ONLINE = VOCAB_SHIFT + 9
31
+ OFFLINE = VOCAB_SHIFT + 10
32
+
33
+ # === Language tag ===
34
+ ENGLISH = VOCAB_SHIFT + 11
35
+
36
+ # === Emotion tags (online only) ===
37
+ HAPPY = VOCAB_SHIFT + 14
38
+ SAD = VOCAB_SHIFT + 15
39
+ ANGRY = VOCAB_SHIFT + 16
40
+ SURPRISE = VOCAB_SHIFT + 17
41
+ NORMAL = VOCAB_SHIFT + 18
42
+ URGENT = VOCAB_SHIFT + 19
43
+
44
+ EMOTION_TO_ID = {
45
+ "surprise": SURPRISE,
46
+ "happy": HAPPY,
47
+ "normal": NORMAL,
48
+ "sad": SAD,
49
+ "angry": ANGRY,
50
+ "urgent": URGENT,
51
+ }
52
+
53
+
54
+ HAPPY_SMILE = "😊"
55
+ SAD_CRY = "😢"
56
+ ANGRY_MAD = "😠"
57
+ SURPRISE_WOW = "😲"
58
+ NORMAL_NEUTRAL = "😐"
59
+ URGENT_WARNING = "⚠️"
src/audiointeraction/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file.
2
+
3
+ import logging
4
+ import re
5
+
6
+ from src.audiointeraction.model import GPT # needs to be imported before config
7
+ from src.audiointeraction.config import Config
8
+ from src.audiointeraction.tokenizer import Tokenizer
9
+
10
+ # Suppress excessive warnings, see https://github.com/pytorch/pytorch/issues/111632
11
+ pattern = re.compile(".*Profiler function .* will be ignored")
12
+ logging.getLogger("torch._dynamo.variables.torch").addFilter(lambda record: not pattern.search(record.getMessage()))
13
+
14
+ # Avoid printing state-dict profiling output at the WARNING level when saving a checkpoint
15
+ logging.getLogger("torch.distributed.fsdp._optim_utils").disabled = True
16
+ logging.getLogger("torch.distributed.fsdp._debug_utils").disabled = True
17
+
18
+ __all__ = ["GPT", "Config", "Tokenizer"]
src/audiointeraction/args.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass
6
+ class TrainArgs:
7
+ """Training-related arguments."""
8
+
9
+ save_interval: Optional[int] = 1000
10
+ """Number of optimizer steps between checkpoint saves."""
11
+ log_interval: int = 1
12
+ """Number of iterations between log lines."""
13
+ global_batch_size: int = 64
14
+ """Total samples per optimizer step across all data-parallel ranks."""
15
+ micro_batch_size: int = 4
16
+ """Samples per data-parallel rank per forward/backward pass."""
17
+ lr_warmup_steps: Optional[int] = 100
18
+ """Number of warmup iterations (linear ramp from 0 to max_lr)."""
19
+ epochs: Optional[int] = None
20
+ """Number of epochs to train."""
21
+ max_steps: Optional[int] = None
22
+ """Hard cap on optimizer steps (overrides epochs if reached first)."""
23
+ max_seq_length: Optional[int] = None
24
+ """Truncate samples longer than this."""
25
+
26
+ def gradient_accumulation_iters(self, devices: int, num_nodes: int = 1) -> int:
27
+ n = self.batch_size(devices, num_nodes) // self.micro_batch_size
28
+ assert n > 0
29
+ return n
30
+
31
+ def batch_size(self, devices: int, num_nodes: int = 1) -> int:
32
+ n = self.global_batch_size // (devices * num_nodes)
33
+ assert n > 0
34
+ return n
35
+
36
+
37
+ @dataclass
38
+ class EvalArgs:
39
+ """Evaluation-related arguments."""
40
+
41
+ interval: int = 600
42
+ """Number of optimizer steps between validation runs."""
43
+ max_new_tokens: Optional[int] = None
44
+ """Generation length cap (only used by validation-time generation paths)."""
45
+ max_iters: int = 100
46
+ """Max number of validation batches per run."""
47
+ initial_validation: bool = False
48
+ """Run one validation pass before the first training step."""
49
+ final_validation: bool = True
50
+ """Run one validation pass after training completes."""
src/audiointeraction/config.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file.
2
+
3
+ from copy import deepcopy
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any, Literal, Optional, Type, Union
7
+
8
+ import torch
9
+ import yaml
10
+ from typing_extensions import Self
11
+ from src.audiointeraction.utils import find_multiple
12
+
13
+
14
+ @dataclass
15
+ class Config:
16
+ name: str = ""
17
+ hf_config: dict = field(default_factory=dict)
18
+ # General size parameters
19
+ block_size: int = 4096
20
+ n_layer: int = 16
21
+ n_embd: int = 4096
22
+ vocab_size: int = 50254
23
+ padding_multiple: int = 512
24
+ padded_vocab_size: Optional[int] = None
25
+ # Transformer block (structure, normalizations)
26
+ norm_class_name: Literal["LayerNorm", "RMSNorm"] = "LayerNorm"
27
+ norm_eps: float = 1e-5
28
+ norm_qk: bool = False
29
+ post_attention_norm: bool = False
30
+ post_mlp_norm: bool = False
31
+ parallel_residual: bool = True
32
+ shared_attention_norm: bool = False
33
+ # Transformer block (self-attention)
34
+ n_head: int = 32
35
+ head_size: Optional[int] = None
36
+ # to use multi-head attention (MHA), set this to `n_head` (default)
37
+ # to use multi-query attention (MQA), set this to 1
38
+ # to use grouped-query attention (GQA), set this to a value in between
39
+ # Example with `n_head=4`
40
+ # ┌───┐┌───┐┌───┐┌───┐ ┌───┐ ┌───┐ ┌───┐
41
+ # │ v ││ v ││ v ││ v │ │ v │ │ v │ │ v │
42
+ # └───┘└───┘└───┘└───┘ └───┘ └───┘ └───┘
43
+ # │ │ │ │ │ │ │
44
+ # ┌───┐┌───┐┌───┐┌───┐ ┌───┐ ┌───┐ ┌───┐
45
+ # │ k ││ k ││ k ││ k │ │ k │ │ k │ │ k │
46
+ # └───┘└───┘└───┘└───┘ └───┘ └───┘ └───┘
47
+ # │ │ │ │ ┌──┴──┐ ┌──┴──┐ ┌────┬──┴─┬────┐
48
+ # ┌───┐┌───┐┌───┐┌───┐ ┌───┐┌───┐┌───┐┌───┐ ┌───┐┌───┐┌───┐┌───┐
49
+ # │ q ││ q ││ q ││ q │ │ q ││ q ││ q ││ q │ │ q ││ q ││ q ││ q │
50
+ # └───┘└───┘└───┘└───┘ └───┘└───┘└───┘└───┘ └───┘└───┘└───┘└───┘
51
+ # ◀──────────────────▶ ◀──────────────────▶ ◀──────────────────▶
52
+ # MHA GQA MQA
53
+ # n_query_groups=4 n_query_groups=2 n_query_groups=1
54
+ #
55
+ # credit https://arxiv.org/pdf/2305.13245.pdf
56
+ n_query_groups: Optional[int] = None
57
+ attn_bias: bool = False
58
+ attention_scores_scalar: Optional[int] = None
59
+ sliding_window_size: Optional[int] = None
60
+ sliding_window_layer_placing: Optional[Literal["all", "interleaved"]] = None
61
+ # if `attention_logit_softcapping` is used, cannot use optimized
62
+ # `torch.nn.functional.scaled_dot_product_attention` (which implements
63
+ # Flash attention), may result in higher memory and runtime footprint.
64
+ attention_logit_softcapping: Optional[float] = None
65
+ # Rotary position embedding (RoPE)
66
+ rope_base: int = 10000
67
+ rotary_percentage: float = 0.25
68
+ rope_condense_ratio: int = 1
69
+ rope_adjustments: Optional[dict] = None
70
+ # Transformer block (MLP)
71
+ intermediate_size: Optional[int] = None
72
+ bias: bool = True
73
+ mlp_class_name: Literal["GptNeoxMLP", "LLaMAMLP", "GemmaMLP", "LLaMAMoE"] = "GptNeoxMLP"
74
+ gelu_approximate: str = "none"
75
+ n_expert: int = 0
76
+ n_expert_per_token: int = 0
77
+ # GPT before/after blocks
78
+ scale_embeddings: bool = False
79
+ lm_head_bias: bool = False
80
+ final_logit_softcapping: Optional[float] = None
81
+
82
+ def __post_init__(self):
83
+ if not self.name:
84
+ self.name = self.hf_config.get("name", self.name)
85
+
86
+ if self.head_size is None:
87
+ assert self.n_embd % self.n_head == 0
88
+ self.head_size = self.n_embd // self.n_head
89
+
90
+ # vocab size should be a power of 2 to be optimal on hardware. compute the closest value
91
+ if self.padded_vocab_size is None:
92
+ self.padded_vocab_size = find_multiple(self.vocab_size, self.padding_multiple)
93
+ else:
94
+ # vocab size shouldn't be larger than padded vocab size
95
+ self.vocab_size = min(self.vocab_size, self.padded_vocab_size)
96
+
97
+ # compute the number of query groups
98
+ if self.n_query_groups is not None:
99
+ assert self.n_head % self.n_query_groups == 0
100
+ else:
101
+ self.n_query_groups = self.n_head
102
+
103
+ # compute the intermediate size for MLP if not set
104
+ if self.intermediate_size is None:
105
+ if self.mlp_class_name == "LLaMAMLP":
106
+ raise ValueError(f"The config {self.name!r}, needs to set the `intermediate_size`")
107
+ self.intermediate_size = 4 * self.n_embd
108
+
109
+ self.rope_n_elem = int(self.rotary_percentage * self.head_size)
110
+
111
+ if self.sliding_window_size is not None:
112
+ self.sliding_window_layer_stride = (
113
+ 1 if (self.sliding_window_layer_placing is None or self.sliding_window_layer_placing == "all") else 2
114
+ )
115
+
116
+ @classmethod
117
+ def from_file(cls, path: Union[str, Path], **kwargs: Any) -> Self:
118
+ with open(path, encoding="utf-8") as fp:
119
+ file_kwargs = yaml.safe_load(fp)
120
+ if file_kwargs is None:
121
+ raise ValueError(f"{path} is empty which is likely unexpected.")
122
+ file_kwargs.update(kwargs)
123
+ return cls(**file_kwargs)
124
+
125
+ @property
126
+ def mlp_class(self) -> Type:
127
+ # `self.mlp_class_name` cannot be the type to keep the config serializable
128
+ import src.audiointeraction.model
129
+ return getattr(src.audiointeraction.model, self.mlp_class_name)
130
+
131
+ @property
132
+ def norm_class(self) -> Type:
133
+ # `self.norm_class_name` cannot be the type to keep the config serializable
134
+
135
+ from functools import partial
136
+
137
+ if self.norm_class_name == "RMSNorm":
138
+
139
+ from src.audiointeraction.model import RMSNorm
140
+
141
+ return partial(RMSNorm, add_unit_offset="Gemma" in self.name)
142
+
143
+ if self.norm_class_name == "LayerNorm" and "OLMo" in self.name:
144
+ # this makes it equivalent to `torch.nn.functional.layer_norm`
145
+ # that is used by OLMo
146
+ # Table 5 caption in the OLMo paper shows this - https://aclanthology.org/2024.acl-long.841
147
+ return partial(torch.nn.LayerNorm, elementwise_affine=False)
148
+
149
+ return getattr(torch.nn, self.norm_class_name)
150
+
src/audiointeraction/dataset/TOKENS.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Single source of truth for special token IDs used by the audio-enhanced GPT.
3
+
4
+ Both `cons_online_data.py` and `cons_offline_data.py` import from here so
5
+ that any change to a token id propagates everywhere.
6
+ """
7
+
8
+ # === Vocabulary layout ===
9
+ VOCAB_SHIFT = 151600
10
+
11
+ # === Role / segment markers ===
12
+ USER = VOCAB_SHIFT + 1
13
+ ASSISTANT = VOCAB_SHIFT + 2
14
+
15
+ TEXT_BEGIN = 151644
16
+ TEXT_END = 151643
17
+ SYSTEM = TEXT_BEGIN # 151644 — same id used as both turn separator and system marker
18
+
19
+ # === Audio markers ===
20
+ AUDIO_BEGIN = 151647
21
+ AUDIO_END = 151648
22
+ AUDIO_PAD = 151646
23
+
24
+ # === Control / padding ===
25
+ PAD = VOCAB_SHIFT + 8
26
+ MASK = -100
27
+ KEEP_SILENCE = VOCAB_SHIFT + 5
28
+
29
+ # === Task mode (offline batch vs online streaming) ===
30
+ ONLINE = VOCAB_SHIFT + 9
31
+ OFFLINE = VOCAB_SHIFT + 10
32
+
33
+ # === Language tag ===
34
+ ENGLISH = VOCAB_SHIFT + 11
35
+
36
+ # === Emotion tags (online only) ===
37
+ HAPPY = VOCAB_SHIFT + 14
38
+ SAD = VOCAB_SHIFT + 15
39
+ ANGRY = VOCAB_SHIFT + 16
40
+ SURPRISE = VOCAB_SHIFT + 17
41
+ NORMAL = VOCAB_SHIFT + 18
42
+ URGENT = VOCAB_SHIFT + 19
43
+
44
+ EMOTION_TO_ID = {
45
+ "surprise": SURPRISE,
46
+ "happy": HAPPY,
47
+ "normal": NORMAL,
48
+ "sad": SAD,
49
+ "angry": ANGRY,
50
+ "urgent": URGENT,
51
+ }
52
+
53
+
54
+ HAPPY_SMILE = "😊"
55
+ SAD_CRY = "😢"
56
+ ANGRY_MAD = "😠"
57
+ SURPRISE_WOW = "😲"
58
+ NORMAL_NEUTRAL = "😐"
59
+ URGENT_WARNING = "⚠️"
src/audiointeraction/dataset/__init__.py ADDED
File without changes
src/audiointeraction/dataset/cons_offline_data.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build offline single-turn SFT samples.
2
+
3
+ Token layout: prefix + [audio_block?] + [user_block?] + assistant_block.
4
+ Three task variants come from which blocks are present:
5
+ - audio + user -> A_T_T
6
+ - audio -> A_T
7
+ - user -> T_T
8
+
9
+ Input (per line): {"conversation": [{"user", "assistant", "audio_path"}, ...]} (first turn only)
10
+ or flat {"user", "assistant", "audio_path"}.
11
+ Output (per line): {"tasks": "offline", "idx", "input_ids", "labels", "audio_pos", "pt_path_dir"}.
12
+
13
+ Edit the constants below, then `python cons_offline_data.py`.
14
+ Re-running picks up where the previous run stopped (already-written idx are skipped).
15
+ """
16
+
17
+ import json
18
+ import os
19
+
20
+ import torch
21
+ from tqdm import tqdm
22
+ from transformers import AutoConfig, Qwen2_5OmniForConditionalGeneration
23
+
24
+ from src.audiointeraction.dataset.utils.load_audio import _load_mel
25
+ from src.audiointeraction.dataset.TOKENS import (
26
+ ASSISTANT, AUDIO_BEGIN, AUDIO_END, AUDIO_PAD, ENGLISH, MASK,
27
+ OFFLINE, SYSTEM, TEXT_BEGIN, TEXT_END, USER,
28
+ )
29
+ from src.audiointeraction.generate.base import resolve_checkpoint_paths
30
+ from src.audiointeraction.tokenizer import Tokenizer
31
+
32
+
33
+ # ============================================================
34
+ # Fill these in before running.
35
+ # ============================================================
36
+ CHECKPOINT_DIR = "" # checkpoint root (tokenizer + qwen_2_5_omni_config + audiointeraction_ChunkwisedEncoder.pth)
37
+ INPUT_JSONL = "" # raw input jsonl
38
+ OUTPUT_JSONL = "" # training-ready output jsonl
39
+ ERROR_LOG = "" # path to append per-sample error messages
40
+ FEATURE_DIR = "" # dir to save audio feature .pt files (one subdir per sample)
41
+ DEVICE = "cuda"
42
+ # ============================================================
43
+
44
+
45
+ DEFAULT_SYSTEM_PROMPT = (
46
+ "You are a helpful assistant. When there is no user text, if the audio contains a question, "
47
+ "please answer it. If it is a sound effect, determine based on the sound whether help is needed."
48
+ )
49
+
50
+ # Cap offline audio length at 20 seconds (matches the prior behavior).
51
+ MAX_AUDIO_SECONDS = 20
52
+
53
+
54
+ # === Audio encoder (lazy, cached per device) ===
55
+ _audio_encoder_cache = {}
56
+
57
+
58
+ def _get_audio_encoder(qwen_omni_ckpt, audio_tower_ckpt, device):
59
+ key = (qwen_omni_ckpt, audio_tower_ckpt, str(device))
60
+ if key not in _audio_encoder_cache:
61
+ print(f"[offline] loading audio_encoder on {device} ...")
62
+ cfg = AutoConfig.from_pretrained(qwen_omni_ckpt)
63
+ enc = Qwen2_5OmniForConditionalGeneration._from_config(cfg).thinker.audio_tower
64
+ enc.load_state_dict(torch.load(audio_tower_ckpt, map_location=device))
65
+ enc.to(device).eval()
66
+ _audio_encoder_cache[key] = enc
67
+ return _audio_encoder_cache[key]
68
+
69
+
70
+ def _extract_and_save_audio_feature(audio_path, save_dir, *,
71
+ qwen_omni_ckpt, audio_tower_ckpt, device):
72
+ """Encode audio, save to `<save_dir>/AudioFeat.pt`, return output_len."""
73
+ _, mel, len_feature, input_len, _ = _load_mel(audio_path, max_seconds=MAX_AUDIO_SECONDS)
74
+ encoder = _get_audio_encoder(qwen_omni_ckpt, audio_tower_ckpt, device)
75
+ with torch.no_grad():
76
+ hidden = encoder(
77
+ mel.to(device),
78
+ torch.tensor([len_feature]).to(device),
79
+ torch.tensor([input_len]).to(device),
80
+ ).last_hidden_state
81
+ hidden = hidden.squeeze(0).cpu()
82
+ output_len = hidden.shape[0]
83
+ os.makedirs(save_dir, exist_ok=True)
84
+ torch.save(hidden, os.path.join(save_dir, "AudioFeat.pt"))
85
+ return output_len
86
+
87
+
88
+ # === Input parsing ===
89
+
90
+ def _extract_first_turn(data_item):
91
+ """Return (user_text, assistant_text, audio_path). Offline is single-turn,
92
+ so a `conversation` list contributes only its first turn.
93
+ """
94
+ convs = data_item.get("conversation", [])
95
+ src = convs[0] if convs else data_item
96
+ return (
97
+ src.get("user"),
98
+ src.get("assistant"),
99
+ src.get("audio_path") or src.get("merge_path"),
100
+ )
101
+
102
+
103
+ # === Sample builder ===
104
+
105
+ def build_offline_sample(data_item, idx, *,
106
+ tokenizer, system_ids, feature_dir,
107
+ qwen_omni_ckpt, audio_tower_ckpt, device):
108
+ """Build one offline SFT sample. Returns a dict with input_ids/labels/audio_pos/pt_path_dir."""
109
+ user_text, assistant_text, audio_path = _extract_first_turn(data_item)
110
+ if not assistant_text or assistant_text == "None":
111
+ raise ValueError("missing assistant text")
112
+
113
+ has_audio = audio_path not in (None, "", "None")
114
+ has_user = user_text not in (None, "", "None")
115
+ assistant_ids = tokenizer.encode(assistant_text).cpu().tolist()
116
+ user_ids = tokenizer.encode(user_text).cpu().tolist() if has_user else []
117
+
118
+ if has_audio:
119
+ pt_path_dir = os.path.join(feature_dir, str(idx))
120
+ output_len = _extract_and_save_audio_feature(
121
+ audio_path, pt_path_dir,
122
+ qwen_omni_ckpt=qwen_omni_ckpt, audio_tower_ckpt=audio_tower_ckpt, device=device,
123
+ )
124
+ else:
125
+ pt_path_dir = None
126
+ output_len = 0
127
+
128
+ prefix = [OFFLINE, ENGLISH, SYSTEM, TEXT_BEGIN] + system_ids + [TEXT_END]
129
+ audio_block = [AUDIO_BEGIN] + [AUDIO_PAD] * output_len + [AUDIO_END] if has_audio else []
130
+ user_block = [USER, TEXT_BEGIN] + user_ids + [TEXT_END] if has_user else []
131
+ assistant_block = [ASSISTANT, TEXT_BEGIN] + assistant_ids + [TEXT_END]
132
+
133
+ input_ids = prefix + audio_block + user_block + assistant_block
134
+ labels = [MASK] * (len(prefix) + len(audio_block) + len(user_block)) + assistant_block
135
+
136
+ # AUDIO_PAD sits right after [AUDIO_BEGIN] (which is at index len(prefix)).
137
+ audio_pos = [(len(prefix) + 1, len(prefix) + 1 + output_len)] if has_audio else None
138
+
139
+ return {
140
+ "input_ids": input_ids,
141
+ "labels": labels,
142
+ "audio_pos": audio_pos,
143
+ "pt_path_dir": pt_path_dir,
144
+ }
145
+
146
+
147
+ # === Main driver ===
148
+
149
+ def _load_processed_indices(output_path):
150
+ """For resume: collect idx of all valid records already in output_path."""
151
+ done = set()
152
+ if not os.path.exists(output_path):
153
+ return done
154
+ with open(output_path, "r", encoding="utf-8") as fr:
155
+ for line in fr:
156
+ try:
157
+ done.add(json.loads(line)["idx"])
158
+ except (json.JSONDecodeError, KeyError):
159
+ continue
160
+ return done
161
+
162
+
163
+ def main():
164
+ for name, value in [("CHECKPOINT_DIR", CHECKPOINT_DIR), ("INPUT_JSONL", INPUT_JSONL),
165
+ ("OUTPUT_JSONL", OUTPUT_JSONL), ("ERROR_LOG", ERROR_LOG),
166
+ ("FEATURE_DIR", FEATURE_DIR)]:
167
+ if not value:
168
+ raise SystemExit(f"Set {name} at the top of cons_offline_data.py before running.")
169
+
170
+ tokenizer_dir, _, qwen_omni_ckpt, audio_tower_ckpt = resolve_checkpoint_paths(CHECKPOINT_DIR)
171
+ os.makedirs(FEATURE_DIR, exist_ok=True)
172
+
173
+ with open(INPUT_JSONL, "r", encoding="utf-8") as f:
174
+ data_lines = f.readlines()
175
+
176
+ processed = _load_processed_indices(OUTPUT_JSONL)
177
+ todo = [i for i in range(len(data_lines)) if i not in processed]
178
+ print(f"Total {len(data_lines)} | already done {len(processed)} | to process {len(todo)}")
179
+
180
+ tokenizer = Tokenizer(tokenizer_dir)
181
+ system_ids = tokenizer.encode(DEFAULT_SYSTEM_PROMPT).cpu().tolist()
182
+
183
+ with open(OUTPUT_JSONL, "a", encoding="utf-8", buffering=1) as fout, \
184
+ open(ERROR_LOG, "a", encoding="utf-8", buffering=1) as ferr:
185
+ for idx in tqdm(todo):
186
+ try:
187
+ data_item = json.loads(data_lines[idx])
188
+ sample = build_offline_sample(
189
+ data_item, idx,
190
+ tokenizer=tokenizer, system_ids=system_ids,
191
+ feature_dir=FEATURE_DIR,
192
+ qwen_omni_ckpt=qwen_omni_ckpt,
193
+ audio_tower_ckpt=audio_tower_ckpt,
194
+ device=DEVICE,
195
+ )
196
+ fout.write(json.dumps(
197
+ {"tasks": "offline", "idx": idx, **sample},
198
+ ensure_ascii=False,
199
+ ) + "\n")
200
+ except Exception as e:
201
+ ferr.write(f"[idx {idx}] {type(e).__name__}: {e}\n")
202
+ ferr.write(data_lines[idx])
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()
src/audiointeraction/dataset/cons_online_data.py ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build online (streaming) SFT samples in 4 inspectable steps.
2
+
3
+ Pipeline:
4
+ step1 — sample a leading-silence length per turn + a tail silence
5
+ (encoder-output frames). No audio touched.
6
+ step2 — load every turn's audio, prepend the sampled silence, concat into
7
+ one waveform, write `<wavs_dir>/<idx>.wav` (16 kHz mono int16).
8
+ step3 — run the Qwen2.5-Omni audio_tower on each wav and dump
9
+ `<features_dir>/<idx>/AudioFeat.pt`.
10
+ step4 — lay out the token-level streaming sequence (input_ids / labels /
11
+ audio_pos) and write a training-ready jsonl.
12
+
13
+ Edit the constants below, then `python cons_online_data.py`. Each step's
14
+ intermediate jsonl is left on disk so you can re-run any step independently
15
+ or inspect the artifacts.
16
+ """
17
+
18
+ import glob
19
+ import json
20
+ import os
21
+ import random
22
+ import wave
23
+
24
+ import numpy as np
25
+ import soundfile as sf
26
+ import whisper
27
+ from tqdm import tqdm
28
+
29
+ from src.audiointeraction.dataset.utils.extract_online_feature import extract_audio_features
30
+ from src.audiointeraction.dataset.utils.load_audio import SAMPLES_PER_FRAME, _load_mel
31
+ from src.audiointeraction.dataset.TOKENS import (
32
+ ASSISTANT, AUDIO_BEGIN, EMOTION_TO_ID, ENGLISH, KEEP_SILENCE, MASK,
33
+ NORMAL, ONLINE, PAD, SYSTEM, TEXT_BEGIN, TEXT_END,
34
+ )
35
+ from src.audiointeraction.generate.base import resolve_checkpoint_paths
36
+ from src.audiointeraction.tokenizer import Tokenizer
37
+
38
+
39
+ # ============================================================
40
+ # Fill these in before running.
41
+ # ============================================================
42
+ CHECKPOINT_DIR = "" # checkpoint root (tokenizer + qwen_2_5_omni_config + audiointeraction_ChunkwisedEncoder.pth)
43
+ INPUT_JSONL = "" # user-supplied raw jsonl
44
+ WORK_DIR = "" # holds intermediate step1/2/3 jsonls + wavs/ + features/
45
+ OUT_TRAIN_JSONL = "" # final training-ready jsonl
46
+ NOISE_DIR = "" # dir of background-noise audio files (wav/flac/ogg/mp3); recursed
47
+
48
+ MIN_NOISE_LEN = 20 # min leading/tail noise length per turn (encoder frames)
49
+ MAX_NOISE_LEN = 60 # max leading/tail noise length per turn
50
+ CHUNK_SIZE = 10 # encoder-output frames per audio chunk
51
+ SEED = 1337
52
+ DEVICE = "cuda"
53
+ # ============================================================
54
+
55
+
56
+ DEFAULT_SYSTEM_PROMPT = (
57
+ "You are a helpful assistant. When there is no user text, if the audio contains a question, "
58
+ "please answer it. If it is a sound effect, determine based on the sound whether help is needed."
59
+ )
60
+
61
+ NO_RESPONSE_MARKERS = {"<no need to response>", "no need to response"}
62
+
63
+
64
+ # === Step 1 — sample silence (filled from a noise library) ===
65
+
66
+ NOISE_AUDIO_EXTS = (".wav", ".flac", ".ogg", ".mp3")
67
+
68
+
69
+ def _scan_noise_dir(noise_dir):
70
+ """Walk noise_dir and return [(path, duration_s), ...] for every audio file."""
71
+ paths = []
72
+ for root, _, files in os.walk(noise_dir):
73
+ for f in files:
74
+ if f.lower().endswith(NOISE_AUDIO_EXTS):
75
+ paths.append(os.path.join(root, f))
76
+ index = []
77
+ for p in tqdm(paths, desc="scan noise"):
78
+ try:
79
+ index.append((p, sf.info(p).duration))
80
+ except Exception:
81
+ # sf.info won't read mp3; fall back to librosa.
82
+ try:
83
+ import librosa
84
+ index.append((p, librosa.get_duration(path=p)))
85
+ except Exception as e:
86
+ print(f"[warn] skip noise {p}: {e}")
87
+ return index
88
+
89
+
90
+ def _pick_noise(noise_index, needed_s, *, margin_s=0.0, max_tries=100):
91
+ """Pick a random noise file. If the draw is shorter than needed, re-draw.
92
+ Returns (path, start_s) — a slice of `needed_s + margin_s` is guaranteed to fit."""
93
+ budget = needed_s + margin_s
94
+ for _ in range(max_tries):
95
+ path, dur = random.choice(noise_index)
96
+ if dur >= budget:
97
+ start_s = random.uniform(0.0, dur - budget)
98
+ return path, round(start_s, 3)
99
+ raise RuntimeError(
100
+ f"Couldn't find a noise file long enough for {budget:.2f}s after {max_tries} tries — "
101
+ f"add longer files to NOISE_DIR."
102
+ )
103
+
104
+
105
+ def _extract_turns(data_item):
106
+ """Return list of {audio_path, assistant, emotion} dicts."""
107
+ convs = data_item.get("conversation", [])
108
+ if convs:
109
+ return [
110
+ {"audio_path": c["audio_path"],
111
+ "assistant": c["assistant"],
112
+ "emotion": c.get("emotion") or "normal"}
113
+ for c in convs
114
+ ]
115
+ if "merge_path" in data_item and "assistant" in data_item:
116
+ return [{
117
+ "audio_path": data_item["merge_path"],
118
+ "assistant": data_item["assistant"],
119
+ "emotion": data_item.get("emotion", "normal"),
120
+ }]
121
+ raise ValueError("missing 'conversation' or single-turn fields")
122
+
123
+
124
+ def step1_sample_silence(input_jsonl, output_jsonl, *,
125
+ noise_dir, min_noise_len, max_noise_len, chunk_size, seed):
126
+ """For each turn, pick a leading noise slice (file + start_s); also one for tail.
127
+
128
+ The tail length is adjusted to a chunk boundary inside step 2, so its noise
129
+ slice is selected with a `chunk_size`-frame margin to guarantee the eventual
130
+ actual length fits in the picked file.
131
+ """
132
+ random.seed(seed)
133
+ os.makedirs(os.path.dirname(os.path.abspath(output_jsonl)) or ".", exist_ok=True)
134
+ noise_index = _scan_noise_dir(noise_dir)
135
+ if not noise_index:
136
+ raise RuntimeError(f"No audio files found under {noise_dir}")
137
+ tail_margin_s = chunk_size * 0.04 # one chunk = 10 frames × 40 ms
138
+
139
+ with open(input_jsonl, "r", encoding="utf-8") as fin, \
140
+ open(output_jsonl, "w", encoding="utf-8") as fout:
141
+ for idx, line in enumerate(tqdm(fin.readlines(), desc="step1")):
142
+ try:
143
+ data_item = json.loads(line)
144
+ turns = _extract_turns(data_item)
145
+ for t in turns:
146
+ t["leading_silence_frames"] = random.randint(min_noise_len, max_noise_len)
147
+ needed_s = t["leading_silence_frames"] * 0.04
148
+ t["leading_noise_path"], t["leading_noise_start_s"] = \
149
+ _pick_noise(noise_index, needed_s)
150
+ tail_frames = random.randint(min_noise_len, max_noise_len)
151
+ tail_path, tail_start_s = _pick_noise(
152
+ noise_index, tail_frames * 0.04, margin_s=tail_margin_s,
153
+ )
154
+ rec = {
155
+ "idx": idx,
156
+ "turns": turns,
157
+ "tail_silence_frames": tail_frames,
158
+ "tail_noise_path": tail_path,
159
+ "tail_noise_start_s": tail_start_s,
160
+ }
161
+ fout.write(json.dumps(rec, ensure_ascii=False) + "\n")
162
+ except Exception as e:
163
+ print(f"[step1 idx {idx}] {type(e).__name__}: {e}")
164
+
165
+
166
+ # === Step 2 — concatenate audio per sample ===
167
+
168
+ def _load_audio_aligned(audio_path):
169
+ """Load audio @ 16 kHz, pad/crop to (output_len * SAMPLES_PER_FRAME) samples.
170
+ Returns (np.float32 array, output_len_frames)."""
171
+ audio, _, _, _, output_len = _load_mel(audio_path)
172
+ arr = np.asarray(audio, dtype=np.float32)
173
+ target = output_len * SAMPLES_PER_FRAME
174
+ if len(arr) < target:
175
+ arr = np.concatenate([arr, np.zeros(target - len(arr), dtype=np.float32)])
176
+ elif len(arr) > target:
177
+ max_start = (len(arr) - target) // SAMPLES_PER_FRAME
178
+ start = random.randint(0, max_start) * SAMPLES_PER_FRAME
179
+ arr = arr[start: start + target]
180
+ return arr, output_len
181
+
182
+
183
+ def _load_noise_segment(noise_path, start_s, n_samples):
184
+ """Pull exactly n_samples from noise_path starting at start_s (16 kHz, mono float).
185
+ Raises if the file actually yields fewer samples than asked (step 1 chose this
186
+ file based on metadata duration — caller should treat it as a data error)."""
187
+ full = whisper.load_audio(noise_path, sr=16000) # float32 in [-1, 1]
188
+ start_idx = int(round(start_s * 16000))
189
+ seg = full[start_idx: start_idx + n_samples]
190
+ if len(seg) < n_samples:
191
+ raise ValueError(
192
+ f"Noise {noise_path} only yields {len(seg)} samples from start {start_s}s, "
193
+ f"need {n_samples}."
194
+ )
195
+ return seg
196
+
197
+
198
+ def _write_wav(path, float_samples):
199
+ """Write a 16 kHz mono int16 wav from float samples in [-1, 1]."""
200
+ arr = np.asarray(float_samples, dtype=np.float32)
201
+ arr_i16 = np.clip(arr * 32767.0, -32768, 32767).astype(np.int16)
202
+ with wave.open(path, "wb") as wf:
203
+ wf.setnchannels(1)
204
+ wf.setsampwidth(2)
205
+ wf.setframerate(16000)
206
+ wf.writeframes(arr_i16.tobytes())
207
+
208
+
209
+ def step2_concat_audio(input_jsonl, output_jsonl, wavs_dir, *, chunk_size, seed):
210
+ """For each step-1 record, splice [noise → audio → noise → audio → ... → tail noise]
211
+ into one wav using the noise slices step 1 picked. Records audio_frames per turn."""
212
+ random.seed(seed)
213
+ os.makedirs(wavs_dir, exist_ok=True)
214
+ os.makedirs(os.path.dirname(os.path.abspath(output_jsonl)) or ".", exist_ok=True)
215
+ with open(input_jsonl, "r", encoding="utf-8") as fin, \
216
+ open(output_jsonl, "w", encoding="utf-8") as fout:
217
+ for line in tqdm(fin.readlines(), desc="step2"):
218
+ rec = json.loads(line)
219
+ try:
220
+ segments = []
221
+ total_frames = 0
222
+ for t in rec["turns"]:
223
+ n = t["leading_silence_frames"] * SAMPLES_PER_FRAME
224
+ segments.append(_load_noise_segment(
225
+ t["leading_noise_path"], t["leading_noise_start_s"], n,
226
+ ))
227
+ seg, n_frames = _load_audio_aligned(t["audio_path"])
228
+ segments.append(seg)
229
+ t["audio_frames"] = n_frames
230
+ total_frames += t["leading_silence_frames"] + n_frames
231
+
232
+ # Round tail length so the total lands on a chunk boundary.
233
+ tail = rec["tail_silence_frames"] - (total_frames + rec["tail_silence_frames"]) % chunk_size
234
+ if tail < 0:
235
+ tail = (chunk_size - (total_frames % chunk_size)) % chunk_size
236
+ segments.append(_load_noise_segment(
237
+ rec["tail_noise_path"], rec["tail_noise_start_s"], tail * SAMPLES_PER_FRAME,
238
+ ))
239
+ rec["tail_silence_frames_actual"] = tail
240
+
241
+ wav_path = os.path.join(wavs_dir, f"{rec['idx']}.wav")
242
+ _write_wav(wav_path, np.concatenate(segments))
243
+ rec["concat_wav_path"] = wav_path
244
+ fout.write(json.dumps(rec, ensure_ascii=False) + "\n")
245
+ except Exception as e:
246
+ print(f"[step2 idx {rec.get('idx')}] {type(e).__name__}: {e}")
247
+
248
+
249
+ # === Step 3 — run audio_tower on each wav ===
250
+
251
+ def step3_extract_features(input_jsonl, output_jsonl, features_dir, *,
252
+ qwen_omni_ckpt, audio_tower_ckpt, device):
253
+ """For each step-2 record, encode the concatenated wav into AudioFeat.pt."""
254
+ os.makedirs(features_dir, exist_ok=True)
255
+ os.makedirs(os.path.dirname(os.path.abspath(output_jsonl)) or ".", exist_ok=True)
256
+ with open(input_jsonl, "r", encoding="utf-8") as fin, \
257
+ open(output_jsonl, "w", encoding="utf-8") as fout:
258
+ for line in tqdm(fin.readlines(), desc="step3"):
259
+ rec = json.loads(line)
260
+ try:
261
+ audio = whisper.load_audio(rec["concat_wav_path"], sr=16000)
262
+ pt_path_dir = os.path.join(features_dir, str(rec["idx"]))
263
+ extract_audio_features(
264
+ audio, pt_path_dir,
265
+ qwen_omni_ckpt=qwen_omni_ckpt,
266
+ audio_tower_ckpt=audio_tower_ckpt,
267
+ device=device,
268
+ )
269
+ rec["pt_path_dir"] = pt_path_dir
270
+ fout.write(json.dumps(rec, ensure_ascii=False) + "\n")
271
+ except Exception as e:
272
+ print(f"[step3 idx {rec.get('idx')}] {type(e).__name__}: {e}")
273
+
274
+
275
+ # === Step 4 — build token-level training samples ===
276
+
277
+ def _silence_chunk(chunk_size):
278
+ """Mid-turn or tail chunk: model should keep waiting."""
279
+ ids = [AUDIO_BEGIN] + [PAD] * chunk_size + [ASSISTANT, KEEP_SILENCE]
280
+ labels = [MASK] + [MASK] * chunk_size + [MASK, KEEP_SILENCE]
281
+ return ids, labels
282
+
283
+
284
+ def _response_chunk(assistant_text, emotion, tokenizer, chunk_size):
285
+ """Last chunk of a turn: actual reply, or silence if marked no-response."""
286
+ if isinstance(assistant_text, str) and assistant_text.strip().lower() in NO_RESPONSE_MARKERS:
287
+ return _silence_chunk(chunk_size)
288
+ emotion_tok = EMOTION_TO_ID.get(emotion.lower(), NORMAL) if isinstance(emotion, str) else NORMAL
289
+ assistant_ids = tokenizer.encode(assistant_text).cpu().tolist()
290
+ ids = [AUDIO_BEGIN] + [PAD] * chunk_size + [ASSISTANT, TEXT_BEGIN, emotion_tok] + assistant_ids + [TEXT_END]
291
+ labels = [MASK] + [MASK] * chunk_size + [MASK, TEXT_BEGIN, emotion_tok] + assistant_ids + [TEXT_END]
292
+ return ids, labels
293
+
294
+
295
+ def step4_build_tokens(input_jsonl, output_jsonl, *, tokenizer_dir, chunk_size):
296
+ """For each step-3 record, lay out input_ids / labels / audio_pos."""
297
+ os.makedirs(os.path.dirname(os.path.abspath(output_jsonl)) or ".", exist_ok=True)
298
+ tokenizer = Tokenizer(tokenizer_dir)
299
+ system_ids = tokenizer.encode(DEFAULT_SYSTEM_PROMPT).cpu().tolist()
300
+ with open(input_jsonl, "r", encoding="utf-8") as fin, \
301
+ open(output_jsonl, "w", encoding="utf-8") as fout:
302
+ for line in tqdm(fin.readlines(), desc="step4"):
303
+ rec = json.loads(line)
304
+ try:
305
+ input_ids = [ONLINE, ENGLISH, SYSTEM, TEXT_BEGIN] + system_ids + [TEXT_END]
306
+ labels = [MASK] * len(input_ids)
307
+ audio_pos = []
308
+
309
+ total_frames = 0
310
+ for i, t in enumerate(rec["turns"]):
311
+ new_chunks = (
312
+ (total_frames + t["leading_silence_frames"] + t["audio_frames"]) // chunk_size
313
+ - total_frames // chunk_size
314
+ )
315
+ total_frames += t["leading_silence_frames"] + t["audio_frames"]
316
+ # First turn gets one extra trailing chunk so the reply has room to land.
317
+ if i == 0:
318
+ new_chunks += 1
319
+ for j in range(new_chunks):
320
+ pos_start = len(input_ids) + 1
321
+ audio_pos.append((pos_start, pos_start + chunk_size))
322
+ if j == new_chunks - 1:
323
+ chunk_ids, chunk_labels = _response_chunk(t["assistant"], t["emotion"], tokenizer, chunk_size)
324
+ else:
325
+ chunk_ids, chunk_labels = _silence_chunk(chunk_size)
326
+ input_ids += chunk_ids
327
+ labels += chunk_labels
328
+
329
+ tail = rec["tail_silence_frames_actual"]
330
+ tail_chunks = (total_frames + tail) // chunk_size - total_frames // chunk_size - 1
331
+ for _ in range(max(0, tail_chunks)):
332
+ pos_start = len(input_ids) + 1
333
+ audio_pos.append((pos_start, pos_start + chunk_size))
334
+ chunk_ids, chunk_labels = _silence_chunk(chunk_size)
335
+ input_ids += chunk_ids
336
+ labels += chunk_labels
337
+
338
+ fout.write(json.dumps({
339
+ "tasks": "online",
340
+ "idx": rec["idx"],
341
+ "input_ids": input_ids,
342
+ "labels": labels,
343
+ "audio_pos": audio_pos,
344
+ "pt_path_dir": rec["pt_path_dir"],
345
+ }, ensure_ascii=False) + "\n")
346
+ except Exception as e:
347
+ print(f"[step4 idx {rec.get('idx')}] {type(e).__name__}: {e}")
348
+
349
+
350
+ # === Main driver ===
351
+
352
+ def main():
353
+ for name, value in [("CHECKPOINT_DIR", CHECKPOINT_DIR), ("INPUT_JSONL", INPUT_JSONL),
354
+ ("WORK_DIR", WORK_DIR), ("OUT_TRAIN_JSONL", OUT_TRAIN_JSONL),
355
+ ("NOISE_DIR", NOISE_DIR)]:
356
+ if not value:
357
+ raise SystemExit(f"Set {name} at the top of cons_online_data.py before running.")
358
+
359
+ tokenizer_dir, _, qwen_omni_ckpt, audio_tower_ckpt = resolve_checkpoint_paths(CHECKPOINT_DIR)
360
+ step1_jsonl = os.path.join(WORK_DIR, "step1.jsonl")
361
+ step2_jsonl = os.path.join(WORK_DIR, "step2.jsonl")
362
+ step3_jsonl = os.path.join(WORK_DIR, "step3.jsonl")
363
+ wavs_dir = os.path.join(WORK_DIR, "wavs")
364
+ features_dir = os.path.join(WORK_DIR, "features")
365
+
366
+ step1_sample_silence(
367
+ INPUT_JSONL, step1_jsonl,
368
+ noise_dir=NOISE_DIR,
369
+ min_noise_len=MIN_NOISE_LEN, max_noise_len=MAX_NOISE_LEN,
370
+ chunk_size=CHUNK_SIZE, seed=SEED,
371
+ )
372
+ step2_concat_audio(
373
+ step1_jsonl, step2_jsonl, wavs_dir,
374
+ chunk_size=CHUNK_SIZE, seed=SEED,
375
+ )
376
+ step3_extract_features(
377
+ step2_jsonl, step3_jsonl, features_dir,
378
+ qwen_omni_ckpt=qwen_omni_ckpt, audio_tower_ckpt=audio_tower_ckpt, device=DEVICE,
379
+ )
380
+ step4_build_tokens(
381
+ step3_jsonl, OUT_TRAIN_JSONL,
382
+ tokenizer_dir=tokenizer_dir, chunk_size=CHUNK_SIZE,
383
+ )
384
+
385
+
386
+ if __name__ == "__main__":
387
+ main()
src/audiointeraction/generate/__init__.py ADDED
File without changes
src/audiointeraction/generate/base.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core streaming generation primitives for the audio-enhanced GPT.
2
+
3
+ The model alternates between two states inside `streaming_generate`:
4
+ - LISTENING: each step consumes one encoder-output chunk of audio. The model
5
+ emits either KEEP_SILENCE (keep listening) or TEXT_BEGIN (start replying).
6
+ - SPEAKING: autoregressive text generation until TEXT_END, then back to
7
+ LISTENING for the next audio chunk.
8
+
9
+ Public surface:
10
+ AUDIO_TOKENS_PER_CHUNK
11
+ sample, encode_audio_chunks, streaming_generate
12
+ """
13
+ SYSTEM_PROMPT = (
14
+ "You are a helpful assistant. When there is no user text, if the audio contains a question, "
15
+ "please answer it. If it is a sound effect, determine based on the sound whether help is needed."
16
+ )
17
+
18
+
19
+
20
+ import os
21
+ import sys
22
+ from typing import List, Optional
23
+
24
+ import numpy as np
25
+ import torch
26
+ import whisper
27
+
28
+ from src.audiointeraction.dataset.TOKENS import (
29
+ ASSISTANT, AUDIO_BEGIN, KEEP_SILENCE, PAD, TEXT_BEGIN, TEXT_END,
30
+ HAPPY, SAD, ANGRY, SURPRISE, NORMAL, URGENT,
31
+ )
32
+ from src.audiointeraction.model import GPT
33
+ from src.audiointeraction.tokenizer import Tokenizer
34
+
35
+
36
+ # Encoder-output frames per [AUDIO_BEGIN, PAD*N, ASSISTANT, ...] block.
37
+ AUDIO_TOKENS_PER_CHUNK = 10
38
+
39
+
40
+ # 情绪 token -> 颜文字(终端/GitHub 都能正常显示)
41
+ EMOTION_KAOMOJI = {
42
+ HAPPY: "(◕‿◕)",
43
+ SAD: "(╥﹏╥)",
44
+ ANGRY: "(╬ಠ益ಠ)",
45
+ SURPRISE: "(⊙o⊙)",
46
+ NORMAL: "(・_・)",
47
+ URGENT: "(°□°;)",
48
+ }
49
+
50
+
51
+ # === Sampling ===
52
+
53
+ def _top_p_filter(logits: torch.Tensor, top_p: float) -> torch.Tensor:
54
+ sorted_logits, sorted_idx = torch.sort(logits, descending=False)
55
+ cumprobs = sorted_logits.softmax(dim=-1).cumsum(dim=-1)
56
+ remove = cumprobs <= (1 - top_p)
57
+ remove[-1:] = 0 # always keep the most probable token
58
+ return logits.masked_fill(remove.scatter(0, sorted_idx, remove), float("-inf"))
59
+
60
+
61
+ def sample(logits: torch.Tensor, *, temperature=1.0, top_k=None, top_p=1.0) -> torch.Tensor:
62
+ """Sample one token id from the last position of `logits` ([1, T, V])."""
63
+ if not 0.0 <= top_p <= 1.0:
64
+ raise ValueError(f"top_p must be in [0, 1], got {top_p}")
65
+ logits = logits[0, -1]
66
+ if top_k is not None:
67
+ v, i = torch.topk(logits, min(top_k, logits.size(-1)))
68
+ logits = torch.full_like(logits, float("-inf")).scatter_(-1, i, v)
69
+ if temperature <= 0.0 and top_p <= 0.0:
70
+ return torch.argmax(logits, dim=-1, keepdim=True)
71
+ if temperature > 0.0:
72
+ logits = logits / temperature
73
+ if top_p < 1.0:
74
+ logits = _top_p_filter(logits, top_p)
75
+ probs = torch.nn.functional.softmax(logits, dim=-1)
76
+ return torch.multinomial(probs, num_samples=1)
77
+
78
+
79
+ # === Audio feature extraction ===
80
+
81
+ def _split_into_chunks(n: int, chunk_size: int) -> List[int]:
82
+ chunks = [chunk_size] * (n // chunk_size)
83
+ if n % chunk_size:
84
+ chunks.append(n % chunk_size)
85
+ return chunks
86
+
87
+
88
+ def _encode_audio_samples(audio: List[float], audio_encoder: torch.nn.Module, device) -> List[torch.Tensor]:
89
+ """Run the audio_tower on raw 16 kHz samples and split the output into AUDIO_TOKENS_PER_CHUNK chunks."""
90
+ audio = list(audio)
91
+ # Pad to a 0.4-s boundary (6400 samples @ 16 kHz).
92
+ if len(audio) % 6400 != 0:
93
+ audio += [0] * (6400 - len(audio) % 6400)
94
+ mel = whisper.log_mel_spectrogram(np.array(audio, dtype=np.float32), n_mels=128)
95
+ len_feature = mel.shape[1]
96
+
97
+ with torch.no_grad():
98
+ feat = audio_encoder(
99
+ torch.tensor(mel).to(device),
100
+ torch.tensor(_split_into_chunks(len_feature, 40)).to(device),
101
+ torch.tensor((len_feature - 1) // 2 + 1).to(device),
102
+ ).last_hidden_state
103
+
104
+ # Drop any trailing partial chunk so each chunk is exactly AUDIO_TOKENS_PER_CHUNK frames.
105
+ keep = feat.shape[0] - feat.shape[0] % AUDIO_TOKENS_PER_CHUNK
106
+ return [feat[i: i + AUDIO_TOKENS_PER_CHUNK] for i in range(0, keep, AUDIO_TOKENS_PER_CHUNK)]
107
+
108
+
109
+ def encode_audio_chunks(audio_path: str, audio_encoder: torch.nn.Module, device) -> List[torch.Tensor]:
110
+ """Run the audio_tower on `audio_path` and split the output into AUDIO_TOKENS_PER_CHUNK chunks."""
111
+ audio = whisper.load_audio(audio_path, sr=16000).tolist()
112
+ return _encode_audio_samples(audio, audio_encoder, device)
113
+
114
+
115
+ def encode_silence_chunks(seconds: float, audio_encoder: torch.nn.Module, device) -> List[torch.Tensor]:
116
+ return _encode_audio_samples([0.0] * int(seconds * 16000), audio_encoder, device)
117
+
118
+
119
+ # === Streaming generation ===
120
+
121
+ def _forward(model, tokens, input_pos, *, input_pos_maxp1, audio_feat):
122
+ return model(
123
+ tokens, None, 1, audio_feat, input_pos,
124
+ input_pos_maxp1=input_pos_maxp1,
125
+ audio_tokens_per_chunk=AUDIO_TOKENS_PER_CHUNK,
126
+ )
127
+
128
+
129
+ def _init_input_pos_maxp1(model, prompt_size, device):
130
+ # input_pos_maxp1 introduces data-dependent shapes; skip if a Thunder module is involved.
131
+ if any(m.__class__.__name__ == "ThunderModule" for m in model.modules()):
132
+ return None
133
+ return torch.tensor(prompt_size, device=device)
134
+
135
+
136
+ def _append_listening_block(token, input_pos, input_pos_maxp1, device):
137
+ """Append `[AUDIO_BEGIN, PAD*N, ASSISTANT]` to the running context."""
138
+ new_tokens = torch.LongTensor([AUDIO_BEGIN] + [PAD] * AUDIO_TOKENS_PER_CHUNK + [ASSISTANT]).to(device)
139
+ new_positions = input_pos[-1] + torch.arange(1, len(new_tokens) + 1, device=device)
140
+ token = torch.cat([token, new_tokens])
141
+ input_pos = torch.cat([input_pos, new_positions])
142
+ if input_pos_maxp1 is not None:
143
+ input_pos_maxp1.add_(len(new_tokens))
144
+ return token, input_pos, input_pos_maxp1
145
+
146
+
147
+ def _advance_one(input_pos, input_pos_maxp1):
148
+ """Move input_pos forward by one (for the next single-token call)."""
149
+ new_pos = input_pos[-1].unsqueeze(0).add_(1)
150
+ if input_pos_maxp1 is not None:
151
+ input_pos_maxp1.add_(1)
152
+ return new_pos, input_pos_maxp1
153
+
154
+
155
+ # === Pretty printing helpers ===
156
+
157
+ class _Pretty:
158
+ """Tiny renderer for the streaming UI. Auto-disables color on non-TTY."""
159
+
160
+ # ANSI codes; emptied below when not a TTY.
161
+ DIM = "\033[2m"
162
+ BOLD = "\033[1m"
163
+ RESET = "\033[0m"
164
+ CYAN = "\033[36m"
165
+ GREEN = "\033[32m"
166
+ YELLOW = "\033[33m"
167
+ MAGENTA = "\033[35m"
168
+ GREY = "\033[90m"
169
+ CLEAR_LINE = "\033[2K\r"
170
+
171
+ BAR_WIDTH = 24
172
+
173
+ def __init__(self, stream=sys.stdout):
174
+ self.stream = stream
175
+ self.use_color = stream.isatty()
176
+ self.use_cr = stream.isatty() # only do in-place updates on a real terminal
177
+ if not self.use_color:
178
+ for name in ("DIM", "BOLD", "RESET", "CYAN", "GREEN",
179
+ "YELLOW", "MAGENTA", "GREY"):
180
+ setattr(self, name, "")
181
+ self.CLEAR_LINE = ""
182
+ # Track whether we're currently sitting on a transient status line
183
+ # (so the next permanent write knows to clear it first).
184
+ self._status_active = False
185
+
186
+ # --- low-level ---
187
+ def _write(self, s: str) -> None:
188
+ self.stream.write(s)
189
+ self.stream.flush()
190
+
191
+ def _clear_status(self) -> None:
192
+ if self._status_active and self.use_cr:
193
+ self._write(self.CLEAR_LINE)
194
+ self._status_active = False
195
+
196
+ # --- public ---
197
+ def header(self, round_idx: int, n_rounds: int, audio_path: str, n_chunks: int) -> None:
198
+ self._clear_status()
199
+ name = os.path.basename(audio_path)
200
+ bar = "━" * 60
201
+ self._write(
202
+ f"\n{self.CYAN}{bar}{self.RESET}\n"
203
+ f"{self.BOLD}{self.CYAN}▶ Round {round_idx}/{n_rounds}{self.RESET} "
204
+ f"{self.DIM}{name}{self.RESET} "
205
+ f"{self.GREY}[{n_chunks} chunks]{self.RESET}\n"
206
+ f"{self.CYAN}{bar}{self.RESET}\n"
207
+ )
208
+
209
+ def status(self, chunk_idx: int, n_chunks: int, silent_run: int, replied: int) -> None:
210
+ """Transient one-line progress; updates in place on a TTY."""
211
+ if not self.use_cr:
212
+ return # don't spam a logfile with thousands of status lines
213
+ filled = int(self.BAR_WIDTH * chunk_idx / max(n_chunks, 1))
214
+ bar = (
215
+ f"{self.GREEN}{'█' * filled}{self.RESET}"
216
+ f"{self.GREY}{'·' * (self.BAR_WIDTH - filled)}{self.RESET}"
217
+ )
218
+ dots = f"{self.DIM}{'·' * min(silent_run, 40)}{self.RESET}" if silent_run else ""
219
+ replied_tag = (
220
+ f" {self.GREEN}✓ {replied} reply{'ies' if replied != 1 else ''}{self.RESET}"
221
+ if replied else ""
222
+ )
223
+ line = (
224
+ f"{self.CLEAR_LINE}"
225
+ f"{self.DIM}listening{self.RESET} {bar} "
226
+ f"{self.BOLD}{chunk_idx:>3}/{n_chunks}{self.RESET}"
227
+ f"{replied_tag} {dots}"
228
+ )
229
+ self._write(line)
230
+ self._status_active = True
231
+
232
+ def reply_begin(self) -> None:
233
+ """Promote to a permanent line: clear status, drop to a new line, print prefix."""
234
+ self._clear_status()
235
+ self._write(f" {self.MAGENTA}▸{self.RESET} ")
236
+
237
+ def reply_token(self, text: str) -> None:
238
+ self._write(text)
239
+
240
+ def reply_emotion(self, token_id: int) -> None:
241
+ """Print the emotion kaomoji inline at the start of a reply."""
242
+ kao = EMOTION_KAOMOJI.get(token_id, f"[emo:{token_id}]")
243
+ self._write(f"{self.YELLOW}{kao}{self.RESET} ")
244
+
245
+ def reply_end(self) -> None:
246
+ self._write("\n")
247
+
248
+ def round_summary(self, replied: int, silent: int, total: int) -> None:
249
+ self._clear_status()
250
+ self._write(
251
+ f" {self.GREY}└─ {replied} reply chunk(s), "
252
+ f"{silent} silent, {total} total{self.RESET}\n"
253
+ )
254
+
255
+ def finish(self) -> None:
256
+ self._clear_status()
257
+
258
+
259
+ def streaming_generate(
260
+ model: GPT,
261
+ audio_encoder: torch.nn.Module,
262
+ tokenizer: Tokenizer,
263
+ prefix_ids: torch.Tensor,
264
+ *,
265
+ rounds: int = 10,
266
+ audio_paths: Optional[List[str]] = None,
267
+ max_returned_tokens: int = 4096,
268
+ temperature: float = 0,
269
+ top_k: Optional[int] = 1,
270
+ top_p: float = 1.0,
271
+ ):
272
+ """Stream audio→text. If `audio_paths` is given, run one round per path
273
+ non-interactively (offline); otherwise prompt stdin each round (online)."""
274
+ device = prefix_ids.device
275
+ ui = _Pretty()
276
+ ui._write(f"{ui.DIM}device: {device}{ui.RESET}\n")
277
+
278
+ token = prefix_ids
279
+ input_pos = torch.arange(0, prefix_ids.size(0), device=device, dtype=torch.int64)
280
+ input_pos_maxp1 = _init_input_pos_maxp1(model, prefix_ids.size(0), device)
281
+
282
+ turns: List[List[int]] = [] # one inner list per assistant turn (across all rounds)
283
+
284
+ if audio_paths is not None:
285
+ steps = []
286
+ for ap in audio_paths:
287
+ steps.append((ap, False))
288
+ steps.append((None, True))
289
+ n_real = len(audio_paths)
290
+ else:
291
+ steps = None
292
+ n_real = rounds
293
+
294
+ n_steps = len(steps) if steps is not None else rounds
295
+ real_idx = 0
296
+ acc_replied = 0
297
+ acc_silent = 0
298
+
299
+ for step_idx in range(n_steps):
300
+ if steps is not None:
301
+ audio_path, is_silence = steps[step_idx]
302
+ else:
303
+ is_silence = False
304
+ ui._clear_status()
305
+ audio_path = input(
306
+ f"{ui.BOLD}Round {real_idx + 1}/{n_real}{ui.RESET} — enter audio path: "
307
+ ).strip()
308
+
309
+ if is_silence:
310
+ audio_chunks = encode_silence_chunks(1.0, audio_encoder, device)
311
+ else:
312
+ audio_chunks = encode_audio_chunks(audio_path, audio_encoder, device)
313
+ real_idx += 1
314
+ acc_replied = 0
315
+ acc_silent = 0
316
+ ui.header(real_idx, n_real, audio_path, len(audio_chunks))
317
+
318
+ # Per-round counters for the progress line / summary.
319
+ replied_chunks = 0
320
+ silent_chunks = 0
321
+ silent_run = 0 # consecutive silent chunks since the last reply (for the dots)
322
+
323
+ # Buffer of decoded ids for the *current* assistant turn — emitted token-by-token.
324
+ # turn[0] is TEXT_BEGIN. turn[1] is *usually* an emotion tag, but if not we
325
+ # treat it as regular text.
326
+ current_turn: List[int] = []
327
+ text_started = False # have we opened a reply line yet?
328
+
329
+ listening, audio_idx = True, -1
330
+ ui.status(0, len(audio_chunks), silent_run, replied_chunks)
331
+
332
+ for _ in range(max_returned_tokens - input_pos.numel()):
333
+ if listening:
334
+ audio_idx += 1
335
+ if audio_idx >= len(audio_chunks):
336
+ break
337
+ token, input_pos, input_pos_maxp1 = _append_listening_block(
338
+ token, input_pos, input_pos_maxp1, device
339
+ )
340
+ logits = _forward(
341
+ model, token.view(1, -1), input_pos,
342
+ input_pos_maxp1=input_pos_maxp1,
343
+ audio_feat=audio_chunks[audio_idx].to(device),
344
+ )
345
+ else:
346
+ logits = _forward(
347
+ model, token.view(1, -1), input_pos,
348
+ input_pos_maxp1=input_pos_maxp1,
349
+ audio_feat=None,
350
+ )
351
+
352
+ token = sample(logits, temperature=temperature, top_k=top_k, top_p=top_p).to(torch.int64)
353
+ int_token = token.item()
354
+ input_pos, input_pos_maxp1 = _advance_one(input_pos, input_pos_maxp1)
355
+
356
+ if listening:
357
+ if int_token == TEXT_BEGIN:
358
+ listening = False
359
+ current_turn = [int_token]
360
+ text_started = False
361
+ # Don't print yet — wait until we know whether the next token is an emotion.
362
+ elif int_token == KEEP_SILENCE:
363
+ turns.append([int_token])
364
+ silent_chunks += 1
365
+ silent_run += 1
366
+ ui.status(audio_idx + 1, len(audio_chunks), silent_run, replied_chunks)
367
+ else:
368
+ raise ValueError(f"Unexpected token {int_token} while listening")
369
+ else:
370
+ current_turn.append(int_token)
371
+
372
+ if int_token == TEXT_END:
373
+ if text_started:
374
+ ui.reply_end()
375
+ turns.append(current_turn)
376
+ current_turn = []
377
+ text_started = False
378
+ replied_chunks += 1
379
+ silent_run = 0
380
+ listening = True
381
+ ui.status(audio_idx + 1, len(audio_chunks), silent_run, replied_chunks)
382
+ else:
383
+ # Layout: [TEXT_BEGIN, (optional EMOTION), t0, t1, ..., TEXT_END]
384
+ # current_turn already has TEXT_BEGIN + this token in it.
385
+ n = len(current_turn)
386
+ if n == 2:
387
+ # First token after TEXT_BEGIN — could be an emotion tag or
388
+ # just regular text if the model skipped the emotion.
389
+ ui.reply_begin()
390
+ text_started = True
391
+ if int_token in EMOTION_KAOMOJI:
392
+ ui.reply_emotion(int_token)
393
+ else:
394
+ piece = tokenizer.decode(torch.tensor([int_token]))
395
+ if piece:
396
+ ui.reply_token(piece)
397
+ elif n >= 3:
398
+ # Stream this token's surface form immediately.
399
+ piece = tokenizer.decode(torch.tensor([int_token]))
400
+ if piece:
401
+ ui.reply_token(piece)
402
+
403
+ if not listening and text_started:
404
+ ui.reply_end()
405
+ text_started = False
406
+
407
+ acc_replied += replied_chunks
408
+ acc_silent += silent_chunks
409
+ if steps is None or is_silence:
410
+ ui.round_summary(acc_replied, acc_silent, acc_replied + acc_silent)
411
+
412
+ ui.finish()
413
+ return turns
src/audiointeraction/model.py ADDED
@@ -0,0 +1,972 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file.
2
+
3
+ """Full definition of a decoder-only transformer-based language model, all of it in this single file.
4
+
5
+ Based on the nanoGPT implementation: https://github.com/karpathy/nanoGPT and
6
+ https://github.com/EleutherAI/gpt-neox/tree/main/megatron/model.
7
+ """
8
+
9
+ import math
10
+ from typing import Any, Optional, Tuple, Union, List
11
+ from functools import partial
12
+ from transformers import AutoConfig, Qwen2_5OmniForConditionalGeneration
13
+ import torch
14
+ import torch.nn as nn
15
+ import torch.nn.functional as F
16
+ from typing_extensions import Self
17
+ import whisper
18
+ from transformers import Qwen2AudioEncoder, Qwen2AudioConfig
19
+ from src.audiointeraction.config import Config
20
+
21
+
22
+ def qkv_reassemble(
23
+ param: torch.Tensor, config: Config
24
+ ) -> torch.Tensor:
25
+ """Reassemble from a normal to an interleaved placement in a QKV matrix.
26
+ [Q, K, V, Q, K, V, ...] --> [Q, Q, ..., K, K, ..., V, V, ...]
27
+ """
28
+ q_per_kv = config.n_head // config.n_query_groups
29
+ qs = []
30
+ ks = []
31
+ vs = []
32
+ for chunk in torch.chunk(param, config.n_query_groups):
33
+ split = torch.split(chunk, [config.head_size * q_per_kv, config.head_size, config.head_size])
34
+ qs.append(split[0])
35
+ ks.append(split[1])
36
+ vs.append(split[2])
37
+ q = torch.cat(qs)
38
+ k = torch.cat(ks)
39
+ v = torch.cat(vs)
40
+ return torch.cat((q, k, v))
41
+
42
+
43
+ class GPT(nn.Module):
44
+ def __init__(self, config: Config) -> None:
45
+ super().__init__()
46
+ assert config.padded_vocab_size is not None
47
+ self.config = config
48
+
49
+ self.lm_head = nn.Linear(
50
+ config.n_embd, config.padded_vocab_size, bias=config.lm_head_bias
51
+ )
52
+
53
+ self.transformer = nn.ModuleDict(
54
+ dict(
55
+ wte=nn.Embedding(config.padded_vocab_size, config.n_embd),
56
+ h=nn.ModuleList(
57
+ Block(config, block_idx)
58
+ for block_idx in range(config.n_layer)
59
+ ),
60
+ ln_f=config.norm_class(config.n_embd, eps=config.norm_eps),
61
+ )
62
+ )
63
+ self.mask_cache: Optional[torch.Tensor] = None
64
+ self.max_seq_length = self.config.block_size
65
+
66
+ @property
67
+ def max_seq_length(self) -> int:
68
+ return self._max_seq_length
69
+
70
+ @max_seq_length.setter
71
+ def max_seq_length(self, value: int) -> None:
72
+ """
73
+ When doing inference, the sequences used might be shorter than the model's context length.
74
+ This allows setting a smaller number to avoid allocating unused memory
75
+ """
76
+ if value > self.config.block_size:
77
+ raise ValueError(
78
+ f"Cannot attend to {value}, block size is only {self.config.block_size}."
79
+ " This is likely because the input text exceeds the supported context length of this model."
80
+ )
81
+ self._max_seq_length = value
82
+ if not hasattr(self, "cos"):
83
+ # first call
84
+ cos, sin = self.rope_cache()
85
+ self.register_buffer("cos", cos, persistent=False)
86
+ self.register_buffer("sin", sin, persistent=False)
87
+ # override
88
+ elif value != self.cos.size(0):
89
+ self.cos, self.sin = self.rope_cache(device=self.cos.device)
90
+ # the mask and kv cache size will get updated on `set_kv_cache`. we cannot update it here because we don't know
91
+ # if the kv cache is expected
92
+ if self.mask_cache is not None and self.mask_cache.shape[-1] < value:
93
+ print(f"Warning: KV cache has length {self.mask_cache.shape[-1]} < {value} = max_seq_length. Call 'set_kv_cache' before doing any forwards!")
94
+
95
+ def reset_parameters(self) -> None:
96
+ # Trigger resetting the rope-cache
97
+ self.cos, self.sin = self.rope_cache(device=self.cos.device)
98
+
99
+ def _init_weights(self, module: nn.Module) -> None:
100
+ """Meant to be used with `gpt.apply(gpt._init_weights)`."""
101
+ if isinstance(module, nn.Linear):
102
+ torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
103
+ if module.bias is not None:
104
+ torch.nn.init.zeros_(module.bias)
105
+ elif isinstance(module, nn.Embedding):
106
+ torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
107
+
108
+
109
+ def fill_in_audio_feature(self,
110
+ input_embeddings: torch.Tensor,
111
+ batch_size: int,
112
+ audio_feats_list,
113
+ audio_pos,
114
+ tasks) -> torch.Tensor:
115
+ """Replace AUDIO_PAD positions in input_embeddings with precomputed audio features.
116
+
117
+ Two fill modes, dispatched per-sample by `tasks[batch_idx]`:
118
+
119
+ - "online": audio is streamed in fixed 10-frame chunks. `audio_pos[i]`
120
+ is a list of (start, end) tuples (each end-start == 10);
121
+ slice the feature tensor by 10 per chunk.
122
+ - "offline": audio is one contiguous block. `audio_pos[i]` is a single
123
+ (start, end) tuple covering `output_len` positions; place
124
+ the entire feature tensor in one shot.
125
+ """
126
+ _, _, emb_dim = input_embeddings.shape
127
+
128
+ if not (batch_size == len(audio_feats_list) == len(audio_pos) == len(tasks)):
129
+ raise ValueError(
130
+ f"length mismatch: batch_size={batch_size}, "
131
+ f"feats={len(audio_feats_list)}, pos={len(audio_pos)}, tasks={len(tasks)}"
132
+ )
133
+
134
+ for batch_idx in range(batch_size):
135
+ audio_feats = audio_feats_list[batch_idx]
136
+ segments = audio_pos[batch_idx]
137
+ if segments is None or segments == -1:
138
+ continue
139
+
140
+ task = tasks[batch_idx]
141
+ if task == "offline":
142
+ # Single big block: place the whole feature tensor at the one segment.
143
+ start, end = segments[0]
144
+ if start >= self.max_seq_length:
145
+ continue
146
+ end = min(end, self.max_seq_length)
147
+ input_embeddings[batch_idx, start:end, :] = audio_feats[: end - start]
148
+ continue
149
+
150
+ # Online: per-10-frame chunk placement.
151
+ for seg_idx, (start, end) in enumerate(segments):
152
+ if start > self.max_seq_length:
153
+ continue
154
+ if end > self.max_seq_length:
155
+ input_embeddings[batch_idx, start:self.max_seq_length, :] = torch.zeros(
156
+ self.max_seq_length - start, emb_dim
157
+ )
158
+ else:
159
+ audio_feat = audio_feats[seg_idx * 10 : (seg_idx + 1) * 10]
160
+ seg_len, feat_dim = audio_feat.shape
161
+ expected_len = end - start
162
+ if seg_len != expected_len or feat_dim != emb_dim:
163
+ raise ValueError(
164
+ f"Loaded feature shape {audio_feat.shape} does not match expected "
165
+ f"({expected_len}, {emb_dim}) at batch {batch_idx}, segment {seg_idx}")
166
+ # Overwrite the embedding segment
167
+ input_embeddings[batch_idx, start:end, :] = audio_feat
168
+
169
+ return input_embeddings
170
+
171
+ def forward(
172
+ self,
173
+ idx: torch.Tensor,
174
+ tasks: Optional[List[str]],
175
+ batch_size: int,
176
+ audio_info: Optional[Union[dict, torch.Tensor]] = None,
177
+ input_pos: Optional[torch.Tensor] = None,
178
+ input_pos_maxp1: Optional[torch.Tensor] = None,
179
+ audio_tokens_per_chunk: int = 10,
180
+ lm_head_chunk_size: int = 0,
181
+ ) -> Union[torch.Tensor, List[torch.Tensor]]:
182
+ """
183
+ If `input_pos` is provided, the KV cache uses K and V vectors for
184
+ positions smaller than entries in `input_pos`. For efficiency, pass
185
+ `input_pos_maxp1` as `max(input_pos) + 1` if already available from
186
+ your forward algorithm. This slices the KV cache buffers and speeds
187
+ up multi-head attention.
188
+
189
+ Without `input_pos_maxp1`, the computation uses the full KV cache
190
+ (`max_seq_length`) with masking applied. Note that inferring
191
+ `input_pos_maxp1` from `input_pos` causes graph breaks and prevents
192
+ compilation.
193
+
194
+ Args:
195
+ idx: Token indices of input sequences, shape `(B, T)`, where `B`
196
+ is batch size.
197
+ input_pos: Optional. Positions of input tokens. The default is
198
+ `arange(T)`. Can have shape `(T,)` or `(B, T)` (batched index).
199
+ input_pos_maxp1: Optional. See above.
200
+ lm_head_chunk_size: Optional. If `lm_head_chunk_size > 0`, the final
201
+ `lm_head` computation is done in chunks of this size.
202
+
203
+ Returns:
204
+ Logit outputs, shape `(B, T, config.padded_vocab_size)`. If
205
+ `lm_head_chunk_size > 0`, this is a list of chunks of shape
206
+ `(B, lm_head_chunk_size, config.padded_vocab_size)`, the final
207
+ entry can be shorter.
208
+
209
+ """
210
+ T = idx.size(1)
211
+ if self.max_seq_length < T:
212
+ raise ValueError(f"Cannot forward sequence of length {T}, max seq length is only {self.max_seq_length}.")
213
+
214
+ if input_pos is not None: # use the kv cache
215
+ if input_pos.dim() > 2:
216
+ # otherwise, things go wrong in `apply_rope`
217
+ raise ValueError(f"input_pos must have 1 or 2 dimensions, input_pos.shape = {input_pos.shape}")
218
+ if input_pos.shape[-1] != T:
219
+ raise ValueError(f"input_pos.shape[-1] = {input_pos.shape[-1]} != {T} = idx.shape[1], must be the same")
220
+ cos = batched_index_select(self.cos, 0, input_pos)
221
+ sin = batched_index_select(self.sin, 0, input_pos)
222
+ if input_pos.dim() == 1:
223
+ cos = cos.unsqueeze(0)
224
+ sin = sin.unsqueeze(0)
225
+ if self.mask_cache is None:
226
+ raise TypeError("You need to call `gpt.set_kv_cache()`")
227
+ mask = batched_index_select(self.mask_cache, 2, input_pos)
228
+ if mask.dim() > 4:
229
+ # the mask cache has a batch dim of 1 in addition to the one
230
+ # we get if input_pos has a batch dimension
231
+ mask = mask.view(*(mask.shape[0:1] + mask.shape[2:]))
232
+ if input_pos_maxp1 is not None:
233
+ # Shorten final dimension so it just covers all `input_pos` entries
234
+ if input_pos_maxp1 > self.max_seq_length:
235
+ raise ValueError(f"Positions in 'input_pos' must be in [0,{self.max_seq_length})")
236
+ mask = mask[..., :input_pos_maxp1]
237
+ else:
238
+ # unsqueeze to have a batch dimension
239
+ cos = self.cos[:T].unsqueeze(0)
240
+ sin = self.sin[:T].unsqueeze(0)
241
+ # `cos`, `sin` have shape (1, T, config.rope_n_elem)
242
+ mask = None # defaults to causal mask
243
+ input_pos_maxp1 = None
244
+
245
+ x = self.transformer.wte(idx) # token embeddings of shape (B, T, n_embd)
246
+
247
+ # Audio feature injection — dispatch on input type. Encoder features are
248
+ # already n_embd-dim (projected by audio_tower.proj), so we place them
249
+ # directly into the input embeddings.
250
+ # - dict : training path, segment-based fill from precomputed features
251
+ # - Tensor : inference path, streaming chunk replacement
252
+ # - None : no audio (e.g. text-only data or inter-token decoding step)
253
+ if isinstance(audio_info, dict):
254
+ # T_T (text-only) samples have audio_pos == None — nothing to fill.
255
+ if audio_info.get("audio_pos") is not None:
256
+ x = self.fill_in_audio_feature(
257
+ x, batch_size, audio_info["feats_paths"], audio_info["audio_pos"], tasks,
258
+ )
259
+ elif torch.is_tensor(audio_info):
260
+ if T > audio_tokens_per_chunk:
261
+ if x.size(0) != 1:
262
+ raise ValueError("inference mode, it is not supported for batch size > 1")
263
+ x[0, T - (audio_tokens_per_chunk + 1): T - 1, :] = audio_info
264
+
265
+ if self.config.scale_embeddings:
266
+ x = x * torch.tensor(self.config.n_embd ** 0.5, dtype=x.dtype)
267
+
268
+ for block in self.transformer.h:
269
+ x = block(x, cos, sin, mask, input_pos, input_pos_maxp1)
270
+
271
+ x = self.transformer.ln_f(x)
272
+ clamp_head = (
273
+ partial(do_softcapping, thresh=self.config.final_logit_softcapping)
274
+ if self.config.final_logit_softcapping is not None
275
+ else nn.Identity()
276
+ )
277
+ if lm_head_chunk_size > 0:
278
+ # chunk the lm head logits to reduce the peak memory used by autograd
279
+ return [
280
+ clamp_head(self.lm_head(x_i))
281
+ for x_i in x.split(lm_head_chunk_size, dim=1)
282
+ ]
283
+ else:
284
+ return clamp_head(self.lm_head(x)) # (B, T, padded_vocab_size)
285
+
286
+ def rope_cache(self, device: Optional[torch.device] = None) -> Tuple[torch.Tensor, torch.Tensor]:
287
+
288
+ if self.config.rope_adjustments is None:
289
+ extra_config = None
290
+
291
+ else:
292
+ adjusted_params_required = ["factor", "low_freq_factor", "high_freq_factor", "original_max_seq_len"]
293
+ params_present = [param in self.config.rope_adjustments for param in adjusted_params_required]
294
+ num_params_present = sum(params_present)
295
+
296
+ if num_params_present == 0:
297
+ extra_config = None # uses standard RoPE
298
+ elif num_params_present == 4:
299
+ # These parameters should always be used together so that we don't interfere with standard rope
300
+ extra_config = {
301
+ name: self.config.rope_adjustments[name]
302
+ for name in adjusted_params_required
303
+ }
304
+ else:
305
+ # Some but not all parameters are specified; raise an error
306
+ missing_params = [
307
+ param for param, present in zip(adjusted_params_required, params_present) if not present
308
+ ]
309
+ raise ValueError(
310
+ f"The following adjusted RoPE parameters are missing in rope_adjustments: {', '.join(missing_params)}. "
311
+ "All adjusted RoPE parameters must be specified together."
312
+ )
313
+
314
+ return build_rope_cache(
315
+ seq_len=self.max_seq_length,
316
+ n_elem=self.config.rope_n_elem,
317
+ device=device,
318
+ condense_ratio=self.config.rope_condense_ratio,
319
+ base=self.config.rope_base,
320
+ extra_config=extra_config,
321
+ )
322
+
323
+ def set_kv_cache(
324
+ self,
325
+ batch_size: int,
326
+ max_seq_length: Optional[int] = None,
327
+ rope_cache_length: Optional[int] = None,
328
+ device: Optional[torch.device] = None,
329
+ dtype: Optional[torch.dtype] = None,
330
+ ) -> None:
331
+ if rope_cache_length is None:
332
+ rope_cache_length = self.cos.size(-1)
333
+
334
+ if max_seq_length is None:
335
+ max_seq_length = self.max_seq_length
336
+
337
+ # initialize the kv cache for all blocks
338
+ for block in self.transformer.h:
339
+ block.attn.kv_cache = block.attn.build_kv_cache(
340
+ batch_size,
341
+ max_seq_length,
342
+ rope_cache_length,
343
+ device,
344
+ dtype,
345
+ )
346
+
347
+ if self.mask_cache is None or self.mask_cache.size(3) != max_seq_length:
348
+ # passing `attn_mask` to SDPA disables the flash implementation. since we only need the mask
349
+ # for the kv-cache support (only during inference), we only create it in that situation
350
+ self.mask_cache = build_mask_cache(max_seq_length, device)
351
+
352
+ def clear_kv_cache(self) -> None:
353
+ self.mask_cache = None
354
+ for block in self.transformer.h:
355
+ block.attn.kv_cache = None
356
+
357
+
358
+ class Block(nn.Module):
359
+ def __init__(
360
+ self,
361
+ config: Config,
362
+ block_idx: int,
363
+ ) -> None:
364
+ super().__init__()
365
+ if not config.parallel_residual and config.shared_attention_norm:
366
+ raise NotImplementedError(
367
+ "No checkpoint amongst the ones we support uses this configuration"
368
+ " (non-parallel residual and shared attention norm)."
369
+ )
370
+
371
+ self.norm_1 = config.norm_class(config.n_embd, eps=config.norm_eps)
372
+ self.attn = CausalSelfAttention(config, block_idx)
373
+ self.post_attention_norm = (
374
+ config.norm_class(config.n_embd, eps=config.norm_eps) if config.post_attention_norm else nn.Identity()
375
+ )
376
+ self.norm_2 = None if config.shared_attention_norm else config.norm_class(config.n_embd, eps=config.norm_eps)
377
+ self.mlp = config.mlp_class(config)
378
+ self.post_mlp_norm = (
379
+ config.norm_class(config.n_embd, eps=config.norm_eps) if config.post_mlp_norm else nn.Identity()
380
+ )
381
+
382
+ self.config = config
383
+
384
+ def forward(
385
+ self,
386
+ x: torch.Tensor,
387
+ cos: torch.Tensor,
388
+ sin: torch.Tensor,
389
+ mask: Optional[torch.Tensor] = None,
390
+ input_pos: Optional[torch.Tensor] = None,
391
+ input_pos_maxp1: Optional[torch.Tensor] = None,
392
+ ) -> torch.Tensor:
393
+ """
394
+ Non-parallel residual Parallel residual
395
+ ┌─ x ┌─ x ──────────────────┐ Note: if `shared_attention_norm` is True,
396
+ │ ↓ │ ↓ ↓ the output from `norm_1` is reused
397
+ │ norm_1 │ norm_1 ───────► norm_2
398
+ │ ↓ │ ↓ ↓
399
+ │ attn │ attn MLP
400
+ │ ↓ │ ↓ ↓
401
+ | post_attn_norm | post_attn_norm post_mlp_norm
402
+ | ↓ | ↓ ↓
403
+ ┌─ └► + └► + ◄─────────────────┘
404
+ | ↓
405
+ │ norm_2
406
+ │ ↓
407
+ │ MLP
408
+ │ ↓
409
+ | post_mlp_norm
410
+ | ↓
411
+ └───► +
412
+ """
413
+
414
+ x_normed = self.norm_1(x)
415
+ attention_output = self.attn(
416
+ x_normed, cos, sin, mask, input_pos, input_pos_maxp1
417
+ )
418
+ attention_output = self.post_attention_norm(attention_output)
419
+
420
+ if self.config.parallel_residual:
421
+ if not self.config.shared_attention_norm:
422
+ x_normed = self.norm_2(x)
423
+ x = attention_output + x
424
+ else:
425
+ x = attention_output + x
426
+ x_normed = self.norm_2(x)
427
+ return self.post_mlp_norm(self.mlp(x_normed)) + x
428
+
429
+
430
+ class CausalSelfAttention(nn.Module):
431
+ def __init__(self, config: Config, block_idx: int) -> None:
432
+ super().__init__()
433
+ # key, query and value projections for all heads, but in a batch
434
+ self.qkv = nn.Linear(
435
+ config.n_embd,
436
+ (config.n_head + 2 * config.n_query_groups) * config.head_size, # support for grouped/multi queries
437
+ bias=config.bias or config.attn_bias,
438
+ )
439
+ # output projection
440
+ self.proj = nn.Linear(
441
+ config.head_size * config.n_head, config.n_embd, bias=config.bias
442
+ )
443
+ # disabled by default
444
+ self.kv_cache: Optional[KVCache] = None
445
+ self.apply_sliding_window_attention = (
446
+ config.sliding_window_size is not None and
447
+ block_idx % config.sliding_window_layer_stride == 0
448
+ )
449
+
450
+ if config.norm_qk:
451
+ self.norm_q = config.norm_class(config.head_size * config.n_head, eps=config.norm_eps)
452
+ self.norm_k = config.norm_class(config.head_size * config.n_query_groups, eps=config.norm_eps)
453
+ else:
454
+ self.norm_q = self.norm_k = None
455
+
456
+ self.config = config
457
+ self.block_idx = block_idx
458
+
459
+ # Attention capture flags (for analysis/visualization, disabled by default)
460
+ self.capture_attn: bool = False
461
+ self.captured_attn_weights: Optional[torch.Tensor] = None # shape: (B, n_head, T_q, T_k)
462
+
463
+ def forward(
464
+ self,
465
+ x: torch.Tensor,
466
+ cos: torch.Tensor,
467
+ sin: torch.Tensor,
468
+ mask: Optional[torch.Tensor] = None,
469
+ input_pos: Optional[torch.Tensor] = None,
470
+ input_pos_maxp1: Optional[torch.Tensor] = None,
471
+ ) -> torch.Tensor:
472
+ # Notation:
473
+ # - B | batch size
474
+ # - T | time-step (sequence length)
475
+ # - C | model's embeddings size (n_embd)
476
+ # - C* | attentions's embeddings size
477
+ # - nh_(q,k,v) | number of heads for query, key and value
478
+ # - hs | head size
479
+ head_size = self.config.head_size
480
+ n_head = self.config.n_head
481
+ n_query_groups = self.config.n_query_groups
482
+ rope_n_elem = self.config.rope_n_elem
483
+ B, T, C = x.size() # batch size, sequence length, embedding dimensionality (n_embd)
484
+
485
+ # Perform a single multiplication operation using a combined QKV matrix to calculate `query`, `key`, and `value`
486
+ # instead of individually multiplying the input `x` with the respective weight matrices.
487
+ qkv = self.qkv(x) # (B, T, 3xC*)
488
+
489
+ # Define query, key and value sizes.
490
+ # If grouped/multi query is enabled, these sizes are not equal (see the diagram in `lit_gpt/config.py::Config`).
491
+ query_size = n_head * head_size
492
+ key_size = value_size = n_query_groups * head_size
493
+ # Split qkv into query, key and value matrices.
494
+ q, k, v = qkv.split((query_size, key_size, value_size), dim=-1) # 3x(B, T, C*)
495
+
496
+ if self.config.norm_qk:
497
+ q = self.norm_q(q)
498
+ k = self.norm_k(k)
499
+
500
+ # To place the num_heads (nh) dimension right after the batch (B) dimension, the first step is to decouple the
501
+ # embedding size (C) into num_heads (nh) and head_size (hs).
502
+ q = q.view(B, T, n_head, head_size) # (B, T, nh_q, hs)
503
+ k = k.view(B, T, n_query_groups, head_size) # (B, T, nh_k, hs)
504
+ v = v.view(B, T, n_query_groups, head_size) # (B, T, nh_v, hs)
505
+
506
+ # The tensors `query`, `key`, and `value` are now accurately structured: within each batch element (B), there are
507
+ # multiple heads (nh), and within each head, there is a sequence of elements (T), each represented by a vector
508
+ # of size `hs`.
509
+ q = q.transpose(1, 2) # (B, nh_q, T, hs)
510
+ k = k.transpose(1, 2) # (B, nh_k, T, hs)
511
+ v = v.transpose(1, 2) # (B, nh_v, T, hs)
512
+
513
+ # Unlike standard positional embeddings rotary embeddings must be applied at every layer.
514
+ q_roped = apply_rope(q[..., : rope_n_elem], cos, sin)
515
+ k_roped = apply_rope(k[..., : rope_n_elem], cos, sin)
516
+ q = torch.cat((q_roped, q[..., rope_n_elem :]), dim=-1) # (B, nh_q, T, hs)
517
+ k = torch.cat((k_roped, k[..., rope_n_elem :]), dim=-1) # (B, nh_k, T, hs)
518
+
519
+ # Apply kv-cache during inference.
520
+ if input_pos is not None:
521
+ if not isinstance(self.kv_cache, KVCache):
522
+ raise TypeError("You need to call `gpt.set_kv_cache()`")
523
+ k, v = self.kv_cache(input_pos, k, v)
524
+ if input_pos_maxp1 is not None:
525
+ # Subselect along sequence dimension
526
+ k = k[..., :input_pos_maxp1, :]
527
+ v = v[..., :input_pos_maxp1, :]
528
+ # k, v: (B, nh_k, input_pos_maxp1, hs)
529
+ # If input_pos_maxp1 is None -> max_seq_length
530
+
531
+ use_flash = (getattr(self.config, "use_flash_attention", True)
532
+ and mask is None
533
+ and n_query_groups == n_head
534
+ )
535
+ if use_flash:
536
+ # FlashAttention: B H T D -> B T H D
537
+ q = q.transpose(1, 2).contiguous() # (B, T, nh_q, hs)
538
+ k = k.transpose(1, 2).contiguous() # (B, T, nh_k, hs)
539
+ v = v.transpose(1, 2).contiguous() # (B, T, nh_v, hs)
540
+ from flash_attn.flash_attn_interface import flash_attn_func
541
+ y = flash_attn_func(q, k, v, dropout_p=0.0, causal=True)
542
+ y = y.transpose(1, 2) # back to B H T D
543
+
544
+ else:
545
+ # Grouped queries: balance the number of heads across all three matrices.
546
+ # NOTE: flash attention requires it in training mode.
547
+ # Multi-query: this step can be skipped since there is only 1 head, allowing us to use broadcasting.
548
+ if n_query_groups != n_head and (input_pos is None or n_query_groups != 1):
549
+ q_per_kv = n_head // n_query_groups
550
+ k = k.repeat_interleave(q_per_kv, dim=1) # (B, nh_q, T, hs)
551
+ v = v.repeat_interleave(q_per_kv, dim=1) # (B, nh_q, T, hs)
552
+
553
+ if self.apply_sliding_window_attention:
554
+ """
555
+ Global Window Sliding window Sliding window
556
+ attention mask + bias = attention mask
557
+ ┌────────────────────────┐ ┌───────────────────────┐ ┌─────────────────────────┐
558
+ │ True False False False │ │ True True True True │ │ True False False False │
559
+ │ True True False False │ │ True True True True │ │ True True False False │
560
+ │ True True True False │ │ False True True True │ │ False True True False │
561
+ │ True True True True │ │ False False True True │ │ False False True True │
562
+ └────────────────────────┘ └───────────────────────┘ └─────────────────────────┘
563
+ """
564
+ if mask is None:
565
+ mask = torch.ones(T, T, dtype=q.dtype, device=q.device).triu(diagonal=1)
566
+ mask.masked_fill_(mask.bool(), float("-inf"))
567
+ mask = mask.view(1, 1, *mask.shape)
568
+ sliding_window_bias = torch.ones_like(mask).tril(diagonal=-self.config.sliding_window_size)
569
+ sliding_window_bias.masked_fill_(sliding_window_bias.bool(), float("-inf"))
570
+ mask += sliding_window_bias
571
+
572
+ # Efficient attention using Flash Attention CUDA kernels.
573
+ # NOTE: efficient implementation is disabled if `mask` is not None or softcapping is enabled.
574
+ # ↓ (B, nh, T, hs) @ (B, nh, T, hs).mT --> (B, nh, T, T) @ (B, nh, T, hs) --> (B, nh, T, hs)
575
+ y = self.scaled_dot_product_attention(q, k, v, mask)
576
+
577
+ # Re-assemble all head outputs side by side.
578
+ y = y.reshape(B, T, head_size * n_head)
579
+
580
+ # Output projection.
581
+ return self.proj(y) # (B, T, C)
582
+
583
+ def scaled_dot_product_attention(
584
+ self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, mask: Optional[torch.Tensor] = None
585
+ ) -> torch.Tensor:
586
+ scale = 1.0 / math.sqrt(self.config.attention_scores_scalar or self.config.head_size)
587
+
588
+ # with softcapping we cannot use SDPA
589
+ if self.config.attention_logit_softcapping is not None:
590
+ scores = q @ k.mT * scale
591
+ scores = do_softcapping(scores, self.config.attention_logit_softcapping)
592
+ if mask is None:
593
+ mask = torch.ones(q.size(2), q.size(2), dtype=q.dtype, device=q.device).triu(diagonal=1)
594
+ mask.masked_fill_(mask.bool(), torch.finfo(q.dtype).min)
595
+ scores = scores + mask
596
+ scores = F.softmax(scores, dim=-1, dtype=torch.float).to(dtype=q.dtype)
597
+ if self.capture_attn:
598
+ self.captured_attn_weights = scores.detach()
599
+ y = scores @ v
600
+ elif self.capture_attn:
601
+ # Manual attention computation to capture weights (bypasses fused SDPA kernel)
602
+ # q: (B, n_head, T_q, hs), k: (B, n_head, T_k, hs)
603
+ scores = torch.matmul(q.float(), k.float().transpose(-2, -1)) * scale
604
+ if mask is not None:
605
+ if mask.dtype == torch.bool:
606
+ scores = scores.masked_fill(~mask, float('-inf'))
607
+ else:
608
+ scores = scores + mask.float()
609
+ else:
610
+ # Apply causal mask manually when no mask is provided (training mode)
611
+ T_q, T_k = q.size(-2), k.size(-2)
612
+ causal = torch.ones(T_q, T_k, device=q.device, dtype=torch.bool).tril(diagonal=T_k - T_q)
613
+ scores = scores.masked_fill(~causal, float('-inf'))
614
+ attn_weights = F.softmax(scores, dim=-1)
615
+ self.captured_attn_weights = attn_weights.detach()
616
+ y = torch.matmul(attn_weights.to(dtype=v.dtype), v)
617
+ return y.transpose(1, 2)
618
+ else:
619
+ y = F.scaled_dot_product_attention(
620
+ q, k, v, attn_mask=mask, dropout_p=0.0, scale=scale, is_causal=mask is None
621
+ )
622
+ return y.transpose(1, 2)
623
+
624
+ def build_kv_cache(
625
+ self,
626
+ batch_size: int,
627
+ max_seq_length: int,
628
+ rope_cache_length: Optional[int] = None,
629
+ device: Optional[torch.device] = None,
630
+ dtype: Optional[torch.dtype] = None,
631
+ ) -> "KVCache":
632
+ v_shape = (batch_size, self.config.n_query_groups, max_seq_length, self.config.head_size)
633
+ if rope_cache_length is None:
634
+ if self.config.rotary_percentage != 1.0:
635
+ raise TypeError("Please pass the `rope_cache_length=gpt.cos.size(-1)` value")
636
+ k_shape = v_shape
637
+ else:
638
+ k_shape = (
639
+ batch_size,
640
+ self.config.n_query_groups,
641
+ max_seq_length,
642
+ rope_cache_length + self.config.head_size - self.config.rope_n_elem,
643
+ )
644
+ return KVCache(k_shape, v_shape, device=device, dtype=dtype)
645
+
646
+ def _load_from_state_dict(self, state_dict: dict, prefix: str, *args: Any, **kwargs: Any) -> None:
647
+ """For compatibility with legacy checkpoints."""
648
+
649
+ for attr in ("weight", "bias"):
650
+ legacy_key = f"{prefix}attn.{attr}"
651
+ current_key = f"{prefix}qkv.{attr}"
652
+ if legacy_key in state_dict:
653
+ state_dict[current_key] = qkv_reassemble(state_dict.pop(legacy_key), self.config)
654
+
655
+ super()._load_from_state_dict(state_dict, prefix, *args, **kwargs)
656
+
657
+
658
+ class GptNeoxMLP(nn.Module):
659
+ def __init__(self, config: Config) -> None:
660
+ super().__init__()
661
+ self.fc = nn.Linear(
662
+ config.n_embd, config.intermediate_size, bias=config.bias
663
+ )
664
+ self.proj = nn.Linear(
665
+ config.intermediate_size, config.n_embd, bias=config.bias
666
+ )
667
+ self.config = config
668
+
669
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
670
+ x = self.fc(x)
671
+ x = F.gelu(x, approximate=self.config.gelu_approximate)
672
+ return self.proj(x)
673
+
674
+
675
+ class LLaMAMLP(nn.Module):
676
+ def __init__(self, config: Config) -> None:
677
+ super().__init__()
678
+ self.fc_1 = nn.Linear(
679
+ config.n_embd, config.intermediate_size, bias=config.bias
680
+ )
681
+ self.fc_2 = nn.Linear(
682
+ config.n_embd, config.intermediate_size, bias=config.bias
683
+ )
684
+ self.proj = nn.Linear(
685
+ config.intermediate_size, config.n_embd, bias=config.bias
686
+ )
687
+ self.config = config
688
+
689
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
690
+ x_fc_1 = self.fc_1(x)
691
+ x_fc_2 = self.fc_2(x)
692
+ x = F.silu(x_fc_1) * x_fc_2
693
+ return self.proj(x)
694
+
695
+
696
+ class GemmaMLP(LLaMAMLP):
697
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
698
+ x_fc_1 = self.fc_1(x)
699
+ x_fc_2 = self.fc_2(x)
700
+ x = F.gelu(x_fc_1, approximate=self.config.gelu_approximate) * x_fc_2
701
+ return self.proj(x)
702
+
703
+
704
+ class LLaMAMoE(nn.Module):
705
+ def __init__(self, config: Config) -> None:
706
+ super().__init__()
707
+ self.gate = nn.Linear(config.n_embd, config.n_expert, bias=False)
708
+ self.experts = nn.ModuleList(LLaMAMLP(config) for _ in range(config.n_expert))
709
+ self.config = config
710
+
711
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
712
+ """
713
+ Derived from: https://github.com/mistralai/mistral-src/blob/b46d6/moe_one_file_ref.py#L203-L219
714
+ See also figure 1 in https://arxiv.org/abs/2211.15841
715
+ """
716
+ B, T, C = x.size() # batch size, sequence length, embedding dimensionality (n_embd)
717
+ x = x.view(-1, C) # (B*T, C)
718
+ router = self.gate(x) # (B*T, n_expert)
719
+ probs, indices = torch.topk(router, self.config.n_expert_per_token) # (B*T, n_expert_per_token)
720
+ probs = probs.softmax(dim=1, dtype=torch.float).to(dtype=x.dtype)
721
+ masks = indices.unsqueeze(-1) == torch.arange(self.config.n_expert, device=x.device)
722
+ masks = masks.permute(2, 0, 1) # (n_expert, B*T, n_expert_per_token)
723
+ y = torch.zeros_like(x) # (B*T, C)
724
+ for mask, expert in zip(masks, self.experts):
725
+ token_idx, expert_idx = torch.where(mask)
726
+ y[token_idx] += probs[token_idx, expert_idx, None] * expert(x[token_idx])
727
+ return y.view(B, T, C)
728
+
729
+
730
+ def build_rope_cache(
731
+ seq_len: int,
732
+ n_elem: int,
733
+ device: Optional[torch.device] = None,
734
+ base: int = 10000,
735
+ condense_ratio: int = 1,
736
+ extra_config: Optional[dict] = None,
737
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
738
+ """
739
+ Enhanced Transformer with Rotary Position Embedding.
740
+
741
+ Args:
742
+ seq_len (int): Sequence length.
743
+ n_elem (int): Number of elements (head dimension).
744
+ device (torch.device, optional): Device for tensor allocations.
745
+ base (int, optional): Base for computing inverse frequencies.
746
+ condense_ratio (int, optional): Ratio to condense the position indices.
747
+ extra_config (dict, optional): Configuration parameters for frequency adjustments (used by Llama 3.1 and 3.2)
748
+
749
+ Returns:
750
+ Tuple[torch.Tensor, torch.Tensor]: Cosine and sine caches for RoPE.
751
+ Shapes are `(seq_len, n_elem)`.
752
+ """
753
+
754
+ # Compute the inverse frequencies theta
755
+ theta = 1.0 / (base ** (torch.arange(0, n_elem, 2, device=device).float() / n_elem))
756
+
757
+ if extra_config is not None:
758
+ orig_context_len = extra_config["original_max_seq_len"]
759
+ factor = extra_config["factor"]
760
+ low_freq_factor = extra_config["low_freq_factor"]
761
+ high_freq_factor = extra_config["high_freq_factor"]
762
+
763
+ wavelen = 2 * torch.pi / theta
764
+ ratio = orig_context_len / wavelen
765
+ smooth_factor = (ratio - low_freq_factor) / (high_freq_factor - low_freq_factor)
766
+ smooth_factor = torch.clamp(smooth_factor, min=0.0, max=1.0)
767
+
768
+ # Compute adjusted_theta without masked indexing
769
+ adjusted_theta = (1 - smooth_factor) * (theta / factor) + smooth_factor * theta
770
+ theta = adjusted_theta
771
+
772
+ # Create position indices `[0, 1, ..., seq_len - 1]`
773
+ ### Zhifei fix bug 1:.
774
+ seq_idx = torch.arange(seq_len, device=device, dtype=torch.float16) / float(condense_ratio)
775
+ # seq_idx = torch.arange(seq_len, device=device) / condense_ratio
776
+
777
+ # Calculate the product of position index and $\theta_i$
778
+ idx_theta = torch.outer(seq_idx, theta).repeat(1, 2)
779
+ # If `n_elem` is odd, the final dimension of `idx_theta` has size
780
+ # `n_elem + 1`, so need to cut something off.
781
+ # Due to a current bug in Hugging Face, in the case `n_elem == 1`, we leave
782
+ # `idx_theta`, `cos`, `sin` as is. Things work out in `apply_rope` due to
783
+ # broadcasting. If we shorten `idx_theta`, unit tests comparing to
784
+ # Hugging Face fail.
785
+ # https://github.com/huggingface/transformers/issues/35233
786
+ if idx_theta.shape[-1] > n_elem > 1:
787
+ idx_theta = idx_theta[..., :n_elem]
788
+
789
+ return torch.cos(idx_theta), torch.sin(idx_theta)
790
+
791
+
792
+ def batched_index_select(t, dim, idx):
793
+ """index_select for batched index and unbatched t"""
794
+ if idx.dim() == 1:
795
+ return torch.index_select(t, dim, idx)
796
+
797
+ *batch_shape, idx_size = idx.shape
798
+ res = torch.index_select(t, dim, idx.reshape(-1)) # flat index
799
+ # split out single batch idx
800
+ res = res.view(*t.shape[:dim], -1, idx_size, *t.shape[dim + 1 :])
801
+ if dim > 0:
802
+ # move batch dim to front, this is np.rollaxis(res, dim, 0) for tensors
803
+ dims = [dim] + list(range(res.dim()))
804
+ del dims[dim + 1]
805
+ res = res.permute(dims)
806
+ # unflatten batch dims
807
+ res = res.view(*batch_shape, *res.shape[1:])
808
+ return res
809
+
810
+
811
+ def batched_index_copy_(t, dim, idx, val):
812
+ """Index copy for batched t, idx, val"""
813
+
814
+ if t.device.type == "mps":
815
+ # Normalize negative dimensions
816
+ if dim < 0:
817
+ dim = t.dim() + dim
818
+ if idx.dim() == 1:
819
+ idx_shape = [1] * val.dim()
820
+ idx_shape[dim] = -1
821
+ idx_expanded = idx.view(*idx_shape)
822
+ idx_expanded = idx_expanded.expand_as(val)
823
+ t.scatter_(dim, idx_expanded, val)
824
+ return t
825
+
826
+ elif idx.dim() == 2:
827
+ assert dim != 0, "Cannot index the batch dimension"
828
+ batch_size = idx.size(0)
829
+ idx_size = idx.size(1)
830
+ assert batch_size == t.size(0) == val.size(0)
831
+
832
+ idx_shape = [batch_size] + [1] * (val.dim() - 1)
833
+ idx_shape[dim] = idx_size
834
+ idx_expanded = idx.view(*idx_shape)
835
+ idx_expanded = idx_expanded.expand_as(val)
836
+
837
+ t.scatter_(dim, idx_expanded, val)
838
+ return t
839
+ else:
840
+ raise NotImplementedError(f"idx.dim() == {idx.dim()} not supported")
841
+
842
+ else:
843
+ if idx.dim() == 1:
844
+ return t.index_copy_(dim, idx, val)
845
+
846
+ assert idx.dim() == 2, f"multiple batch dims not yet {idx.shape=}"
847
+ assert dim != 0, f"cannot index batch dim {dim=}"
848
+ batch_size, idx_size = idx.shape
849
+ assert batch_size == t.size(0)
850
+ assert batch_size == val.size(0)
851
+
852
+ # if we can view the batch and indexed dimensions together, we could
853
+ # do index trickery. This is, sadly, not the case for kvcache so we
854
+ # fall back to for loop
855
+ for i in range(batch_size):
856
+ unbatched_dim = dim if dim < 0 else dim - 1
857
+ t[i].index_copy_(unbatched_dim, idx[i], val[i])
858
+ return t
859
+
860
+
861
+ def apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
862
+ """
863
+ Applies RoPE transform to `x`. Note that `cos`, `sin` need to have a batch
864
+ dimension.
865
+
866
+ Args:
867
+ x: Input tensor, `(B, ..., T, head_size)`
868
+ cos: Cached cosines, `(B, T, head_size)` or `(1, T, head_size)`
869
+ sin: Cached sines, `(B, T, head_size)` or `(1, T, head_size)`
870
+
871
+ Returns:
872
+ Encoded tensor, `(B, ..., T, head_size)`
873
+ """
874
+ if cos.dim() != 3:
875
+ raise ValueError(f"cos must be three-dimensional, but shape is {cos.shape}")
876
+ if cos.shape != sin.shape:
877
+ raise ValueError(f"cos, sin must have same shape, but cos.shape={cos.shape}, sin.shape={sin.shape}")
878
+ head_size_half = x.size(-1) // 2
879
+ x1 = x[..., : head_size_half] # (B, ..., T, head_size/2)
880
+ x2 = x[..., head_size_half :] # (B, ..., T, head_size/2)
881
+ rotated = torch.cat((-x2, x1), dim=-1) # (B, ..., T, head_size)
882
+ dims_diff = x.dim() - cos.dim()
883
+ if dims_diff > 0:
884
+ # Ensure that shapes of `x`, `cos`, `sin` align
885
+ new_shape = cos.shape[0:1] + (1,) * dims_diff + cos.shape[1:]
886
+ cos = cos.view(*new_shape)
887
+ sin = sin.view(*new_shape)
888
+
889
+ roped = (x * cos) + (rotated * sin)
890
+ return roped.to(dtype=x.dtype)
891
+
892
+
893
+ def do_softcapping(x: torch.Tensor, thresh: float) -> torch.Tensor:
894
+ return torch.tanh(x / thresh) * thresh
895
+
896
+
897
+ class KVCache(nn.Module):
898
+ """
899
+ Buffers `k`, `v` have shape
900
+ `(batch_size, n_query_groups, max_seq_length, head_size)`.
901
+ """
902
+ def __init__(
903
+ self,
904
+ k_shape: Tuple[int, int, int, int],
905
+ v_shape: Tuple[int, int, int, int],
906
+ device: Optional[torch.device] = None,
907
+ dtype: Optional[torch.dtype] = None,
908
+ ) -> None:
909
+ super().__init__()
910
+ self.register_buffer("k", torch.zeros(k_shape, device=device, dtype=dtype), persistent=False)
911
+ self.register_buffer("v", torch.zeros(v_shape, device=device, dtype=dtype), persistent=False)
912
+
913
+ def forward(self, input_pos: torch.Tensor, k: torch.Tensor, v: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
914
+ """
915
+ Writes new values `k` and `v` into the cache at the positions specified
916
+ by `input_pos` along the sequence dimension (`max_seq_length`). The batch
917
+ size of `k` and `v` (`bs`) must be smaller or equal to `KVCache` batch
918
+ size. Returns the full buffers, adjusted to the batch size `bs`.
919
+
920
+ Args:
921
+ input_pos: Position index, `(bs, T)` or `(T,)`
922
+ k: New values, `(bs, n_query_groups, T, head_size)`
923
+ v: New values, `(bs, n_query_groups, T, head_size)`
924
+
925
+ Returns:
926
+ k_full, v_full, `(bs, n_query_groups, max_seq_length, head_size)`
927
+
928
+ """
929
+ # move the buffer to the activation dtype for when AMP is used
930
+ self.k = self.k.to(k.dtype)
931
+ self.v = self.v.to(v.dtype)
932
+ # update the cache
933
+ bs = k.size(0)
934
+ k = batched_index_copy_(self.k[:bs, ...], -2, input_pos, k)
935
+ v = batched_index_copy_(self.v[:bs, ...], -2, input_pos, v)
936
+ return k, v
937
+
938
+ def reset_parameters(self) -> None:
939
+ torch.nn.init.zeros_(self.k)
940
+ torch.nn.init.zeros_(self.v)
941
+
942
+
943
+ def build_mask_cache(max_seq_length: int, device: Optional[torch.device] = None) -> torch.Tensor:
944
+ ones = torch.ones((max_seq_length, max_seq_length), device=device, dtype=torch.bool)
945
+ return torch.tril(ones).unsqueeze(0).unsqueeze(0)
946
+
947
+
948
+ class RMSNorm(torch.nn.Module):
949
+ """Root Mean Square Layer Normalization.
950
+
951
+ Derived from https://github.com/bzhangGo/rmsnorm/blob/master/rmsnorm_torch.py. BSD 3-Clause License:
952
+ https://github.com/bzhangGo/rmsnorm/blob/master/LICENSE.
953
+ """
954
+
955
+ def __init__(self, size: int, dim: int = -1, eps: float = 1e-6, add_unit_offset: bool = False) -> None:
956
+ super().__init__()
957
+ self.weight = torch.nn.Parameter(torch.ones(size))
958
+ self.eps = eps
959
+ self.dim = dim
960
+ self.add_unit_offset = add_unit_offset
961
+
962
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
963
+ dtype = x.dtype
964
+ x = x.float()
965
+ # NOTE: the original RMSNorm paper implementation is not equivalent
966
+ norm_x = torch.mean(x * x, dim=self.dim, keepdim=True)
967
+ x_normed = x * torch.rsqrt(norm_x + self.eps)
968
+ weight = (1 + self.weight) if self.add_unit_offset else self.weight
969
+ return (x_normed * weight.float()).to(dtype=dtype)
970
+
971
+ def reset_parameters(self) -> None:
972
+ torch.nn.init.ones_(self.weight)
src/audiointeraction/tokenizer.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file.
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional, Union, Iterable, Iterator
6
+
7
+ from src.audiointeraction.utils import fix_and_load_json
8
+ import torch
9
+
10
+
11
+ class Tokenizer:
12
+ def __init__(self, checkpoint_dir: Union[Path, str]) -> None:
13
+ checkpoint_dir = Path(checkpoint_dir)
14
+ if not checkpoint_dir.exists():
15
+ raise NotADirectoryError(f"The checkpoint directory does not exist: {str(checkpoint_dir)}")
16
+
17
+ self.model_name = checkpoint_dir.stem
18
+ self.use_bos = self.check_if_bos_token_used(checkpoint_dir)
19
+ self.bos_id = None
20
+ self.eos_id = None
21
+
22
+ # some checkpoints have both files, `.json` takes precedence
23
+ if (vocabulary_path := checkpoint_dir / "tokenizer.json").is_file():
24
+ from tokenizers import Tokenizer as HFTokenizer
25
+
26
+ self.processor = HFTokenizer.from_file(str(vocabulary_path))
27
+ self.backend = "huggingface"
28
+
29
+ if (special_tokens_path := checkpoint_dir / "tokenizer_config.json").is_file():
30
+ with open(special_tokens_path, encoding="utf-8") as fp:
31
+ config = json.load(fp)
32
+ bos_token = config.get("bos_token")
33
+ eos_token = config.get("eos_token")
34
+ if bos_token is not None and isinstance(bos_token, dict):
35
+ bos_token = bos_token.get("content")
36
+ if eos_token is not None and isinstance(eos_token, dict):
37
+ eos_token = eos_token.get("content")
38
+ self.bos_id = self.token_to_id(bos_token) if bos_token is not None else None
39
+ self.eos_id = self.token_to_id(eos_token) if eos_token is not None else None
40
+ if (special_tokens_path := checkpoint_dir / "generation_config.json").is_file():
41
+ try:
42
+ with open(special_tokens_path, encoding="utf-8") as fp:
43
+ config = json.load(fp)
44
+ except json.JSONDecodeError: # Some files like the Llama 3.2 one have bugs
45
+ with open(special_tokens_path, encoding="utf-8") as fp:
46
+ json_string = fp.read()
47
+ config = fix_and_load_json(json_string)
48
+ if self.bos_id is None:
49
+ self.bos_id = config.get("bos_token_id")
50
+ if self.eos_id is None:
51
+ self.eos_id = config.get("eos_token_id")
52
+
53
+ elif (vocabulary_path := checkpoint_dir / "tokenizer.model").is_file():
54
+ from sentencepiece import SentencePieceProcessor
55
+
56
+ self.processor = SentencePieceProcessor(model_file=str(vocabulary_path))
57
+ self.backend = "sentencepiece"
58
+ self.bos_id = self.processor.bos_id()
59
+ self.eos_id = self.processor.eos_id()
60
+ else:
61
+ raise NotImplementedError
62
+
63
+ # NOTE: A temporary fix until it's resolved on Tokenizers side.
64
+ # LlaMA tokenizer strips leading spaces if to decode a single token at a time.
65
+ # https://github.com/huggingface/transformers/issues/31643
66
+ self.apply_decoding_fix = None
67
+ if (config_path := checkpoint_dir / "tokenizer_config.json").is_file():
68
+ with open(config_path, encoding="utf-8") as fp:
69
+ self.apply_decoding_fix = "LlamaTokenizer" in json.load(fp)["tokenizer_class"]
70
+
71
+ @property
72
+ def vocab_size(self) -> int:
73
+ if self.backend == "huggingface":
74
+ return self.processor.get_vocab_size(with_added_tokens=False)
75
+ if self.backend == "sentencepiece":
76
+ return self.processor.vocab_size()
77
+ raise RuntimeError
78
+
79
+ def token_to_id(self, token: str) -> int:
80
+ if self.backend == "huggingface":
81
+ id_ = self.processor.token_to_id(token)
82
+ elif self.backend == "sentencepiece":
83
+ id_ = self.processor.piece_to_id(token)
84
+ else:
85
+ raise RuntimeError
86
+ if id_ is None:
87
+ raise ValueError(f"token {token!r} not found in the collection.")
88
+ return id_
89
+
90
+ def check_if_bos_token_used(self, checkpoint_dir: Path) -> bool:
91
+ if not (tokenizer_config_path := checkpoint_dir / "tokenizer_config.json").is_file():
92
+ return False
93
+ with open(tokenizer_config_path, encoding="utf-8") as fp:
94
+ config = json.load(fp)
95
+ # for LlaMA-3 tokenizer there is no `add_bos_token` at all and `tokenizer_class` is only
96
+ # `PreTrainedTokenizerFast`
97
+ if checkpoint_dir.stem.startswith(("Meta-Llama-3", "Llama-3")):
98
+ return True
99
+ if checkpoint_dir.stem.startswith("SmolLM2") and checkpoint_dir.name.endswith("Instruct"):
100
+ return True
101
+ if "add_bos_token" in config:
102
+ return config["add_bos_token"]
103
+ # if `add_bos_token` isn't in the config file, but LLaMA tokenizer is used - return True.
104
+ # ex: https://huggingface.co/stabilityai/StableBeluga2/blob/main/tokenizer_config.json#L2
105
+ return config.get("tokenizer_class") == "LlamaTokenizer"
106
+
107
+ def encode(
108
+ self,
109
+ string: str,
110
+ device: Optional[torch.device] = None,
111
+ bos: Optional[bool] = None,
112
+ eos: bool = False,
113
+ max_length: int = -1,
114
+ ) -> torch.Tensor:
115
+ if self.backend == "huggingface":
116
+ tokens = self.processor.encode(string).ids
117
+ elif self.backend == "sentencepiece":
118
+ tokens = self.processor.encode(string)
119
+ else:
120
+ raise RuntimeError(f"`{self.backend}` is not supported.")
121
+ if tokens is None:
122
+ raise ValueError("`self.processor` returned tokens of None value.")
123
+
124
+ if bos or (bos is None and self.use_bos):
125
+ if self.bos_id is None:
126
+ raise NotImplementedError("This tokenizer does not have a defined bos token.")
127
+ if not tokens or tokens[0] != self.bos_id:
128
+ tokens = [self.bos_id] + tokens
129
+ # if the processor misbehaves and adds `bos` token no matter what
130
+ elif tokens and tokens[0] == self.bos_id:
131
+ tokens = tokens[1:]
132
+
133
+ if eos and (not tokens or tokens[-1] != self.eos_id):
134
+ tokens = tokens + [self.eos_id]
135
+ # if the processor misbehaves and adds `eos` token no matter what
136
+ elif tokens and tokens[-1] == self.eos_id:
137
+ tokens = tokens[:-1]
138
+
139
+ if max_length > 0:
140
+ tokens = tokens[:max_length]
141
+ return torch.tensor(tokens, dtype=torch.int, device=device)
142
+
143
+ def decode(self, tensor: torch.Tensor) -> str:
144
+ tokens = [tensor.item()] if tensor.ndim == 0 else tensor.tolist()
145
+ if len(tokens) == 1 and self.apply_decoding_fix:
146
+ dummy_token_id = 33 # \x1e
147
+ dummy_token = self.processor.decode([dummy_token_id])
148
+ if dummy_token != "\x1e":
149
+ dummy_token_id = 165 # \x1e is different in salamandra tokenizers
150
+ dummy_token = self.processor.decode([dummy_token_id])
151
+ return self.processor.decode([dummy_token_id] + tokens)[len(dummy_token) :]
152
+ return self.processor.decode(tokens)
153
+
154
+ def decode_stream(self, token_stream: Iterable[torch.Tensor], device: Optional[torch.device] = None) -> Iterator[str]:
155
+ if self.backend == "huggingface":
156
+ try:
157
+ for token in token_stream:
158
+ yield self.decode(token)
159
+ except KeyboardInterrupt:
160
+ return
161
+ elif self.backend == "sentencepiece":
162
+ # TODO: Is there a way to not have to do this?
163
+ # This may actually affect our tokens per second.
164
+
165
+ # sentencepiece does not support decoding token-by-token because it adds spaces based on the surrounding tokens
166
+ # meaning that we need to decode everything each time
167
+ so_far = torch.tensor([], dtype=torch.long, device=device)
168
+ decoded_so_far = ""
169
+ try:
170
+ for token in token_stream:
171
+ so_far = so_far.to(device=token.device)
172
+ so_far = torch.cat((so_far, token.view(-1)))
173
+ decoded_new = self.decode(so_far)
174
+ yield decoded_new[len(decoded_so_far) :]
175
+ decoded_so_far = decoded_new
176
+ except KeyboardInterrupt:
177
+ return
178
+ else:
179
+ raise NotImplementedError(self.backend)
src/audiointeraction/utils.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training / inference helpers."""
2
+
3
+ import inspect
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Any, Iterable, List, Literal, Optional, Union
11
+
12
+ import lightning as L
13
+ import torch
14
+ import torch.nn as nn
15
+ from lightning.fabric.loggers import CSVLogger, TensorBoardLogger
16
+ from lightning.fabric.strategies import FSDPStrategy
17
+ from lightning.fabric.utilities.load import _lazy_load as lazy_load
18
+ from lightning.pytorch.cli import instantiate_class
19
+ from lightning.pytorch.loggers import WandbLogger
20
+ from typing_extensions import Self
21
+
22
+
23
+ # === Output dir / checkpoint resume ===
24
+
25
+ def init_out_dir(out_dir: Path) -> Path:
26
+ if not isinstance(out_dir, Path):
27
+ out_dir = Path(out_dir)
28
+ if not out_dir.is_absolute() and "LIGHTNING_ARTIFACTS_DIR" in os.environ:
29
+ return Path(os.getenv("LIGHTNING_ARTIFACTS_DIR")) / out_dir
30
+ return out_dir
31
+
32
+
33
+ def find_resume_path(resume: Union[bool, Literal["auto"], Path], out_dir: Path) -> Optional[Path]:
34
+ if not resume or isinstance(resume, Path):
35
+ return resume or None
36
+ # `resume` is True or "auto"; find the newest lit_model.pth under out_dir.
37
+ candidates = list(out_dir.rglob("lit_model.pth"))
38
+ if not candidates:
39
+ if resume == "auto":
40
+ return None
41
+ raise FileNotFoundError(
42
+ f"resume=True but no `lit_model.pth` under {out_dir}; set resume='auto' to skip or False to start fresh."
43
+ )
44
+ return max(candidates, key=lambda p: p.stat().st_mtime)
45
+
46
+
47
+ # === Misc math / introspection ===
48
+
49
+ def find_multiple(n: int, k: int) -> int:
50
+ if n % k == 0:
51
+ return n
52
+ return n + k - (n % k)
53
+
54
+
55
+ def num_parameters(module: nn.Module, requires_grad: Optional[bool] = None) -> int:
56
+ total = 0
57
+ for p in module.parameters():
58
+ if requires_grad is None or p.requires_grad == requires_grad:
59
+ total += p.numel()
60
+ return total
61
+
62
+
63
+ # === Cross-entropy ===
64
+
65
+ def chunked_cross_entropy(
66
+ logits: Union[torch.Tensor, List[torch.Tensor]],
67
+ targets: torch.Tensor,
68
+ chunk_size: int = 128,
69
+ ignore_index: int = -100,
70
+ ) -> torch.Tensor:
71
+ """Memory-friendly cross-entropy: optionally chunk along the seq axis to bound the activation peak."""
72
+ if isinstance(logits, list):
73
+ if chunk_size == 0:
74
+ logits = torch.cat(logits, dim=1).reshape(-1, logits[0].size(-1))
75
+ return torch.nn.functional.cross_entropy(logits, targets.reshape(-1), ignore_index=ignore_index)
76
+ logit_chunks = [c.reshape(-1, c.size(-1)) for c in logits]
77
+ target_chunks = [c.reshape(-1) for c in targets.split(logits[0].size(1), dim=1)]
78
+ loss_chunks = [
79
+ torch.nn.functional.cross_entropy(lc, tc, ignore_index=ignore_index, reduction="none")
80
+ for lc, tc in zip(logit_chunks, target_chunks)
81
+ ]
82
+ non_masked = (targets != ignore_index).sum()
83
+ return torch.cat(loss_chunks).sum() / non_masked.maximum(torch.ones_like(non_masked))
84
+
85
+ logits = logits.reshape(-1, logits.size(-1))
86
+ targets = targets.reshape(-1)
87
+ if chunk_size == 0:
88
+ return torch.nn.functional.cross_entropy(logits, targets, ignore_index=ignore_index)
89
+ logit_chunks = logits.split(chunk_size)
90
+ target_chunks = targets.split(chunk_size)
91
+ loss_chunks = [
92
+ torch.nn.functional.cross_entropy(lc, tc, ignore_index=ignore_index, reduction="none")
93
+ for lc, tc in zip(logit_chunks, target_chunks)
94
+ ]
95
+ non_masked = (targets != ignore_index).sum()
96
+ return torch.cat(loss_chunks).sum() / non_masked.maximum(torch.ones_like(non_masked))
97
+
98
+
99
+ # === Precision / checkpoint IO ===
100
+
101
+ def get_default_supported_precision(training: bool) -> str:
102
+ """Return bf16 if the GPU supports it, otherwise 16-bit. Picks `-mixed` for training, `-true` for inference."""
103
+ if torch.cuda.is_available() and torch.cuda.is_bf16_supported():
104
+ return "bf16-mixed" if training else "bf16-true"
105
+ return "16-mixed" if training else "16-true"
106
+
107
+
108
+ def load_checkpoint(fabric: L.Fabric, model: nn.Module, checkpoint_path: Path, strict: bool = True) -> None:
109
+ if isinstance(fabric.strategy, FSDPStrategy):
110
+ fabric.load_raw(checkpoint_path, model, strict=strict)
111
+ else:
112
+ state_dict = lazy_load(checkpoint_path)
113
+ state_dict = state_dict.get("model", state_dict)
114
+ model.load_state_dict(state_dict, strict=strict)
115
+
116
+
117
+ # === Iteration ===
118
+
119
+ class CycleIterator:
120
+ """Indefinitely cycles through an iterable, exposing the current epoch count."""
121
+
122
+ def __init__(self, iterable: Iterable) -> None:
123
+ self.iterable = iterable
124
+ self.epoch = 0
125
+ self._iterator = None
126
+
127
+ def __next__(self) -> Any:
128
+ if self._iterator is None:
129
+ self._iterator = iter(self.iterable)
130
+ try:
131
+ return next(self._iterator)
132
+ except StopIteration:
133
+ self._iterator = iter(self.iterable)
134
+ self.epoch += 1
135
+ return next(self._iterator)
136
+
137
+ def __iter__(self) -> Self:
138
+ return self
139
+
140
+
141
+ # === Setup helpers (out dir, logger, devices, optimizer) ===
142
+
143
+ def copy_config_files(source_dir: Path, out_dir: Path) -> None:
144
+ """Copy model_config.yaml and tokenizer files (when present) into the output directory."""
145
+ for name in ("config.json", "generation_config.json", "model_config.yaml",
146
+ "tokenizer.json", "tokenizer.model", "tokenizer_config.json"):
147
+ src = source_dir / name
148
+ if src.exists():
149
+ shutil.copy(src, out_dir)
150
+
151
+
152
+ def parse_devices(devices: Union[str, int]) -> int:
153
+ if devices in (-1, "auto"):
154
+ return torch.cuda.device_count() or 1
155
+ if isinstance(devices, int) and devices > 0:
156
+ return devices
157
+ raise ValueError(f"Devices must be 'auto' or a positive integer, got: {devices!r}")
158
+
159
+
160
+ def choose_logger(
161
+ logger_name: Literal["csv", "tensorboard", "wandb"],
162
+ out_dir: Path,
163
+ name: str,
164
+ log_interval: int = 1,
165
+ resume: Optional[bool] = None,
166
+ **kwargs: Any,
167
+ ):
168
+ if logger_name == "csv":
169
+ return CSVLogger(root_dir=(out_dir / "logs"), name="csv", flush_logs_every_n_steps=log_interval, **kwargs)
170
+ if logger_name == "tensorboard":
171
+ return TensorBoardLogger(root_dir=(out_dir / "logs"), name="tensorboard", **kwargs)
172
+ if logger_name == "wandb":
173
+ return WandbLogger(project=name, resume=resume, **kwargs)
174
+ raise ValueError(f"`--logger_name={logger_name}` is not a valid option. Choose from 'csv', 'tensorboard', 'wandb'.")
175
+
176
+
177
+ def instantiate_torch_optimizer(optimizer, model_parameters, **kwargs):
178
+ """Resolve `optimizer` (str name like 'AdamW' or a dict with class_path/init_args) to an actual optimizer."""
179
+ if isinstance(optimizer, str):
180
+ class_module, class_name = optimizer.rsplit(".", 1) if "." in optimizer else ("torch.optim", optimizer)
181
+ module = __import__(class_module, fromlist=[class_name])
182
+ optimizer_cls = getattr(module, class_name)
183
+ valid = set(inspect.signature(optimizer_cls).parameters)
184
+ kwargs = {k: v for k, v in kwargs.items() if k in valid}
185
+ return optimizer_cls(model_parameters, **kwargs)
186
+ if isinstance(optimizer, dict):
187
+ optimizer = dict(optimizer)
188
+ class_module, class_name = optimizer["class_path"].rsplit(".", 1)
189
+ module = __import__(class_module, fromlist=[class_name])
190
+ valid = set(inspect.signature(getattr(module, class_name)).parameters)
191
+ optimizer["init_args"].update({k: v for k, v in kwargs.items() if k in valid})
192
+ return instantiate_class(model_parameters, optimizer)
193
+ raise ValueError(f'Unrecognized "optimizer" value: {optimizer}')
194
+
195
+
196
+ # === GPU topology (nvlink / xgmi) ===
197
+
198
+ def check_nvlink_connectivity(fabric=None):
199
+ """Best-effort check that the multi-GPU interconnect is fast. Prints a warning if not."""
200
+ custom_print = fabric.print if fabric is not None else print
201
+ if os.getenv("RANK", "0") != "0":
202
+ return
203
+ try:
204
+ if not torch.cuda.is_available():
205
+ custom_print("No GPUs available")
206
+ return
207
+ gpu_name = torch.cuda.get_device_properties(0).name.lower()
208
+ if "nvidia" in gpu_name:
209
+ _check_nvidia_connectivity(custom_print)
210
+ elif "advanced micro devices" in gpu_name or "amd" in gpu_name:
211
+ _check_amd_connectivity(custom_print)
212
+ else:
213
+ custom_print(f"Unrecognized GPU vendor: {gpu_name}")
214
+ except Exception as e:
215
+ custom_print(f"An error occurred while checking GPU connectivity: {e}")
216
+
217
+
218
+ def _check_nvidia_connectivity(custom_print):
219
+ result = subprocess.run(["nvidia-smi", "topo", "-m"], stdout=subprocess.PIPE, text=True)
220
+ if result.returncode != 0:
221
+ custom_print("Failed to run nvidia-smi"); return
222
+ lines = result.stdout.strip().split("\n")
223
+ start = next((i for i, line in enumerate(lines) if "GPU0" in line), None)
224
+ if start is None:
225
+ custom_print("Failed to parse nvidia-smi output"); return
226
+ headers = lines[start].split()
227
+ gpu_count = len([h for h in headers if re.match(r"^GPU\d+$", h)])
228
+ all_nvlink = all(
229
+ all("NV" in conn for conn in row.split()[1:1 + gpu_count] if conn != "X")
230
+ for row in lines[start + 1: start + 1 + gpu_count]
231
+ )
232
+ if all_nvlink:
233
+ custom_print("All GPUs are fully connected via NVLink.")
234
+ else:
235
+ custom_print("Warning: not all GPUs are fully connected via NVLink — multi-GPU throughput may be suboptimal.")
236
+
237
+
238
+ def _check_amd_connectivity(custom_print):
239
+ result = subprocess.run(["rocm-smi", "--showtopotype"], stdout=subprocess.PIPE, text=True)
240
+ if result.returncode != 0:
241
+ custom_print("Failed to run rocm-smi"); return
242
+ lines = result.stdout.strip().split("\n")
243
+ gpu_idx = next((i for i, line in enumerate(lines) if re.match(r"^\s*GPU0", line)), None)
244
+ if gpu_idx is None or gpu_idx == 0:
245
+ custom_print("Failed to parse rocm-smi output (no GPU headers found)"); return
246
+ headers = lines[gpu_idx - 1].strip().split()
247
+ gpu_count = len([h for h in headers if re.match(r"^GPU\d+$", h)])
248
+ gpu_lines = [l.strip() for l in lines[gpu_idx: gpu_idx + gpu_count] if re.match(r"^\s*GPU\d+", l)]
249
+ if len(gpu_lines) != gpu_count:
250
+ custom_print("Mismatch in GPU count when parsing rocm-smi output"); return
251
+ all_xgmi = all(
252
+ all(conn in ("XGMI", "0") for conn in row.split()[1:1 + gpu_count])
253
+ for row in gpu_lines
254
+ )
255
+ if all_xgmi:
256
+ custom_print("All GPUs are fully connected via XGMI.")
257
+ else:
258
+ custom_print("Warning: not all GPUs are fully connected via XGMI — multi-GPU throughput may be suboptimal.")
259
+
260
+
261
+ # === Misc ===
262
+
263
+ def fix_and_load_json(s: str):
264
+ """Tolerant JSON parser: strips trailing commas and inserts missing ones before retrying."""
265
+ s = re.sub(r',(\s*[}\]])', r'\1', s)
266
+ s = re.sub(r'(?<=[}\]0-9truefalsenull"])\s*(\n\s*)"', r',\1"', s)
267
+ try:
268
+ return json.loads(s)
269
+ except json.JSONDecodeError as e:
270
+ raise ValueError(f"Failed to parse JSON after fixing: {e}")
271
+
272
+
273
+ def create_finetuning_performance_report(training_time: float, device_type: str) -> str:
274
+ lines = [
275
+ "",
276
+ "| ------------------------------------------------------",
277
+ f"| Training Time : {training_time:.2f} s",
278
+ ]
279
+ if device_type == "cuda":
280
+ memory_used = torch.cuda.max_memory_allocated() / 1e9
281
+ lines.append(f"| Peak Memory : {memory_used:.2f} GB")
282
+ lines.append("| ------------------------------------------------------")
283
+ return "\n".join(lines)
284
+
285
+
286
+
287
+
288
+
289
+
290
+
utils.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+ import torch
6
+ from transformers import AutoConfig
7
+ from transformers.models.qwen2_5_omni.modeling_qwen2_5_omni import Qwen2_5OmniAudioEncoder
8
+
9
+ from src.audiointeraction.generate.base import AUDIO_TOKENS_PER_CHUNK # noqa: F401 (re-export for callers)
10
+ from src.audiointeraction.model import GPT, Config
11
+ from src.audiointeraction.utils import load_checkpoint
12
+
13
+
14
+ def set_seed(seed: int = 1337) -> None:
15
+ random.seed(seed)
16
+ np.random.seed(seed)
17
+ torch.manual_seed(seed)
18
+ torch.cuda.manual_seed_all(seed)
19
+ torch.backends.cudnn.deterministic = True
20
+ torch.backends.cudnn.benchmark = False
21
+
22
+ import json
23
+ from pathlib import Path
24
+
25
+ from safetensors.torch import load_file
26
+
27
+
28
+ def load_model(fabric, model_config_dir, checkpoint_dir):
29
+ """Load a GPT from a local sharded safetensors directory.
30
+
31
+ `checkpoint_dir` must contain:
32
+ model.safetensors.index.json
33
+ model-00001-of-0000N.safetensors
34
+ ...
35
+ """
36
+ config = Config.from_file(Path(model_config_dir) / "model_config.yaml")
37
+ with fabric.init_module(empty_init=(fabric.world_size > 1)):
38
+ model = GPT(config)
39
+ model = fabric.setup(model)
40
+
41
+ checkpoint_dir = Path(checkpoint_dir)
42
+ index_path = checkpoint_dir / "model.safetensors.index.json"
43
+ if not index_path.is_file():
44
+ raise FileNotFoundError(
45
+ f"No model.safetensors.index.json under {checkpoint_dir}. "
46
+ f"Expected a sharded safetensors directory."
47
+ )
48
+
49
+ with open(index_path) as f:
50
+ index = json.load(f)
51
+ shard_files = sorted(set(index["weight_map"].values()))
52
+
53
+ state_dict = {}
54
+ for shard in shard_files:
55
+ state_dict.update(load_file(str(checkpoint_dir / shard), device="cpu"))
56
+
57
+ missing, unexpected = model.load_state_dict(state_dict, strict=True)
58
+ if missing or unexpected:
59
+ print(f"[load_model] missing={missing[:3]}… unexpected={unexpected[:3]}…")
60
+ return model
61
+
62
+ def load_audio_encoder(qwen_omni_ckpt, audio_tower_ckpt, device):
63
+ print(qwen_omni_ckpt)
64
+ cfg = AutoConfig.from_pretrained(qwen_omni_ckpt)
65
+ # Omni 的 config 是嵌套的:thinker_config.audio_config 才是 audio encoder 的配置
66
+ audio_cfg = cfg.thinker_config.audio_config
67
+ encoder = Qwen2_5OmniAudioEncoder._from_config(audio_cfg)
68
+ state_dict = torch.load(audio_tower_ckpt, map_location=device)
69
+ encoder.load_state_dict(state_dict)
70
+ encoder.to(device).requires_grad_(False).eval()
71
+ return encoder
72
+
73
+
74
+ def resolve_checkpoint_paths(checkpoint_dir: str):
75
+ """Map a single checkpoint root → (model_config_dir, trained_checkpoint,
76
+ qwen_omni_ckpt, audio_tower_ckpt). The release layout is:
77
+
78
+ <checkpoint_dir>/
79
+ model_config.yaml + tokenizer.json + ... ← model_config_dir = root
80
+ audiointeraction_LM.pt
81
+ audiointeraction_ChunkwisedEncoder.pth
82
+ qwen_2_5_omni_config/
83
+ """
84
+
85
+ ckpt = Path(checkpoint_dir)
86
+ return (
87
+ str(ckpt),
88
+ str(ckpt),
89
+ str(ckpt / "qwen25OmniConfig"),
90
+ str(ckpt / "audiointeraction_ChunkwisedEncoder.pth"),
91
+ )
92
+
93
+ def get_best_device():
94
+ if torch.cuda.is_available():
95
+ return torch.device("cuda")
96
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
97
+ return torch.device("mps")
98
+ return torch.device("cpu")
99
+