Spaces:
Running on Zero
Running on Zero
Upload folder using huggingface_hub
Browse files- .gitattributes +5 -0
- README.md +21 -6
- app.py +391 -0
- requirements.txt +16 -0
- sample/01_count_bark/sample01_01.mp3 +0 -0
- sample/01_count_bark/sample01_02.wav +3 -0
- sample/01_count_bark/sequence.json +8 -0
- sample/02_translate/sample02_01.mp3 +0 -0
- sample/02_translate/sample02_02.wav +3 -0
- sample/02_translate/sample02_03.mp3 +0 -0
- sample/02_translate/sample02_04.wav +3 -0
- sample/02_translate/sequence.json +6 -0
- sample/03_cough_music/sample03_01.wav +3 -0
- sample/03_cough_music/sample03_02.mp3 +0 -0
- sample/03_cough_music/sample03_03.wav +3 -0
- sample/03_cough_music/sequence.json +5 -0
- src/__init__.py +0 -0
- src/audiointeraction/TOKENS.py +59 -0
- src/audiointeraction/__init__.py +18 -0
- src/audiointeraction/args.py +50 -0
- src/audiointeraction/config.py +150 -0
- src/audiointeraction/dataset/TOKENS.py +59 -0
- src/audiointeraction/dataset/__init__.py +0 -0
- src/audiointeraction/dataset/cons_offline_data.py +206 -0
- src/audiointeraction/dataset/cons_online_data.py +387 -0
- src/audiointeraction/generate/__init__.py +0 -0
- src/audiointeraction/generate/base.py +413 -0
- src/audiointeraction/model.py +972 -0
- src/audiointeraction/tokenizer.py +179 -0
- src/audiointeraction/utils.py +290 -0
- utils.py +99 -0
.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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.19.0
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|