WaveCut commited on
Commit
b81e2e4
verified
1 Parent(s): 3f726e2

Create Audex 2B ZeroGPU demo

Browse files
Files changed (4) hide show
  1. .gitignore +7 -0
  2. README.md +37 -7
  3. app.py +860 -0
  4. requirements.txt +13 -0
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .gradio/
4
+ .env
5
+ audex-cache/
6
+ tmp/
7
+
README.md CHANGED
@@ -1,13 +1,43 @@
1
  ---
2
- title: Nemotron Labs Audex
3
- emoji: 馃惃
4
- colorFrom: red
5
- colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Nemotron-Labs-Audex
 
 
 
3
  sdk: gradio
4
  sdk_version: 6.19.0
 
5
  app_file: app.py
6
+ python_version: 3.12
7
+ license: other
8
+ short_description: Audio and text demo for NVIDIA Nemotron-Labs-Audex-2B.
9
+ models:
10
+ - nvidia/Nemotron-Labs-Audex-2B
11
+ tags:
12
+ - audio-understanding
13
+ - speech-recognition
14
+ - speech-translation
15
+ - text-generation
16
+ - nemotron-labs-audex
17
  ---
18
 
19
+ # Nemotron-Labs-Audex
20
+
21
+ Public Gradio ZeroGPU demo for
22
+ [nvidia/Nemotron-Labs-Audex-2B](https://huggingface.co/nvidia/Nemotron-Labs-Audex-2B).
23
+
24
+ Audex-2B is a unified audio-text LLM from NVIDIA. It supports audio
25
+ understanding, speech recognition, speech translation, and text reasoning while
26
+ using a single transformer decoder with an audio encoder and projected audio
27
+ embeddings.
28
+
29
+ Useful links:
30
+
31
+ - Model card: https://huggingface.co/nvidia/Nemotron-Labs-Audex-2B
32
+ - Technical report: https://huggingface.co/papers/2607.05196
33
+
34
+ This Space intentionally runs only the 2B model on ZeroGPU.
35
+
36
+ The Space predownloads the model snapshot with the Space owner's `HF_TOKEN`
37
+ secret when available, then keeps the loaded model resident across requests.
38
+ Generation progress is split into input resolution, model snapshot, GPU model
39
+ load, audio feature extraction, prompt preparation, token generation, and output
40
+ decoding.
41
+
42
+ Your use of this model is governed by the NVIDIA Oneway Noncommercial License
43
+ linked from the model card.
app.py ADDED
@@ -0,0 +1,860 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import gc
4
+ import math
5
+ import os
6
+ import random
7
+ import tempfile
8
+ import threading
9
+ import time
10
+ import traceback
11
+ import wave
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Callable
15
+ from urllib.parse import urlparse
16
+
17
+ import gradio as gr
18
+ import numpy as np
19
+ import requests
20
+ import spaces
21
+ import torch
22
+ from huggingface_hub import snapshot_download
23
+ from transformers import (
24
+ AutoConfig,
25
+ AutoFeatureExtractor,
26
+ AutoModelForCausalLM,
27
+ AutoTokenizer,
28
+ StoppingCriteria,
29
+ StoppingCriteriaList,
30
+ )
31
+
32
+
33
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
34
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
35
+
36
+ APP_TITLE = "Nemotron-Labs-Audex"
37
+ MODEL_REPO_ID = "nvidia/Nemotron-Labs-Audex-2B"
38
+ MODEL_CARD_URL = "https://huggingface.co/nvidia/Nemotron-Labs-Audex-2B"
39
+ PAPER_URL = "https://huggingface.co/papers/2607.05196"
40
+ TEXT_VOCAB_SIZE = 131072
41
+ MAX_AUDIO_BYTES = 80 * 1024 * 1024
42
+ SAMPLE_RATE = 16000
43
+ CACHE_DIR = os.getenv(
44
+ "AUDEX_CACHE_DIR",
45
+ "/data/audex-cache" if os.path.isdir("/data") else "/tmp/audex-cache",
46
+ )
47
+ APP_DIR = Path(__file__).resolve().parent
48
+ ASSET_DIR = APP_DIR / "assets" / "examples"
49
+ TMP_DIR = Path("/tmp/audex-inputs")
50
+
51
+ ALLOW_PATTERNS = [
52
+ "checkpoint_folder_full/**",
53
+ "README.md",
54
+ "LICENSE",
55
+ "license/**",
56
+ ]
57
+
58
+ SOUND_PLACEHOLDER = "<sound>"
59
+ SOUND_TOKEN = "<so_embedding>"
60
+ SOUND_START_TOKEN = "<so_start>"
61
+ SOUND_END_TOKEN = "<so_end>"
62
+ IM_END_TOKEN = "<|im_end|>"
63
+
64
+
65
+ @dataclass
66
+ class LoadedModel:
67
+ model_dir: Path
68
+ model: AutoModelForCausalLM
69
+ tokenizer: AutoTokenizer
70
+ feature_extractor: AutoFeatureExtractor
71
+ config: AutoConfig
72
+
73
+
74
+ _MODEL_LOCK = threading.Lock()
75
+ _LOADED: LoadedModel | None = None
76
+ _SNAPSHOT_PATH: Path | None = None
77
+ _PRELOAD_THREAD: threading.Thread | None = None
78
+ _PRELOAD_ERROR: BaseException | None = None
79
+
80
+
81
+ TASKS = {
82
+ "audio-understanding": {
83
+ "label": "Audio understanding",
84
+ "prompt": "Describe the audio in detail.",
85
+ "needs_audio": True,
86
+ "temperature": 0.7,
87
+ "top_p": 0.9,
88
+ "reasoning": False,
89
+ },
90
+ "speech-recognition": {
91
+ "label": "Speech recognition",
92
+ "prompt": "Transcribe the speech in the input audio.",
93
+ "needs_audio": True,
94
+ "temperature": 1.0,
95
+ "top_p": 1.0,
96
+ "reasoning": False,
97
+ },
98
+ "speech-translation": {
99
+ "label": "Speech translation",
100
+ "prompt": "Translate the speech in the input audio into English.",
101
+ "needs_audio": True,
102
+ "temperature": 1.0,
103
+ "top_p": 1.0,
104
+ "reasoning": False,
105
+ },
106
+ "text-reasoning": {
107
+ "label": "Text-only reasoning",
108
+ "prompt": "Explain why a unified audio-text model can preserve text reasoning while learning audio tasks.",
109
+ "needs_audio": False,
110
+ "temperature": 0.7,
111
+ "top_p": 0.9,
112
+ "reasoning": True,
113
+ },
114
+ "custom": {
115
+ "label": "Custom",
116
+ "prompt": "What is happening in this audio?",
117
+ "needs_audio": True,
118
+ "temperature": 0.7,
119
+ "top_p": 0.9,
120
+ "reasoning": False,
121
+ },
122
+ }
123
+
124
+
125
+ def _task_choices() -> list[tuple[str, str]]:
126
+ return [(cfg["label"], key) for key, cfg in TASKS.items()]
127
+
128
+
129
+ def _hub_token() -> str | None:
130
+ return os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN")
131
+
132
+
133
+ def _format_seconds(seconds: float) -> str:
134
+ seconds = max(0.0, float(seconds))
135
+ if seconds < 10:
136
+ return f"{seconds:.1f}s"
137
+ minutes, secs = divmod(int(round(seconds)), 60)
138
+ if minutes:
139
+ return f"{minutes}m {secs:02d}s"
140
+ return f"{secs}s"
141
+
142
+
143
+ def _torch_cleanup() -> None:
144
+ gc.collect()
145
+ if torch.cuda.is_available():
146
+ torch.cuda.empty_cache()
147
+ torch.cuda.ipc_collect()
148
+
149
+
150
+ def _friendly_error(exc: BaseException) -> str:
151
+ text = str(exc)
152
+ lowered = text.lower()
153
+ if "cuda out of memory" in lowered or "outofmemoryerror" in lowered:
154
+ return (
155
+ "CUDA ran out of memory. Try a shorter audio clip, fewer max tokens, "
156
+ "or wait for the Space to restart cleanly."
157
+ )
158
+ if "401" in lowered or "403" in lowered or "gated" in lowered:
159
+ return (
160
+ "The model download was rejected by Hugging Face. Configure the Space "
161
+ "owner `HF_TOKEN` secret with access to the model and restart the Space."
162
+ )
163
+ return f"{type(exc).__name__}: {text}"
164
+
165
+
166
+ def _ensure_example_assets() -> None:
167
+ ASSET_DIR.mkdir(parents=True, exist_ok=True)
168
+ tone = ASSET_DIR / "tone_440hz.wav"
169
+ chirp = ASSET_DIR / "chirp_with_noise.wav"
170
+ if not tone.exists():
171
+ _write_wav(tone, _sine_wave(440.0, 2.0, 0.35))
172
+ if not chirp.exists():
173
+ _write_wav(chirp, _chirp_wave(220.0, 880.0, 3.0, 0.30))
174
+
175
+
176
+ def _sine_wave(freq: float, seconds: float, amp: float) -> np.ndarray:
177
+ t = np.linspace(0, seconds, int(SAMPLE_RATE * seconds), endpoint=False)
178
+ return (amp * np.sin(2 * np.pi * freq * t)).astype(np.float32)
179
+
180
+
181
+ def _chirp_wave(start_freq: float, end_freq: float, seconds: float, amp: float) -> np.ndarray:
182
+ t = np.linspace(0, seconds, int(SAMPLE_RATE * seconds), endpoint=False)
183
+ freqs = np.linspace(start_freq, end_freq, t.shape[0])
184
+ phase = 2 * np.pi * np.cumsum(freqs) / SAMPLE_RATE
185
+ noise = np.random.default_rng(7).normal(0, 0.02, t.shape[0])
186
+ return (amp * np.sin(phase) + noise).astype(np.float32)
187
+
188
+
189
+ def _write_wav(path: Path, audio: np.ndarray) -> None:
190
+ pcm = np.clip(audio, -1, 1)
191
+ pcm = (pcm * 32767).astype("<i2")
192
+ with wave.open(str(path), "wb") as handle:
193
+ handle.setnchannels(1)
194
+ handle.setsampwidth(2)
195
+ handle.setframerate(SAMPLE_RATE)
196
+ handle.writeframes(pcm.tobytes())
197
+
198
+
199
+ def _example_samples() -> list[list[object]]:
200
+ _ensure_example_assets()
201
+ return [
202
+ [
203
+ "audio-understanding",
204
+ str(ASSET_DIR / "tone_440hz.wav"),
205
+ "",
206
+ "Describe the audio in one sentence.",
207
+ False,
208
+ 128,
209
+ 0.7,
210
+ 0.9,
211
+ 0,
212
+ ],
213
+ [
214
+ "audio-understanding",
215
+ str(ASSET_DIR / "chirp_with_noise.wav"),
216
+ "",
217
+ "What kind of sound pattern do you hear?",
218
+ False,
219
+ 160,
220
+ 0.7,
221
+ 0.9,
222
+ 0,
223
+ ],
224
+ [
225
+ "text-reasoning",
226
+ None,
227
+ "",
228
+ "In three concise bullets, explain what makes Audex a unified audio-text model.",
229
+ True,
230
+ 256,
231
+ 0.7,
232
+ 0.9,
233
+ 0,
234
+ ],
235
+ ]
236
+
237
+
238
+ def ensure_model_snapshot() -> Path:
239
+ global _SNAPSHOT_PATH
240
+ if _SNAPSHOT_PATH is not None:
241
+ return _SNAPSHOT_PATH
242
+
243
+ local_path = snapshot_download(
244
+ repo_id=MODEL_REPO_ID,
245
+ allow_patterns=ALLOW_PATTERNS,
246
+ cache_dir=CACHE_DIR,
247
+ token=_hub_token(),
248
+ )
249
+ _SNAPSHOT_PATH = Path(local_path)
250
+ return _SNAPSHOT_PATH
251
+
252
+
253
+ def _preload_snapshot() -> None:
254
+ global _PRELOAD_ERROR
255
+ try:
256
+ ensure_model_snapshot()
257
+ except BaseException as exc: # noqa: BLE001 - stored and surfaced to UI
258
+ _PRELOAD_ERROR = exc
259
+ traceback.print_exc()
260
+
261
+
262
+ def start_preload() -> None:
263
+ global _PRELOAD_THREAD
264
+ if os.getenv("AUDEX_PRELOAD", "1") == "0":
265
+ return
266
+ if _PRELOAD_THREAD is not None:
267
+ return
268
+ _PRELOAD_THREAD = threading.Thread(target=_preload_snapshot, daemon=True)
269
+ _PRELOAD_THREAD.start()
270
+
271
+
272
+ def wait_for_preload(progress: gr.Progress) -> float:
273
+ started = time.monotonic()
274
+ thread = _PRELOAD_THREAD
275
+ while thread is not None and thread.is_alive():
276
+ elapsed = time.monotonic() - started
277
+ progress(0.04, desc=f"Predownloading Audex-2B snapshot with Space HF_TOKEN | elapsed {_format_seconds(elapsed)}")
278
+ time.sleep(2)
279
+ if _PRELOAD_ERROR is not None:
280
+ raise _PRELOAD_ERROR
281
+ ensure_model_snapshot()
282
+ return time.monotonic() - started
283
+
284
+
285
+ def refresh_remote_code_cache(model_dir: Path) -> None:
286
+ module_name = model_dir.resolve().name.replace("-", "_").replace(".", "_")
287
+ cache_root = Path(os.getenv("HF_MODULES_CACHE", Path.home() / ".cache/huggingface/modules"))
288
+ cache_path = cache_root / "transformers_modules" / module_name
289
+ if cache_path.exists():
290
+ import shutil
291
+
292
+ shutil.rmtree(cache_path)
293
+
294
+
295
+ def resolve_audio_preprocessor_path(model_dir: Path, config) -> str:
296
+ path = getattr(config, "audio_preprocessor_path", None) or "audio_preprocessor"
297
+ candidate = Path(path)
298
+ if not candidate.is_absolute():
299
+ candidate = model_dir / candidate
300
+ return str(candidate)
301
+
302
+
303
+ def load_model(progress: gr.Progress) -> tuple[LoadedModel, float]:
304
+ global _LOADED
305
+ with _MODEL_LOCK:
306
+ if _LOADED is not None:
307
+ progress(0.18, desc="Model already loaded on GPU")
308
+ return _LOADED, 0.0
309
+
310
+ started = time.monotonic()
311
+ snapshot_path = ensure_model_snapshot()
312
+ model_dir = snapshot_path / "checkpoint_folder_full"
313
+ refresh_remote_code_cache(model_dir)
314
+
315
+ progress(0.10, desc="Loading tokenizer and audio preprocessor")
316
+ tokenizer = AutoTokenizer.from_pretrained(model_dir, trust_remote_code=True)
317
+ config = AutoConfig.from_pretrained(model_dir, trust_remote_code=True)
318
+ feature_extractor = AutoFeatureExtractor.from_pretrained(
319
+ resolve_audio_preprocessor_path(model_dir, config)
320
+ )
321
+
322
+ if torch.cuda.is_available():
323
+ device = "cuda:0"
324
+ dtype = torch.bfloat16
325
+ else:
326
+ device = "cpu"
327
+ dtype = torch.float32
328
+
329
+ progress(0.14, desc=f"Loading Audex-2B weights to {device}")
330
+ model = AutoModelForCausalLM.from_pretrained(
331
+ model_dir,
332
+ trust_remote_code=True,
333
+ torch_dtype=dtype,
334
+ device_map={"": device},
335
+ )
336
+ model.eval()
337
+ _LOADED = LoadedModel(
338
+ model_dir=model_dir,
339
+ model=model,
340
+ tokenizer=tokenizer,
341
+ feature_extractor=feature_extractor,
342
+ config=config,
343
+ )
344
+ return _LOADED, time.monotonic() - started
345
+
346
+
347
+ def normalize_audio(audio: np.ndarray) -> np.ndarray:
348
+ audio = np.asarray(audio)
349
+ if audio.ndim == 2:
350
+ if audio.shape[1] <= 2:
351
+ audio = audio.mean(axis=1)
352
+ elif audio.shape[0] <= 2:
353
+ audio = audio.mean(axis=0)
354
+ else:
355
+ raise ValueError(f"Unsupported audio shape: {audio.shape}")
356
+
357
+ if audio.dtype == np.int16:
358
+ audio = audio.astype(np.float32) / 32768.0
359
+ elif audio.dtype != np.float32:
360
+ audio = audio.astype(np.float32)
361
+
362
+ max_abs = float(np.abs(audio).max()) if audio.size else 0.0
363
+ if max_abs > 1.0:
364
+ audio = audio / max_abs
365
+ return audio.astype(np.float32, copy=False)
366
+
367
+
368
+ def load_audio(audio_path: str, target_sr: int = SAMPLE_RATE) -> tuple[np.ndarray, int]:
369
+ import librosa
370
+
371
+ audio_data, sr = librosa.load(audio_path, sr=target_sr, mono=True)
372
+ return normalize_audio(audio_data), sr
373
+
374
+
375
+ def split_audio_into_clips(
376
+ audio: np.ndarray,
377
+ sample_rate: int = SAMPLE_RATE,
378
+ clip_duration: float = 30.0,
379
+ ) -> list[np.ndarray]:
380
+ audio = normalize_audio(audio)
381
+ clip_samples = int(round(sample_rate * clip_duration))
382
+ if clip_samples <= 0:
383
+ raise ValueError(f"Invalid clip duration: {clip_duration}")
384
+ if audio.size == 0:
385
+ audio = np.zeros(1, dtype=np.float32)
386
+
387
+ num_clips = max(1, math.ceil(audio.shape[0] / clip_samples))
388
+ clips: list[np.ndarray] = []
389
+ for idx in range(num_clips):
390
+ start = idx * clip_samples
391
+ clip = audio[start : start + clip_samples]
392
+ if clip.shape[0] < clip_samples:
393
+ clip = np.pad(clip, (0, clip_samples - clip.shape[0]))
394
+ clips.append(clip.astype(np.float32, copy=False))
395
+ return clips
396
+
397
+
398
+ def extract_whisper_features(
399
+ feature_extractor,
400
+ audio: np.ndarray,
401
+ sample_rate: int,
402
+ clip_duration: float,
403
+ ) -> torch.Tensor:
404
+ clips = split_audio_into_clips(audio, sample_rate=sample_rate, clip_duration=clip_duration)
405
+ features = feature_extractor(
406
+ clips,
407
+ sampling_rate=sample_rate,
408
+ return_tensors="pt",
409
+ padding="max_length",
410
+ return_attention_mask=False,
411
+ )
412
+ input_features = features.input_features
413
+ if input_features.ndim != 3:
414
+ raise ValueError(f"Expected 3D Whisper features, got {tuple(input_features.shape)}")
415
+ return input_features
416
+
417
+
418
+ def build_prompt_template(prompt: str, reasoning: bool, has_audio: bool) -> str:
419
+ audio_prefix = "<sound>\n" if has_audio else ""
420
+ if reasoning:
421
+ return f"<|im_start|>user\n{audio_prefix}{prompt}<|im_end|>\n<|im_start|>assistant\n<think>\n"
422
+ return f"<|im_start|>user\n{audio_prefix}{prompt}<|im_end|>\n<|im_start|>assistant\n<think></think>"
423
+
424
+
425
+ def expand_sound_placeholder(prompt: str, num_embeddings: int) -> str:
426
+ if prompt.count(SOUND_PLACEHOLDER) != 1:
427
+ raise ValueError(
428
+ f"Expected exactly one {SOUND_PLACEHOLDER}, found {prompt.count(SOUND_PLACEHOLDER)}"
429
+ )
430
+ replacement = SOUND_START_TOKEN + (SOUND_TOKEN * num_embeddings) + SOUND_END_TOKEN
431
+ return prompt.replace(SOUND_PLACEHOLDER, replacement)
432
+
433
+
434
+ def split_thinking(response: str) -> tuple[str, str]:
435
+ if "</think>" not in response:
436
+ return "", response.strip()
437
+ thinking = response.rsplit("</think>", 1)[0].strip() + "</think>"
438
+ prediction = response.rsplit("</think>", 1)[1].strip()
439
+ return thinking, prediction
440
+
441
+
442
+ def clean_response(tokenizer, output_ids, prompt_len: int) -> tuple[str, str, str]:
443
+ new_tokens = output_ids[0, prompt_len:]
444
+ response = tokenizer.decode(new_tokens, skip_special_tokens=False)
445
+ response = response.split(IM_END_TOKEN, 1)[0].strip()
446
+ thinking, answer = split_thinking(response)
447
+ return response, thinking, answer
448
+
449
+
450
+ def validate_https_url(url: str) -> str:
451
+ parsed = urlparse(url)
452
+ if parsed.scheme != "https" or not parsed.netloc:
453
+ raise ValueError("Audio URL must be a valid HTTPS URL.")
454
+ return url
455
+
456
+
457
+ def download_audio_url(url: str, progress: gr.Progress) -> tuple[str, float]:
458
+ started = time.monotonic()
459
+ TMP_DIR.mkdir(parents=True, exist_ok=True)
460
+ suffix = Path(urlparse(url).path).suffix[:10] or ".audio"
461
+ target = TMP_DIR / f"audex-url-{int(started * 1000)}-{random.randint(0, 9999)}{suffix}"
462
+
463
+ with requests.get(url, stream=True, timeout=(10, 120)) as resp:
464
+ resp.raise_for_status()
465
+ total = int(resp.headers.get("content-length") or 0)
466
+ downloaded = 0
467
+ with target.open("wb") as handle:
468
+ for chunk in resp.iter_content(chunk_size=1024 * 1024):
469
+ if not chunk:
470
+ continue
471
+ downloaded += len(chunk)
472
+ if downloaded > MAX_AUDIO_BYTES:
473
+ raise ValueError("Audio URL is too large. Limit is 80 MB.")
474
+ handle.write(chunk)
475
+ if total:
476
+ progress(
477
+ min(0.10, 0.02 + 0.08 * downloaded / total),
478
+ desc=f"Downloading HTTPS audio {downloaded / 1_000_000:.1f}/{total / 1_000_000:.1f} MB",
479
+ )
480
+ else:
481
+ progress(
482
+ 0.04,
483
+ desc=f"Downloading HTTPS audio {downloaded / 1_000_000:.1f} MB",
484
+ )
485
+ return str(target), time.monotonic() - started
486
+
487
+
488
+ def resolve_audio_input(
489
+ audio_file: str | None,
490
+ audio_url: str,
491
+ needs_audio: bool,
492
+ progress: gr.Progress,
493
+ ) -> tuple[str | None, float]:
494
+ if not needs_audio:
495
+ return None, 0.0
496
+ audio_url = audio_url.strip()
497
+ if audio_url:
498
+ progress(0.02, desc="Resolving HTTPS audio URL")
499
+ return download_audio_url(validate_https_url(audio_url), progress)
500
+ if audio_file:
501
+ return audio_file, 0.0
502
+ raise ValueError("Provide audio from the microphone, upload a file, or enter an HTTPS audio URL.")
503
+
504
+
505
+ class GenerationProgress(StoppingCriteria):
506
+ def __init__(self, prompt_len: int, max_new_tokens: int, progress: gr.Progress) -> None:
507
+ self.prompt_len = int(prompt_len)
508
+ self.max_new_tokens = max(1, int(max_new_tokens))
509
+ self.progress = progress
510
+ self.started = time.monotonic()
511
+ self.last_reported = -1
512
+
513
+ def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor, **kwargs) -> bool:
514
+ new_tokens = max(0, int(input_ids.shape[-1]) - self.prompt_len)
515
+ if new_tokens != self.last_reported:
516
+ self.last_reported = new_tokens
517
+ elapsed = time.monotonic() - self.started
518
+ fraction = min(0.92, 0.62 + 0.30 * min(new_tokens, self.max_new_tokens) / self.max_new_tokens)
519
+ speed = new_tokens / elapsed if elapsed > 0 and new_tokens > 0 else 0.0
520
+ self.progress(
521
+ fraction,
522
+ desc=(
523
+ f"Generating text tokens {new_tokens}/{self.max_new_tokens} | "
524
+ f"elapsed {_format_seconds(elapsed)} | {speed:.1f} tok/s"
525
+ ),
526
+ )
527
+ return False
528
+
529
+
530
+ def task_defaults(task: str):
531
+ cfg = TASKS[task]
532
+ return (
533
+ gr.update(value=cfg["prompt"]),
534
+ gr.update(value=cfg["reasoning"]),
535
+ gr.update(value=cfg["temperature"]),
536
+ gr.update(value=cfg["top_p"]),
537
+ gr.update(visible=bool(cfg["needs_audio"])),
538
+ gr.update(visible=bool(cfg["needs_audio"])),
539
+ )
540
+
541
+
542
+ def estimate_duration(
543
+ task: str,
544
+ audio_file: str | None,
545
+ audio_url: str,
546
+ prompt: str,
547
+ reasoning: bool,
548
+ max_new_tokens: int,
549
+ temperature: float,
550
+ top_p: float,
551
+ top_k: int,
552
+ *_args,
553
+ **_kwargs,
554
+ ) -> int:
555
+ del task, audio_file, audio_url, prompt, reasoning, temperature, top_p, top_k
556
+ loaded = _LOADED is not None
557
+ base = 40 if loaded else 90
558
+ return min(240, base + int(max_new_tokens) // 24)
559
+
560
+
561
+ @spaces.GPU(duration=estimate_duration)
562
+ def generate(
563
+ task: str,
564
+ audio_file: str | None,
565
+ audio_url: str,
566
+ prompt: str,
567
+ reasoning: bool,
568
+ max_new_tokens: int,
569
+ temperature: float,
570
+ top_p: float,
571
+ top_k: int,
572
+ progress: gr.Progress = gr.Progress(track_tqdm=False),
573
+ ):
574
+ total_started = time.monotonic()
575
+ timings: dict[str, float] = {}
576
+ prompt = prompt.strip()
577
+ if not prompt:
578
+ return "", "", "Enter a prompt."
579
+
580
+ try:
581
+ task_cfg = TASKS[task]
582
+ needs_audio = bool(task_cfg["needs_audio"])
583
+ progress(0.01, desc="Resolving inputs")
584
+ resolved_audio, timings["audio_fetch"] = resolve_audio_input(
585
+ audio_file=audio_file,
586
+ audio_url=audio_url,
587
+ needs_audio=needs_audio,
588
+ progress=progress,
589
+ )
590
+
591
+ timings["snapshot_wait"] = wait_for_preload(progress)
592
+ loaded, timings["model_load"] = load_model(progress)
593
+ model = loaded.model
594
+ tokenizer = loaded.tokenizer
595
+ config = loaded.config
596
+
597
+ input_features = None
598
+ feature_started = time.monotonic()
599
+ if needs_audio:
600
+ progress(0.24, desc="Loading and resampling audio")
601
+ audio, sr = load_audio(str(resolved_audio), target_sr=SAMPLE_RATE)
602
+ duration = audio.shape[0] / float(sr)
603
+ progress(0.32, desc=f"Extracting Whisper audio features from {_format_seconds(duration)} audio")
604
+ input_features = extract_whisper_features(
605
+ loaded.feature_extractor,
606
+ audio,
607
+ sample_rate=sr,
608
+ clip_duration=float(getattr(config, "sound_clip_duration", 30.0)),
609
+ )
610
+ num_embeddings = input_features.shape[0] * int(getattr(config, "sound_embedding_size", 750))
611
+ else:
612
+ duration = 0.0
613
+ num_embeddings = 0
614
+ timings["audio_features"] = time.monotonic() - feature_started
615
+
616
+ prompt_started = time.monotonic()
617
+ progress(0.42, desc="Building ChatML prompt")
618
+ formatted_prompt = build_prompt_template(prompt, reasoning=reasoning, has_audio=needs_audio)
619
+ if needs_audio:
620
+ formatted_prompt = expand_sound_placeholder(formatted_prompt, num_embeddings)
621
+ tokenized = tokenizer(formatted_prompt, return_tensors="pt", add_special_tokens=False)
622
+ input_ids = tokenized.input_ids
623
+ attention_mask = tokenized.attention_mask if "attention_mask" in tokenized else torch.ones_like(input_ids)
624
+ prompt_len = int(input_ids.shape[-1])
625
+ device = model.device
626
+ input_ids = input_ids.to(device)
627
+ attention_mask = attention_mask.to(device)
628
+ if input_features is not None:
629
+ input_features = input_features.to(device)
630
+ timings["prompt"] = time.monotonic() - prompt_started
631
+
632
+ eos_token_id = tokenizer.convert_tokens_to_ids(IM_END_TOKEN)
633
+ if eos_token_id is None or eos_token_id == tokenizer.unk_token_id:
634
+ eos_token_id = getattr(config, "eos_token_id", None)
635
+
636
+ max_new_tokens = int(max_new_tokens)
637
+ temperature = float(temperature)
638
+ top_p = float(top_p)
639
+ top_k = int(top_k)
640
+ do_sample = temperature != 1.0 or 0.0 < top_p < 1.0 or top_k > 0
641
+ generation_kwargs = {
642
+ "do_sample": do_sample,
643
+ "eos_token_id": eos_token_id,
644
+ "pad_token_id": tokenizer.pad_token_id or getattr(config, "pad_token_id", 0),
645
+ "stopping_criteria": StoppingCriteriaList(
646
+ [GenerationProgress(prompt_len, max_new_tokens, progress)]
647
+ ),
648
+ }
649
+ if do_sample:
650
+ generation_kwargs["temperature"] = temperature
651
+ if top_p > 0.0:
652
+ generation_kwargs["top_p"] = top_p
653
+ if top_k > 0:
654
+ generation_kwargs["top_k"] = top_k
655
+
656
+ progress(0.60, desc="Starting model generation")
657
+ generation_started = time.monotonic()
658
+ with torch.inference_mode():
659
+ output_ids = model.generate(
660
+ input_ids=input_ids,
661
+ attention_mask=attention_mask,
662
+ input_features=input_features,
663
+ max_new_tokens=max_new_tokens,
664
+ **generation_kwargs,
665
+ )
666
+ timings["generation"] = time.monotonic() - generation_started
667
+
668
+ decode_started = time.monotonic()
669
+ progress(0.96, desc="Decoding output")
670
+ raw_response, thinking, answer = clean_response(tokenizer, output_ids, prompt_len)
671
+ timings["decode"] = time.monotonic() - decode_started
672
+ timings["total"] = time.monotonic() - total_started
673
+ _torch_cleanup()
674
+
675
+ if not answer and raw_response:
676
+ answer = raw_response
677
+
678
+ summary = build_summary(
679
+ task=TASKS[task]["label"],
680
+ prompt_tokens=prompt_len,
681
+ output_tokens=max(0, int(output_ids.shape[-1]) - prompt_len),
682
+ audio_duration=duration,
683
+ timings=timings,
684
+ )
685
+ return thinking, answer, summary
686
+ except Exception as exc:
687
+ traceback.print_exc()
688
+ return "", "", _friendly_error(exc)
689
+
690
+
691
+ def build_summary(
692
+ *,
693
+ task: str,
694
+ prompt_tokens: int,
695
+ output_tokens: int,
696
+ audio_duration: float,
697
+ timings: dict[str, float],
698
+ ) -> str:
699
+ token_speed = output_tokens / timings["generation"] if timings.get("generation") else 0.0
700
+ audio_line = (
701
+ f"{_format_seconds(audio_duration)} audio 路 " if audio_duration > 0 else ""
702
+ )
703
+ return (
704
+ "### Run summary\n\n"
705
+ f"`{MODEL_REPO_ID}` 路 {task} 路 {audio_line}{prompt_tokens} prompt tokens 路 "
706
+ f"{output_tokens} output tokens\n\n"
707
+ "| Phase | Time |\n"
708
+ "| --- | ---: |\n"
709
+ f"| Total backend time | {_format_seconds(timings.get('total', 0))} |\n"
710
+ f"| HTTPS audio fetch | {_format_seconds(timings.get('audio_fetch', 0))} |\n"
711
+ f"| Snapshot wait/download | {_format_seconds(timings.get('snapshot_wait', 0))} |\n"
712
+ f"| Model load/reuse | {_format_seconds(timings.get('model_load', 0))} |\n"
713
+ f"| Audio feature extraction | {_format_seconds(timings.get('audio_features', 0))} |\n"
714
+ f"| Prompt preparation | {_format_seconds(timings.get('prompt', 0))} |\n"
715
+ f"| Token generation | {_format_seconds(timings.get('generation', 0))} |\n"
716
+ f"| Decode | {_format_seconds(timings.get('decode', 0))} |\n"
717
+ f"| Generation speed | {token_speed:.2f} tok/s |\n"
718
+ )
719
+
720
+
721
+ APP_CSS = """
722
+ .gradio-container {
723
+ max-width: 1280px !important;
724
+ }
725
+
726
+ #run_summary table {
727
+ width: 100%;
728
+ }
729
+
730
+ #run_summary th,
731
+ #run_summary td {
732
+ padding: 6px 8px;
733
+ }
734
+
735
+ #run_summary th:last-child,
736
+ #run_summary td:last-child {
737
+ text-align: right;
738
+ white-space: nowrap;
739
+ }
740
+ """
741
+
742
+
743
+ _ensure_example_assets()
744
+ start_preload()
745
+
746
+
747
+ with gr.Blocks(title=APP_TITLE, css=APP_CSS) as demo:
748
+ gr.Markdown(
749
+ f"""
750
+ # {APP_TITLE}
751
+
752
+ Audio and text demo for [`{MODEL_REPO_ID}`]({MODEL_CARD_URL}).
753
+ Audex-2B supports audio understanding, speech recognition, speech translation,
754
+ and text reasoning. See the [technical report]({PAPER_URL}).
755
+ """
756
+ )
757
+
758
+ with gr.Row():
759
+ with gr.Column(scale=1, min_width=380):
760
+ task = gr.Radio(
761
+ choices=_task_choices(),
762
+ value="audio-understanding",
763
+ label="Task",
764
+ )
765
+ audio = gr.Audio(
766
+ label="Record or upload audio",
767
+ sources=["microphone", "upload"],
768
+ type="filepath",
769
+ visible=True,
770
+ )
771
+ audio_url = gr.Textbox(
772
+ label="HTTPS audio URL",
773
+ placeholder="https://example.com/audio.wav",
774
+ visible=True,
775
+ )
776
+ prompt = gr.Textbox(
777
+ label="Prompt",
778
+ value=TASKS["audio-understanding"]["prompt"],
779
+ lines=4,
780
+ max_lines=10,
781
+ )
782
+ with gr.Row():
783
+ reasoning = gr.Checkbox(label="Thinking mode", value=False)
784
+ top_k = gr.Number(label="Top-k", value=0, precision=0, minimum=0)
785
+ with gr.Row():
786
+ max_new_tokens = gr.Slider(
787
+ minimum=32,
788
+ maximum=2048,
789
+ step=32,
790
+ value=512,
791
+ label="Max new tokens",
792
+ )
793
+ with gr.Row():
794
+ temperature = gr.Slider(
795
+ minimum=0.1,
796
+ maximum=1.5,
797
+ step=0.1,
798
+ value=0.7,
799
+ label="Temperature",
800
+ )
801
+ top_p = gr.Slider(
802
+ minimum=0.1,
803
+ maximum=1.0,
804
+ step=0.05,
805
+ value=0.9,
806
+ label="Top-p",
807
+ )
808
+ run = gr.Button("Generate", variant="primary")
809
+
810
+ with gr.Column(scale=1, min_width=420):
811
+ answer = gr.Textbox(label="Answer", lines=12, show_copy_button=True)
812
+ thinking = gr.Textbox(label="Thinking", lines=8, show_copy_button=True)
813
+ summary = gr.Markdown(
814
+ "The model snapshot starts predownloading with the Space owner's `HF_TOKEN` when the Space starts.",
815
+ elem_id="run_summary",
816
+ )
817
+
818
+ gr.Examples(
819
+ examples=_example_samples(),
820
+ inputs=[
821
+ task,
822
+ audio,
823
+ audio_url,
824
+ prompt,
825
+ reasoning,
826
+ max_new_tokens,
827
+ temperature,
828
+ top_p,
829
+ top_k,
830
+ ],
831
+ label="Examples",
832
+ )
833
+
834
+ task.change(
835
+ task_defaults,
836
+ inputs=task,
837
+ outputs=[prompt, reasoning, temperature, top_p, audio, audio_url],
838
+ )
839
+ run.click(
840
+ generate,
841
+ inputs=[
842
+ task,
843
+ audio,
844
+ audio_url,
845
+ prompt,
846
+ reasoning,
847
+ max_new_tokens,
848
+ temperature,
849
+ top_p,
850
+ top_k,
851
+ ],
852
+ outputs=[thinking, answer, summary],
853
+ api_name="generate",
854
+ concurrency_limit=1,
855
+ )
856
+
857
+ demo.queue(default_concurrency_limit=1)
858
+
859
+ if __name__ == "__main__":
860
+ demo.launch(allowed_paths=[str(ASSET_DIR)])
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio==6.19.0
2
+ spaces>=0.50.0
3
+ torch==2.9.1
4
+ transformers>=5.0.0
5
+ accelerate>=1.12.0
6
+ safetensors>=0.7.0
7
+ huggingface_hub[hf_xet]>=1.22.0
8
+ hf-transfer>=0.1.4
9
+ librosa>=0.11.0
10
+ soundfile>=0.13.0
11
+ requests>=2.32.0
12
+ numpy>=2.2.0
13
+