Spaces:
Running on Zero
Running on Zero
Upload folder using huggingface_hub
Browse files- .gitattributes +2 -0
- README.md +58 -7
- app.py +336 -0
- assets/ami_meeting_2speakers.wav +3 -0
- assets/ami_meeting_4speakers.wav +3 -0
- requirements.txt +8 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
assets/ami_meeting_2speakers.wav filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
assets/ami_meeting_4speakers.wav filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,13 +1,64 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.26.0
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: A2R Speaker-Attributed Reasoning
|
| 3 |
+
emoji: 🔊
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.26.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
short_description: Ask WHO is speaking in multi-party audio with A2R
|
| 10 |
+
python_version: "3.12"
|
| 11 |
+
startup_duration_timeout: 1h
|
| 12 |
+
models:
|
| 13 |
+
- PleasedPenguin/A2R-30B-A3B
|
| 14 |
+
tags:
|
| 15 |
+
- audio
|
| 16 |
+
- speaker-attribution
|
| 17 |
+
- speech-reasoning
|
| 18 |
---
|
| 19 |
|
| 20 |
+
# A2R-30B-A3B · Speaker-Attributed Reasoning
|
| 21 |
+
|
| 22 |
+
Gradio ZeroGPU demo for
|
| 23 |
+
[**PleasedPenguin/A2R-30B-A3B**](https://huggingface.co/PleasedPenguin/A2R-30B-A3B)
|
| 24 |
+
— the merged 30B (3B active, MoE) speech-language model from
|
| 25 |
+
**HEAR Who Said What: Unlocking Speaker-Attributed Reasoning via Counterfactual
|
| 26 |
+
Voice Grounding** (EMNLP 2026).
|
| 27 |
+
|
| 28 |
+
A2R is `Qwen/Qwen3-Omni-30B-A3B-Instruct` fine-tuned with GRPO on CASH-60K
|
| 29 |
+
counterfactual voice-grounding queries, so it answers questions about **who** is
|
| 30 |
+
speaking in multi-party audio — speaker counting, turn order, voice identity,
|
| 31 |
+
overlap — rather than only what is said. It reasons before answering and closes
|
| 32 |
+
with a structured block:
|
| 33 |
+
|
| 34 |
+
```
|
| 35 |
+
<reasoning> … </reasoning><answer>{"Answer": "B"}</answer>
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## How it works
|
| 39 |
+
|
| 40 |
+
- Upload or record a **multi-speaker clip**, ask a question about the speakers
|
| 41 |
+
(optionally with answer options).
|
| 42 |
+
- The demo feeds the audio as a single 16 kHz waveform followed by the
|
| 43 |
+
question, matching the prompt format of the paper's
|
| 44 |
+
[evaluation harness](https://github.com/dwsmart32/HEAR) (Qwen default system
|
| 45 |
+
prompt, temperature 0.6, top-p 0.95).
|
| 46 |
+
- Both the parsed answer and the reasoning trace are shown.
|
| 47 |
+
|
| 48 |
+
The thinker runs in bf16 on a ZeroGPU `xlarge` slice (the model is
|
| 49 |
+
text-output only — `enable_audio_output: false` — so the speech talker is not
|
| 50 |
+
loaded).
|
| 51 |
+
|
| 52 |
+
## Example clips
|
| 53 |
+
|
| 54 |
+
The bundled examples are individual-headset-microphone segments from the
|
| 55 |
+
[AMI Meeting Corpus](https://groups.inf.ed.ac.uk/ami/corpus/), © AMI Project,
|
| 56 |
+
licensed under **CC BY 4.0**. AMI is one of the source corpora of the HEAR
|
| 57 |
+
benchmark, so these are faithful example inputs. The gated HEAR synthetic
|
| 58 |
+
audio itself is non-redistributable and is not used here.
|
| 59 |
+
|
| 60 |
+
Links:
|
| 61 |
+
- Paper: https://huggingface.co/papers/2608.29120
|
| 62 |
+
- Model: https://huggingface.co/PleasedPenguin/A2R-30B-A3B
|
| 63 |
+
- Code: https://github.com/dwsmart32/HEAR
|
| 64 |
+
- Benchmark: https://huggingface.co/datasets/PleasedPenguin/HEAR
|
app.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A2R-30B-A3B — speaker-attributed reasoning over multi-party audio (ZeroGPU).
|
| 2 |
+
|
| 3 |
+
A2R (HEAR Who Said What, EMNLP 2026) is Qwen3-Omni-30B-A3B-Instruct trained with
|
| 4 |
+
GRPO on CASH-60K to answer questions about WHO is speaking in multi-party audio,
|
| 5 |
+
not only what is said. This demo loads the merged checkpoint's thinker
|
| 6 |
+
(the model is text-output only: `enable_audio_output: false`) in bf16 on a
|
| 7 |
+
ZeroGPU `xlarge` slice and answers speaker-attribution questions about an
|
| 8 |
+
uploaded clip.
|
| 9 |
+
|
| 10 |
+
The prompt follows the paper's evaluation harness (dwsmart32/HEAR): the audio is
|
| 11 |
+
presented first as a single waveform, then the question + options, with Qwen's
|
| 12 |
+
default system prompt, temperature 0.6 / top_p 0.95. A2R closes with
|
| 13 |
+
`<reasoning>…</reasoning><answer>{"Answer": "X"}</answer>`; we surface both the
|
| 14 |
+
reasoning trace and the parsed answer.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
|
| 19 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 20 |
+
os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import re
|
| 24 |
+
import time
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
import spaces # must precede torch / transformers
|
| 28 |
+
import gradio as gr
|
| 29 |
+
import numpy as np
|
| 30 |
+
import torch
|
| 31 |
+
import soundfile as sf
|
| 32 |
+
from transformers import AutoProcessor, Qwen3OmniMoeThinkerForConditionalGeneration
|
| 33 |
+
|
| 34 |
+
MODEL_ID = "PleasedPenguin/A2R-30B-A3B"
|
| 35 |
+
PAPER_URL = "https://huggingface.co/papers/2608.29120"
|
| 36 |
+
MODEL_URL = f"https://huggingface.co/{MODEL_ID}"
|
| 37 |
+
CODE_URL = "https://github.com/dwsmart32/HEAR"
|
| 38 |
+
|
| 39 |
+
# Qwen's official default system prompt — what the paper's harness applies to a
|
| 40 |
+
# non-baseline Qwen3-Omni checkpoint.
|
| 41 |
+
SYSTEM_PROMPT = (
|
| 42 |
+
"You are Qwen, a virtual human developed by the Qwen Team, Alibaba Group, "
|
| 43 |
+
"capable of perceiving auditory and visual inputs, as well as generating "
|
| 44 |
+
"text and speech."
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
APP_DIR = Path(__file__).resolve().parent
|
| 48 |
+
ASSET_DIR = APP_DIR / "assets"
|
| 49 |
+
MAX_AUDIO_SECONDS = 120
|
| 50 |
+
SAMPLE_RATE = 16000
|
| 51 |
+
|
| 52 |
+
# --------------------------------------------------------------------------- #
|
| 53 |
+
# Module-scope model load (bf16 thinker only, ~63 GB — needs a 96GB xlarge).
|
| 54 |
+
# ZeroGPU packs the weights to disk at startup and streams them into VRAM on
|
| 55 |
+
# the first @spaces.GPU call.
|
| 56 |
+
# --------------------------------------------------------------------------- #
|
| 57 |
+
print(f"Loading processor for {MODEL_ID} ...", flush=True)
|
| 58 |
+
PROCESSOR = AutoProcessor.from_pretrained(MODEL_ID)
|
| 59 |
+
|
| 60 |
+
print("Loading A2R thinker (30B-A3B MoE, bf16) ...", flush=True)
|
| 61 |
+
MODEL = Qwen3OmniMoeThinkerForConditionalGeneration.from_pretrained(
|
| 62 |
+
MODEL_ID,
|
| 63 |
+
dtype=torch.bfloat16,
|
| 64 |
+
low_cpu_mem_usage=True,
|
| 65 |
+
attn_implementation="sdpa",
|
| 66 |
+
).to("cuda")
|
| 67 |
+
MODEL.eval()
|
| 68 |
+
print("A2R thinker loaded and moved to cuda.", flush=True)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# --------------------------------------------------------------------------- #
|
| 72 |
+
# Audio helpers
|
| 73 |
+
# --------------------------------------------------------------------------- #
|
| 74 |
+
def load_audio(audio_path: str, target_sr: int = SAMPLE_RATE):
|
| 75 |
+
"""Load any user-supplied audio file to mono float32 at 16 kHz."""
|
| 76 |
+
audio, sr = sf.read(audio_path, dtype="float32", always_2d=False)
|
| 77 |
+
audio = np.asarray(audio)
|
| 78 |
+
if audio.ndim == 2: # stereo -> mono
|
| 79 |
+
if audio.shape[1] <= audio.shape[0]:
|
| 80 |
+
audio = audio.mean(axis=1)
|
| 81 |
+
else:
|
| 82 |
+
audio = audio.mean(axis=0)
|
| 83 |
+
if sr != target_sr:
|
| 84 |
+
import librosa
|
| 85 |
+
|
| 86 |
+
audio = librosa.resample(audio, orig_sr=sr, target_sr=target_sr)
|
| 87 |
+
return audio.astype(np.float32), target_sr
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _seconds(audio: np.ndarray) -> float:
|
| 91 |
+
return len(audio) / SAMPLE_RATE
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# --------------------------------------------------------------------------- #
|
| 95 |
+
# Prompt construction (mirrors the HEAR harness prompt builder)
|
| 96 |
+
# --------------------------------------------------------------------------- #
|
| 97 |
+
def build_instruction(question: str, options: str) -> str:
|
| 98 |
+
"""Assemble the question text the way the paper's harness does.
|
| 99 |
+
|
| 100 |
+
`options` is a free-form textarea; if the user typed option lines we append
|
| 101 |
+
them as "(A) ... (B) ..." is left verbatim, otherwise the question is asked
|
| 102 |
+
open-ended. The harness always asks for a JSON answer.
|
| 103 |
+
"""
|
| 104 |
+
opts = (options or "").strip()
|
| 105 |
+
body = question.strip()
|
| 106 |
+
if opts:
|
| 107 |
+
body = f"{body}\n\nOptions:\n{opts}"
|
| 108 |
+
return f"{body}\n\nRespond in JSON format, e.g. {{\"Answer\": \"A\"}}"
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def build_conversation(audio: np.ndarray, instruction: str):
|
| 112 |
+
return [
|
| 113 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 114 |
+
{
|
| 115 |
+
"role": "user",
|
| 116 |
+
"content": [
|
| 117 |
+
{"type": "audio", "audio": audio},
|
| 118 |
+
{"type": "text", "text": instruction},
|
| 119 |
+
],
|
| 120 |
+
},
|
| 121 |
+
]
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
ANSWER_RE = re.compile(r"<answer>\s*(.*?)\s*</answer>", re.DOTALL | re.IGNORECASE)
|
| 125 |
+
REASONING_RE = re.compile(
|
| 126 |
+
r"<reasoning>\s*(.*?)\s*</reasoning>", re.DOTALL | re.IGNORECASE
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def parse_output(text: str):
|
| 131 |
+
"""Split the model's `<reasoning>`/`<answer>` blocks, per the model card."""
|
| 132 |
+
reasoning_m = REASONING_RE.search(text)
|
| 133 |
+
answer_m = ANSWER_RE.search(text)
|
| 134 |
+
reasoning = reasoning_m.group(1).strip() if reasoning_m else ""
|
| 135 |
+
answer_raw = answer_m.group(1).strip() if answer_m else ""
|
| 136 |
+
answer_letter = ""
|
| 137 |
+
if answer_raw:
|
| 138 |
+
try:
|
| 139 |
+
obj = json.loads(answer_raw)
|
| 140 |
+
answer_letter = str(obj.get("Answer", "")).strip()
|
| 141 |
+
except json.JSONDecodeError:
|
| 142 |
+
answer_letter = answer_raw.strip()
|
| 143 |
+
return reasoning, answer_letter, answer_raw
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# --------------------------------------------------------------------------- #
|
| 147 |
+
# Inference
|
| 148 |
+
# --------------------------------------------------------------------------- #
|
| 149 |
+
@spaces.GPU(duration=180, size="xlarge")
|
| 150 |
+
def answer_question(
|
| 151 |
+
audio_path: str,
|
| 152 |
+
question: str,
|
| 153 |
+
options: str = "",
|
| 154 |
+
max_new_tokens: int = 1024,
|
| 155 |
+
temperature: float = 0.6,
|
| 156 |
+
top_p: float = 0.95,
|
| 157 |
+
progress=gr.Progress(),
|
| 158 |
+
):
|
| 159 |
+
"""Answer a speaker-attribution question about a multi-speaker audio clip.
|
| 160 |
+
|
| 161 |
+
Args:
|
| 162 |
+
audio_path: path to the audio clip (multi-party speech works best).
|
| 163 |
+
question: the question about who speaks / speaker identity.
|
| 164 |
+
options: optional answer options, one per line, e.g. "(A) Alice".
|
| 165 |
+
max_new_tokens: maximum number of tokens A2R may generate.
|
| 166 |
+
temperature: sampling temperature (the paper uses 0.6).
|
| 167 |
+
top_p: nucleus sampling threshold (the paper uses 0.95).
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
(answer, reasoning, raw_output)
|
| 171 |
+
"""
|
| 172 |
+
t0 = time.time()
|
| 173 |
+
if not audio_path:
|
| 174 |
+
raise gr.Error("Please upload or record an audio clip first.")
|
| 175 |
+
if not (question or "").strip():
|
| 176 |
+
raise gr.Error("Please enter a question about the speakers.")
|
| 177 |
+
|
| 178 |
+
audio, sr = load_audio(audio_path)
|
| 179 |
+
dur = _seconds(audio)
|
| 180 |
+
if dur < 0.5:
|
| 181 |
+
raise gr.Error("The audio clip is too short to reason about speakers.")
|
| 182 |
+
if dur > MAX_AUDIO_SECONDS:
|
| 183 |
+
audio = audio[: MAX_AUDIO_SECONDS * SAMPLE_RATE]
|
| 184 |
+
dur = MAX_AUDIO_SECONDS
|
| 185 |
+
|
| 186 |
+
instruction = build_instruction(question, options)
|
| 187 |
+
conversation = build_conversation(audio, instruction)
|
| 188 |
+
text = PROCESSOR.apply_chat_template(
|
| 189 |
+
conversation, add_generation_prompt=True, tokenize=False
|
| 190 |
+
)
|
| 191 |
+
inputs = PROCESSOR(
|
| 192 |
+
text=text, audio=[audio], return_tensors="pt", padding=True
|
| 193 |
+
)
|
| 194 |
+
inputs = {k: v.to("cuda") for k, v in inputs.items()}
|
| 195 |
+
|
| 196 |
+
with torch.inference_mode():
|
| 197 |
+
out_ids = MODEL.generate(
|
| 198 |
+
**inputs,
|
| 199 |
+
max_new_tokens=int(max_new_tokens),
|
| 200 |
+
do_sample=float(temperature) > 0,
|
| 201 |
+
temperature=float(temperature) if float(temperature) > 0 else None,
|
| 202 |
+
top_p=float(top_p),
|
| 203 |
+
)
|
| 204 |
+
gen_ids = out_ids[:, inputs["input_ids"].shape[1]:]
|
| 205 |
+
response = PROCESSOR.batch_decode(
|
| 206 |
+
gen_ids, skip_special_tokens=True, clean_up_tokenization_spaces=False
|
| 207 |
+
)[0].strip()
|
| 208 |
+
|
| 209 |
+
reasoning, answer_letter, answer_raw = parse_output(response)
|
| 210 |
+
elapsed = time.time() - t0
|
| 211 |
+
stats = f"⏱ {elapsed:.1f}s · {dur:.0f}s audio · {len(gen_ids[0])} tokens"
|
| 212 |
+
display_answer = answer_letter if answer_letter else response
|
| 213 |
+
return display_answer, reasoning, response, stats
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# --------------------------------------------------------------------------- #
|
| 217 |
+
# UI
|
| 218 |
+
# --------------------------------------------------------------------------- #
|
| 219 |
+
EXAMPLES = [
|
| 220 |
+
[
|
| 221 |
+
str(ASSET_DIR / "ami_meeting_4speakers.wav"),
|
| 222 |
+
"Four people are talking. Who speaks first, and who speaks immediately after them?",
|
| 223 |
+
"",
|
| 224 |
+
],
|
| 225 |
+
[
|
| 226 |
+
str(ASSET_DIR / "ami_meeting_4speakers.wav"),
|
| 227 |
+
"How many distinct speakers can you hear in this recording?",
|
| 228 |
+
"",
|
| 229 |
+
],
|
| 230 |
+
[
|
| 231 |
+
str(ASSET_DIR / "ami_meeting_2speakers.wav"),
|
| 232 |
+
"Two speakers alternate in this clip. Who spoke last — the same person who spoke first, or the other one?",
|
| 233 |
+
"(A) The same person who spoke first\n(B) The other speaker",
|
| 234 |
+
],
|
| 235 |
+
]
|
| 236 |
+
|
| 237 |
+
CSS = """
|
| 238 |
+
#hero {text-align: center; margin-bottom: 6px;}
|
| 239 |
+
#hero h1 {font-size: 2rem; margin-bottom: 4px;}
|
| 240 |
+
"""
|
| 241 |
+
|
| 242 |
+
with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="A2R Speaker-Attributed Reasoning") as demo:
|
| 243 |
+
with gr.Column():
|
| 244 |
+
gr.HTML(
|
| 245 |
+
"""
|
| 246 |
+
<div id="hero">
|
| 247 |
+
<h1>🔊 A2R-30B-A3B · Speaker-Attributed Reasoning</h1>
|
| 248 |
+
<p style="font-size:1.05rem; opacity:.85;">
|
| 249 |
+
<i>HEAR Who Said What: Unlocking Speaker-Attributed Reasoning via
|
| 250 |
+
Counterfactual Voice Grounding</i> — ask <b>who</b> is speaking
|
| 251 |
+
in multi-party audio, not only what is said.
|
| 252 |
+
</p>
|
| 253 |
+
</div>
|
| 254 |
+
"""
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
with gr.Row(equal_height=False):
|
| 258 |
+
with gr.Column(scale=5):
|
| 259 |
+
audio_in = gr.Audio(
|
| 260 |
+
label="Multi-speaker audio clip",
|
| 261 |
+
sources=["upload", "microphone"],
|
| 262 |
+
type="filepath",
|
| 263 |
+
format="wav",
|
| 264 |
+
)
|
| 265 |
+
question_in = gr.Textbox(
|
| 266 |
+
label="Question about the speakers",
|
| 267 |
+
placeholder="e.g. Who speaks second? / Who spoke the longest? / How many speakers do you hear?",
|
| 268 |
+
lines=2,
|
| 269 |
+
value="Who speaks first, and who speaks immediately after them?",
|
| 270 |
+
)
|
| 271 |
+
options_in = gr.Textbox(
|
| 272 |
+
label="Answer options (optional, one per line)",
|
| 273 |
+
placeholder="(A) The first speaker\n(B) The second speaker\n(C) Someone else",
|
| 274 |
+
lines=3,
|
| 275 |
+
)
|
| 276 |
+
run_btn = gr.Button("Reason about the speakers", variant="primary")
|
| 277 |
+
|
| 278 |
+
with gr.Column(scale=4):
|
| 279 |
+
answer_out = gr.Textbox(label="Answer", show_copy_button=True)
|
| 280 |
+
reasoning_out = gr.Markdown(label="Reasoning trace")
|
| 281 |
+
stats_out = gr.Markdown()
|
| 282 |
+
with gr.Accordion("Raw model output", open=False):
|
| 283 |
+
raw_box = gr.Textbox(label="Raw output", lines=6, show_copy_button=True)
|
| 284 |
+
|
| 285 |
+
with gr.Accordion("Advanced settings", open=False):
|
| 286 |
+
with gr.Row():
|
| 287 |
+
max_new_tokens_in = gr.Slider(
|
| 288 |
+
128, 2048, value=1024, step=128,
|
| 289 |
+
label="Max new tokens",
|
| 290 |
+
info="A2R reasons before answering; 1024 covers most questions.",
|
| 291 |
+
)
|
| 292 |
+
temperature_in = gr.Slider(
|
| 293 |
+
0.0, 1.5, value=0.6, step=0.05,
|
| 294 |
+
label="Temperature",
|
| 295 |
+
info="The paper's evaluation uses 0.6.",
|
| 296 |
+
)
|
| 297 |
+
top_p_in = gr.Slider(
|
| 298 |
+
0.1, 1.0, value=0.95, step=0.05, label="Top-p"
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
gr.Examples(
|
| 302 |
+
examples=EXAMPLES,
|
| 303 |
+
inputs=[audio_in, question_in, options_in],
|
| 304 |
+
fn=answer_question,
|
| 305 |
+
outputs=[answer_out, reasoning_out, raw_box, stats_out],
|
| 306 |
+
cache_examples=True,
|
| 307 |
+
cache_mode="lazy",
|
| 308 |
+
label="Try an example (real meeting audio from the AMI corpus, CC BY 4.0)",
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
gr.Markdown(
|
| 312 |
+
f"""
|
| 313 |
+
**About.** A2R is `Qwen/Qwen3-Omni-30B-A3B-Instruct` fine-tuned with GRPO on
|
| 314 |
+
CASH-60K counterfactual voice-grounding queries, so it answers *who* said
|
| 315 |
+
what rather than just transcribing. It emits a reasoning trace followed by
|
| 316 |
+
`<answer>{{"Answer": "…"}}</answer>` — both are shown above.
|
| 317 |
+
Example clips are individual-headset-mic segments from the
|
| 318 |
+
[AMI Meeting Corpus](https://groups.inf.ed.ac.uk/ami/corpus/) (CC BY 4.0),
|
| 319 |
+
the same source corpus family the HEAR benchmark is built on.
|
| 320 |
+
|
| 321 |
+
📄 [Paper](https://huggingface.co/papers/2608.29120) ·
|
| 322 |
+
🤗 [Model](https://huggingface.co/PleasedPenguin/A2R-30B-A3B) ·
|
| 323 |
+
💻 [Code](https://github.com/dwsmart32/HEAR) ·
|
| 324 |
+
📊 [HEAR benchmark](https://huggingface.co/datasets/PleasedPenguin/HEAR)
|
| 325 |
+
"""
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
run_btn.click(
|
| 329 |
+
fn=answer_question,
|
| 330 |
+
inputs=[audio_in, question_in, options_in,
|
| 331 |
+
max_new_tokens_in, temperature_in, top_p_in],
|
| 332 |
+
outputs=[answer_out, reasoning_out, raw_box, stats_out],
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
if __name__ == "__main__":
|
| 336 |
+
demo.launch(mcp_server=True)
|
assets/ami_meeting_2speakers.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b9a5a62d4a60eb8e6425e4dbf5f5f543fdd6f0cc4c310336ff1c62cc9866be28
|
| 3 |
+
size 640044
|
assets/ami_meeting_4speakers.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0821e18f5758472e743a73da8ea57b079bb23f1197725d548263eba1ef21521f
|
| 3 |
+
size 1024044
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
transformers==5.16.1
|
| 2 |
+
accelerate>=1.8.0
|
| 3 |
+
safetensors>=0.4.5
|
| 4 |
+
soundfile>=0.13.0
|
| 5 |
+
librosa>=0.11.0
|
| 6 |
+
numpy>=1.26
|
| 7 |
+
hf-transfer>=0.1.4
|
| 8 |
+
torchvision
|