File size: 13,384 Bytes
3612fd5
7b592f7
3612fd5
 
7b592f7
 
 
 
 
3612fd5
7b592f7
 
3612fd5
 
7b592f7
3612fd5
 
 
7b592f7
3612fd5
 
7b592f7
4857f4f
c82db63
3612fd5
 
 
 
4857f4f
 
7b592f7
 
3612fd5
4857f4f
7b592f7
 
 
 
 
 
 
3612fd5
7b592f7
3612fd5
7b592f7
a689aaa
3612fd5
 
 
7b592f7
 
 
 
 
3612fd5
 
 
 
 
 
 
 
7b592f7
c82db63
4857f4f
 
 
 
 
 
 
 
 
3612fd5
c82db63
3612fd5
 
 
 
 
 
 
 
a689aaa
7b592f7
3612fd5
 
7b592f7
 
3612fd5
7b592f7
3612fd5
7b592f7
 
 
3612fd5
 
 
 
 
 
 
 
 
a689aaa
3612fd5
 
a689aaa
4857f4f
3612fd5
 
 
4857f4f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c82db63
7b592f7
3612fd5
 
62d8717
3612fd5
 
 
 
 
 
 
 
 
7b592f7
 
3612fd5
7b592f7
3612fd5
 
 
a689aaa
3612fd5
 
 
 
 
 
 
7b592f7
 
3612fd5
e1ffc0b
3612fd5
7b592f7
3612fd5
c82db63
3612fd5
 
 
 
7b592f7
3612fd5
 
7b592f7
3612fd5
7b592f7
3612fd5
c82db63
3612fd5
 
 
 
 
 
 
 
 
 
 
 
704df66
 
e57c076
704df66
 
c82db63
3612fd5
 
 
7b592f7
 
3612fd5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62d8717
3612fd5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e57c076
7b592f7
3612fd5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4857f4f
3612fd5
 
 
7b592f7
3612fd5
7b592f7
3612fd5
 
7b592f7
3612fd5
7b592f7
3612fd5
 
 
7b592f7
3612fd5
 
 
 
 
 
 
 
c82db63
3612fd5
7b592f7
3612fd5
7b592f7
 
3612fd5
7b592f7
3612fd5
 
7b592f7
 
3612fd5
 
 
 
 
 
 
7b592f7
3612fd5
 
7b592f7
 
 
3612fd5
c82db63
7b592f7
3612fd5
 
 
 
 
 
 
 
 
 
7b592f7
3612fd5
7b592f7
 
 
3612fd5
 
 
 
 
 
 
 
 
7b592f7
 
 
 
878ee9b
 
 
 
7b592f7
878ee9b
3612fd5
 
7b592f7
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
"""Audio Interaction Model β€” Gradio demo.

Takes an audio clip (microphone or file) and an optional text instruction,
streams a text response from the AudioInteraction model.
"""

import os
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")

import spaces  # MUST come before torch / any CUDA-touching import
import torch

# Monkey-patch torch.load to allow loading the audio encoder checkpoint
# (which pickles numpy/object globals and fails under torch 2.6+ weights_only=True default)
_orig_torch_load = torch.load
def _patched_torch_load(*args, **kwargs):
    return _orig_torch_load(*args, **{**kwargs, "weights_only": kwargs.get("weights_only", False)})
torch.load = _patched_torch_load

import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

import random
import numpy as np
import gradio as gr
import whisper
import tempfile
import time
import json
from pathlib import Path
from transformers import AutoConfig
from transformers.models.qwen2_5_omni.modeling_qwen2_5_omni import Qwen2_5OmniAudioEncoder
from huggingface_hub import snapshot_download
from safetensors.torch import load_file

from src.audiointeraction.dataset.TOKENS import (
    ASSISTANT, AUDIO_BEGIN, ENGLISH, KEEP_SILENCE, ONLINE, PAD,
    SYSTEM, TEXT_BEGIN, TEXT_END,
    HAPPY, SAD, ANGRY, SURPRISE, NORMAL, URGENT,
)
from src.audiointeraction.generate.base import (
    AUDIO_TOKENS_PER_CHUNK, encode_audio_chunks, encode_silence_chunks, sample
)
from src.audiointeraction.model import GPT, Config
from src.audiointeraction.tokenizer import Tokenizer

# ── Constants ──────────────────────────────────────────────

MODEL_ID = "zhifeixie/AudioInteraction"
SYSTEM_PROMPT = (
    "You are a helpful assistant. When there is no user text, if the audio contains a question, "
    "please answer it. If it is a sound effect, determine based on the sound whether help is needed."
)

EMOTION_EMOJI = {
    HAPPY:    "😊",
    SAD:      "😒",
    ANGRY:    "😠",
    SURPRISE: "😲",
    NORMAL:   "😐",
    URGENT:   "⚠️",
}


def set_seed(seed: int = 1337) -> None:
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False


# ── Model loading (module scope, eager) ────────────────────

def _resolve_checkpoint_paths(checkpoint_dir: str):
    ckpt = Path(checkpoint_dir)
    return (
        str(ckpt),
        str(ckpt),
        str(ckpt / "qwen25OmniConfig"),
        str(ckpt / "audiointeraction_ChunkwisedEncoder.pth"),
    )


def _load_audio_encoder(qwen_omni_ckpt, audio_tower_ckpt, device):
    cfg = AutoConfig.from_pretrained(qwen_omni_ckpt)
    audio_cfg = cfg.thinker_config.audio_config
    encoder = Qwen2_5OmniAudioEncoder._from_config(audio_cfg)
    state_dict = torch.load(audio_tower_ckpt, map_location=device)
    encoder.load_state_dict(state_dict)
    encoder.to(device).requires_grad_(False).eval()
    return encoder


# Download model weights at module scope
print("Downloading model weights from HuggingFace...")
ckpt_dir = snapshot_download(
    repo_id=MODEL_ID,
    repo_type="model",
    local_dir="./checkpoints",
    resume_download=True,
)
print(f"Model downloaded to {ckpt_dir}")

model_config_dir, trained_checkpoint, qwen_omni_ckpt, audio_tower_ckpt = \
    _resolve_checkpoint_paths(ckpt_dir)

set_seed(1337)
device = "cuda"

print("Loading language model...")
config = Config.from_file(Path(model_config_dir) / "model_config.yaml")
model = GPT(config)
model.max_seq_length = config.block_size

checkpoint_dir = Path(trained_checkpoint)
index_path = checkpoint_dir / "model.safetensors.index.json"
with open(index_path) as f:
    index = json.load(f)
shard_files = sorted(set(index["weight_map"].values()))
state_dict = {}
for shard in shard_files:
    state_dict.update(load_file(str(checkpoint_dir / shard), device="cpu"))
missing, unexpected = model.load_state_dict(state_dict, strict=True)
if missing or unexpected:
    print(f"[load_model] missing={missing[:3]}… unexpected={unexpected[:3]}…")
del state_dict  # free memory

model = model.to(device).to(torch.bfloat16)
model.eval()

print("Loading audio encoder...")
audio_encoder = _load_audio_encoder(qwen_omni_ckpt, audio_tower_ckpt, device)
# Keep audio encoder in float32 β€” the original code does not convert it to bf16

tokenizer = Tokenizer(model_config_dir)

system_ids = tokenizer.encode(SYSTEM_PROMPT).cpu().tolist()
prefix_ids = torch.LongTensor(
    [ONLINE, ENGLISH, SYSTEM, TEXT_BEGIN] + system_ids + [TEXT_END]
).to(device)

print("Model loaded successfully!")


# ── Inference ──────────────────────────────────────────────

@spaces.GPU(duration=180)
def interact(audio_file, text_instruction=""):
    """Process an audio clip and optional text instruction, return the model's text response.

    Args:
        audio_file: Path to an audio file (wav, mp3, m4a, etc.).
        text_instruction: Optional text instruction to guide the model's response.

    Returns:
        A tuple of (full_response_text, status_text) where full_response_text
        is the model's text response and status_text provides processing info.
    """
    if audio_file is None:
        return "Please provide an audio file.", "No audio input"

    t0 = time.perf_counter()

    # Set up KV cache on the correct device
    model.set_kv_cache(batch_size=1, device=device, dtype=torch.bfloat16)
    model.max_seq_length = model.config.block_size
    # Ensure cos/sin rope buffers are on the right device
    model.cos = model.cos.to(device)
    model.sin = model.sin.to(device)

    # Build prefix
    token = prefix_ids.clone()
    input_pos = torch.arange(0, prefix_ids.size(0), device=device, dtype=torch.int64)
    input_pos_maxp1 = torch.tensor(prefix_ids.size(0), device=device)

    # If text instruction provided, append it
    if text_instruction.strip():
        text_ids = tokenizer.encode(text_instruction).cpu().tolist()
        text_tokens = torch.LongTensor(
            [TEXT_BEGIN] + text_ids + [TEXT_END]
        ).to(device)
        token = torch.cat([token, text_tokens])
        input_pos = torch.cat([input_pos, torch.arange(
            prefix_ids.size(0), prefix_ids.size(0) + len(text_tokens),
            device=device, dtype=torch.int64
        )])

    # Encode audio
    audio_chunks = encode_audio_chunks(audio_file, audio_encoder, device)
    # Follow the original inference pattern: audio then 1s silence
    # The model often responds during the silence round after audio
    silence_chunks = encode_silence_chunks(1.0, audio_encoder, device)
    all_chunks = audio_chunks + silence_chunks
    n_chunks = len(all_chunks)

    # Run streaming inference
    response_parts = []
    current_text = []
    listening = True
    audio_idx = -1
    emotion_tag = ""
    max_tokens = 4096

    try:
        with torch.inference_mode():
            for _ in range(max_tokens - input_pos.numel()):
                if listening:
                    audio_idx += 1
                    if audio_idx >= n_chunks:
                        break
                    # Append [AUDIO_BEGIN, PAD*N, ASSISTANT]
                    new_tokens = torch.LongTensor(
                        [AUDIO_BEGIN] + [PAD] * AUDIO_TOKENS_PER_CHUNK + [ASSISTANT]
                    ).to(device)
                    new_positions = input_pos[-1] + torch.arange(1, len(new_tokens) + 1, device=device)
                    token = torch.cat([token, new_tokens])
                    input_pos = torch.cat([input_pos, new_positions])
                    input_pos_maxp1 = input_pos_maxp1 + len(new_tokens)

                    logits = model(
                        token.view(1, -1), None, 1,
                        all_chunks[audio_idx].to(device),
                        input_pos,
                        input_pos_maxp1=input_pos_maxp1,
                        audio_tokens_per_chunk=AUDIO_TOKENS_PER_CHUNK,
                    )
                else:
                    logits = model(
                        token.view(1, -1), None, 1,
                        None,
                        input_pos,
                        input_pos_maxp1=input_pos_maxp1,
                        audio_tokens_per_chunk=AUDIO_TOKENS_PER_CHUNK,
                    )

                token = sample(logits, temperature=0.0, top_p=0.0).to(torch.int64)
                int_token = token.item()
                input_pos = input_pos[-1].unsqueeze(0).add_(1)
                input_pos_maxp1 = input_pos_maxp1 + 1

                if listening:
                    if int_token == TEXT_BEGIN:
                        listening = False
                        current_text = [int_token]
                    elif int_token == KEEP_SILENCE:
                        pass  # model stays silent
                    else:
                        break
                else:
                    current_text.append(int_token)
                    if int_token == TEXT_END:
                        # Decode the turn (skip TEXT_BEGIN and optional emotion)
                        text_tokens = current_text[1:-1]  # strip TEXT_BEGIN and TEXT_END
                        if text_tokens:
                            # Check if first token is an emotion tag
                            if text_tokens[0] in EMOTION_EMOJI:
                                emotion_tag = EMOTION_EMOJI[text_tokens[0]]
                                text_tokens = text_tokens[1:]
                            decoded = tokenizer.decode(torch.tensor(text_tokens))
                            if decoded:
                                response_parts.append((emotion_tag, decoded))
                        current_text = []
                        listening = True
                    elif len(current_text) >= 2:
                        # Could be streaming text, but we collect at TEXT_END
                        pass

        # Handle any incomplete turn
        if not listening and len(current_text) > 1:
            text_tokens = current_text[1:]  # skip TEXT_BEGIN
            if text_tokens and text_tokens[0] in EMOTION_EMOJI:
                emotion_tag = EMOTION_EMOJI[text_tokens[0]]
                text_tokens = text_tokens[1:]
            decoded = tokenizer.decode(torch.tensor(text_tokens))
            if decoded:
                response_parts.append((emotion_tag, decoded))

    finally:
        model.clear_kv_cache()

    elapsed = time.perf_counter() - t0

    if not response_parts:
        return "The model listened to the audio but chose to stay silent. (This is expected for non-speech sounds when no response is warranted.)", \
               f"Processed {n_chunks} audio chunks in {elapsed:.1f}s β€” model stayed silent"

    # Format response
    formatted = []
    for emoji, text in response_parts:
        if emoji:
            formatted.append(f"{emoji} {text}")
        else:
            formatted.append(text)
    full_response = "\n".join(formatted)

    status = f"Processed {n_chunks} audio chunks in {elapsed:.1f}s β€” {len(response_parts)} response(s)"

    return full_response, status


# ── Gradio UI ──────────────────────────────────────────────

CSS = """
#col-container { max-width: 900px; margin: 0 auto; }
"""

with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo:
    gr.Markdown("# πŸŽ™οΈ Audio Interaction Model")
    gr.Markdown(
        "Upload or record an audio clip β€” the model listens and responds with text. "
        "This is a demo of [AudioInteraction](https://huggingface.co/zhifeixie/AudioInteraction), "
        "a streaming audio-language model that perceives audio and decides when to speak."
    )

    with gr.Column(elem_id="col-container"):
        with gr.Row():
            audio_input = gr.Audio(
                label="Audio Input",
                type="filepath",
                sources=["microphone", "upload"],
            )

        text_input = gr.Textbox(
            label="Text Instruction (optional)",
            placeholder="e.g., 'Translate this to English' or leave blank for auto-response",
            lines=2,
        )

        run_btn = gr.Button("Run", variant="primary")

        with gr.Row():
            response_output = gr.Textbox(
                label="Model Response",
                lines=8,
                interactive=False,
            )

        status_output = gr.Textbox(
            label="Status",
            interactive=False,
        )

    run_btn.click(
        fn=interact,
        inputs=[audio_input, text_input],
        outputs=[response_output, status_output],
    )

    gr.Examples(
        examples=[
            ["samples/sample01_count_bark.wav"],
            ["samples/sample03_cough_music.wav"],
            ["samples/sample02a_translate.wav"],
            ["samples/sample02b_translate.wav"],
        ],
        inputs=[audio_input],
        outputs=[response_output, status_output],
        fn=interact,
        cache_examples=True,
        cache_mode="lazy",
    )

demo.launch(mcp_server=True)