multimodalart HF Staff commited on
Commit
c82db63
·
verified ·
1 Parent(s): a689aaa

Wrap inference in torch.no_grad() to avoid autograd-graph memory pressure

Browse files
Files changed (1) hide show
  1. app.py +285 -258
app.py CHANGED
@@ -1,34 +1,53 @@
1
- """Audio Interaction Model — Gradio demo.
2
 
3
- Takes an audio clip (microphone or file) and an optional text instruction,
4
- streams a text response from the AudioInteraction model.
 
 
 
5
  """
6
 
7
  import os
 
 
8
  os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
- import spaces # MUST come before torch / any CUDA-touching import
11
  import torch
12
 
13
- # Monkey-patch torch.load to allow loading the audio encoder checkpoint
14
- # (which pickles numpy/object globals and fails under torch 2.6+ weights_only=True default)
15
  _orig_torch_load = torch.load
16
- def _patched_torch_load(*args, **kwargs):
17
- return _orig_torch_load(*args, **{**kwargs, "weights_only": kwargs.get("weights_only", False)})
18
- torch.load = _patched_torch_load
19
 
20
- import sys
21
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
 
 
 
22
 
23
- import lightning as L
24
- import numpy as np
25
  import gradio as gr
26
- import whisper
27
- import tempfile
28
- import time
29
  from transformers import AutoConfig
30
  from transformers.models.qwen2_5_omni.modeling_qwen2_5_omni import Qwen2_5OmniAudioEncoder
31
- from huggingface_hub import snapshot_download
 
 
 
32
 
33
  from src.audiointeraction.dataset.TOKENS import (
34
  ASSISTANT, AUDIO_BEGIN, ENGLISH, KEEP_SILENCE, ONLINE, PAD,
@@ -36,327 +55,335 @@ from src.audiointeraction.dataset.TOKENS import (
36
  HAPPY, SAD, ANGRY, SURPRISE, NORMAL, URGENT,
37
  )
38
  from src.audiointeraction.generate.base import (
39
- AUDIO_TOKENS_PER_CHUNK, encode_audio_chunks, encode_silence_chunks, sample
 
 
40
  )
41
- from src.audiointeraction.model import GPT, Config
 
42
  from src.audiointeraction.tokenizer import Tokenizer
43
- from src.audiointeraction.utils import get_default_supported_precision
44
-
45
- from safetensors.torch import load_file
46
- from pathlib import Path
47
- import json
48
 
49
- # ── Constants ──────────────────────────────────────────────
 
 
 
 
50
 
51
- MODEL_ID = "zhifeixie/AudioInteraction"
52
  SYSTEM_PROMPT = (
53
  "You are a helpful assistant. When there is no user text, if the audio contains a question, "
54
  "please answer it. If it is a sound effect, determine based on the sound whether help is needed."
55
  )
56
 
57
- EMOTION_EMOJI = {
58
- HAPPY: "😊",
59
- SAD: "😢",
60
- ANGRY: "😠",
61
- SURPRISE: "😲",
62
- NORMAL: "😐",
63
- URGENT: "⚠️",
64
- }
65
-
66
 
67
- # ── Model loading (module scope, eager) ────────────────────
 
 
68
 
69
- def _resolve_checkpoint_paths(checkpoint_dir: str):
70
- ckpt = Path(checkpoint_dir)
71
- return (
72
- str(ckpt),
73
- str(ckpt),
74
- str(ckpt / "qwen25OmniConfig"),
75
- str(ckpt / "audiointeraction_ChunkwisedEncoder.pth"),
 
76
  )
 
77
 
78
 
79
- def _load_model(model_config_dir, trained_checkpoint):
80
- """Load GPT from sharded safetensors."""
81
- config = Config.from_file(Path(model_config_dir) / "model_config.yaml")
82
- model = GPT(config)
83
- model.max_seq_length = config.block_size
 
 
 
84
 
85
- checkpoint_dir = Path(trained_checkpoint)
86
- index_path = checkpoint_dir / "model.safetensors.index.json"
87
- if not index_path.is_file():
88
- raise FileNotFoundError(f"No model.safetensors.index.json under {checkpoint_dir}")
 
89
 
 
 
 
90
  with open(index_path) as f:
91
  index = json.load(f)
92
  shard_files = sorted(set(index["weight_map"].values()))
93
-
94
  state_dict = {}
95
  for shard in shard_files:
96
- state_dict.update(load_file(str(checkpoint_dir / shard), device="cpu"))
97
-
98
  missing, unexpected = model.load_state_dict(state_dict, strict=True)
99
  if missing or unexpected:
100
- print(f"[load_model] missing={missing[:3]} unexpected={unexpected[:3]}")
101
- return model
 
 
 
 
 
 
102
 
103
 
104
- def _load_audio_encoder(qwen_omni_ckpt, audio_tower_ckpt, device):
105
- cfg = AutoConfig.from_pretrained(qwen_omni_ckpt)
 
106
  audio_cfg = cfg.thinker_config.audio_config
107
  encoder = Qwen2_5OmniAudioEncoder._from_config(audio_cfg)
108
- state_dict = torch.load(audio_tower_ckpt, map_location=device)
109
  encoder.load_state_dict(state_dict)
110
- encoder.to(device).requires_grad_(False).eval()
 
 
111
  return encoder
112
 
113
 
114
- # Download model weights at module scope
115
- print("Downloading model weights from HuggingFace...")
116
- ckpt_dir = snapshot_download(
117
- repo_id=MODEL_ID,
118
- repo_type="model",
119
- local_dir="./checkpoints",
120
- resume_download=True,
121
- )
122
- print(f"Model downloaded to {ckpt_dir}")
123
-
124
- model_config_dir, trained_checkpoint, qwen_omni_ckpt, audio_tower_ckpt = \
125
- _resolve_checkpoint_paths(ckpt_dir)
126
-
127
- set_seed_called = False
128
- # We don't use Fabric on ZeroGPU — load directly and move to cuda
129
- device = "cuda"
130
- precision = "bf16-true"
131
-
132
- print("Loading language model...")
133
- model = _load_model(model_config_dir, trained_checkpoint)
134
- model = model.to(device).to(torch.bfloat16)
135
- model.eval()
136
-
137
- print("Loading audio encoder...")
138
- audio_encoder = _load_audio_encoder(qwen_omni_ckpt, audio_tower_ckpt, device)
139
- audio_encoder = audio_encoder.to(torch.bfloat16)
140
-
141
- tokenizer = Tokenizer(model_config_dir)
142
 
143
  system_ids = tokenizer.encode(SYSTEM_PROMPT).cpu().tolist()
144
- prefix_ids = torch.LongTensor(
145
- [ONLINE, ENGLISH, SYSTEM, TEXT_BEGIN] + system_ids + [TEXT_END]
146
- ).to(device)
147
 
148
- print("Model loaded successfully!")
 
 
149
 
 
150
 
151
- # ── Inference ──────────────────────────────────────────────
152
 
153
- @spaces.GPU(duration=180)
154
- def interact(audio_file, text_instruction=""):
155
- """Process an audio clip and optional text instruction, return the model's text response.
156
 
157
- Args:
158
- audio_file: Path to an audio file (wav, mp3, m4a, etc.).
159
- text_instruction: Optional text instruction to guide the model's response.
 
 
 
 
160
 
161
- Returns:
162
- A tuple of (full_response_text, status_text) where full_response_text
163
- is the model's text response and status_text provides processing info.
 
164
  """
165
  if audio_file is None:
166
- return "Please provide an audio file.", "No audio input"
 
 
167
 
168
- t0 = time.perf_counter()
 
169
 
170
- # Set up KV cache
171
- model.set_kv_cache(batch_size=1)
172
- model.max_seq_length = model.config.block_size
173
 
174
- # Build prefix
175
- token = prefix_ids.clone()
176
  input_pos = torch.arange(0, prefix_ids.size(0), device=device, dtype=torch.int64)
177
- input_pos_maxp1 = torch.tensor(prefix_ids.size(0), device=device)
178
 
179
- # If text instruction provided, append it
180
- if text_instruction.strip():
181
- text_ids = tokenizer.encode(text_instruction).cpu().tolist()
182
- text_tokens = torch.LongTensor(
183
- [TEXT_BEGIN] + text_ids + [TEXT_END]
184
- ).to(device)
185
- token = torch.cat([token, text_tokens])
186
- input_pos = torch.cat([input_pos, torch.arange(
187
- prefix_ids.size(0), prefix_ids.size(0) + len(text_tokens),
188
- device=device, dtype=torch.int64
189
- )])
190
-
191
- # Encode audio
192
  audio_chunks = encode_audio_chunks(audio_file, audio_encoder, device)
193
- n_chunks = len(audio_chunks)
194
 
195
- # Run streaming inference
196
- response_parts = []
197
- current_text = []
 
 
 
 
 
 
198
  listening = True
199
  audio_idx = -1
200
- emotion_tag = ""
201
- max_tokens = 4096
202
-
203
- try:
204
- with torch.inference_mode():
205
- for _ in range(max_tokens - input_pos.numel()):
206
- if listening:
207
- audio_idx += 1
208
- if audio_idx >= n_chunks:
209
- break
210
- # Append [AUDIO_BEGIN, PAD*N, ASSISTANT]
211
- new_tokens = torch.LongTensor(
212
- [AUDIO_BEGIN] + [PAD] * AUDIO_TOKENS_PER_CHUNK + [ASSISTANT]
213
- ).to(device)
214
- new_positions = input_pos[-1] + torch.arange(1, len(new_tokens) + 1, device=device)
215
- token = torch.cat([token, new_tokens])
216
- input_pos = torch.cat([input_pos, new_positions])
217
- input_pos_maxp1 = input_pos_maxp1 + len(new_tokens)
218
-
219
- logits = model(
220
- token.view(1, -1), None, 1,
221
- audio_chunks[audio_idx].to(device).to(torch.bfloat16),
222
- input_pos,
223
- input_pos_maxp1=input_pos_maxp1,
224
- audio_tokens_per_chunk=AUDIO_TOKENS_PER_CHUNK,
225
- )
226
- else:
227
- logits = model(
228
- token.view(1, -1), None, 1,
229
- None,
230
- input_pos,
231
- input_pos_maxp1=input_pos_maxp1,
232
- audio_tokens_per_chunk=AUDIO_TOKENS_PER_CHUNK,
233
- )
234
-
235
- token = sample(logits, temperature=0.0, top_p=0.0).to(torch.int64)
236
- int_token = token.item()
237
- input_pos = input_pos[-1].unsqueeze(0).add_(1)
238
- input_pos_maxp1 = input_pos_maxp1 + 1
239
-
240
- if listening:
241
- if int_token == TEXT_BEGIN:
242
- listening = False
243
- current_text = [int_token]
244
- elif int_token == KEEP_SILENCE:
245
- pass # model stays silent
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  else:
247
- break
248
- else:
249
- current_text.append(int_token)
250
- if int_token == TEXT_END:
251
- # Decode the turn (skip TEXT_BEGIN and optional emotion)
252
- text_tokens = current_text[1:-1] # strip TEXT_BEGIN and TEXT_END
253
- if text_tokens:
254
- # Check if first token is an emotion tag
255
- if text_tokens[0] in EMOTION_EMOJI:
256
- emotion_tag = EMOTION_EMOJI[text_tokens[0]]
257
- text_tokens = text_tokens[1:]
258
- decoded = tokenizer.decode(torch.tensor(text_tokens))
259
- if decoded:
260
- response_parts.append((emotion_tag, decoded))
261
- current_text = []
262
- listening = True
263
- elif len(current_text) >= 2:
264
- # Could be streaming text, but we collect at TEXT_END
265
- pass
266
-
267
- # Handle any incomplete turn
268
- if not listening and len(current_text) > 1:
269
- text_tokens = current_text[1:] # skip TEXT_BEGIN
270
- if text_tokens[0] in EMOTION_EMOJI if text_tokens else False:
271
- emotion_tag = EMOTION_EMOJI[text_tokens[0]]
272
- text_tokens = text_tokens[1:]
273
- decoded = tokenizer.decode(torch.tensor(text_tokens))
274
  if decoded:
275
- response_parts.append((emotion_tag, decoded))
276
 
277
- finally:
278
- model.clear_kv_cache()
279
 
280
- elapsed = time.perf_counter() - t0
 
281
 
282
- if not response_parts:
283
- return "The model listened to the audio but chose to stay silent. (This is expected for non-speech sounds when no response is warranted.)", \
284
- f"Processed {n_chunks} audio chunks in {elapsed:.1f}s — model stayed silent"
285
 
286
- # Format response
287
- formatted = []
288
- for emoji, text in response_parts:
289
- if emoji:
290
- formatted.append(f"{emoji} {text}")
291
- else:
292
- formatted.append(text)
293
- full_response = "\n".join(formatted)
294
 
295
- status = f"Processed {n_chunks} audio chunks in {elapsed:.1f}s — {len(response_parts)} response(s)"
 
 
 
 
 
296
 
297
- return full_response, status
 
298
 
 
 
 
299
 
300
- # ── Gradio UI ──────────────────────────────────────────────
 
 
301
 
302
- CSS = """
303
- #col-container { max-width: 900px; margin: 0 auto; }
304
- .dark .gradio-container { color: var(--body-text-color); }
305
  """
306
 
307
- with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo:
308
- gr.Markdown("# 🎙️ Audio Interaction Model")
309
- gr.Markdown(
310
- "Upload or record an audio clip — the model listens and responds with text. "
311
- "This is a demo of [AudioInteraction](https://huggingface.co/zhifeixie/AudioInteraction), "
312
- "a streaming audio-language model that perceives audio and decides when to speak."
313
- )
314
 
315
- with gr.Column(elem_id="col-container"):
316
- with gr.Row():
317
  audio_input = gr.Audio(
318
  label="Audio Input",
319
  type="filepath",
320
- sources=["microphone", "upload"],
321
  )
 
 
 
 
 
 
322
 
323
- text_input = gr.Textbox(
324
- label="Text Instruction (optional)",
325
- placeholder="e.g., 'Translate this to English' or leave blank for auto-response",
326
- lines=2,
327
- )
328
-
329
- run_btn = gr.Button("Run", variant="primary")
330
-
331
- with gr.Row():
332
- response_output = gr.Textbox(
333
  label="Model Response",
334
- lines=8,
335
  interactive=False,
336
  )
337
 
338
- status_output = gr.Textbox(
339
- label="Status",
340
- interactive=False,
341
- )
342
-
343
- run_btn.click(
344
- fn=interact,
345
- inputs=[audio_input, text_input],
346
- outputs=[response_output, status_output],
347
  )
348
 
349
  gr.Examples(
350
  examples=[
351
- ["samples/sample01_01.mp3", ""],
352
- ["samples/sample02_01.mp3", "Translate this to English"],
353
- ["samples/sample03_01.wav", ""],
 
354
  ],
355
- inputs=[audio_input, text_input],
356
- outputs=[response_output, status_output],
357
- fn=interact,
358
  cache_examples=True,
359
  cache_mode="lazy",
360
  )
361
 
 
 
 
 
 
 
 
 
 
 
 
362
  demo.launch(mcp_server=True)
 
1
+ """Audio Interaction Model — Gradio demo on ZeroGPU.
2
 
3
+ Upload one or more audio clips (or record from microphone) and the model
4
+ will listen, decide whether to respond, and generate a text answer.
5
+
6
+ Based on the paper: "Audio Interaction Model" (arXiv:2606.05121)
7
+ Model weights: https://huggingface.co/zhifeixie/AudioInteraction
8
  """
9
 
10
  import os
11
+
12
+ # Avoid allocator fragmentation under memory spikes
13
  os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
14
+ # Prevent numba from initializing CUDA before spaces can patch torch
15
+ os.environ.setdefault("NUMBA_DISABLE_CUDA", "1")
16
+
17
+ # Install openai-whisper with --no-deps to avoid it downgrading torch.
18
+ # Its real runtime deps (tiktoken, more-itertools, tqdm, scipy) are in requirements.txt.
19
+ import subprocess, sys
20
+ try:
21
+ import whisper
22
+ except ImportError:
23
+ subprocess.run(
24
+ [sys.executable, "-m", "pip", "install", "--no-deps", "openai-whisper"],
25
+ check=True,
26
+ )
27
 
28
+ import spaces # MUST come before any CUDA-touching import
29
  import torch
30
 
31
+ # Patch torch.load to disable weights_only (the audio encoder checkpoint
32
+ # was saved with older torch that pickles numpy/object globals).
33
  _orig_torch_load = torch.load
34
+ torch.load = lambda *a, **k: _orig_torch_load(*a, **{**k, "weights_only": k.get("weights_only", False)})
 
 
35
 
36
+ import json
37
+ import sys as _sys
38
+ import time
39
+ from pathlib import Path
40
+ from typing import List, Optional, Tuple
41
 
 
 
42
  import gradio as gr
43
+ import numpy as np
44
+ from huggingface_hub import snapshot_download
 
45
  from transformers import AutoConfig
46
  from transformers.models.qwen2_5_omni.modeling_qwen2_5_omni import Qwen2_5OmniAudioEncoder
47
+ from safetensors.torch import load_file
48
+
49
+ # Add the app root to PYTHONPATH so `src.*` and `utils` imports resolve
50
+ _sys.path.insert(0, str(Path(__file__).resolve().parent))
51
 
52
  from src.audiointeraction.dataset.TOKENS import (
53
  ASSISTANT, AUDIO_BEGIN, ENGLISH, KEEP_SILENCE, ONLINE, PAD,
 
55
  HAPPY, SAD, ANGRY, SURPRISE, NORMAL, URGENT,
56
  )
57
  from src.audiointeraction.generate.base import (
58
+ AUDIO_TOKENS_PER_CHUNK,
59
+ sample,
60
+ encode_audio_chunks,
61
  )
62
+ from src.audiointeraction.model import GPT
63
+ from src.audiointeraction.config import Config
64
  from src.audiointeraction.tokenizer import Tokenizer
 
 
 
 
 
65
 
66
+ MODEL_REPO = "zhifeixie/AudioInteraction"
67
+ EMOTION_EMOJI = {
68
+ HAPPY: "😊", SAD: "😢", ANGRY: "😠", SURPRISE: "😲",
69
+ NORMAL: "😐", URGENT: "⚠️",
70
+ }
71
 
 
72
  SYSTEM_PROMPT = (
73
  "You are a helpful assistant. When there is no user text, if the audio contains a question, "
74
  "please answer it. If it is a sound effect, determine based on the sound whether help is needed."
75
  )
76
 
 
 
 
 
 
 
 
 
 
77
 
78
+ # ---------------------------------------------------------------------------
79
+ # Model loading (ZeroGPU-safe: no Lightning Fabric, no fabric.setup/init_module)
80
+ # ---------------------------------------------------------------------------
81
 
82
+ def _download_checkpoints() -> str:
83
+ """Download model weights from HuggingFace Hub and return local path."""
84
+ local_dir = "/home/user/checkpoints"
85
+ os.makedirs(local_dir, exist_ok=True)
86
+ snapshot_download(
87
+ repo_id=MODEL_REPO,
88
+ repo_type="model",
89
+ local_dir=local_dir,
90
  )
91
+ return local_dir
92
 
93
 
94
+ CHECKPOINT_DIR = _download_checkpoints()
95
+
96
+ ckpt = Path(CHECKPOINT_DIR)
97
+ MODEL_CONFIG_DIR = str(ckpt)
98
+ TRAINED_CHECKPOINT = str(ckpt)
99
+ QWEN_OMNI_CKPT = str(ckpt / "qwen25OmniConfig")
100
+ AUDIO_TOWER_CKPT = str(ckpt / "audiointeraction_ChunkwisedEncoder.pth")
101
+
102
 
103
+ def _load_model():
104
+ """Load the GPT model from sharded safetensors (ZeroGPU-safe, no Fabric)."""
105
+ config = Config.from_file(Path(MODEL_CONFIG_DIR) / "model_config.yaml")
106
+ # Force SDPA attention (flash_attn not available / sm_120 incompatible with FA3)
107
+ config.use_flash_attention = False
108
 
109
+ model = GPT(config)
110
+
111
+ index_path = Path(TRAINED_CHECKPOINT) / "model.safetensors.index.json"
112
  with open(index_path) as f:
113
  index = json.load(f)
114
  shard_files = sorted(set(index["weight_map"].values()))
 
115
  state_dict = {}
116
  for shard in shard_files:
117
+ state_dict.update(load_file(str(Path(TRAINED_CHECKPOINT) / shard), device="cpu"))
 
118
  missing, unexpected = model.load_state_dict(state_dict, strict=True)
119
  if missing or unexpected:
120
+ print(f"[load_model] missing={missing[:3]}... unexpected={unexpected[:3]}...")
121
+
122
+ # Use bf16 precision for inference
123
+ model = model.to(torch.bfloat16)
124
+ # .to("cuda") is intercepted by ZeroGPU's spaces hijack — safe at module scope
125
+ model.to("cuda")
126
+ model.eval()
127
+ return model, config
128
 
129
 
130
+ def _load_audio_encoder():
131
+ """Load the Qwen2.5-Omni audio encoder."""
132
+ cfg = AutoConfig.from_pretrained(QWEN_OMNI_CKPT)
133
  audio_cfg = cfg.thinker_config.audio_config
134
  encoder = Qwen2_5OmniAudioEncoder._from_config(audio_cfg)
135
+ state_dict = torch.load(AUDIO_TOWER_CKPT, map_location="cpu")
136
  encoder.load_state_dict(state_dict)
137
+ encoder = encoder.to(torch.bfloat16)
138
+ # .to("cuda") is intercepted by ZeroGPU's spaces hijack
139
+ encoder.to("cuda").requires_grad_(False).eval()
140
  return encoder
141
 
142
 
143
+ print("Loading model and audio encoder...")
144
+ model, model_config = _load_model()
145
+ audio_encoder = _load_audio_encoder()
146
+ tokenizer = Tokenizer(MODEL_CONFIG_DIR)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
  system_ids = tokenizer.encode(SYSTEM_PROMPT).cpu().tolist()
149
+ _prefix_token_list = [ONLINE, ENGLISH, SYSTEM, TEXT_BEGIN] + system_ids + [TEXT_END]
 
 
150
 
151
+ # Set up KV cache (no fabric.init_tensor needed)
152
+ model.set_kv_cache(batch_size=1)
153
+ model.eval()
154
 
155
+ print("Model and audio encoder loaded successfully!")
156
 
 
157
 
158
+ # ---------------------------------------------------------------------------
159
+ # Inference
160
+ # ---------------------------------------------------------------------------
161
 
162
+ @spaces.GPU(duration=120)
163
+ @torch.no_grad()
164
+ def interact_with_audio(
165
+ audio_file: str,
166
+ text_instruction: str = "",
167
+ ) -> str:
168
+ """Listen to an audio clip and generate a text response.
169
 
170
+ Upload an audio file (wav/mp3/m4a/flac/ogg) and optionally add a text
171
+ instruction. The model will listen to the audio and decide whether to
172
+ respond it may answer a question, transcribe speech, translate,
173
+ describe a sound, or stay silent if the audio doesn't need a response.
174
  """
175
  if audio_file is None:
176
+ return "Please provide an audio file."
177
+
178
+ device = next(model.parameters()).device
179
 
180
+ # Build prefix tokens on the correct device
181
+ prefix_ids = torch.LongTensor(_prefix_token_list).to(device)
182
 
183
+ # Reset KV cache for a fresh session
184
+ model.clear_kv_cache()
185
+ model.set_kv_cache(batch_size=1, device=device, dtype=torch.bfloat16)
186
 
187
+ token = prefix_ids
 
188
  input_pos = torch.arange(0, prefix_ids.size(0), device=device, dtype=torch.int64)
189
+ prompt_size = prefix_ids.size(0)
190
 
191
+ # Encode the audio file into chunks
 
 
 
 
 
 
 
 
 
 
 
 
192
  audio_chunks = encode_audio_chunks(audio_file, audio_encoder, device)
 
193
 
194
+ # If there's a text instruction, prepend it as text tokens
195
+ if text_instruction.strip():
196
+ # Insert text instruction after the system prompt
197
+ instruction_ids = tokenizer.encode(text_instruction.strip()).cpu().tolist()
198
+ text_tokens = [TEXT_BEGIN] + instruction_ids + [TEXT_END]
199
+ text_tensor = torch.LongTensor(text_tokens).to(device)
200
+ token = torch.cat([token, text_tensor])
201
+
202
+ turns: List[List[int]] = []
203
  listening = True
204
  audio_idx = -1
205
+ current_turn: List[int] = []
206
+ text_started = False
207
+ emotion_prefix = ""
208
+
209
+ max_steps = 4096
210
+
211
+ for _ in range(max_steps - input_pos.numel()):
212
+ if listening:
213
+ audio_idx += 1
214
+ if audio_idx >= len(audio_chunks):
215
+ break
216
+ # Append listening block: [AUDIO_BEGIN, PAD*N, ASSISTANT]
217
+ new_tokens = torch.LongTensor(
218
+ [AUDIO_BEGIN] + [PAD] * AUDIO_TOKENS_PER_CHUNK + [ASSISTANT]
219
+ ).to(device)
220
+ new_positions = input_pos[-1] + torch.arange(1, len(new_tokens) + 1, device=device)
221
+ token = torch.cat([token, new_tokens])
222
+ input_pos = torch.cat([input_pos, new_positions])
223
+
224
+ logits = model(
225
+ token.view(1, -1), None, 1,
226
+ audio_chunks[audio_idx].to(device),
227
+ input_pos,
228
+ input_pos_maxp1=None,
229
+ audio_tokens_per_chunk=AUDIO_TOKENS_PER_CHUNK,
230
+ )
231
+ else:
232
+ logits = model(
233
+ token.view(1, -1), None, 1,
234
+ None,
235
+ input_pos,
236
+ input_pos_maxp1=None,
237
+ audio_tokens_per_chunk=AUDIO_TOKENS_PER_CHUNK,
238
+ )
239
+
240
+ token = sample(logits, temperature=0.0, top_p=0.0).to(torch.int64)
241
+ int_token = token.item()
242
+ # Advance input_pos by one -- REPLACE, not append: once the KV cache
243
+ # holds everything before it, the next forward call only needs the
244
+ # position of the single new token (see the reference `_advance_one`
245
+ # in src/audiointeraction/generate/base.py, which returns just
246
+ # `new_pos`). Concatenating here made input_pos grow every step and
247
+ # blow past the cache length.
248
+ new_pos = input_pos[-1].unsqueeze(0).add_(1)
249
+ input_pos = new_pos
250
+
251
+ if listening:
252
+ if int_token == TEXT_BEGIN:
253
+ listening = False
254
+ current_turn = [int_token]
255
+ text_started = False
256
+ emotion_prefix = ""
257
+ elif int_token == KEEP_SILENCE:
258
+ turns.append([int_token])
259
+ else:
260
+ # Unexpected token
261
+ break
262
+ else:
263
+ current_turn.append(int_token)
264
+ if int_token == TEXT_END:
265
+ turns.append(current_turn)
266
+ current_turn = []
267
+ text_started = False
268
+ listening = True
269
+ else:
270
+ n = len(current_turn)
271
+ if n == 2:
272
+ text_started = True
273
+ if int_token in EMOTION_EMOJI:
274
+ emotion_prefix = EMOTION_EMOJI[int_token] + " "
275
  else:
276
+ piece = tokenizer.decode(torch.tensor([int_token]))
277
+ if piece:
278
+ # Start output
279
+ pass
280
+ elif n >= 3:
281
+ pass
282
+
283
+ # Decode all turns into text
284
+ responses = []
285
+ for turn in turns:
286
+ if turn[0] == KEEP_SILENCE:
287
+ continue
288
+ # Extract text tokens: skip TEXT_BEGIN and optional emotion tag
289
+ text_tokens_list = turn[1:] # skip TEXT_BEGIN
290
+ if text_tokens_list and text_tokens_list[-1] == TEXT_END:
291
+ text_tokens_list = text_tokens_list[:-1]
292
+ # Check if first token is an emotion tag
293
+ emotion = ""
294
+ if text_tokens_list and text_tokens_list[0] in EMOTION_EMOJI:
295
+ emotion = EMOTION_EMOJI[text_tokens_list[0]] + " "
296
+ text_tokens_list = text_tokens_list[1:]
297
+
298
+ if text_tokens_list:
299
+ decoded = tokenizer.decode(torch.tensor(text_tokens_list))
 
 
 
300
  if decoded:
301
+ responses.append(emotion + decoded)
302
 
303
+ model.clear_kv_cache()
 
304
 
305
+ if not responses:
306
+ return "🔇 The model listened to the audio and chose to stay silent. (This is expected for non-question sounds — the model decides for itself when to speak.)"
307
 
308
+ return "\n\n".join(responses)
 
 
309
 
 
 
 
 
 
 
 
 
310
 
311
+ # ---------------------------------------------------------------------------
312
+ # Gradio UI
313
+ # ---------------------------------------------------------------------------
314
+
315
+ DESCRIPTION = """
316
+ # Audio Interaction Model
317
 
318
+ This is a demo of the **Audio Interaction Model** from the paper
319
+ [Audio Interaction Model](https://arxiv.org/abs/2606.05121).
320
 
321
+ The model is an always-on streaming audio language model that listens to
322
+ audio and **decides for itself when to speak**. Upload an audio clip
323
+ (or record from microphone) and the model will:
324
 
325
+ - 🎧 **Listen** to the audio and understand its content
326
+ - 🧠 **Decide** whether a response is needed
327
+ - 💬 **Respond** with text (transcription, answer, translation, description, etc.)
328
 
329
+ The model may also choose to stay silent (⟨Silent⟩) if the audio doesn't
330
+ warrant a response this is a core feature of the Audio Interaction Model,
331
+ not a bug!
332
  """
333
 
334
+ with gr.Blocks(title="Audio Interaction Model") as demo:
335
+ gr.Markdown(DESCRIPTION)
 
 
 
 
 
336
 
337
+ with gr.Row():
338
+ with gr.Column():
339
  audio_input = gr.Audio(
340
  label="Audio Input",
341
  type="filepath",
342
+ sources=["upload", "microphone"],
343
  )
344
+ text_instruction = gr.Textbox(
345
+ label="Text Instruction (optional)",
346
+ placeholder="e.g. 'Translate this to English' or leave empty",
347
+ lines=2,
348
+ )
349
+ submit_btn = gr.Button("Interact", variant="primary")
350
 
351
+ with gr.Column():
352
+ output_text = gr.Textbox(
 
 
 
 
 
 
 
 
353
  label="Model Response",
354
+ lines=10,
355
  interactive=False,
356
  )
357
 
358
+ submit_btn.click(
359
+ fn=interact_with_audio,
360
+ inputs=[audio_input, text_instruction],
361
+ outputs=output_text,
 
 
 
 
 
362
  )
363
 
364
  gr.Examples(
365
  examples=[
366
+ ["sample/01_count_bark/sample01_01.mp3", ""],
367
+ ["sample/01_count_bark/sample01_02.wav", ""],
368
+ ["sample/02_translate/sample02_01.mp3", "Translate to English"],
369
+ ["sample/03_cough_music/sample03_01.wav", ""],
370
  ],
371
+ inputs=[audio_input, text_instruction],
372
+ outputs=output_text,
373
+ fn=interact_with_audio,
374
  cache_examples=True,
375
  cache_mode="lazy",
376
  )
377
 
378
+ gr.Markdown("""
379
+ ---
380
+ **Model**: [zhifeixie/AudioInteraction](https://huggingface.co/zhifeixie/AudioInteraction)
381
+ | **Paper**: [arXiv:2606.05121](https://arxiv.org/abs/2606.05121)
382
+ | **Code**: [GitHub](https://github.com/xzf-thu/Audio-Interaction)
383
+
384
+ The model is a 3B parameter streaming audio language model based on
385
+ Qwen2.5-Omni. It processes audio in 400ms chunks and generates text
386
+ responses when it decides intervention is needed.
387
+ """)
388
+
389
  demo.launch(mcp_server=True)