Spaces:
Running on Zero
Running on Zero
multimodalart HF Staff
Examples take audio only (drop empty text instruction column)
878ee9b verified | """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 ββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) |