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

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -38,3 +38,4 @@ sample/02_translate/sample02_02.wav filter=lfs diff=lfs merge=lfs -text
38
  sample/02_translate/sample02_04.wav filter=lfs diff=lfs merge=lfs -text
39
  sample/03_cough_music/sample03_01.wav filter=lfs diff=lfs merge=lfs -text
40
  sample/03_cough_music/sample03_03.wav filter=lfs diff=lfs merge=lfs -text
 
 
38
  sample/02_translate/sample02_04.wav filter=lfs diff=lfs merge=lfs -text
39
  sample/03_cough_music/sample03_01.wav filter=lfs diff=lfs merge=lfs -text
40
  sample/03_cough_music/sample03_03.wav filter=lfs diff=lfs merge=lfs -text
41
+ samples/sample03_01.wav filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,28 +1,19 @@
1
  ---
2
  title: Audio Interaction Model
3
- emoji: 🎧
4
  colorFrom: blue
5
  colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
  app_file: app.py
9
  short_description: Streaming audio-language model that listens and responds
10
  python_version: "3.12"
11
- startup_duration_timeout: 30m
12
  ---
13
 
14
- # Audio Interaction Model
 
15
 
16
- This Space demos the **Audio Interaction Model** from the paper
17
- [Audio Interaction Model](https://arxiv.org/abs/2606.05121).
18
-
19
- The model is an always-on streaming audio language model that listens to
20
- audio and decides for itself when to speak. Upload an audio clip or record
21
- from microphone, and the model will listen, understand, and respond with text.
22
-
23
- ## Model
24
-
25
- - **Weights**: [zhifeixie/AudioInteraction](https://huggingface.co/zhifeixie/AudioInteraction)
26
- - **Architecture**: 3B parameter GPT based on Qwen2.5-Omni
27
- - **Paper**: [arXiv:2606.05121](https://arxiv.org/abs/2606.05121)
28
- - **Code**: [GitHub](https://github.com/xzf-thu/Audio-Interaction)
 
1
  ---
2
  title: Audio Interaction Model
3
+ emoji: 🎙️
4
  colorFrom: blue
5
  colorTo: red
6
  sdk: gradio
7
+ sdk_version: 6.15.1
8
  app_file: app.py
9
  short_description: Streaming audio-language model that listens and responds
10
  python_version: "3.12"
11
+ startup_duration_timeout: 1h
12
  ---
13
 
14
+ Audio Interaction Model demo — a streaming audio-language model that perceives audio
15
+ and decides when to speak. Upload or record audio and get a text response.
16
 
17
+ Model: [zhifeixie/AudioInteraction](https://huggingface.co/zhifeixie/AudioInteraction)
18
+ Paper: [arXiv:2606.05121](https://arxiv.org/abs/2606.05121)
19
+ Code: [GitHub](https://github.com/xzf-thu/Audio-Interaction)
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,53 +1,34 @@
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,334 +36,327 @@ from src.audiointeraction.dataset.TOKENS import (
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
- def interact_with_audio(
164
- audio_file: str,
165
- text_instruction: str = "",
166
- ) -> str:
167
- """Listen to an audio clip and generate a text response.
168
 
169
- Upload an audio file (wav/mp3/m4a/flac/ogg) and optionally add a text
170
- instruction. The model will listen to the audio and decide whether to
171
- respond it may answer a question, transcribe speech, translate,
172
- describe a sound, or stay silent if the audio doesn't need a response.
 
 
 
173
  """
174
  if audio_file is None:
175
- return "Please provide an audio file."
176
-
177
- device = next(model.parameters()).device
178
 
179
- # Build prefix tokens on the correct device
180
- prefix_ids = torch.LongTensor(_prefix_token_list).to(device)
181
 
182
- # Reset KV cache for a fresh session
183
- model.clear_kv_cache()
184
- model.set_kv_cache(batch_size=1, device=device, dtype=torch.bfloat16)
185
 
186
- token = prefix_ids
 
187
  input_pos = torch.arange(0, prefix_ids.size(0), device=device, dtype=torch.int64)
188
- prompt_size = prefix_ids.size(0)
189
 
190
- # Encode the audio file into chunks
191
- audio_chunks = encode_audio_chunks(audio_file, audio_encoder, device)
192
-
193
- # If there's a text instruction, prepend it as text tokens
194
  if text_instruction.strip():
195
- # Insert text instruction after the system prompt
196
- instruction_ids = tokenizer.encode(text_instruction.strip()).cpu().tolist()
197
- text_tokens = [TEXT_BEGIN] + instruction_ids + [TEXT_END]
198
- text_tensor = torch.LongTensor(text_tokens).to(device)
199
- token = torch.cat([token, text_tensor])
 
 
 
 
 
 
 
 
200
 
201
- turns: List[List[int]] = []
 
 
202
  listening = True
203
  audio_idx = -1
204
- current_turn: List[int] = []
205
- text_started = False
206
- emotion_prefix = ""
207
-
208
- max_steps = 4096
209
-
210
- for _ in range(max_steps - input_pos.numel()):
211
- if listening:
212
- audio_idx += 1
213
- if audio_idx >= len(audio_chunks):
214
- break
215
- # Append listening block: [AUDIO_BEGIN, PAD*N, ASSISTANT]
216
- new_tokens = torch.LongTensor(
217
- [AUDIO_BEGIN] + [PAD] * AUDIO_TOKENS_PER_CHUNK + [ASSISTANT]
218
- ).to(device)
219
- new_positions = input_pos[-1] + torch.arange(1, len(new_tokens) + 1, device=device)
220
- token = torch.cat([token, new_tokens])
221
- input_pos = torch.cat([input_pos, new_positions])
222
-
223
- logits = model(
224
- token.view(1, -1), None, 1,
225
- audio_chunks[audio_idx].to(device),
226
- input_pos,
227
- input_pos_maxp1=None,
228
- audio_tokens_per_chunk=AUDIO_TOKENS_PER_CHUNK,
229
- )
230
- else:
231
- logits = model(
232
- token.view(1, -1), None, 1,
233
- None,
234
- input_pos,
235
- input_pos_maxp1=None,
236
- audio_tokens_per_chunk=AUDIO_TOKENS_PER_CHUNK,
237
- )
238
-
239
- token = sample(logits, temperature=0.0, top_p=0.0).to(torch.int64)
240
- int_token = token.item()
241
- # Advance input_pos by one -- REPLACE, not append: once the KV cache
242
- # holds everything before it, the next forward call only needs the
243
- # position of the single new token (see the reference `_advance_one`
244
- # in src/audiointeraction/generate/base.py, which returns just
245
- # `new_pos`). Concatenating here made input_pos grow every step and
246
- # blow past the cache length.
247
- new_pos = input_pos[-1].unsqueeze(0).add_(1)
248
- input_pos = new_pos
249
-
250
- if listening:
251
- if int_token == TEXT_BEGIN:
252
- listening = False
253
- current_turn = [int_token]
254
- text_started = False
255
- emotion_prefix = ""
256
- elif int_token == KEEP_SILENCE:
257
- turns.append([int_token])
258
- else:
259
- # Unexpected token
260
- break
261
- else:
262
- current_turn.append(int_token)
263
- if int_token == TEXT_END:
264
- turns.append(current_turn)
265
- current_turn = []
266
- text_started = False
267
- listening = True
268
- else:
269
- n = len(current_turn)
270
- if n == 2:
271
- text_started = True
272
- if int_token in EMOTION_EMOJI:
273
- emotion_prefix = EMOTION_EMOJI[int_token] + " "
274
  else:
275
- piece = tokenizer.decode(torch.tensor([int_token]))
276
- if piece:
277
- # Start output
278
- pass
279
- elif n >= 3:
280
- pass
281
-
282
- # Decode all turns into text
283
- responses = []
284
- for turn in turns:
285
- if turn[0] == KEEP_SILENCE:
286
- continue
287
- # Extract text tokens: skip TEXT_BEGIN and optional emotion tag
288
- text_tokens_list = turn[1:] # skip TEXT_BEGIN
289
- if text_tokens_list and text_tokens_list[-1] == TEXT_END:
290
- text_tokens_list = text_tokens_list[:-1]
291
- # Check if first token is an emotion tag
292
- emotion = ""
293
- if text_tokens_list and text_tokens_list[0] in EMOTION_EMOJI:
294
- emotion = EMOTION_EMOJI[text_tokens_list[0]] + " "
295
- text_tokens_list = text_tokens_list[1:]
296
-
297
- if text_tokens_list:
298
- decoded = tokenizer.decode(torch.tensor(text_tokens_list))
 
 
 
299
  if decoded:
300
- responses.append(emotion + decoded)
301
-
302
- model.clear_kv_cache()
303
 
304
- if not responses:
305
- 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.)"
306
 
307
- return "\n\n".join(responses)
308
 
 
 
 
309
 
310
- # ---------------------------------------------------------------------------
311
- # Gradio UI
312
- # ---------------------------------------------------------------------------
 
 
 
 
 
313
 
314
- DESCRIPTION = """
315
- # Audio Interaction Model
316
 
317
- This is a demo of the **Audio Interaction Model** from the paper
318
- [Audio Interaction Model](https://arxiv.org/abs/2606.05121).
319
 
320
- The model is an always-on streaming audio language model that listens to
321
- audio and **decides for itself when to speak**. Upload an audio clip
322
- (or record from microphone) and the model will:
323
 
324
- - 🎧 **Listen** to the audio and understand its content
325
- - 🧠 **Decide** whether a response is needed
326
- - 💬 **Respond** with text (transcription, answer, translation, description, etc.)
327
 
328
- The model may also choose to stay silent (⟨Silent⟩) if the audio doesn't
329
- warrant a response this is a core feature of the Audio Interaction Model,
330
- not a bug!
331
  """
332
 
333
- with gr.Blocks(title="Audio Interaction Model") as demo:
334
- gr.Markdown(DESCRIPTION)
 
 
 
 
 
335
 
336
- with gr.Row():
337
- with gr.Column():
338
  audio_input = gr.Audio(
339
  label="Audio Input",
340
  type="filepath",
341
- sources=["upload", "microphone"],
342
- )
343
- text_instruction = gr.Textbox(
344
- label="Text Instruction (optional)",
345
- placeholder="e.g. 'Translate this to English' or leave empty",
346
- lines=2,
347
  )
348
- submit_btn = gr.Button("Interact", variant="primary")
349
 
350
- with gr.Column():
351
- output_text = gr.Textbox(
 
 
 
 
 
 
 
 
352
  label="Model Response",
353
- lines=10,
354
  interactive=False,
355
  )
356
 
357
- submit_btn.click(
358
- fn=interact_with_audio,
359
- inputs=[audio_input, text_instruction],
360
- outputs=output_text,
 
 
 
 
 
361
  )
362
 
363
  gr.Examples(
364
  examples=[
365
- ["sample/01_count_bark/sample01_01.mp3", ""],
366
- ["sample/01_count_bark/sample01_02.wav", ""],
367
- ["sample/02_translate/sample02_01.mp3", "Translate to English"],
368
- ["sample/03_cough_music/sample03_01.wav", ""],
369
  ],
370
- inputs=[audio_input, text_instruction],
371
- outputs=output_text,
372
- fn=interact_with_audio,
373
  cache_examples=True,
374
  cache_mode="lazy",
375
  )
376
 
377
- gr.Markdown("""
378
- ---
379
- **Model**: [zhifeixie/AudioInteraction](https://huggingface.co/zhifeixie/AudioInteraction)
380
- | **Paper**: [arXiv:2606.05121](https://arxiv.org/abs/2606.05121)
381
- | **Code**: [GitHub](https://github.com/xzf-thu/Audio-Interaction)
382
-
383
- The model is a 3B parameter streaming audio language model based on
384
- Qwen2.5-Omni. It processes audio in 400ms chunks and generates text
385
- responses when it decides intervention is needed.
386
- """)
387
-
388
  demo.launch(mcp_server=True)
 
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
  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)
requirements.txt CHANGED
@@ -1,15 +1,10 @@
 
1
  lightning
2
- transformers>=5.0.0
3
  safetensors
4
- tokenizers
5
  sentencepiece
6
- torchaudio
7
- librosa
8
- numpy
9
- pyyaml
10
  einops
11
- tiktoken
12
- more-itertools
13
- tqdm
14
- scipy
15
- numba
 
1
+ transformers==4.57.6
2
  lightning
3
+ openai-whisper
4
  safetensors
 
5
  sentencepiece
6
+ tokenizers
 
 
 
7
  einops
8
+ numpy
9
+ torchaudio
10
+ pyyaml
 
 
samples/sample01_01.mp3 ADDED
Binary file (89 kB). View file
 
samples/sample02_01.mp3 ADDED
Binary file (73.9 kB). View file
 
samples/sample03_01.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2451bf4cae9cfeeab9de667a1c0163dbe158e5ce687bf55077cbcbee4047297b
3
+ size 460878
src/audiointeraction/dataset/utils/__init__.py ADDED
File without changes
src/audiointeraction/dataset/utils/extract_online_feature.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Encode raw audio samples with Qwen2.5-Omni's audio tower; save to AudioFeat.pt.
2
+
3
+ Pure library — paths come in as arguments. The audio encoder is loaded once
4
+ per (qwen_omni_ckpt, audio_tower_ckpt, device) tuple and cached for reuse.
5
+ """
6
+
7
+ import os
8
+
9
+ import numpy as np
10
+ import torch
11
+ import whisper
12
+ from transformers import AutoConfig, Qwen2_5OmniForConditionalGeneration
13
+
14
+
15
+ _encoder_cache = {} # keyed by (qwen_omni_ckpt, audio_tower_ckpt, str(device))
16
+
17
+
18
+ def _load_encoder(qwen_omni_ckpt, audio_tower_ckpt, device):
19
+ cfg = AutoConfig.from_pretrained(qwen_omni_ckpt)
20
+ enc = Qwen2_5OmniForConditionalGeneration._from_config(cfg).thinker.audio_tower
21
+ enc.load_state_dict(torch.load(audio_tower_ckpt, map_location=device))
22
+ return enc.to(device).requires_grad_(False).eval()
23
+
24
+
25
+ def _split_into_chunks(n, chunk_size):
26
+ chunks = [chunk_size] * (n // chunk_size)
27
+ if n % chunk_size:
28
+ chunks.append(n % chunk_size)
29
+ return chunks
30
+
31
+
32
+ def extract_audio_features(audio_samples, save_dir, *,
33
+ qwen_omni_ckpt, audio_tower_ckpt, device="cuda"):
34
+ """Encode raw audio samples; save the feature tensor to `<save_dir>/AudioFeat.pt`."""
35
+ key = (qwen_omni_ckpt, audio_tower_ckpt, str(device))
36
+ if key not in _encoder_cache:
37
+ _encoder_cache[key] = _load_encoder(qwen_omni_ckpt, audio_tower_ckpt, device)
38
+ encoder = _encoder_cache[key]
39
+
40
+ mel = whisper.log_mel_spectrogram(np.array(audio_samples, dtype=np.float32), n_mels=128)
41
+ len_feature = mel.shape[1]
42
+
43
+ with torch.no_grad():
44
+ feat = encoder(
45
+ torch.tensor(mel).to(device),
46
+ torch.tensor(_split_into_chunks(len_feature, 40)).to(device),
47
+ torch.tensor((len_feature - 1) // 2 + 1).to(device),
48
+ ).last_hidden_state
49
+
50
+ os.makedirs(save_dir, exist_ok=True)
51
+ torch.save(feat.detach().cpu(), os.path.join(save_dir, "AudioFeat.pt"))
src/audiointeraction/dataset/utils/load_audio.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared audio loading helpers for the dataset preprocessing scripts.
3
+
4
+ The audio_tower in Qwen2.5-Omni does two stride-2 conv downsamples, so a mel
5
+ spectrogram of length L produces ((L-1)//2 + 1 - 2)//2 + 1 encoder-output
6
+ frames. We refer to these encoder-output frames just as "frames" throughout.
7
+ """
8
+
9
+ import librosa
10
+ import numpy as np
11
+ import whisper
12
+
13
+
14
+ # Number of raw audio samples (@ 16 kHz) per encoder-output frame.
15
+ # 16000 Hz * 40 ms/frame = 640 samples / frame.
16
+ SAMPLES_PER_FRAME = 640
17
+
18
+
19
+ def _count_output_lengths(input_lengths: int):
20
+ """Two-step downsample matching the audio_tower's conv stack."""
21
+ input_lengths = (input_lengths - 1) // 2 + 1
22
+ output_lengths = (input_lengths - 2) // 2 + 1
23
+ return input_lengths, output_lengths
24
+
25
+
26
+ def _load_mel(audio_path: str, max_seconds: float = None):
27
+ """Load audio @ 16 kHz, compute log-mel + downsampled lengths.
28
+
29
+ Args:
30
+ audio_path: path to audio file readable by librosa.
31
+ max_seconds: optional truncation of the raw audio (offline samples
32
+ cap at 20 s; online uses the full clip).
33
+
34
+ Returns:
35
+ audio : list[float], raw audio samples (post-truncation, pre-mel-padding).
36
+ mel : torch.Tensor of shape (128, len_feature), log-mel spectrogram.
37
+ len_feature : int, mel.shape[1] (the mel time axis length).
38
+ input_len : int, length after the first conv downsample.
39
+ output_len : int, length after both conv downsamples (encoder frames).
40
+ """
41
+ audio_np, _ = librosa.load(audio_path, sr=16000)
42
+ audio = audio_np.tolist()
43
+
44
+ if max_seconds is not None:
45
+ audio = audio[: int(max_seconds * 16000)]
46
+
47
+ # Pad to a 160-sample multiple for whisper's mel-hop alignment.
48
+ audio_for_mel = (
49
+ audio if len(audio) % 160 == 0
50
+ else audio + [0] * (160 - len(audio) % 160)
51
+ )
52
+ mel = whisper.log_mel_spectrogram(np.array(audio_for_mel, dtype=np.float32), n_mels=128)
53
+ len_feature = mel.shape[1]
54
+ input_len, output_len = _count_output_lengths(len_feature)
55
+
56
+ return audio, mel, len_feature, input_len, output_len