L0SG commited on
Commit
4164484
·
verified ·
1 Parent(s): 6596c08

Polish unified Audex demo

Browse files

Add unified 30B-A3B and 2B inference, streaming text and speech generation, reasoning controls, curated examples, local setup scripts, and ZeroGPU runtime support.

.gitattributes CHANGED
@@ -35,5 +35,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  examples/mlk_speech.wav filter=lfs diff=lfs merge=lfs -text
37
  examples/sample_speech.wav filter=lfs diff=lfs merge=lfs -text
 
38
  wheels/causal_conv1d-1.6.2.post1-cp310-cp310-linux_x86_64.whl filter=lfs diff=lfs merge=lfs -text
39
  wheels/mamba_ssm-2.3.2.post1-cp310-cp310-linux_x86_64.whl filter=lfs diff=lfs merge=lfs -text
 
 
 
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  examples/mlk_speech.wav filter=lfs diff=lfs merge=lfs -text
37
  examples/sample_speech.wav filter=lfs diff=lfs merge=lfs -text
38
+ wheels/*.whl filter=lfs diff=lfs merge=lfs -text
39
  wheels/causal_conv1d-1.6.2.post1-cp310-cp310-linux_x86_64.whl filter=lfs diff=lfs merge=lfs -text
40
  wheels/mamba_ssm-2.3.2.post1-cp310-cp310-linux_x86_64.whl filter=lfs diff=lfs merge=lfs -text
41
+ examples/korean_speech.wav filter=lfs diff=lfs merge=lfs -text
42
+ examples/question_1059.mp3 filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .cache/
2
+ .local/
3
+ .venv/
4
+ __pycache__/
5
+ _wheelout/
6
+ *.pyc
README.md CHANGED
@@ -1,32 +1,37 @@
1
  ---
2
- title: Nemotron Labs Audex 30B A3B
3
  emoji: 🎧
4
  colorFrom: purple
5
  colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.20.0
8
  app_file: app.py
9
- short_description: Audio understanding, speech recognition & translation
10
  python_version: "3.10"
11
  startup_duration_timeout: 1h
12
  ---
13
 
14
- # Nemotron-Labs-Audex-30B-A3B
15
 
16
- A Gradio demo for [`nvidia/Nemotron-Labs-Audex-30B-A3B`](https://huggingface.co/nvidia/Nemotron-Labs-Audex-30B-A3B),
17
- a unified audio-text MoE (30B total / 3B active) built on Nemotron-Cascade-2.
18
- This demo exposes its **audio understanding, speech recognition, and speech
19
- translation** capabilities via the official Hugging Face inference path.
20
 
21
- ## Correct numerics
22
 
23
- The model is a Nemotron-H hybrid (Mamba2 + attention + MLP). The Mamba layers
24
- require the compiled CUDA fast path from `mamba-ssm` (`selective_scan_cuda`) and
25
- `causal-conv1d`; the pure-torch fallback produces degenerate output. This Space
26
- builds both extensions from source against the runtime's exact toolchain
27
- (torch 2.11.0+cu130, CUDA 13.0, Python 3.10, sm_120) on first boot and caches
28
- the resulting wheels back into the repo (`wheels/`) for fast subsequent boots.
29
 
30
- Runs on ZeroGPU `xlarge` (full 96 GB card) since the bf16 weights are ~65 GB.
31
 
32
- *Licensed for non-commercial use only (NVIDIA One-Way Noncommercial License).*
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Nemotron-Labs-Audex
3
  emoji: 🎧
4
  colorFrom: purple
5
  colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.20.0
8
  app_file: app.py
9
+ short_description: Unified audio-text intelligence
10
  python_version: "3.10"
11
  startup_duration_timeout: 1h
12
  ---
13
 
14
+ # Nemotron-Labs-Audex
15
 
16
+ A Gradio demo for
17
+ [`nvidia/Nemotron-Labs-Audex-30B-A3B`](https://huggingface.co/nvidia/Nemotron-Labs-Audex-30B-A3B)
18
+ and [`nvidia/Nemotron-Labs-Audex-2B`](https://huggingface.co/nvidia/Nemotron-Labs-Audex-2B).
19
+ The model selector defaults to the 30B-A3B and can switch to 2B model.
20
 
21
+ The demo includes **audio understanding, speech recognition, speech translation, text reasoning, text-to-speech, and speech-to-speech**.
22
 
23
+ Reasoning has no separate token cap by default for either model. `Max new tokens` sets a total cap shared by reasoning and the final answer.
 
 
 
 
 
24
 
25
+ ## Run locally
26
 
27
+ ```bash
28
+ git clone https://huggingface.co/spaces/L0SG/Nemotron-Labs-Audex
29
+ cd Nemotron-Labs-Audex
30
+ bash setup_local.sh
31
+ bash run_local.sh
32
+ ```
33
+
34
+ `setup_local.sh` creates an isolated `.venv` and installs PyTorch when needed.
35
+ Use `AUDEX_TORCH_PACKAGE` or standard pip index environment variables to select a platform-specific PyTorch build.
36
+
37
+ `run_local.sh` detects the selected GPU architecture and creates a matching local CUDA-extension cache.
app.py CHANGED
@@ -1,41 +1,233 @@
1
  """
2
- Nemotron-Labs-Audex-30B-A3BAudio Understanding / Speech Recognition & Translation demo.
3
-
4
- This unified audio-text MoE (30B total, 3B active) is a Nemotron-H hybrid
5
- (Mamba2 + attention + MLP). Correct numerics REQUIRE the compiled CUDA fast
6
- path from `mamba-ssm` (selective_scan_cuda) and `causal-conv1d`; the pure-torch
7
- fallback produces degenerate/repeated tokens. We therefore build both extensions
8
- from source against the Space's exact torch (2.11.0+cu130) / CUDA 13.0 / py3.10
9
- / sm_120 toolchain, cache the resulting wheels back into this Space repo, and
10
- reuse them on subsequent boots.
11
  """
12
  import os
13
  os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
14
  os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
15
 
16
  import glob
 
 
 
17
  import subprocess
18
  import sys
19
  import time
 
 
 
20
  from pathlib import Path
 
 
21
 
22
  # Import spaces FIRST (before torch / any CUDA-touching import) so its
23
  # torch.cuda.* monkey-patch is installed. The kernel build below runs in
24
  # subprocesses, so it does not initialize CUDA in this process.
25
  import spaces # noqa: E402
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  APP_DIR = Path(__file__).resolve().parent
28
- WHEELS_DIR = APP_DIR / "wheels"
29
- REPO_ID = os.environ.get("SPACE_ID", "L0SG/nemotron-labs-audex-30b-a3b")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
- # Pinned versions recommended by the model card.
32
- CAUSAL_CONV1D_SPEC = "causal-conv1d==1.6.2.post1"
33
- MAMBA_SSM_SPEC = "mamba-ssm==2.3.2.post1"
34
- # sm_120 (RTX PRO 6000 Blackwell). Build env has no GPU, so arch must be explicit.
35
- TORCH_ARCH = "12.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
- def _run(cmd, env=None, timeout=3000):
 
 
 
 
39
  print(f"[build] $ {' '.join(cmd)}", flush=True)
40
  p = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=timeout)
41
  if p.stdout:
@@ -50,45 +242,128 @@ def _run(cmd, env=None, timeout=3000):
50
  return p
51
 
52
 
53
- def _installed(mod):
54
  try:
55
- __import__(mod)
56
- return True
57
- except Exception:
 
 
 
 
 
 
 
58
  return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
 
61
- def _pip(*args):
62
- _run([sys.executable, "-m", "pip"] + list(args))
63
 
64
 
65
- def _build_env():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  env = dict(os.environ)
67
  env["MAMBA_FORCE_BUILD"] = "TRUE"
68
  env["CAUSAL_CONV1D_FORCE_BUILD"] = "TRUE"
69
- env["TORCH_CUDA_ARCH_LIST"] = TORCH_ARCH
70
  env["MAX_JOBS"] = env.get("MAX_JOBS", "4")
71
- # Ensure nvcc is discoverable.
72
- cuda_home = env.get("CUDA_HOME") or "/cuda-image/usr/local/cuda-13.0"
73
  env["CUDA_HOME"] = cuda_home
74
  env["PATH"] = f"{cuda_home}/bin:" + env.get("PATH", "")
75
  return env
76
 
77
 
78
- def ensure_kernels():
79
  """Install compiled causal-conv1d + mamba-ssm. Use cached wheels if present,
80
  otherwise build from source and cache the wheels back into the repo."""
81
- if _installed("causal_conv1d") and _installed("selective_scan_cuda"):
82
  print("[build] kernels already importable", flush=True)
83
  return
84
 
85
- WHEELS_DIR.mkdir(exist_ok=True)
86
- cached = sorted(glob.glob(str(WHEELS_DIR / "*.whl")))
87
- if cached:
 
 
 
 
 
 
88
  print(f"[build] installing cached wheels: {cached}", flush=True)
89
  try:
90
  _pip("install", "--no-deps", "--no-build-isolation", *cached)
91
- if _installed("causal_conv1d") and _installed("selective_scan_cuda"):
92
  print("[build] cached wheels installed OK", flush=True)
93
  return
94
  print("[build] cached wheels imported incompletely; rebuilding", flush=True)
@@ -96,25 +371,47 @@ def ensure_kernels():
96
  print(f"[build] cached wheel install failed ({e!r}); rebuilding", flush=True)
97
 
98
  env = _build_env()
99
- out_dir = APP_DIR / "_wheelout"
100
- out_dir.mkdir(exist_ok=True)
101
 
102
  # Build wheels (no-build-isolation => uses the preinstalled torch).
103
  t0 = time.time()
104
  print("[build] building causal-conv1d + mamba-ssm from source (this can take ~20 min)", flush=True)
105
- _pip("wheel", "--no-build-isolation", "--no-deps", "-w", str(out_dir),
106
- CAUSAL_CONV1D_SPEC)
 
 
 
 
 
 
 
107
  # mamba-ssm needs causal-conv1d importable during its own build; install it first.
108
- built = sorted(glob.glob(str(out_dir / "causal_conv1d*.whl")))
109
- _pip("install", "--no-deps", *built)
110
- _pip("wheel", "--no-build-isolation", "--no-deps", "-w", str(out_dir),
111
- MAMBA_SSM_SPEC)
 
 
 
 
 
 
 
 
 
 
 
112
  print(f"[build] source build finished in {time.time()-t0:.0f}s", flush=True)
113
 
114
- all_wheels = sorted(glob.glob(str(out_dir / "*.whl")))
 
 
 
 
 
115
  _pip("install", "--no-deps", *all_wheels)
116
 
117
- if not (_installed("causal_conv1d") and _installed("selective_scan_cuda")):
118
  raise RuntimeError("kernel build completed but imports still fail")
119
  print("[build] kernels built + installed OK", flush=True)
120
 
@@ -125,6 +422,10 @@ def ensure_kernels():
125
  if not dst.exists():
126
  import shutil
127
  shutil.copy(w, dst)
 
 
 
 
128
  from huggingface_hub import HfApi
129
  tok = os.environ.get("HF_TOKEN")
130
  if tok:
@@ -147,8 +448,14 @@ ensure_kernels()
147
  # ---- Now safe to bring in torch / model ----
148
  import torch
149
  import gradio as gr
 
150
  from huggingface_hub import snapshot_download
151
- from transformers import AutoConfig, AutoFeatureExtractor, AutoModelForCausalLM, AutoTokenizer
 
 
 
 
 
152
 
153
  from audio_utils import (
154
  IM_END_TOKEN,
@@ -160,41 +467,37 @@ from audio_utils import (
160
  resolve_audio_preprocessor_path,
161
  split_thinking,
162
  )
163
-
164
- MODEL_ID = "nvidia/Nemotron-Labs-Audex-30B-A3B"
165
- SUBFOLDER = "checkpoint_folder_full"
166
- SAMPLE_RATE = 16000
167
-
168
- print("[load] downloading checkpoint…", flush=True)
169
- LOCAL_DIR = snapshot_download(
170
- MODEL_ID,
171
- allow_patterns=[f"{SUBFOLDER}/*"],
172
- token=os.environ.get("HF_TOKEN"),
173
  )
174
- MODEL_PATH = os.path.join(LOCAL_DIR, SUBFOLDER)
175
- print(f"[load] checkpoint at {MODEL_PATH}", flush=True)
176
 
177
- tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True)
178
- config = AutoConfig.from_pretrained(MODEL_PATH, trust_remote_code=True)
179
- feature_extractor = AutoFeatureExtractor.from_pretrained(
180
- resolve_audio_preprocessor_path(MODEL_PATH, config)
181
- )
182
- print("[load] tokenizer/config/feature_extractor loaded", flush=True)
183
 
184
- model = AutoModelForCausalLM.from_pretrained(
185
- MODEL_PATH,
186
- trust_remote_code=True,
187
- dtype=torch.bfloat16,
188
- low_cpu_mem_usage=True,
189
- ).eval()
190
- model = model.to("cuda")
191
- print("[load] model loaded and moved to cuda (packed by ZeroGPU)", flush=True)
 
 
 
192
 
193
- # Verify fast path is active (import-time flag inside the remote modeling module).
194
- try:
195
- import transformers_modules # noqa
196
- except Exception:
197
- pass
 
 
198
 
199
 
200
  def _find_fast_path_flag() -> bool | None:
@@ -204,16 +507,168 @@ def _find_fast_path_flag() -> bool | None:
204
  return None
205
 
206
 
207
- TASK_PROMPTS = {
208
- "Describe the audio": "Describe the audio in detail.",
209
- "Transcribe (ASR)": "Transcribe the speech in the input audio.",
210
- "Translate speech to English": "Translate the speech in the input audio into English.",
211
- "Answer a question about the audio": "What is being said in this audio, and what is the tone?",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  }
213
- GREEDY_TASKS = {"Transcribe (ASR)", "Translate speech to English"}
214
- MAX_AUDIO_DURATION_SECONDS = 120.0
215
- MAX_NEW_TOKENS = 512
216
- MAX_GPU_DURATION_SECONDS = 60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
 
219
  def _probe_audio_duration(audio: str | None) -> float:
@@ -248,7 +703,138 @@ def _estimate(
248
  return MAX_GPU_DURATION_SECONDS
249
 
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  def _generate(
 
252
  audio: str,
253
  prompt: str,
254
  reasoning: bool,
@@ -256,10 +842,11 @@ def _generate(
256
  temperature: float,
257
  top_p: float,
258
  greedy: bool,
259
- ) -> tuple[str, str]:
 
260
  started_at = time.perf_counter()
261
- ff = _find_fast_path_flag()
262
- print(f"[gpu] is_fast_path_available={ff}", flush=True)
263
 
264
  wav, sr = load_audio(audio, target_sr=SAMPLE_RATE)
265
  audio_duration = wav.shape[-1] / sr
@@ -270,24 +857,41 @@ def _generate(
270
  )
271
  if max_new_tokens > MAX_NEW_TOKENS:
272
  raise gr.Error(f"This demo supports up to {MAX_NEW_TOKENS} output tokens.")
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
  input_features = extract_whisper_features(
275
- feature_extractor, wav, sample_rate=sr,
276
- clip_duration=float(getattr(config, "sound_clip_duration", 30.0)),
 
 
 
 
 
277
  )
278
- num_embeddings = input_features.shape[0] * int(getattr(config, "sound_embedding_size", 750))
279
  formatted = build_prompt_template(prompt.strip(), reasoning=bool(reasoning),
280
  prompt_repitition="none")
281
  expanded = expand_sound_placeholder(formatted, num_embeddings)
282
- tok = tokenizer(expanded, return_tensors="pt", add_special_tokens=False)
283
  input_ids = tok.input_ids.to("cuda")
284
  attention_mask = (tok.attention_mask if "attention_mask" in tok
285
  else build_attention_mask(input_ids)).to("cuda")
286
  input_features = input_features.to("cuda")
287
 
288
- eos_token_id = tokenizer.convert_tokens_to_ids(IM_END_TOKEN)
289
- if eos_token_id is None or eos_token_id == tokenizer.unk_token_id:
290
- eos_token_id = getattr(config, "eos_token_id", None)
291
 
292
  temperature = max(float(temperature), 1e-4)
293
  top_p = float(top_p)
@@ -295,31 +899,36 @@ def _generate(
295
  gen_kwargs = dict(
296
  do_sample=do_sample,
297
  eos_token_id=eos_token_id,
298
- pad_token_id=tokenizer.pad_token_id or getattr(config, "pad_token_id", 0),
 
299
  max_new_tokens=int(max_new_tokens),
 
300
  )
 
301
  if do_sample:
302
  gen_kwargs["temperature"] = temperature
303
  if top_p > 0.0:
304
  gen_kwargs["top_p"] = top_p
305
 
306
- with torch.inference_mode():
307
- out = model.generate(
308
- input_ids=input_ids,
309
- attention_mask=attention_mask,
310
- input_features=input_features,
311
- **gen_kwargs,
312
- )
313
- new_tokens = out[0, input_ids.shape[-1]:]
314
- response = tokenizer.decode(new_tokens, skip_special_tokens=False)
315
- response = response.split(IM_END_TOKEN, 1)[0].strip()
316
- thinking, prediction = split_thinking(response)
 
 
 
317
  print(
318
  f"[gpu] audio_seconds={audio_duration:.1f} greedy={greedy} "
319
  f"elapsed_seconds={time.perf_counter() - started_at:.1f}",
320
  flush=True,
321
  )
322
- return thinking, prediction
323
 
324
 
325
  @spaces.GPU(duration=_estimate, size="xlarge")
@@ -336,7 +945,7 @@ def run(
336
 
337
  Predefined ASR and translation tasks use the model's recommended greedy
338
  decoding. Other tasks and custom instructions use the sampling controls.
339
- Audio may be up to two minutes long, with up to 512 output tokens.
340
 
341
  Returns:
342
  The final answer and, when enabled, the reasoning trace.
@@ -345,9 +954,14 @@ def run(
345
  return "Please provide an audio input.", ""
346
 
347
  custom_instruction = custom_prompt.strip() if custom_prompt else ""
348
- prompt = custom_instruction or TASK_PROMPTS.get(task, TASK_PROMPTS["Describe the audio"])
349
- greedy = not custom_instruction and task in GREEDY_TASKS
350
- thinking, answer = _generate(
 
 
 
 
 
351
  audio,
352
  prompt,
353
  reasoning,
@@ -355,77 +969,762 @@ def run(
355
  temperature=float(temperature),
356
  top_p=float(top_p),
357
  greedy=greedy,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  return answer, thinking
360
 
361
 
362
- THEME = gr.themes.Citrus()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
 
364
- with gr.Blocks(title="Nemotron-Labs-Audex-30B-A3B") as demo:
 
 
 
365
  gr.Markdown(
366
- "# 🎧 Nemotron-Labs-Audex-30B-A3B\n"
367
- "Unified audio-text MoE (30B total / 3B active) for **audio understanding, "
368
- "speech recognition, and speech translation**. Runs the official Hugging Face "
369
- "inference path with compiled `mamba-ssm` + `causal-conv1d` CUDA kernels for "
370
- "correct numerics.\n\n"
371
- "*Non-commercial use only (NVIDIA One-Way Noncommercial License).*"
 
 
 
 
 
372
  )
373
  with gr.Row():
374
  with gr.Column():
375
- audio_in = gr.Audio(
 
 
 
 
 
 
 
 
 
 
376
  type="filepath",
377
- label="Input audio (up to 2 minutes)",
378
  sources=["upload", "microphone"],
379
  )
380
- task = gr.Radio(
381
- choices=list(TASK_PROMPTS.keys()),
382
- value="Transcribe (ASR)",
383
- label="Task",
384
  )
385
- custom_prompt = gr.Textbox(
386
- label="Custom instruction (optional overrides Task)",
387
- placeholder="e.g. Summarize what the speaker is saying.",
388
- lines=2,
389
  )
390
- run_btn = gr.Button("Run", variant="primary")
 
 
391
  with gr.Accordion("Advanced options", open=False):
392
- reasoning = gr.Checkbox(value=False, label="Enable reasoning (<think>) mode")
393
- max_new_tokens = gr.Slider(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  16,
395
  MAX_NEW_TOKENS,
396
- value=256,
397
  step=16,
398
  label="Max new tokens",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  )
400
- temperature = gr.Slider(0.1, 1.5, value=0.7, step=0.05, label="Temperature")
401
- top_p = gr.Slider(0.1, 1.0, value=0.9, step=0.05, label="Top-p")
402
  gr.Markdown(
403
- "ASR and speech translation use greedy decoding. "
404
- "Sampling controls apply to other tasks and custom instructions."
 
 
405
  )
406
  with gr.Column():
407
- answer_out = gr.Textbox(label="Answer", lines=10)
408
- thinking_out = gr.Textbox(label="Reasoning trace (if enabled)", lines=6)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
 
410
- run_btn.click(
411
- run,
412
- inputs=[audio_in, task, custom_prompt, reasoning, max_new_tokens, temperature, top_p],
413
- outputs=[answer_out, thinking_out],
414
- api_name="run",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  )
 
416
 
 
 
 
 
417
  gr.Examples(
418
  examples=[
419
- ["examples/mlk_speech.wav", "Transcribe (ASR)", "", False, 256, 0.7, 0.9],
420
- ["examples/sample_speech.wav", "Transcribe (ASR)", "", False, 256, 0.7, 0.9],
421
- ["examples/mlk_speech.wav", "Describe the audio", "", False, 256, 0.7, 0.9],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  ],
423
- inputs=[audio_in, task, custom_prompt, reasoning, max_new_tokens, temperature, top_p],
424
- outputs=[answer_out, thinking_out],
425
- fn=run,
426
  cache_examples=False,
427
- run_on_click=True,
428
  )
429
 
430
  if __name__ == "__main__":
431
- demo.queue(max_size=8).launch(theme=THEME, ssr_mode=False, mcp_server=True)
 
 
 
 
 
 
 
 
1
  """
2
+ Nemotron-Labs-Audex — unified audio and text demo for the 30B-A3B and 2B models.
3
+
4
+ The 30B-A3B model uses a Mamba2-Transformer Hybrid MoE backbone (30B total,
5
+ 3B active). Its correct numerics require the compiled CUDA fast path from
6
+ `mamba-ssm` and `causal-conv1d`; the pure-PyTorch fallback produces degenerate
7
+ or repeated tokens. The Space reuses its cached Blackwell wheels, while local
8
+ runtimes can build and cache wheels for their CUDA architecture.
 
 
9
  """
10
  import os
11
  os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
12
  os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
13
 
14
  import glob
15
+ import hashlib
16
+ import importlib.util
17
+ import re
18
  import subprocess
19
  import sys
20
  import time
21
+ from collections.abc import Iterator
22
+ from dataclasses import dataclass
23
+ from importlib.metadata import PackageNotFoundError, version
24
  from pathlib import Path
25
+ from threading import Event, Thread
26
+ from types import ModuleType
27
 
28
  # Import spaces FIRST (before torch / any CUDA-touching import) so its
29
  # torch.cuda.* monkey-patch is installed. The kernel build below runs in
30
  # subprocesses, so it does not initialize CUDA in this process.
31
  import spaces # noqa: E402
32
 
33
+
34
+ @dataclass(frozen=True)
35
+ class AudexRuntime:
36
+ model: object
37
+ tokenizer: object
38
+ config: object
39
+ feature_extractor: object
40
+ speech_decoder: object
41
+ cache_implementation: str | None = None
42
+
43
+
44
+ def _max_reasoning_budget(max_new_tokens: int) -> int:
45
+ budget = max(16, (int(max_new_tokens) - 16) * 10 // 11)
46
+ return max(16, budget // 16 * 16)
47
+
48
+
49
+ # Runtime and build configuration
50
  APP_DIR = Path(__file__).resolve().parent
51
+ IS_ZERO_GPU = os.environ.get("SPACES_ZERO_GPU") == "1"
52
+ DEFAULT_WHEELS_DIR = APP_DIR / ("wheels" if IS_ZERO_GPU else ".local/wheels")
53
+ DEFAULT_BUILD_DIR = APP_DIR / ("_wheelout" if IS_ZERO_GPU else ".local/build")
54
+ WHEELS_DIR = Path(os.environ.get("AUDEX_WHEELS_DIR", str(DEFAULT_WHEELS_DIR)))
55
+ BUILD_DIR = Path(os.environ.get("AUDEX_BUILD_DIR", str(DEFAULT_BUILD_DIR)))
56
+ REPO_ID = os.environ.get("SPACE_ID", "L0SG/Nemotron-Labs-Audex")
57
+ CAUSAL_CONV1D_VERSION = "1.6.2.post1"
58
+ MAMBA_SSM_VERSION = "2.3.1"
59
+ CAUSAL_CONV1D_SPEC = f"causal-conv1d=={CAUSAL_CONV1D_VERSION}"
60
+ MAMBA_SSM_SPEC = f"mamba-ssm=={MAMBA_SSM_VERSION}"
61
+ TORCH_ARCH = os.environ.get("AUDEX_TORCH_ARCH", "12.0" if IS_ZERO_GPU else "")
62
+
63
+ # Model configuration
64
+ MODEL_30B_ID = "nvidia/Nemotron-Labs-Audex-30B-A3B"
65
+ MODEL_2B_ID = "nvidia/Nemotron-Labs-Audex-2B"
66
+ MODEL_SUBFOLDER = "checkpoint_folder_full"
67
+ DECODER_SUBFOLDER = "audex_causal_speech_decoder"
68
+ DEFAULT_MODEL_NAME = "Nemotron-Labs-Audex-30B-A3B"
69
+ MODEL_2B_NAME = "Nemotron-Labs-Audex-2B"
70
+
71
+ # Shared generation configuration
72
+ SAMPLE_RATE = 16000
73
+ MAX_AUDIO_DURATION_SECONDS = float(os.environ.get("AUDEX_MAX_AUDIO_SECONDS", "120"))
74
+ MAX_NEW_TOKENS = int(os.environ.get("AUDEX_MAX_NEW_TOKENS", "2048"))
75
+ DEFAULT_MAX_NEW_TOKENS = min(
76
+ int(os.environ.get("AUDEX_DEFAULT_MAX_NEW_TOKENS", "256")),
77
+ MAX_NEW_TOKENS,
78
+ )
79
+ DEFAULT_REASONING_BUDGET = max(
80
+ 0,
81
+ min(
82
+ int(os.environ.get("AUDEX_DEFAULT_REASONING_BUDGET", "0")),
83
+ _max_reasoning_budget(DEFAULT_MAX_NEW_TOKENS),
84
+ ),
85
+ )
86
+ STREAM_CHUNK_SIZE = max(1, int(os.environ.get("AUDEX_STREAM_CHUNK_SIZE", "8")))
87
+ MAX_GPU_DURATION_SECONDS = 60
88
+ MAX_TOKEN_WARNING = (
89
+ "⚠️ Maximum new-token limit reached; this output is incomplete. "
90
+ "Increase Max new tokens and run again."
91
+ )
92
+ TEXT_VOCAB_SIZE = 131072
93
+ TEXT_SEED = 100
94
+ TEXT_SYSTEM_PROMPT = (
95
+ "You are a helpful and harmless assistant.\n\n"
96
+ "You are not allowed to use any tools."
97
+ )
98
+
99
+ # Task names and prompts
100
+ TEXT_TASK = "Text reasoning"
101
+ TTS_TASK = "Text to speech (TTS)"
102
+ S2S_TASK = "Speech to speech (S2S)"
103
+ LEGACY_TASK_PROMPTS = {
104
+ "Describe the audio": "Describe the audio in detail.",
105
+ "Transcribe (ASR)": "Transcribe the speech in the input audio.",
106
+ "Translate speech to English": "Translate the spoken content in the audio to English.",
107
+ "Answer a question about the audio": "Where is the communication likely taking place?",
108
+ }
109
+ LEGACY_GREEDY_TASKS = {"Transcribe (ASR)", "Translate speech to English"}
110
+ MMLU_PRO_EXAMPLE_PROMPT = (
111
+ "Question:\n"
112
+ "Which organelle is primarily responsible for ATP production in eukaryotic cells?\n\n"
113
+ "Answer Choices:\n"
114
+ "(A) Golgi apparatus\n"
115
+ "(B) Mitochondrion\n"
116
+ "(C) Lysosome\n"
117
+ "(D) Endoplasmic reticulum\n\n"
118
+ "Conclude your response with the sentence `The answer is \\boxed{{X}}.`, "
119
+ "in which X is the correct capital letter of your choice."
120
+ )
121
+ S2S_RESPONSE_PROMPT = (
122
+ "SYSTEM & FORMATTING INSTRUCTION: You are Audex, created by NVIDIA based on "
123
+ "the Nemotron-Cascade-2 architecture. You may output your reasoning, followed "
124
+ "by your final response. You may format your reasoning block however you like. "
125
+ "However, your final response must strictly follow these rules: "
126
+ "* Write in plain, unformatted prose like a book or newspaper article. "
127
+ "* Do not use markdown, bullet points, lists, or headers. "
128
+ "* Standard numbers, abbreviations, and symbols are acceptable. "
129
+ "* [CRITICAL] You must press enter after every single sentence, placing each "
130
+ "sentence on its own separate line."
131
+ )
132
+ S2S_TASK_INSTRUCTION = (
133
+ "Use the response prompt above only to control the style of your final answer. "
134
+ "Do not quote, explain, or analyze the response prompt. "
135
+ "Answer the user request in the spoken transcript below. "
136
+ "The final answer must be one short, self-contained sentence of at most "
137
+ "20 words while preserving every requested name, number, and conclusion."
138
+ )
139
+ S2S_UI_PROMPT = f"{S2S_RESPONSE_PROMPT}\n\n{S2S_TASK_INSTRUCTION}"
140
+ TTS_EXAMPLE_TEXT = (
141
+ "Artificial intelligence is helping people understand and create sound in new ways."
142
+ )
143
 
144
+ # Task-specific limits and feature switches
145
+ TTS_MAX_NEW_TOKENS = min(
146
+ int(os.environ.get("AUDEX_TTS_MAX_NEW_TOKENS", "192")),
147
+ MAX_NEW_TOKENS,
148
+ )
149
+ S2S_TEXT_MAX_NEW_TOKENS = int(os.environ.get("AUDEX_S2S_TEXT_MAX_NEW_TOKENS", "2048"))
150
+ S2S_REASONING_BUDGET = int(os.environ.get("AUDEX_S2S_REASONING_BUDGET", "0"))
151
+ S2S_GPU_DURATION_SECONDS = int(os.environ.get("AUDEX_S2S_GPU_DURATION_SECONDS", "120"))
152
+ S2S_SPOKEN_MAX_WORDS = 20
153
+ S2S_TTS_MAX_NEW_TOKENS = int(os.environ.get("AUDEX_S2S_TTS_MAX_NEW_TOKENS", "2400"))
154
+ S2S_TTS_SEGMENT_SILENCE_SECONDS = 0.2
155
+ TTS_STREAMING_PLAYER_ENABLED = (
156
+ os.environ.get("AUDEX_TTS_STREAMING_PLAYER", "false").lower() == "true"
157
+ )
158
+ TASK_SPECS = {
159
+ "Speech recognition (ASR)": {
160
+ "modality": "audio",
161
+ "template": "<sound>\nTranscribe the speech in the input audio.",
162
+ "reasoning": False,
163
+ "temperature": 1.0,
164
+ "top_p": 1.0,
165
+ "greedy": True,
166
+ },
167
+ "Speech translation (AST)": {
168
+ "modality": "audio",
169
+ "template": "<sound>\nTranslate the spoken content in the audio to English.",
170
+ "reasoning": False,
171
+ "temperature": 1.0,
172
+ "top_p": 1.0,
173
+ "greedy": True,
174
+ },
175
+ "Audio description": {
176
+ "modality": "audio",
177
+ "template": "Describe the audio in detail.\n<sound>",
178
+ "reasoning": False,
179
+ "temperature": 0.7,
180
+ "top_p": 0.9,
181
+ "greedy": False,
182
+ },
183
+ "Audio question answering": {
184
+ "modality": "audio",
185
+ "template": "Where is the communication likely taking place?\n<sound>",
186
+ "reasoning": False,
187
+ "temperature": 0.7,
188
+ "top_p": 0.9,
189
+ "greedy": False,
190
+ },
191
+ TEXT_TASK: {
192
+ "modality": "text",
193
+ "template": MMLU_PRO_EXAMPLE_PROMPT,
194
+ "reasoning": True,
195
+ "temperature": 1.0,
196
+ "top_p": 0.95,
197
+ "greedy": False,
198
+ },
199
+ TTS_TASK: {
200
+ "modality": "tts",
201
+ "template": TTS_EXAMPLE_TEXT,
202
+ "reasoning": False,
203
+ "guidance_scale": 2.0,
204
+ "temperature": 0.8,
205
+ "top_p": 1.0,
206
+ "greedy": False,
207
+ },
208
+ S2S_TASK: {
209
+ "modality": "s2s",
210
+ "template": S2S_UI_PROMPT,
211
+ "reasoning": True,
212
+ "reasoning_budget": min(
213
+ S2S_REASONING_BUDGET,
214
+ _max_reasoning_budget(S2S_TEXT_MAX_NEW_TOKENS),
215
+ ),
216
+ "guidance_scale": 1.5,
217
+ "temperature": 1.0,
218
+ "top_p": 0.95,
219
+ "greedy": False,
220
+ "max_new_tokens": S2S_TEXT_MAX_NEW_TOKENS,
221
+ "max_new_tokens_limit": S2S_TEXT_MAX_NEW_TOKENS,
222
+ },
223
+ }
224
 
225
 
226
+ def _run(
227
+ cmd: list[str],
228
+ env: dict[str, str] | None = None,
229
+ timeout: int = 3000,
230
+ ) -> subprocess.CompletedProcess[str]:
231
  print(f"[build] $ {' '.join(cmd)}", flush=True)
232
  p = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=timeout)
233
  if p.stdout:
 
242
  return p
243
 
244
 
245
+ def _installed_version(package: str) -> str | None:
246
  try:
247
+ return version(package)
248
+ except PackageNotFoundError:
249
+ return None
250
+
251
+
252
+ def _kernels_ready() -> bool:
253
+ if (
254
+ _installed_version("causal-conv1d") != CAUSAL_CONV1D_VERSION
255
+ or _installed_version("mamba-ssm") != MAMBA_SSM_VERSION
256
+ ):
257
  return False
258
+ try:
259
+ from causal_conv1d import causal_conv1d_fn, causal_conv1d_update
260
+ from mamba_ssm.ops.triton.selective_state_update import selective_state_update
261
+ from mamba_ssm.ops.triton.ssd_combined import (
262
+ mamba_chunk_scan_combined,
263
+ mamba_split_conv1d_scan_combined,
264
+ )
265
+ except ImportError:
266
+ return False
267
+ return all(
268
+ kernel is not None
269
+ for kernel in (
270
+ causal_conv1d_fn,
271
+ causal_conv1d_update,
272
+ selective_state_update,
273
+ mamba_chunk_scan_combined,
274
+ mamba_split_conv1d_scan_combined,
275
+ )
276
+ )
277
 
278
 
279
+ def _pip(*args: str, env: dict[str, str] | None = None) -> None:
280
+ _run([sys.executable, "-m", "pip", *args], env=env)
281
 
282
 
283
+ def _detect_torch_arch() -> str:
284
+ if TORCH_ARCH:
285
+ return TORCH_ARCH
286
+ result = subprocess.run(
287
+ [
288
+ sys.executable,
289
+ "-c",
290
+ (
291
+ "import torch; "
292
+ "assert torch.cuda.is_available(), 'CUDA is not available'; "
293
+ "major, minor = torch.cuda.get_device_capability(); "
294
+ "print(f'{major}.{minor}')"
295
+ ),
296
+ ],
297
+ capture_output=True,
298
+ text=True,
299
+ )
300
+ if result.returncode != 0:
301
+ raise RuntimeError(
302
+ "Unable to detect the GPU architecture. Set AUDEX_TORCH_ARCH "
303
+ f"explicitly. Details: {result.stderr.strip()}"
304
+ )
305
+ return result.stdout.strip()
306
+
307
+
308
+ def _detect_cuda_home() -> str:
309
+ if cuda_home := os.environ.get("CUDA_HOME"):
310
+ if (Path(cuda_home) / "bin/nvcc").is_file():
311
+ return cuda_home
312
+ raise RuntimeError(f"CUDA_HOME does not contain bin/nvcc: {cuda_home}")
313
+ if IS_ZERO_GPU:
314
+ return "/cuda-image/usr/local/cuda-13.0"
315
+
316
+ result = subprocess.run(
317
+ [
318
+ sys.executable,
319
+ "-c",
320
+ "from torch.utils.cpp_extension import CUDA_HOME; print(CUDA_HOME or '')",
321
+ ],
322
+ capture_output=True,
323
+ text=True,
324
+ )
325
+ candidates = [result.stdout.strip(), "/usr/local/cuda"]
326
+ for candidate in candidates:
327
+ if candidate and (Path(candidate) / "bin/nvcc").is_file():
328
+ return candidate
329
+ raise RuntimeError(
330
+ "A CUDA toolkit with nvcc is required to build the 30B Mamba kernels. "
331
+ "Install the toolkit or set CUDA_HOME."
332
+ )
333
+
334
+
335
+ def _build_env() -> dict[str, str]:
336
  env = dict(os.environ)
337
  env["MAMBA_FORCE_BUILD"] = "TRUE"
338
  env["CAUSAL_CONV1D_FORCE_BUILD"] = "TRUE"
339
+ env["TORCH_CUDA_ARCH_LIST"] = _detect_torch_arch()
340
  env["MAX_JOBS"] = env.get("MAX_JOBS", "4")
341
+ cuda_home = _detect_cuda_home()
 
342
  env["CUDA_HOME"] = cuda_home
343
  env["PATH"] = f"{cuda_home}/bin:" + env.get("PATH", "")
344
  return env
345
 
346
 
347
+ def ensure_kernels() -> None:
348
  """Install compiled causal-conv1d + mamba-ssm. Use cached wheels if present,
349
  otherwise build from source and cache the wheels back into the repo."""
350
+ if _kernels_ready():
351
  print("[build] kernels already importable", flush=True)
352
  return
353
 
354
+ WHEELS_DIR.mkdir(parents=True, exist_ok=True)
355
+ cached_causal = sorted(
356
+ glob.glob(str(WHEELS_DIR / f"causal_conv1d-{CAUSAL_CONV1D_VERSION}-*.whl"))
357
+ )
358
+ cached_mamba = sorted(
359
+ glob.glob(str(WHEELS_DIR / f"mamba_ssm-{MAMBA_SSM_VERSION}-*.whl"))
360
+ )
361
+ if cached_causal and cached_mamba:
362
+ cached = [cached_causal[-1], cached_mamba[-1]]
363
  print(f"[build] installing cached wheels: {cached}", flush=True)
364
  try:
365
  _pip("install", "--no-deps", "--no-build-isolation", *cached)
366
+ if _kernels_ready():
367
  print("[build] cached wheels installed OK", flush=True)
368
  return
369
  print("[build] cached wheels imported incompletely; rebuilding", flush=True)
 
371
  print(f"[build] cached wheel install failed ({e!r}); rebuilding", flush=True)
372
 
373
  env = _build_env()
374
+ BUILD_DIR.mkdir(parents=True, exist_ok=True)
 
375
 
376
  # Build wheels (no-build-isolation => uses the preinstalled torch).
377
  t0 = time.time()
378
  print("[build] building causal-conv1d + mamba-ssm from source (this can take ~20 min)", flush=True)
379
+ _pip(
380
+ "wheel",
381
+ "--no-build-isolation",
382
+ "--no-deps",
383
+ "-w",
384
+ str(BUILD_DIR),
385
+ CAUSAL_CONV1D_SPEC,
386
+ env=env,
387
+ )
388
  # mamba-ssm needs causal-conv1d importable during its own build; install it first.
389
+ built_causal = sorted(
390
+ glob.glob(str(BUILD_DIR / f"causal_conv1d-{CAUSAL_CONV1D_VERSION}-*.whl"))
391
+ )
392
+ if not built_causal:
393
+ raise RuntimeError(f"build produced no wheel for {CAUSAL_CONV1D_SPEC}")
394
+ _pip("install", "--no-deps", built_causal[-1])
395
+ _pip(
396
+ "wheel",
397
+ "--no-build-isolation",
398
+ "--no-deps",
399
+ "-w",
400
+ str(BUILD_DIR),
401
+ MAMBA_SSM_SPEC,
402
+ env=env,
403
+ )
404
  print(f"[build] source build finished in {time.time()-t0:.0f}s", flush=True)
405
 
406
+ built_mamba = sorted(
407
+ glob.glob(str(BUILD_DIR / f"mamba_ssm-{MAMBA_SSM_VERSION}-*.whl"))
408
+ )
409
+ if not built_mamba:
410
+ raise RuntimeError(f"build produced no wheel for {MAMBA_SSM_SPEC}")
411
+ all_wheels = [built_causal[-1], built_mamba[-1]]
412
  _pip("install", "--no-deps", *all_wheels)
413
 
414
+ if not _kernels_ready():
415
  raise RuntimeError("kernel build completed but imports still fail")
416
  print("[build] kernels built + installed OK", flush=True)
417
 
 
422
  if not dst.exists():
423
  import shutil
424
  shutil.copy(w, dst)
425
+ if not IS_ZERO_GPU:
426
+ print(f"[build] kernels cached locally in {WHEELS_DIR}", flush=True)
427
+ return
428
+
429
  from huggingface_hub import HfApi
430
  tok = os.environ.get("HF_TOKEN")
431
  if tok:
 
448
  # ---- Now safe to bring in torch / model ----
449
  import torch
450
  import gradio as gr
451
+ import numpy as np
452
  from huggingface_hub import snapshot_download
453
+ from transformers import (
454
+ AutoConfig,
455
+ AutoFeatureExtractor,
456
+ AutoModelForCausalLM,
457
+ AutoTokenizer,
458
+ )
459
 
460
  from audio_utils import (
461
  IM_END_TOKEN,
 
467
  resolve_audio_preprocessor_path,
468
  split_thinking,
469
  )
470
+ from reasoning_utils import ReasoningBudgetLogitsProcessor
471
+ from tts_player import TTS_PLAYER_CSS, TTS_PLAYER_JS, TTS_PLAYER_TEMPLATE, player_value
472
+ from tts_utils import (
473
+ EventStoppingCriteria,
474
+ TokenIdStreamer,
475
+ encode_pcm_chunk,
476
+ load_speech_decoder,
477
+ stream_tts,
478
+ write_wav,
 
479
  )
 
 
480
 
 
 
 
 
 
 
481
 
482
+ def _load_native_module(model_path: str) -> ModuleType:
483
+ module_path = Path(model_path).resolve() / "modeling_nemotron_h_audio_native.py"
484
+ if not module_path.is_file():
485
+ raise FileNotFoundError(
486
+ f"Native Audex model adapter not found in checkpoint: {module_path}"
487
+ )
488
+
489
+ path_hash = hashlib.sha256(str(module_path).encode()).hexdigest()[:12]
490
+ module_name = f"audex_native_{path_hash}"
491
+ if module_name in sys.modules:
492
+ return sys.modules[module_name]
493
 
494
+ spec = importlib.util.spec_from_file_location(module_name, module_path)
495
+ if spec is None or spec.loader is None:
496
+ raise ImportError(f"Unable to load native Audex model adapter: {module_path}")
497
+ module = importlib.util.module_from_spec(spec)
498
+ sys.modules[module_name] = module
499
+ spec.loader.exec_module(module)
500
+ return module
501
 
502
 
503
  def _find_fast_path_flag() -> bool | None:
 
507
  return None
508
 
509
 
510
+ def _resolve_model_paths(
511
+ model_id: str,
512
+ model_name: str,
513
+ model_path_env: str,
514
+ decoder_path_env: str,
515
+ ) -> tuple[str, str]:
516
+ model_path_override = os.environ.get(model_path_env)
517
+ if model_path_override:
518
+ model_path = Path(model_path_override).expanduser().resolve()
519
+ if not model_path.is_dir():
520
+ raise FileNotFoundError(f"{model_path_env} does not exist: {model_path}")
521
+ model_root = model_path.parent
522
+ print(f"[load] using local {model_name} checkpoint at {model_path}", flush=True)
523
+ else:
524
+ print(f"[load] downloading {model_name} checkpoint…", flush=True)
525
+ model_root = Path(
526
+ snapshot_download(
527
+ model_id,
528
+ allow_patterns=[
529
+ f"{MODEL_SUBFOLDER}/*",
530
+ f"{DECODER_SUBFOLDER}/*",
531
+ ],
532
+ token=os.environ.get("HF_TOKEN"),
533
+ )
534
+ )
535
+ model_path = model_root / MODEL_SUBFOLDER
536
+
537
+ decoder_path = (
538
+ Path(
539
+ os.environ.get(
540
+ decoder_path_env,
541
+ str(model_root / DECODER_SUBFOLDER),
542
+ )
543
+ )
544
+ .expanduser()
545
+ .resolve()
546
+ )
547
+ print(f"[load] {model_name} checkpoint at {model_path}", flush=True)
548
+ print(f"[load] {model_name} speech decoder at {decoder_path}", flush=True)
549
+ return str(model_path), str(decoder_path)
550
+
551
+
552
+ def _load_30b_runtime() -> AudexRuntime:
553
+ model_path, decoder_path = _resolve_model_paths(
554
+ MODEL_30B_ID,
555
+ DEFAULT_MODEL_NAME,
556
+ "AUDEX_MODEL_PATH",
557
+ "AUDEX_DECODER_PATH",
558
+ )
559
+ native_module = _load_native_module(model_path)
560
+ config_class = native_module.NemotronHAudexConfig
561
+ model_class = native_module.NemotronHAudexForConditionalGeneration
562
+ tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
563
+ chat_template_path = Path(model_path) / "chat_template.jinja"
564
+ if chat_template_path.is_file():
565
+ tokenizer.chat_template = chat_template_path.read_text()
566
+ config = config_class.from_pretrained(model_path)
567
+ feature_extractor = AutoFeatureExtractor.from_pretrained(
568
+ resolve_audio_preprocessor_path(model_path, config)
569
+ )
570
+ print(f"[load] {DEFAULT_MODEL_NAME} tokenizer/config/feature extractor loaded", flush=True)
571
+
572
+ model_load_kwargs = {
573
+ "pretrained_model_name_or_path": model_path,
574
+ "dtype": torch.bfloat16,
575
+ "low_cpu_mem_usage": True,
576
+ "output_loading_info": True,
577
+ }
578
+ if not IS_ZERO_GPU:
579
+ model_load_kwargs["device_map"] = {"": "cuda:0"}
580
+ model, loading_info = model_class.from_pretrained(**model_load_kwargs)
581
+ invalid_keys = {
582
+ key: loading_info[key]
583
+ for key in ("missing_keys", "unexpected_keys", "mismatched_keys")
584
+ if loading_info[key]
585
+ }
586
+ if invalid_keys:
587
+ raise RuntimeError(f"Native checkpoint loading was incomplete: {invalid_keys}")
588
+ model = model.eval()
589
+ if IS_ZERO_GPU:
590
+ model = model.to("cuda")
591
+ runtime_name = "packed by ZeroGPU" if IS_ZERO_GPU else "local CUDA"
592
+ print(f"[load] {DEFAULT_MODEL_NAME} loaded on {runtime_name}", flush=True)
593
+
594
+ if not _find_fast_path_flag():
595
+ raise RuntimeError(
596
+ "Transformers loaded without the native Mamba2-Transformer Hybrid fast path."
597
+ )
598
+ print("[load] native Mamba2-Transformer Hybrid fast path active", flush=True)
599
+ speech_decoder = load_speech_decoder(decoder_path)
600
+ print(f"[load] {DEFAULT_MODEL_NAME} speech decoder loaded", flush=True)
601
+ return AudexRuntime(
602
+ model=model,
603
+ tokenizer=tokenizer,
604
+ config=config,
605
+ feature_extractor=feature_extractor,
606
+ speech_decoder=speech_decoder,
607
+ )
608
+
609
+
610
+ def _load_2b_runtime() -> AudexRuntime:
611
+ model_path, decoder_path = _resolve_model_paths(
612
+ MODEL_2B_ID,
613
+ MODEL_2B_NAME,
614
+ "AUDEX_2B_MODEL_PATH",
615
+ "AUDEX_2B_DECODER_PATH",
616
+ )
617
+ tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
618
+ chat_template_path = Path(model_path) / "chat_template.jinja"
619
+ if chat_template_path.is_file():
620
+ tokenizer.chat_template = chat_template_path.read_text()
621
+ config = AutoConfig.from_pretrained(model_path, trust_remote_code=True)
622
+ feature_extractor = AutoFeatureExtractor.from_pretrained(
623
+ resolve_audio_preprocessor_path(model_path, config)
624
+ )
625
+ model_load_kwargs = {
626
+ "pretrained_model_name_or_path": model_path,
627
+ "trust_remote_code": True,
628
+ "dtype": torch.bfloat16,
629
+ "low_cpu_mem_usage": True,
630
+ }
631
+ if not IS_ZERO_GPU:
632
+ model_load_kwargs["device_map"] = {"": "cuda:0"}
633
+ model = AutoModelForCausalLM.from_pretrained(**model_load_kwargs).eval()
634
+ if IS_ZERO_GPU:
635
+ model = model.to("cuda")
636
+ runtime_name = "packed by ZeroGPU" if IS_ZERO_GPU else "local CUDA"
637
+ print(f"[load] {MODEL_2B_NAME} loaded on {runtime_name}", flush=True)
638
+ speech_decoder = load_speech_decoder(decoder_path)
639
+ print(f"[load] {MODEL_2B_NAME} speech decoder loaded", flush=True)
640
+ return AudexRuntime(
641
+ model=model,
642
+ tokenizer=tokenizer,
643
+ config=config,
644
+ feature_extractor=feature_extractor,
645
+ speech_decoder=speech_decoder,
646
+ cache_implementation="static",
647
+ )
648
+
649
+
650
+ model_runtimes = {
651
+ DEFAULT_MODEL_NAME: _load_30b_runtime(),
652
+ MODEL_2B_NAME: _load_2b_runtime(),
653
  }
654
+
655
+
656
+ def _blocked_output_token_ids(runtime: AudexRuntime) -> list[int]:
657
+ sound_token_ids = {
658
+ int(getattr(runtime.config, name))
659
+ for name in ("sound_token_id", "sound_start_token_id", "sound_end_token_id")
660
+ if getattr(runtime.config, name, None) is not None
661
+ }
662
+ return sorted(
663
+ set(range(TEXT_VOCAB_SIZE, int(runtime.config.vocab_size))) | sound_token_ids
664
+ )
665
+
666
+
667
+ def _get_runtime(model_name: str) -> AudexRuntime:
668
+ try:
669
+ return model_runtimes[model_name]
670
+ except KeyError as error:
671
+ raise gr.Error(f"Unknown model: {model_name}") from error
672
 
673
 
674
  def _probe_audio_duration(audio: str | None) -> float:
 
703
  return MAX_GPU_DURATION_SECONDS
704
 
705
 
706
+ def _stream_fields(response: str, reasoning: bool) -> tuple[str, str]:
707
+ visible = response
708
+ for marker in (IM_END_TOKEN, "<|end_of_text|>", "</s>"):
709
+ visible = visible.split(marker, 1)[0]
710
+
711
+ if not reasoning:
712
+ _, answer = split_thinking(visible)
713
+ return answer, ""
714
+ if "</think>" not in visible:
715
+ return "", visible.removeprefix("<think>").strip()
716
+
717
+ thinking, answer = visible.rsplit("</think>", 1)
718
+ return answer.strip(), thinking.removeprefix("<think>").strip()
719
+
720
+
721
+ def _stream_model_generate(
722
+ runtime: AudexRuntime,
723
+ generation_kwargs: dict[str, object],
724
+ reasoning: bool,
725
+ reasoning_budget: int | None = None,
726
+ ) -> Iterator[tuple[str, str]]:
727
+ model_kwargs = dict(generation_kwargs)
728
+ eos_token_id = model_kwargs.get("eos_token_id")
729
+ eos_token_ids = {eos_token_id} if isinstance(eos_token_id, int) else set(eos_token_id or [])
730
+ logits_processors = list(model_kwargs.pop("logits_processor", []))
731
+ if reasoning and reasoning_budget is not None and reasoning_budget > 0:
732
+ prompt_length = int(model_kwargs["input_ids"].shape[-1])
733
+ logits_processors.append(
734
+ ReasoningBudgetLogitsProcessor(
735
+ runtime.tokenizer,
736
+ prompt_length=prompt_length,
737
+ reasoning_budget=int(reasoning_budget),
738
+ )
739
+ )
740
+ streamer = TokenIdStreamer()
741
+ cancel_event = Event()
742
+ generation_error: list[BaseException] = []
743
+ generated_tokens = 0
744
+ buffered_token_ids: list[int] = []
745
+ response = ""
746
+ last_fields: tuple[str, str] | None = None
747
+ generation_finished = False
748
+
749
+ def generate() -> None:
750
+ try:
751
+ stopping_criteria = list(model_kwargs.pop("stopping_criteria", []))
752
+ cache_kwargs = (
753
+ {
754
+ "cache_implementation": runtime.cache_implementation,
755
+ "disable_compile": True,
756
+ }
757
+ if runtime.cache_implementation
758
+ else {}
759
+ )
760
+ with torch.inference_mode():
761
+ runtime.model.generate(
762
+ **model_kwargs,
763
+ **cache_kwargs,
764
+ logits_processor=logits_processors,
765
+ stopping_criteria=[
766
+ *stopping_criteria,
767
+ EventStoppingCriteria(cancel_event),
768
+ ],
769
+ streamer=streamer,
770
+ use_cache=True,
771
+ )
772
+ except BaseException as error:
773
+ generation_error.append(error)
774
+ streamer.fail(error)
775
+
776
+ def flush() -> tuple[str, str]:
777
+ nonlocal response
778
+ response += runtime.tokenizer.decode(
779
+ buffered_token_ids,
780
+ skip_special_tokens=False,
781
+ clean_up_tokenization_spaces=False,
782
+ )
783
+ buffered_token_ids.clear()
784
+ return _stream_fields(response, reasoning)
785
+
786
+ thread = Thread(target=generate, daemon=True)
787
+ thread.start()
788
+ try:
789
+ for token_id in streamer:
790
+ generated_tokens += 1
791
+ buffered_token_ids.append(token_id)
792
+ if token_id in eos_token_ids:
793
+ generation_finished = True
794
+ if len(buffered_token_ids) < STREAM_CHUNK_SIZE and not generation_finished:
795
+ continue
796
+
797
+ fields = flush()
798
+ if fields != last_fields:
799
+ yield fields
800
+ last_fields = fields
801
+ if generation_finished:
802
+ break
803
+
804
+ if generation_error:
805
+ raise generation_error[0]
806
+ if buffered_token_ids:
807
+ fields = flush()
808
+ if fields != last_fields:
809
+ yield fields
810
+ last_fields = fields
811
+
812
+ hit_max_tokens = (
813
+ not generation_finished
814
+ and generated_tokens >= int(model_kwargs["max_new_tokens"])
815
+ )
816
+ final_fields = _stream_fields(response, reasoning)
817
+ if reasoning and generation_finished and "</think>" not in response:
818
+ final_fields = _stream_fields(response, reasoning=False)
819
+ if hit_max_tokens:
820
+ answer, thinking = final_fields
821
+ answer = f"{answer}\n\n{MAX_TOKEN_WARNING}".strip()
822
+ final_fields = answer, thinking
823
+ if final_fields != last_fields:
824
+ yield final_fields
825
+ print(
826
+ f"[gpu] generated_tokens={generated_tokens} "
827
+ f"reasoning_closed={'</think>' in response} "
828
+ f"hit_max_tokens={hit_max_tokens}",
829
+ flush=True,
830
+ )
831
+ finally:
832
+ cancel_event.set()
833
+ thread.join(timeout=10)
834
+
835
+
836
  def _generate(
837
+ runtime: AudexRuntime,
838
  audio: str,
839
  prompt: str,
840
  reasoning: bool,
 
842
  temperature: float,
843
  top_p: float,
844
  greedy: bool,
845
+ reasoning_budget: int | None = None,
846
+ ) -> Iterator[tuple[str, str]]:
847
  started_at = time.perf_counter()
848
+ if runtime is model_runtimes[DEFAULT_MODEL_NAME]:
849
+ print(f"[gpu] is_fast_path_available={_find_fast_path_flag()}", flush=True)
850
 
851
  wav, sr = load_audio(audio, target_sr=SAMPLE_RATE)
852
  audio_duration = wav.shape[-1] / sr
 
857
  )
858
  if max_new_tokens > MAX_NEW_TOKENS:
859
  raise gr.Error(f"This demo supports up to {MAX_NEW_TOKENS} output tokens.")
860
+ if (
861
+ reasoning
862
+ and reasoning_budget is not None
863
+ and reasoning_budget > 0
864
+ and (
865
+ reasoning_budget >= max_new_tokens
866
+ or reasoning_budget > _max_reasoning_budget(max_new_tokens)
867
+ )
868
+ ):
869
+ raise gr.Error(
870
+ "Reasoning budget is too high for max new tokens after reserving "
871
+ "the newline grace window and final answer."
872
+ )
873
 
874
  input_features = extract_whisper_features(
875
+ runtime.feature_extractor,
876
+ wav,
877
+ sample_rate=sr,
878
+ clip_duration=float(getattr(runtime.config, "sound_clip_duration", 30.0)),
879
+ )
880
+ num_embeddings = input_features.shape[0] * int(
881
+ getattr(runtime.config, "sound_embedding_size", 750)
882
  )
 
883
  formatted = build_prompt_template(prompt.strip(), reasoning=bool(reasoning),
884
  prompt_repitition="none")
885
  expanded = expand_sound_placeholder(formatted, num_embeddings)
886
+ tok = runtime.tokenizer(expanded, return_tensors="pt", add_special_tokens=False)
887
  input_ids = tok.input_ids.to("cuda")
888
  attention_mask = (tok.attention_mask if "attention_mask" in tok
889
  else build_attention_mask(input_ids)).to("cuda")
890
  input_features = input_features.to("cuda")
891
 
892
+ eos_token_id = runtime.tokenizer.convert_tokens_to_ids(IM_END_TOKEN)
893
+ if eos_token_id is None or eos_token_id == runtime.tokenizer.unk_token_id:
894
+ eos_token_id = getattr(runtime.config, "eos_token_id", None)
895
 
896
  temperature = max(float(temperature), 1e-4)
897
  top_p = float(top_p)
 
899
  gen_kwargs = dict(
900
  do_sample=do_sample,
901
  eos_token_id=eos_token_id,
902
+ pad_token_id=runtime.tokenizer.pad_token_id
903
+ or getattr(runtime.config, "pad_token_id", 0),
904
  max_new_tokens=int(max_new_tokens),
905
+ suppress_tokens=_blocked_output_token_ids(runtime),
906
  )
907
+ # The official top-k value is 0, which means no top-k filter in Transformers.
908
  if do_sample:
909
  gen_kwargs["temperature"] = temperature
910
  if top_p > 0.0:
911
  gen_kwargs["top_p"] = top_p
912
 
913
+ torch.manual_seed(0)
914
+ torch.cuda.manual_seed_all(0)
915
+ generation_kwargs = {
916
+ **gen_kwargs,
917
+ "input_ids": input_ids,
918
+ "attention_mask": attention_mask,
919
+ "input_features": input_features,
920
+ }
921
+ yield from _stream_model_generate(
922
+ runtime,
923
+ generation_kwargs,
924
+ reasoning,
925
+ reasoning_budget,
926
+ )
927
  print(
928
  f"[gpu] audio_seconds={audio_duration:.1f} greedy={greedy} "
929
  f"elapsed_seconds={time.perf_counter() - started_at:.1f}",
930
  flush=True,
931
  )
 
932
 
933
 
934
  @spaces.GPU(duration=_estimate, size="xlarge")
 
945
 
946
  Predefined ASR and translation tasks use the model's recommended greedy
947
  decoding. Other tasks and custom instructions use the sampling controls.
948
+ Input duration and output length are limited by the current runtime.
949
 
950
  Returns:
951
  The final answer and, when enabled, the reasoning trace.
 
954
  return "Please provide an audio input.", ""
955
 
956
  custom_instruction = custom_prompt.strip() if custom_prompt else ""
957
+ prompt = custom_instruction or LEGACY_TASK_PROMPTS.get(
958
+ task,
959
+ LEGACY_TASK_PROMPTS["Describe the audio"],
960
+ )
961
+ greedy = not custom_instruction and task in LEGACY_GREEDY_TASKS
962
+ answer, thinking = "", ""
963
+ for answer, thinking in _generate(
964
+ model_runtimes[DEFAULT_MODEL_NAME],
965
  audio,
966
  prompt,
967
  reasoning,
 
969
  temperature=float(temperature),
970
  top_p=float(top_p),
971
  greedy=greedy,
972
+ ):
973
+ pass
974
+ return answer, thinking
975
+
976
+
977
+ def _estimate_text(
978
+ prompt: str,
979
+ reasoning: bool,
980
+ max_new_tokens: int,
981
+ temperature: float,
982
+ top_p: float,
983
+ *args: object,
984
+ **kwargs: object,
985
+ ) -> int:
986
+ if not prompt.strip() or int(max_new_tokens) > MAX_NEW_TOKENS:
987
+ return 10
988
+ return MAX_GPU_DURATION_SECONDS
989
+
990
+
991
+ def _generate_text(
992
+ runtime: AudexRuntime,
993
+ prompt: str,
994
+ reasoning: bool,
995
+ max_new_tokens: int,
996
+ temperature: float,
997
+ top_p: float,
998
+ reasoning_budget: int | None = None,
999
+ seed: int = TEXT_SEED,
1000
+ ) -> Iterator[tuple[str, str]]:
1001
+ """Run text-only inference with the official Audex chat template."""
1002
+ if not prompt.strip():
1003
+ yield "Please provide a text prompt.", ""
1004
+ return
1005
+ if max_new_tokens > MAX_NEW_TOKENS:
1006
+ raise gr.Error(f"This demo supports up to {MAX_NEW_TOKENS} output tokens.")
1007
+ if (
1008
+ reasoning
1009
+ and reasoning_budget is not None
1010
+ and reasoning_budget > 0
1011
+ and (
1012
+ reasoning_budget >= max_new_tokens
1013
+ or reasoning_budget > _max_reasoning_budget(max_new_tokens)
1014
+ )
1015
+ ):
1016
+ raise gr.Error(
1017
+ "Reasoning budget is too high for max new tokens after reserving "
1018
+ "the newline grace window and final answer."
1019
+ )
1020
+
1021
+ formatted = runtime.tokenizer.apply_chat_template(
1022
+ [
1023
+ {"role": "system", "content": TEXT_SYSTEM_PROMPT},
1024
+ {"role": "user", "content": prompt.strip()},
1025
+ ],
1026
+ tokenize=False,
1027
+ add_generation_prompt=True,
1028
+ enable_thinking=reasoning,
1029
+ )
1030
+ tok = runtime.tokenizer(formatted, return_tensors="pt", add_special_tokens=False)
1031
+ input_ids = tok.input_ids.to("cuda")
1032
+ attention_mask = (
1033
+ tok.attention_mask if "attention_mask" in tok else build_attention_mask(input_ids)
1034
+ ).to("cuda")
1035
+
1036
+ temperature = max(float(temperature), 1e-4)
1037
+ top_p = float(top_p)
1038
+ do_sample = (temperature != 1.0) or (0.0 < top_p < 1.0)
1039
+ gen_kwargs = {
1040
+ "do_sample": do_sample,
1041
+ "eos_token_id": runtime.tokenizer.convert_tokens_to_ids(IM_END_TOKEN),
1042
+ "pad_token_id": runtime.tokenizer.pad_token_id
1043
+ or getattr(runtime.config, "pad_token_id", 0),
1044
+ "max_new_tokens": int(max_new_tokens),
1045
+ "suppress_tokens": _blocked_output_token_ids(runtime),
1046
+ }
1047
+ if do_sample:
1048
+ gen_kwargs["temperature"] = temperature
1049
+ if top_p > 0.0:
1050
+ gen_kwargs["top_p"] = top_p
1051
+
1052
+ started_at = time.perf_counter()
1053
+ torch.manual_seed(seed)
1054
+ torch.cuda.manual_seed_all(seed)
1055
+ generation_kwargs = {
1056
+ **gen_kwargs,
1057
+ "input_ids": input_ids,
1058
+ "attention_mask": attention_mask,
1059
+ }
1060
+ yield from _stream_model_generate(
1061
+ runtime,
1062
+ generation_kwargs,
1063
+ reasoning,
1064
+ reasoning_budget,
1065
  )
1066
+ print(
1067
+ f"[gpu] text_input_tokens={input_ids.shape[-1]} "
1068
+ f"elapsed_seconds={time.perf_counter() - started_at:.1f}",
1069
+ flush=True,
1070
+ )
1071
+
1072
+
1073
+ def _generate_tts(
1074
+ runtime: AudexRuntime,
1075
+ text: str,
1076
+ max_new_tokens: int,
1077
+ temperature: float,
1078
+ top_p: float,
1079
+ *,
1080
+ top_k: int = 0,
1081
+ guidance_scale: float = 2.0,
1082
+ token_limit: int | None = None,
1083
+ segment_sentences: bool = False,
1084
+ ) -> Iterator[tuple[str, str | None, dict[str, object]]]:
1085
+ text = text.strip()
1086
+ if not text:
1087
+ yield "Please provide text to synthesize.", None, player_value(0, reset=True)
1088
+ return
1089
+ max_tokens = TTS_MAX_NEW_TOKENS if token_limit is None else token_limit
1090
+ if max_new_tokens > max_tokens:
1091
+ raise gr.Error(f"TTS supports up to {max_tokens} speech tokens.")
1092
+
1093
+ segments = _split_sentence_segments(text) if segment_sentences else [text]
1094
+ if not segments:
1095
+ raise gr.Error("Text response had no final answer for TTS.")
1096
+
1097
+ sequence = 0
1098
+ chunks: list[np.ndarray] = []
1099
+ first_token_seconds: float | None = None
1100
+ total_tokens = 0
1101
+ request_tag = hashlib.sha256(text.encode()).hexdigest()[:8]
1102
+ started_at = time.perf_counter()
1103
+ torch.cuda.reset_peak_memory_stats()
1104
+ print(f"[gpu] tts_request={request_tag} started", flush=True)
1105
+ initial_player = (
1106
+ player_value(sequence, reset=True) if TTS_STREAMING_PLAYER_ENABLED else gr.skip()
1107
+ )
1108
+ yield "Generating speech tokens…", None, initial_player
1109
+
1110
+ try:
1111
+ for segment_index, segment in enumerate(segments, start=1):
1112
+ segment_tokens = 0
1113
+ for event in stream_tts(
1114
+ model=runtime.model,
1115
+ tokenizer=runtime.tokenizer,
1116
+ decoder=runtime.speech_decoder,
1117
+ text=segment,
1118
+ max_new_tokens=int(max_new_tokens),
1119
+ temperature=float(temperature),
1120
+ top_p=float(top_p),
1121
+ top_k=top_k,
1122
+ guidance_scale=guidance_scale,
1123
+ ):
1124
+ segment_tokens = event.token_count
1125
+ if event.token_count and first_token_seconds is None:
1126
+ first_token_seconds = time.perf_counter() - started_at
1127
+ if event.pcm is not None:
1128
+ chunks.append(event.pcm)
1129
+ sequence += 1
1130
+ player_update = (
1131
+ player_value(
1132
+ sequence,
1133
+ pcm=encode_pcm_chunk(event.pcm),
1134
+ token_count=total_tokens + event.token_count,
1135
+ )
1136
+ if TTS_STREAMING_PLAYER_ENABLED
1137
+ else gr.skip()
1138
+ )
1139
+ yield (
1140
+ f"Generating speech segment {segment_index}/{len(segments)} "
1141
+ f"· {total_tokens + event.token_count} tokens",
1142
+ None,
1143
+ player_update,
1144
+ )
1145
+
1146
+ total_tokens += segment_tokens
1147
+ if segment_index < len(segments):
1148
+ chunks.append(
1149
+ np.zeros(
1150
+ round(SAMPLE_RATE * S2S_TTS_SEGMENT_SILENCE_SECONDS),
1151
+ dtype=np.float32,
1152
+ )
1153
+ )
1154
+
1155
+ if not chunks:
1156
+ raise RuntimeError("TTS completed without producing audio")
1157
+ waveform = np.concatenate(chunks)
1158
+ wav_path = write_wav(waveform)
1159
+ sequence += 1
1160
+ elapsed_seconds = time.perf_counter() - started_at
1161
+ peak_gib = torch.cuda.max_memory_allocated() / (1024**3)
1162
+ token_rate = total_tokens / max(elapsed_seconds, 1e-6)
1163
+ print(
1164
+ f"[gpu] tts_request={request_tag} tts_tokens={total_tokens} "
1165
+ f"ttfc_seconds={first_token_seconds:.2f} "
1166
+ f"tokens_per_second={token_rate:.2f} audio_seconds={waveform.size / SAMPLE_RATE:.2f} "
1167
+ f"elapsed_seconds={elapsed_seconds:.2f} peak_memory_gib={peak_gib:.2f}",
1168
+ flush=True,
1169
+ )
1170
+ yield (
1171
+ f"Complete · {waveform.size / SAMPLE_RATE:.1f}s audio",
1172
+ wav_path,
1173
+ (
1174
+ player_value(sequence, token_count=total_tokens, done=True)
1175
+ if TTS_STREAMING_PLAYER_ENABLED
1176
+ else gr.skip()
1177
+ ),
1178
+ )
1179
+ except GeneratorExit:
1180
+ print(f"[gpu] tts_request={request_tag} cancelled", flush=True)
1181
+ raise
1182
+
1183
+
1184
+ def _split_sentence_segments(text: str) -> list[str]:
1185
+ return [
1186
+ part.strip()
1187
+ for line in text.splitlines()
1188
+ for part in re.findall(r"[^.!?]+[.!?]+[\"')\]]*|[^.!?]+$", line.strip())
1189
+ if part.strip()
1190
+ ]
1191
+
1192
+
1193
+ def _clean_transcription(text: str) -> str:
1194
+ first_quote = text.find("'")
1195
+ last_quote = text.rfind("'")
1196
+ if first_quote != -1 and last_quote > first_quote:
1197
+ return text[first_quote + 1 : last_quote].strip()
1198
+ return text.strip()
1199
+
1200
+
1201
+ def _compose_s2s_input(response_prompt: str, transcript: str) -> str:
1202
+ return (
1203
+ f"{response_prompt.strip()}\n\n"
1204
+ f"<spoken_transcript>\n{transcript.strip()}\n</spoken_transcript>"
1205
+ )
1206
+
1207
+
1208
+ def _limit_spoken_answer(text: str) -> str:
1209
+ segments = _split_sentence_segments(text)
1210
+ sentence = segments[-1] if segments else text.strip()
1211
+ words = sentence.split()
1212
+ if len(words) <= S2S_SPOKEN_MAX_WORDS:
1213
+ return sentence
1214
+ return " ".join(words[:S2S_SPOKEN_MAX_WORDS]).rstrip(",;:") + "."
1215
+
1216
+
1217
+ def _generate_s2s(
1218
+ runtime: AudexRuntime,
1219
+ audio: str,
1220
+ response_prompt: str,
1221
+ reasoning: bool,
1222
+ reasoning_budget: int,
1223
+ max_new_tokens: int,
1224
+ temperature: float,
1225
+ top_p: float,
1226
+ guidance_scale: float,
1227
+ ) -> Iterator[tuple[object, object, object, object]]:
1228
+ yield "Transcribing input speech…", "", gr.skip(), gr.skip()
1229
+ transcript = ""
1230
+ for transcription, _ in _generate(
1231
+ runtime,
1232
+ audio,
1233
+ "Transcribe the input speech.",
1234
+ False,
1235
+ min(256, MAX_NEW_TOKENS),
1236
+ temperature=1.0,
1237
+ top_p=1.0,
1238
+ greedy=True,
1239
+ ):
1240
+ transcript = _clean_transcription(transcription)
1241
+ yield f"Transcript: {transcript}", "", gr.skip(), gr.skip()
1242
+ if not transcript:
1243
+ raise gr.Error("Speech-to-speech transcription produced no text.")
1244
+
1245
+ text_input = _compose_s2s_input(response_prompt, transcript)
1246
+ candidate_answer = ""
1247
+ thinking = ""
1248
+ for current_answer, thinking in _generate_text(
1249
+ runtime,
1250
+ text_input,
1251
+ reasoning,
1252
+ int(max_new_tokens),
1253
+ temperature,
1254
+ top_p,
1255
+ reasoning_budget=reasoning_budget,
1256
+ ):
1257
+ candidate_answer = current_answer or candidate_answer
1258
+ yield "", thinking, gr.skip(), gr.skip()
1259
+ if MAX_TOKEN_WARNING in candidate_answer:
1260
+ yield candidate_answer, thinking, gr.skip(), gr.skip()
1261
+ return
1262
+ if not candidate_answer and not thinking:
1263
+ raise gr.Error("Speech-to-speech response generation produced no text.")
1264
+
1265
+ answer = _limit_spoken_answer(candidate_answer)
1266
+ if not answer:
1267
+ raise gr.Error("Speech-to-speech response generation produced no final answer.")
1268
+ yield answer, thinking, gr.skip(), gr.skip()
1269
+
1270
+ for _, wav_path, player in _generate_tts(
1271
+ runtime,
1272
+ answer,
1273
+ S2S_TTS_MAX_NEW_TOKENS,
1274
+ 0.1,
1275
+ 1.0,
1276
+ top_k=80,
1277
+ guidance_scale=guidance_scale,
1278
+ token_limit=S2S_TTS_MAX_NEW_TOKENS,
1279
+ segment_sentences=True,
1280
+ ):
1281
+ yield answer, thinking, wav_path or gr.skip(), player
1282
+
1283
+
1284
+ @spaces.GPU(duration=_estimate_text, size="xlarge")
1285
+ def run_text(
1286
+ prompt: str,
1287
+ reasoning: bool,
1288
+ max_new_tokens: int,
1289
+ temperature: float,
1290
+ top_p: float,
1291
+ ) -> tuple[str, str]:
1292
+ """Run the legacy text-only API endpoint."""
1293
+ answer, thinking = "", ""
1294
+ for answer, thinking in _generate_text(
1295
+ model_runtimes[DEFAULT_MODEL_NAME],
1296
+ prompt,
1297
+ reasoning,
1298
+ max_new_tokens,
1299
+ temperature,
1300
+ top_p,
1301
+ ):
1302
+ pass
1303
  return answer, thinking
1304
 
1305
 
1306
+ def _estimate_unified(
1307
+ model_name: str,
1308
+ task: str,
1309
+ audio: str | None,
1310
+ prompt: str,
1311
+ reasoning: bool,
1312
+ reasoning_budget: int,
1313
+ max_new_tokens: int,
1314
+ temperature: float,
1315
+ top_p: float,
1316
+ guidance_scale: float,
1317
+ *args: object,
1318
+ **kwargs: object,
1319
+ ) -> int:
1320
+ if task == S2S_TASK:
1321
+ return S2S_GPU_DURATION_SECONDS
1322
+ if task in {TEXT_TASK, TTS_TASK}:
1323
+ if task == TTS_TASK and int(max_new_tokens) > TTS_MAX_NEW_TOKENS:
1324
+ return 10
1325
+ return _estimate_text(
1326
+ prompt,
1327
+ reasoning,
1328
+ max_new_tokens,
1329
+ temperature,
1330
+ top_p,
1331
+ )
1332
+ return _estimate(
1333
+ audio,
1334
+ task,
1335
+ prompt,
1336
+ reasoning,
1337
+ max_new_tokens,
1338
+ temperature,
1339
+ top_p,
1340
+ )
1341
+
1342
+
1343
+ @spaces.GPU(duration=_estimate_unified, size="xlarge")
1344
+ def run_unified(
1345
+ model_name: str,
1346
+ task: str,
1347
+ audio: str | None,
1348
+ prompt: str,
1349
+ reasoning: bool,
1350
+ reasoning_budget: int,
1351
+ max_new_tokens: int,
1352
+ temperature: float,
1353
+ top_p: float,
1354
+ guidance_scale: float,
1355
+ ) -> Iterator[tuple[object, object, object, object]]:
1356
+ """Run a selected Audex model for audio, text, or speech generation."""
1357
+ yield "", "", gr.skip(), gr.skip()
1358
+ runtime = _get_runtime(model_name)
1359
+ settings = TASK_SPECS[task]
1360
+ if settings["modality"] == "s2s":
1361
+ if audio is None:
1362
+ yield "Please provide an audio input.", "", gr.skip(), gr.skip()
1363
+ return
1364
+ yield from _generate_s2s(
1365
+ runtime,
1366
+ audio,
1367
+ prompt,
1368
+ reasoning,
1369
+ int(reasoning_budget),
1370
+ int(max_new_tokens),
1371
+ temperature,
1372
+ top_p,
1373
+ float(guidance_scale),
1374
+ )
1375
+ return
1376
+ if settings["modality"] == "tts":
1377
+ for status, wav_path, player in _generate_tts(
1378
+ runtime,
1379
+ prompt,
1380
+ int(max_new_tokens),
1381
+ temperature,
1382
+ top_p,
1383
+ guidance_scale=float(guidance_scale),
1384
+ ):
1385
+ yield status, "", wav_path or gr.skip(), player
1386
+ return
1387
+ if settings["modality"] == "text":
1388
+ for answer, thinking in _generate_text(
1389
+ runtime,
1390
+ prompt,
1391
+ reasoning,
1392
+ max_new_tokens,
1393
+ temperature,
1394
+ top_p,
1395
+ reasoning_budget=int(reasoning_budget),
1396
+ ):
1397
+ yield answer, thinking, gr.skip(), gr.skip()
1398
+ return
1399
+ if audio is None:
1400
+ yield "Please provide an audio input.", "", gr.skip(), gr.skip()
1401
+ return
1402
+
1403
+ instruction = (prompt.strip() or str(settings["template"])).replace("<sound>", "").strip()
1404
+ for answer, thinking in _generate(
1405
+ runtime,
1406
+ audio,
1407
+ instruction,
1408
+ reasoning,
1409
+ int(max_new_tokens),
1410
+ temperature=float(temperature),
1411
+ top_p=float(top_p),
1412
+ greedy=bool(settings["greedy"]),
1413
+ reasoning_budget=int(reasoning_budget),
1414
+ ):
1415
+ yield answer, thinking, gr.skip(), gr.skip()
1416
+
1417
+
1418
+ def unified_task_defaults(
1419
+ task: str,
1420
+ ) -> tuple[dict[str, object], ...]:
1421
+ settings = TASK_SPECS[task]
1422
+ is_audio = settings["modality"] in {"audio", "s2s"}
1423
+ is_tts = settings["modality"] == "tts"
1424
+ has_speech_output = settings["modality"] in {"tts", "s2s"}
1425
+ reasoning = bool(settings["reasoning"])
1426
+ default_max_new_tokens = int(settings.get("max_new_tokens", DEFAULT_MAX_NEW_TOKENS))
1427
+ max_new_tokens_limit = int(settings.get("max_new_tokens_limit", MAX_NEW_TOKENS))
1428
+ reasoning_budget_limit = _max_reasoning_budget(default_max_new_tokens)
1429
+ return (
1430
+ gr.update(visible=is_audio),
1431
+ gr.update(
1432
+ value=str(settings["template"]),
1433
+ lines=8 if settings["modality"] == "s2s" else (3 if is_audio else 8),
1434
+ label=(
1435
+ "Text to synthesize"
1436
+ if is_tts
1437
+ else (
1438
+ "Response instruction / prompt"
1439
+ if settings["modality"] == "s2s"
1440
+ else "Task template / prompt"
1441
+ )
1442
+ ),
1443
+ ),
1444
+ gr.update(value=reasoning, visible=not is_tts),
1445
+ gr.update(
1446
+ value=min(
1447
+ int(settings.get("reasoning_budget", DEFAULT_REASONING_BUDGET)),
1448
+ reasoning_budget_limit,
1449
+ ),
1450
+ maximum=reasoning_budget_limit,
1451
+ visible=not is_tts,
1452
+ interactive=reasoning,
1453
+ ),
1454
+ float(settings["temperature"]),
1455
+ float(settings["top_p"]),
1456
+ gr.update(
1457
+ value=TTS_MAX_NEW_TOKENS if is_tts else default_max_new_tokens,
1458
+ maximum=TTS_MAX_NEW_TOKENS if is_tts else max_new_tokens_limit,
1459
+ ),
1460
+ gr.update(
1461
+ value=float(settings.get("guidance_scale", 2.0)),
1462
+ visible=has_speech_output,
1463
+ ),
1464
+ gr.update(label="Status" if is_tts else "Answer"),
1465
+ gr.update(visible=not is_tts),
1466
+ gr.update(value=None, visible=has_speech_output),
1467
+ gr.update(
1468
+ value=player_value(0, reset=True),
1469
+ visible=is_tts and TTS_STREAMING_PLAYER_ENABLED,
1470
+ ),
1471
+ )
1472
+
1473
+
1474
+ def update_reasoning_budget_limit(
1475
+ max_new_tokens: int,
1476
+ reasoning_budget: int,
1477
+ ) -> dict[str, object]:
1478
+ maximum = _max_reasoning_budget(max_new_tokens)
1479
+ return gr.update(maximum=maximum, value=min(int(reasoning_budget), maximum))
1480
 
1481
+
1482
+ theme = gr.themes.Citrus()
1483
+
1484
+ with gr.Blocks(title="Nemotron-Labs-Audex") as demo:
1485
  gr.Markdown(
1486
+ "# 🎧 Nemotron-Labs-Audex\n"
1487
+ '<div style="display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;">'
1488
+ '<a href="https://arxiv.org/abs/2607.05196">'
1489
+ '<img alt="Technical Report" src="https://img.shields.io/badge/2607.05196-Technical_Report-blue"></a>'
1490
+ '<a href="https://huggingface.co/collections/nvidia/nemotron-labs-audex">'
1491
+ '<img alt="Models" src="https://img.shields.io/badge/🤗-Models-blue"></a>'
1492
+ "</div>\n\n"
1493
+ "This is an interactive demo of [Nemotron-Labs-Audex-30B-A3B](https://huggingface.co/nvidia/Nemotron-Labs-Audex-30B-A3B) and [Nemotron-Labs-Audex-2B](https://huggingface.co/nvidia/Nemotron-Labs-Audex-2B). \n\n"
1494
+ "Audex extends the vocabulary for discrete audio tokens, as well as an audio encoder for audio inputs. Audex delivers strong abilities on audio tasks (audio understanding, speech recognition and translation, text-to-speech, audio generation, and speech-to-speech generation) while preserving very compelling reasoning, alignment, knowledge, long-context, and agentic capabilities of its text-only LLM backbone with marginal or no regression. Audex operates in both **thinking** and **instruct** (non-thinking) modes. \n\n"
1495
+ "Task selection loads a default prompt template and inference recipe.\n\n"
1496
+ "Your use of this model is governed by the [NVIDIA OneWay Noncommercial License](https://huggingface.co/nvidia/Nemotron-Labs-Audex-30B-A3B/blob/main/license/NVIDIA-OneWay-Noncommercial-License.docx/)."
1497
  )
1498
  with gr.Row():
1499
  with gr.Column():
1500
+ unified_model = gr.Radio(
1501
+ choices=list(model_runtimes),
1502
+ value=DEFAULT_MODEL_NAME,
1503
+ label="Model",
1504
+ )
1505
+ unified_task = gr.Dropdown(
1506
+ choices=list(TASK_SPECS),
1507
+ value="Speech recognition (ASR)",
1508
+ label="Task",
1509
+ )
1510
+ unified_audio = gr.Audio(
1511
  type="filepath",
1512
+ label=f"Input audio (up to {MAX_AUDIO_DURATION_SECONDS / 60:g} minutes)",
1513
  sources=["upload", "microphone"],
1514
  )
1515
+ unified_prompt = gr.Textbox(
1516
+ value=str(TASK_SPECS["Speech recognition (ASR)"]["template"]),
1517
+ label="Task template / prompt",
1518
+ lines=3,
1519
  )
1520
+ gr.Markdown(
1521
+ "`<sound>` marks where audio embeddings enter the prompt; the app "
1522
+ "replaces it automatically. Text-only tasks have no input-modality token."
 
1523
  )
1524
+ with gr.Row():
1525
+ unified_run_btn = gr.Button("Run", variant="primary", elem_id="audex-run")
1526
+ unified_stop_btn = gr.Button("Stop", variant="stop", elem_id="audex-stop")
1527
  with gr.Accordion("Advanced options", open=False):
1528
+ unified_reasoning = gr.Checkbox(
1529
+ value=False,
1530
+ label="Enable reasoning (<think>) mode",
1531
+ )
1532
+ unified_reasoning_budget = gr.Slider(
1533
+ 0,
1534
+ _max_reasoning_budget(MAX_NEW_TOKENS),
1535
+ value=DEFAULT_REASONING_BUDGET,
1536
+ step=16,
1537
+ label="Reasoning budget (0 = unlimited)",
1538
+ info=(
1539
+ "No reasoning cap by default. Set a positive threshold to enable "
1540
+ "a decoding-time cap with a 10% newline grace."
1541
+ ),
1542
+ interactive=False,
1543
+ )
1544
+ unified_max_new_tokens = gr.Slider(
1545
  16,
1546
  MAX_NEW_TOKENS,
1547
+ value=DEFAULT_MAX_NEW_TOKENS,
1548
  step=16,
1549
  label="Max new tokens",
1550
+ info=(
1551
+ "Total cap shared by reasoning and the final answer. "
1552
+ "If reached, the displayed output is incomplete."
1553
+ ),
1554
+ )
1555
+ unified_temperature = gr.Slider(
1556
+ 0.1,
1557
+ 1.5,
1558
+ value=1.0,
1559
+ step=0.05,
1560
+ label="Temperature",
1561
+ )
1562
+ unified_top_p = gr.Slider(
1563
+ 0.1,
1564
+ 1.0,
1565
+ value=1.0,
1566
+ step=0.05,
1567
+ label="Top-p",
1568
+ )
1569
+ unified_guidance_scale = gr.Slider(
1570
+ 1.0,
1571
+ 3.0,
1572
+ value=2.0,
1573
+ step=0.1,
1574
+ label="TTS CFG scale",
1575
+ visible=False,
1576
  )
 
 
1577
  gr.Markdown(
1578
+ "Task selection restores the official defaults. ASR and AST use "
1579
+ "greedy decoding; audio understanding and text reasoning use sampling. "
1580
+ "Reasoning has no separate cap unless you set one. TTS uses CFG 2.0; "
1581
+ "speech-to-speech uses CFG 1.5."
1582
  )
1583
  with gr.Column():
1584
+ unified_answer_out = gr.Textbox(label="Answer", lines=10)
1585
+ unified_thinking_out = gr.Textbox(
1586
+ label="Reasoning trace (if enabled)",
1587
+ lines=10,
1588
+ )
1589
+ unified_tts_audio_out = gr.Audio(
1590
+ label="Generated speech",
1591
+ type="filepath",
1592
+ autoplay=False,
1593
+ visible=False,
1594
+ )
1595
+ unified_tts_player = gr.HTML(
1596
+ value=player_value(0, reset=True),
1597
+ html_template=TTS_PLAYER_TEMPLATE,
1598
+ css_template=TTS_PLAYER_CSS,
1599
+ js_on_load=TTS_PLAYER_JS,
1600
+ apply_default_css=False,
1601
+ visible=False,
1602
+ )
1603
+
1604
+ unified_task.change(
1605
+ unified_task_defaults,
1606
+ inputs=unified_task,
1607
+ outputs=[
1608
+ unified_audio,
1609
+ unified_prompt,
1610
+ unified_reasoning,
1611
+ unified_reasoning_budget,
1612
+ unified_temperature,
1613
+ unified_top_p,
1614
+ unified_max_new_tokens,
1615
+ unified_guidance_scale,
1616
+ unified_answer_out,
1617
+ unified_thinking_out,
1618
+ unified_tts_audio_out,
1619
+ unified_tts_player,
1620
+ ],
1621
+ api_name=False,
1622
+ )
1623
+ unified_reasoning.change(
1624
+ lambda enabled: gr.update(interactive=enabled),
1625
+ inputs=unified_reasoning,
1626
+ outputs=unified_reasoning_budget,
1627
+ api_name=False,
1628
+ )
1629
+ unified_max_new_tokens.change(
1630
+ update_reasoning_budget_limit,
1631
+ inputs=[unified_max_new_tokens, unified_reasoning_budget],
1632
+ outputs=unified_reasoning_budget,
1633
+ api_name=False,
1634
+ )
1635
 
1636
+ unified_run_event = unified_run_btn.click(
1637
+ run_unified,
1638
+ inputs=[
1639
+ unified_model,
1640
+ unified_task,
1641
+ unified_audio,
1642
+ unified_prompt,
1643
+ unified_reasoning,
1644
+ unified_reasoning_budget,
1645
+ unified_max_new_tokens,
1646
+ unified_temperature,
1647
+ unified_top_p,
1648
+ unified_guidance_scale,
1649
+ ],
1650
+ outputs=[
1651
+ unified_answer_out,
1652
+ unified_thinking_out,
1653
+ unified_tts_audio_out,
1654
+ unified_tts_player,
1655
+ ],
1656
+ api_name="run_unified",
1657
+ concurrency_id="audex-gpu",
1658
+ concurrency_limit=1,
1659
  )
1660
+ unified_stop_btn.click(fn=None, cancels=[unified_run_event], api_name=False)
1661
 
1662
+ gr.Markdown(
1663
+ "### Curated examples\n"
1664
+ "Audio and text examples share the same task-driven interface."
1665
+ )
1666
  gr.Examples(
1667
  examples=[
1668
+ [
1669
+ "Speech recognition (ASR)",
1670
+ "examples/mlk_speech.wav",
1671
+ TASK_SPECS["Speech recognition (ASR)"]["template"],
1672
+ ],
1673
+ [
1674
+ "Speech recognition (ASR)",
1675
+ "examples/sample_speech.wav",
1676
+ TASK_SPECS["Speech recognition (ASR)"]["template"],
1677
+ ],
1678
+ [
1679
+ "Speech translation (AST)",
1680
+ "examples/korean_speech.wav",
1681
+ TASK_SPECS["Speech translation (AST)"]["template"],
1682
+ ],
1683
+ [
1684
+ "Audio description",
1685
+ "examples/mlk_speech.wav",
1686
+ TASK_SPECS["Audio description"]["template"],
1687
+ ],
1688
+ [
1689
+ "Audio description",
1690
+ "examples/sample_speech.wav",
1691
+ TASK_SPECS["Audio description"]["template"],
1692
+ ],
1693
+ [
1694
+ "Audio question answering",
1695
+ "examples/mlk_speech.wav",
1696
+ TASK_SPECS["Audio question answering"]["template"],
1697
+ ],
1698
+ [
1699
+ TEXT_TASK,
1700
+ None,
1701
+ MMLU_PRO_EXAMPLE_PROMPT,
1702
+ ],
1703
+ [
1704
+ S2S_TASK,
1705
+ "examples/question_1059.mp3",
1706
+ S2S_UI_PROMPT,
1707
+ ],
1708
+ [
1709
+ TTS_TASK,
1710
+ None,
1711
+ TTS_EXAMPLE_TEXT,
1712
+ ],
1713
+ ],
1714
+ inputs=[
1715
+ unified_task,
1716
+ unified_audio,
1717
+ unified_prompt,
1718
  ],
 
 
 
1719
  cache_examples=False,
 
1720
  )
1721
 
1722
  if __name__ == "__main__":
1723
+ demo.queue(max_size=8).launch(
1724
+ server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"),
1725
+ server_port=int(os.environ.get("GRADIO_SERVER_PORT", "7860")),
1726
+ share=os.environ.get("GRADIO_SHARE", "false").lower() == "true",
1727
+ theme=theme,
1728
+ ssr_mode=False,
1729
+ mcp_server=True,
1730
+ )
examples/korean_speech.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:65857d13573b3f2f793092865cc4da3ddd46d921c6f9ea6eb77e263aba00779c
3
+ size 255532
examples/question_1059.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f4615906395ee1dc6fdcb33dac1be954b211d32b7096132c8d110530d0d385d5
3
+ size 228000
reasoning_utils.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import Any
5
+
6
+ import torch
7
+ from transformers.generation.logits_process import LogitsProcessor
8
+
9
+
10
+ class ReasoningBudgetLogitsProcessor(LogitsProcessor):
11
+ """Apply an optional reasoning-token budget for one sequence."""
12
+
13
+ def __init__(
14
+ self,
15
+ tokenizer: Any,
16
+ prompt_length: int,
17
+ reasoning_budget: int,
18
+ grace_fraction: float = 0.1,
19
+ early_exit: str = ".\n</think>\n\n",
20
+ ) -> None:
21
+ if reasoning_budget < 1:
22
+ raise ValueError("reasoning_budget must be positive")
23
+
24
+ self.tokenizer = tokenizer
25
+ self.prompt_length = prompt_length
26
+ self.reasoning_budget = reasoning_budget
27
+ self.hard_limit = reasoning_budget + max(
28
+ 1, math.ceil(reasoning_budget * grace_fraction)
29
+ )
30
+ self.reasoning_end_ids = tokenizer.encode(
31
+ "</think>", add_special_tokens=False
32
+ )
33
+ self.early_exit_ids = tokenizer.encode(
34
+ early_exit, add_special_tokens=False
35
+ )
36
+ self.forced_index: int | None = None
37
+ self.done = False
38
+
39
+ def _force(self, scores: torch.FloatTensor, token_id: int) -> torch.FloatTensor:
40
+ scores.fill_(-float("inf"))
41
+ scores[:, token_id] = 0
42
+ return scores
43
+
44
+ def __call__(
45
+ self,
46
+ input_ids: torch.LongTensor,
47
+ scores: torch.FloatTensor,
48
+ ) -> torch.FloatTensor:
49
+ if self.done:
50
+ return scores
51
+ if input_ids.shape[0] != 1:
52
+ raise ValueError("Reasoning budget control requires batch size 1")
53
+
54
+ generated_ids = input_ids[0, self.prompt_length :].tolist()
55
+ if self.forced_index is not None:
56
+ self.forced_index += 1
57
+ if self.forced_index >= len(self.early_exit_ids):
58
+ self.done = True
59
+ return scores
60
+ return self._force(scores, self.early_exit_ids[self.forced_index])
61
+
62
+ if generated_ids[-len(self.reasoning_end_ids) :] == self.reasoning_end_ids:
63
+ self.done = True
64
+ return scores
65
+
66
+ generated_tokens = len(generated_ids)
67
+ if generated_tokens < self.reasoning_budget:
68
+ return scores
69
+
70
+ ended_line = bool(
71
+ generated_ids
72
+ and "\n"
73
+ in self.tokenizer.decode(
74
+ [generated_ids[-1]],
75
+ skip_special_tokens=False,
76
+ clean_up_tokenization_spaces=False,
77
+ )
78
+ )
79
+ if not ended_line and generated_tokens < self.hard_limit:
80
+ return scores
81
+
82
+ self.forced_index = 0
83
+ return self._force(scores, self.early_exit_ids[0])
requirements.txt CHANGED
@@ -1,4 +1,4 @@
1
- transformers>=4.53.3
2
  accelerate
3
  sentencepiece
4
  librosa
 
1
+ transformers==5.14.0
2
  accelerate
3
  sentencepiece
4
  librosa
run_local.sh ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ VENV_DIR="${AUDEX_VENV_DIR:-$APP_DIR/.venv}"
6
+ PYTHON="$VENV_DIR/bin/python"
7
+
8
+ if [[ ! -x "$PYTHON" ]]; then
9
+ echo "Missing local environment. Run: bash $APP_DIR/setup_local.sh" >&2
10
+ exit 1
11
+ fi
12
+
13
+ export CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-0}"
14
+
15
+ gpu_info="$("$PYTHON" - <<'PY'
16
+ import re
17
+ import sys
18
+
19
+ import torch
20
+
21
+ if not torch.cuda.is_available():
22
+ raise SystemExit("CUDA is unavailable in this PyTorch environment.")
23
+
24
+ major, minor = torch.cuda.get_device_capability()
25
+ cuda = (torch.version.cuda or "unknown").replace(".", "")
26
+ torch_version = re.sub(r"[^0-9A-Za-z]+", "", torch.__version__.split("+", 1)[0])
27
+ python_version = f"{sys.version_info.major}{sys.version_info.minor}"
28
+ memory_gib = torch.cuda.get_device_properties(0).total_memory / 1024**3
29
+ print(f"{major}.{minor}\tsm{major}{minor}-cu{cuda}-torch{torch_version}-py{python_version}\t{memory_gib:.0f}")
30
+ PY
31
+ )"
32
+ IFS=$'\t' read -r detected_arch runtime_tag memory_gib <<< "$gpu_info"
33
+
34
+ export AUDEX_TORCH_ARCH="${AUDEX_TORCH_ARCH:-$detected_arch}"
35
+ export AUDEX_WHEELS_DIR="${AUDEX_WHEELS_DIR:-$APP_DIR/.local/wheels/$runtime_tag}"
36
+ export AUDEX_BUILD_DIR="${AUDEX_BUILD_DIR:-$APP_DIR/.local/build/$runtime_tag}"
37
+ export AUDEX_MAX_AUDIO_SECONDS="${AUDEX_MAX_AUDIO_SECONDS:-900}"
38
+ export AUDEX_MAX_NEW_TOKENS="${AUDEX_MAX_NEW_TOKENS:-2048}"
39
+ export AUDEX_DEFAULT_MAX_NEW_TOKENS="${AUDEX_DEFAULT_MAX_NEW_TOKENS:-1024}"
40
+ export AUDEX_TTS_MAX_NEW_TOKENS="${AUDEX_TTS_MAX_NEW_TOKENS:-1024}"
41
+ export AUDEX_S2S_TEXT_MAX_NEW_TOKENS="${AUDEX_S2S_TEXT_MAX_NEW_TOKENS:-2048}"
42
+ export AUDEX_S2S_TTS_MAX_NEW_TOKENS="${AUDEX_S2S_TTS_MAX_NEW_TOKENS:-2400}"
43
+ export GRADIO_SERVER_NAME="${GRADIO_SERVER_NAME:-0.0.0.0}"
44
+ export GRADIO_SERVER_PORT="${GRADIO_SERVER_PORT:-7860}"
45
+ export GRADIO_SHARE="${GRADIO_SHARE:-false}"
46
+
47
+ echo "GPU architecture: $AUDEX_TORCH_ARCH (${memory_gib} GiB)"
48
+ echo "Models: Audex-30B-A3B and Audex-2B (downloaded from the Hub if uncached)"
49
+
50
+ exec "$PYTHON" "$APP_DIR/app.py"
setup_local.sh ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ VENV_DIR="${AUDEX_VENV_DIR:-$APP_DIR/.venv}"
6
+ PYTHON="$VENV_DIR/bin/python"
7
+
8
+ if [[ ! -x "$PYTHON" ]]; then
9
+ python3 -m venv "$VENV_DIR"
10
+ fi
11
+
12
+ "$PYTHON" -m pip install --upgrade pip
13
+ if ! "$PYTHON" -c "import torch" >/dev/null 2>&1; then
14
+ "$PYTHON" -m pip install "${AUDEX_TORCH_PACKAGE:-torch}"
15
+ fi
16
+ "$PYTHON" -m pip install \
17
+ --requirement "$APP_DIR/requirements.txt" \
18
+ "gradio==6.20.0" \
19
+ "huggingface_hub" \
20
+ "spaces>=0.51.0"
21
+
22
+ echo "Local environment ready: $VENV_DIR"
23
+ echo "Launch with: bash $APP_DIR/run_local.sh"
tts_player.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ _WORKLET_SOURCE = (Path(__file__).parent / "web" / "PCMPlayerWorklet.js").read_text()
8
+
9
+ TTS_PLAYER_TEMPLATE = """
10
+ <div class="audex-player">
11
+ <div class="audex-player-status">Ready</div>
12
+ <button class="audex-player-enable" type="button">Enable playback</button>
13
+ <audio class="audex-player-audio" controls></audio>
14
+ <a class="audex-player-download" hidden download="audex-tts.wav">Download WAV</a>
15
+ </div>
16
+ """
17
+
18
+ TTS_PLAYER_CSS = """
19
+ .audex-player {
20
+ display: grid;
21
+ gap: 0.75rem;
22
+ padding: 1rem;
23
+ border: 1px solid var(--border-color-primary);
24
+ border-radius: var(--radius-lg);
25
+ }
26
+ .audex-player-status {
27
+ color: var(--body-text-color-subdued);
28
+ }
29
+ .audex-player-audio {
30
+ width: 100%;
31
+ }
32
+ .audex-player-download {
33
+ color: var(--link-text-color);
34
+ font-weight: 600;
35
+ }
36
+ """
37
+
38
+ TTS_PLAYER_JS = """
39
+ const WORKLET_SOURCE = __WORKLET_SOURCE__;
40
+ const status = element.querySelector(".audex-player-status");
41
+ const enable = element.querySelector(".audex-player-enable");
42
+ const audio = element.querySelector(".audex-player-audio");
43
+ const download = element.querySelector(".audex-player-download");
44
+ let context = null;
45
+ let player = null;
46
+ let chunks = [];
47
+ let wavUrl = null;
48
+ let lastSequence = -1;
49
+
50
+ const ensurePlayer = async () => {
51
+ if (context) {
52
+ await context.resume();
53
+ return;
54
+ }
55
+ context = new AudioContext();
56
+ const blob = new Blob([WORKLET_SOURCE], { type: "text/javascript" });
57
+ const moduleUrl = URL.createObjectURL(blob);
58
+ await context.audioWorklet.addModule(moduleUrl);
59
+ URL.revokeObjectURL(moduleUrl);
60
+ player = new AudioWorkletNode(
61
+ context,
62
+ "audex-pcm-player",
63
+ { outputChannelCount: [1] },
64
+ );
65
+ player.connect(context.destination);
66
+ player.port.onmessage = (event) => {
67
+ if (event.data.type === "underrun") {
68
+ status.textContent = "Buffering generated speech…";
69
+ }
70
+ };
71
+ await context.resume();
72
+ };
73
+
74
+ const decodePcm = (encoded) => {
75
+ const binary = atob(encoded);
76
+ const bytes = new Uint8Array(binary.length);
77
+ for (let index = 0; index < binary.length; index += 1) {
78
+ bytes[index] = binary.charCodeAt(index);
79
+ }
80
+ return new Float32Array(bytes.buffer);
81
+ };
82
+
83
+ const resample = (input, sourceRate, targetRate) => {
84
+ if (sourceRate === targetRate) return input;
85
+ const output = new Float32Array(Math.max(1, Math.round(input.length * targetRate / sourceRate)));
86
+ const ratio = sourceRate / targetRate;
87
+ for (let index = 0; index < output.length; index += 1) {
88
+ const position = index * ratio;
89
+ const left = Math.floor(position);
90
+ const right = Math.min(left + 1, input.length - 1);
91
+ const fraction = position - left;
92
+ output[index] = input[left] * (1 - fraction) + input[right] * fraction;
93
+ }
94
+ return output;
95
+ };
96
+
97
+ const buildWav = (parts, sampleRate) => {
98
+ const length = parts.reduce((total, part) => total + part.length, 0);
99
+ const buffer = new ArrayBuffer(44 + length * 2);
100
+ const view = new DataView(buffer);
101
+ const write = (offset, text) => {
102
+ for (let index = 0; index < text.length; index += 1) {
103
+ view.setUint8(offset + index, text.charCodeAt(index));
104
+ }
105
+ };
106
+ write(0, "RIFF");
107
+ view.setUint32(4, 36 + length * 2, true);
108
+ write(8, "WAVE");
109
+ write(12, "fmt ");
110
+ view.setUint32(16, 16, true);
111
+ view.setUint16(20, 1, true);
112
+ view.setUint16(22, 1, true);
113
+ view.setUint32(24, sampleRate, true);
114
+ view.setUint32(28, sampleRate * 2, true);
115
+ view.setUint16(32, 2, true);
116
+ view.setUint16(34, 16, true);
117
+ write(36, "data");
118
+ view.setUint32(40, length * 2, true);
119
+ let offset = 44;
120
+ for (const part of parts) {
121
+ for (const sample of part) {
122
+ const clipped = Math.max(-1, Math.min(1, sample));
123
+ view.setInt16(offset, clipped < 0 ? clipped * 32768 : clipped * 32767, true);
124
+ offset += 2;
125
+ }
126
+ }
127
+ return new Blob([buffer], { type: "audio/wav" });
128
+ };
129
+
130
+ const reset = async () => {
131
+ chunks = [];
132
+ lastSequence = -1;
133
+ audio.removeAttribute("src");
134
+ audio.load();
135
+ download.hidden = true;
136
+ if (wavUrl) URL.revokeObjectURL(wavUrl);
137
+ wavUrl = null;
138
+ await ensurePlayer();
139
+ player.port.postMessage({ type: "reset" });
140
+ status.textContent = "Waiting for speech tokens…";
141
+ };
142
+
143
+ const consume = async () => {
144
+ const value = props.value || {};
145
+ if (value.sequence === lastSequence) return;
146
+ lastSequence = value.sequence;
147
+ if (value.reset) await reset();
148
+ if (value.pcm) {
149
+ await ensurePlayer();
150
+ const pcm = decodePcm(value.pcm);
151
+ chunks.push(pcm.slice());
152
+ const playback = resample(pcm, value.sample_rate || 16000, context.sampleRate);
153
+ player.port.postMessage({ type: "audio", samples: playback.buffer }, [playback.buffer]);
154
+ status.textContent = `Streaming ${value.token_count || 0} speech tokens…`;
155
+ }
156
+ if (value.done && chunks.length) {
157
+ const wav = buildWav(chunks, value.sample_rate || 16000);
158
+ wavUrl = URL.createObjectURL(wav);
159
+ audio.src = wavUrl;
160
+ download.href = wavUrl;
161
+ download.hidden = false;
162
+ status.textContent = `Complete · ${value.token_count || 0} speech tokens`;
163
+ }
164
+ };
165
+
166
+ const runButton = document.getElementById("audex-run");
167
+ if (runButton) runButton.addEventListener("click", () => ensurePlayer());
168
+ enable.addEventListener("click", async () => {
169
+ await ensurePlayer();
170
+ status.textContent = "Playback enabled";
171
+ });
172
+ const stopButton = document.getElementById("audex-stop");
173
+ if (stopButton) {
174
+ stopButton.addEventListener("click", () => {
175
+ if (player) player.port.postMessage({ type: "reset" });
176
+ status.textContent = "Stopped";
177
+ });
178
+ }
179
+ watch("value", consume);
180
+ consume();
181
+ """.replace("__WORKLET_SOURCE__", json.dumps(_WORKLET_SOURCE))
182
+
183
+
184
+ def player_value(
185
+ sequence: int,
186
+ *,
187
+ pcm: str | None = None,
188
+ token_count: int = 0,
189
+ done: bool = False,
190
+ reset: bool = False,
191
+ ) -> dict[str, object]:
192
+ return {
193
+ "sequence": sequence,
194
+ "sample_rate": 16000,
195
+ "pcm": pcm,
196
+ "token_count": token_count,
197
+ "done": done,
198
+ "reset": reset,
199
+ }
tts_utils.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import tempfile
5
+ import time
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from queue import Queue
9
+ from threading import Event, Thread
10
+ from typing import Any, Iterator
11
+
12
+ import numpy as np
13
+ import soundfile as sf
14
+ import torch
15
+ from transformers import AutoModel
16
+ from transformers.generation.logits_process import LogitsProcessor
17
+ from transformers.generation.stopping_criteria import StoppingCriteria
18
+ from transformers.generation.streamers import BaseStreamer
19
+
20
+
21
+ SAMPLE_RATE = 16000
22
+ TTS_PREFIX = "<|text to speech|> Generate speech for this transcription. "
23
+ DEFAULT_SYSTEM_PROMPT = (
24
+ "You are a helpful and harmless assistant.\n\n"
25
+ "You are not allowed to use any tools."
26
+ )
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class SpeechTokenMap:
31
+ start: int
32
+ end: int
33
+ codec_start: int
34
+ codec_end: int
35
+ eos: int
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class TTSStreamEvent:
40
+ pcm: np.ndarray | None
41
+ token_count: int
42
+ elapsed_seconds: float
43
+ done: bool = False
44
+
45
+
46
+ class SpeechTokenLogitsProcessor(LogitsProcessor):
47
+ def __init__(self, token_map: SpeechTokenMap):
48
+ self.token_map = token_map
49
+
50
+ def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor) -> torch.FloatTensor:
51
+ eos_scores = scores[:, self.token_map.eos].clone()
52
+ scores[:, : self.token_map.end] = -float("inf")
53
+ scores[:, self.token_map.codec_end + 1 :] = -float("inf")
54
+ scores[:, self.token_map.eos] = eos_scores
55
+ return scores
56
+
57
+
58
+ class EventStoppingCriteria(StoppingCriteria):
59
+ def __init__(self, event: Event):
60
+ self.event = event
61
+
62
+ def __call__(
63
+ self,
64
+ input_ids: torch.LongTensor,
65
+ scores: torch.FloatTensor,
66
+ **kwargs: Any,
67
+ ) -> torch.BoolTensor:
68
+ return torch.full(
69
+ (input_ids.shape[0],),
70
+ self.event.is_set(),
71
+ dtype=torch.bool,
72
+ device=input_ids.device,
73
+ )
74
+
75
+
76
+ class TokenIdStreamer(BaseStreamer):
77
+ _END = object()
78
+
79
+ def __init__(self) -> None:
80
+ self.queue: Queue[int | BaseException | object] = Queue()
81
+ self.is_prompt = True
82
+
83
+ def put(self, value: torch.Tensor) -> None:
84
+ if self.is_prompt:
85
+ self.is_prompt = False
86
+ return
87
+ token_ids = value.reshape(-1).tolist()
88
+ if len(token_ids) != 1:
89
+ raise ValueError(f"TTS streaming requires batch size 1, got {len(token_ids)} tokens")
90
+ self.queue.put(int(token_ids[0]))
91
+
92
+ def end(self) -> None:
93
+ self.queue.put(self._END)
94
+
95
+ def fail(self, error: BaseException) -> None:
96
+ self.queue.put(error)
97
+ self.end()
98
+
99
+ def __iter__(self) -> Iterator[int]:
100
+ while True:
101
+ item = self.queue.get()
102
+ if item is self._END:
103
+ return
104
+ if isinstance(item, BaseException):
105
+ raise item
106
+ yield int(item)
107
+
108
+
109
+ def build_tts_prompt(text: str, tokenizer: Any) -> str:
110
+ messages = [
111
+ {"role": "system", "content": DEFAULT_SYSTEM_PROMPT},
112
+ {"role": "user", "content": f"{TTS_PREFIX}{text}"},
113
+ ]
114
+ return (
115
+ tokenizer.apply_chat_template(
116
+ messages,
117
+ tokenize=False,
118
+ add_generation_prompt=True,
119
+ enable_thinking=False,
120
+ )
121
+ + "<speechgen_start>"
122
+ )
123
+
124
+
125
+ def build_tts_null_prompt(cond_prompt: str, tokenizer: Any, max_iters: int = 64) -> str:
126
+ target_len = len(tokenizer.encode(cond_prompt))
127
+
128
+ def template(null_text: str) -> str:
129
+ return build_tts_prompt(null_text, tokenizer)
130
+
131
+ base_len = len(tokenizer.encode(template("")))
132
+ n_unk = max(1, target_len - base_len)
133
+ for _ in range(max_iters):
134
+ prompt = template("<unk>" * n_unk)
135
+ current_len = len(tokenizer.encode(prompt))
136
+ if current_len == target_len:
137
+ return prompt
138
+ n_unk += 1 if current_len < target_len else -1
139
+ n_unk = max(1, n_unk)
140
+ raise ValueError("Unable to construct an equal-length TTS CFG null prompt")
141
+
142
+
143
+ def build_speech_token_map(tokenizer: Any) -> SpeechTokenMap:
144
+ token_map = SpeechTokenMap(
145
+ start=tokenizer.convert_tokens_to_ids("<speechgen_start>"),
146
+ end=tokenizer.convert_tokens_to_ids("<speechgen_end>"),
147
+ codec_start=tokenizer.convert_tokens_to_ids("<speechcodec_0>"),
148
+ codec_end=tokenizer.convert_tokens_to_ids("<speechcodec_65535>"),
149
+ eos=int(tokenizer.eos_token_id),
150
+ )
151
+ expected = (131075, 131076, 131077, 196612)
152
+ actual = (token_map.start, token_map.end, token_map.codec_start, token_map.codec_end)
153
+ if actual != expected:
154
+ raise ValueError(f"Unexpected Audex speech token layout: expected={expected}, actual={actual}")
155
+ return token_map
156
+
157
+
158
+ def load_speech_decoder(model_path: str, device: str = "cuda") -> Any:
159
+ path = Path(model_path)
160
+ for filename in ("config.json", "model.safetensors"):
161
+ if not (path / filename).is_file():
162
+ raise FileNotFoundError(f"Speech decoder file not found: {path / filename}")
163
+
164
+ decoder, loading_info = AutoModel.from_pretrained(
165
+ str(path),
166
+ trust_remote_code=True,
167
+ output_loading_info=True,
168
+ )
169
+ invalid_keys = {
170
+ key: loading_info[key]
171
+ for key in ("missing_keys", "unexpected_keys", "mismatched_keys")
172
+ if loading_info[key]
173
+ }
174
+ if invalid_keys:
175
+ raise RuntimeError(f"Speech decoder checkpoint loading was incomplete: {invalid_keys}")
176
+
177
+ for module in decoder.modules():
178
+ if {"theta", "cache"} <= getattr(module, "_non_persistent_buffers_set", set()):
179
+ module.rope_init(device=module.theta.device)
180
+ module._rope_ready = False
181
+
182
+ sample_rate = int(getattr(decoder.config, "sample_rate", SAMPLE_RATE))
183
+ if sample_rate != SAMPLE_RATE:
184
+ raise ValueError(f"Expected a {SAMPLE_RATE} Hz speech decoder, got {sample_rate} Hz")
185
+ decoder = decoder.to(device).eval()
186
+ for parameter in decoder.parameters():
187
+ parameter.requires_grad = False
188
+ return decoder
189
+
190
+
191
+ def stream_tts(
192
+ model: Any,
193
+ tokenizer: Any,
194
+ decoder: Any,
195
+ text: str,
196
+ max_new_tokens: int,
197
+ *,
198
+ temperature: float = 0.8,
199
+ top_p: float = 1.0,
200
+ top_k: int = 0,
201
+ guidance_scale: float = 2.0,
202
+ seed: int = 0,
203
+ chunk_frames: int = 5,
204
+ ) -> Iterator[TTSStreamEvent]:
205
+ text = text.strip()
206
+ if not text:
207
+ raise ValueError("Text to synthesize must not be empty")
208
+
209
+ token_map = build_speech_token_map(tokenizer)
210
+ cond_prompt = build_tts_prompt(text, tokenizer)
211
+ uncond_prompt = build_tts_null_prompt(cond_prompt, tokenizer)
212
+ cond_ids = tokenizer.encode(cond_prompt, return_tensors="pt").to(model.device)
213
+ uncond_ids = tokenizer.encode(uncond_prompt, return_tensors="pt").to(model.device)
214
+ if cond_ids.shape != uncond_ids.shape:
215
+ raise ValueError(
216
+ f"TTS CFG prompt lengths differ: conditional={cond_ids.shape[-1]}, "
217
+ f"unconditional={uncond_ids.shape[-1]}"
218
+ )
219
+
220
+ streamer = TokenIdStreamer()
221
+ cancel_event = Event()
222
+ session = decoder.create_session(
223
+ chunk_frames=chunk_frames,
224
+ sample_rate=SAMPLE_RATE,
225
+ return_numpy=True,
226
+ )
227
+ generation_error: list[BaseException] = []
228
+ started_at = time.perf_counter()
229
+
230
+ def generate() -> None:
231
+ try:
232
+ torch.manual_seed(seed)
233
+ torch.cuda.manual_seed_all(seed)
234
+ model.generate(
235
+ input_ids=cond_ids,
236
+ attention_mask=torch.ones_like(cond_ids),
237
+ negative_prompt_ids=uncond_ids,
238
+ negative_prompt_attention_mask=torch.ones_like(uncond_ids),
239
+ guidance_scale=guidance_scale,
240
+ do_sample=True,
241
+ temperature=temperature,
242
+ top_p=top_p,
243
+ **({"top_k": top_k} if top_k > 0 else {}),
244
+ max_new_tokens=max_new_tokens,
245
+ eos_token_id=[token_map.end, token_map.eos],
246
+ pad_token_id=tokenizer.pad_token_id or token_map.eos,
247
+ logits_processor=[SpeechTokenLogitsProcessor(token_map)],
248
+ stopping_criteria=[EventStoppingCriteria(cancel_event)],
249
+ streamer=streamer,
250
+ use_cache=True,
251
+ )
252
+ except BaseException as error:
253
+ generation_error.append(error)
254
+ streamer.fail(error)
255
+
256
+ thread = Thread(target=generate, daemon=True)
257
+ thread.start()
258
+ token_count = 0
259
+ completed = False
260
+ try:
261
+ for token_id in streamer:
262
+ if token_id == token_map.end or token_id == token_map.eos:
263
+ completed = True
264
+ break
265
+ if not token_map.codec_start <= token_id <= token_map.codec_end:
266
+ raise RuntimeError(f"Unexpected token in TTS output: {token_id}")
267
+
268
+ token_count += 1
269
+ codec_index = token_id - token_map.codec_start
270
+ if token_count == 1:
271
+ yield TTSStreamEvent(
272
+ pcm=None,
273
+ token_count=token_count,
274
+ elapsed_seconds=time.perf_counter() - started_at,
275
+ )
276
+ for _, pcm in session.push([[codec_index]]):
277
+ yield TTSStreamEvent(
278
+ pcm=np.asarray(pcm, dtype=np.float32),
279
+ token_count=token_count,
280
+ elapsed_seconds=time.perf_counter() - started_at,
281
+ )
282
+ else:
283
+ completed = True
284
+
285
+ if generation_error:
286
+ raise generation_error[0]
287
+ if completed:
288
+ for _, pcm in session.flush():
289
+ yield TTSStreamEvent(
290
+ pcm=np.asarray(pcm, dtype=np.float32),
291
+ token_count=token_count,
292
+ elapsed_seconds=time.perf_counter() - started_at,
293
+ )
294
+ yield TTSStreamEvent(
295
+ pcm=None,
296
+ token_count=token_count,
297
+ elapsed_seconds=time.perf_counter() - started_at,
298
+ done=True,
299
+ )
300
+ finally:
301
+ cancel_event.set()
302
+ thread.join(timeout=10)
303
+
304
+
305
+ def encode_pcm_chunk(pcm: np.ndarray) -> str:
306
+ samples = np.asarray(pcm, dtype="<f4")
307
+ return base64.b64encode(samples.tobytes()).decode("ascii")
308
+
309
+
310
+ def write_wav(pcm: np.ndarray, sample_rate: int = SAMPLE_RATE) -> str:
311
+ output = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
312
+ output.close()
313
+ sf.write(output.name, np.asarray(pcm, dtype=np.float32), sample_rate, subtype="PCM_16")
314
+ return output.name
web/PCMPlayerWorklet.js ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class PCMPlayerProcessor extends AudioWorkletProcessor {
2
+ constructor() {
3
+ super();
4
+ this.queue = [];
5
+ this.offset = 0;
6
+ this.bufferedSamples = 0;
7
+ this.started = false;
8
+ this.startThreshold = Math.round(sampleRate * 0.25);
9
+ this.port.onmessage = (event) => {
10
+ if (event.data.type === "reset") {
11
+ this.queue = [];
12
+ this.offset = 0;
13
+ this.bufferedSamples = 0;
14
+ this.started = false;
15
+ return;
16
+ }
17
+ if (event.data.type === "audio") {
18
+ const samples = new Float32Array(event.data.samples);
19
+ this.queue.push(samples);
20
+ this.bufferedSamples += samples.length;
21
+ }
22
+ };
23
+ }
24
+
25
+ process(_inputs, outputs) {
26
+ const output = outputs[0][0];
27
+ output.fill(0);
28
+ if (!this.started) {
29
+ this.started = this.bufferedSamples >= this.startThreshold;
30
+ if (!this.started) {
31
+ return true;
32
+ }
33
+ }
34
+
35
+ let outputOffset = 0;
36
+ while (outputOffset < output.length && this.queue.length) {
37
+ const current = this.queue[0];
38
+ const count = Math.min(output.length - outputOffset, current.length - this.offset);
39
+ output.set(current.subarray(this.offset, this.offset + count), outputOffset);
40
+ outputOffset += count;
41
+ this.offset += count;
42
+ this.bufferedSamples -= count;
43
+ if (this.offset === current.length) {
44
+ this.queue.shift();
45
+ this.offset = 0;
46
+ }
47
+ }
48
+ if (this.started && outputOffset < output.length) {
49
+ this.port.postMessage({ type: "underrun" });
50
+ }
51
+ return true;
52
+ }
53
+ }
54
+
55
+ registerProcessor("audex-pcm-player", PCMPlayerProcessor);