Spaces:
Running on Zero
Running on Zero
A2R-30B-A3B speaker-attributed reasoning demo
Browse files- .gitattributes +3 -0
- README.md +20 -49
- app.py +226 -308
- examples/ami_four_speakers.wav +3 -0
- examples/ami_three_speakers.wav +3 -0
- examples/ami_two_speakers.wav +3 -0
- requirements.txt +5 -7
.gitattributes
CHANGED
|
@@ -35,3 +35,6 @@ saved_model/**/* 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
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 38 |
+
examples/ami_four_speakers.wav filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
examples/ami_three_speakers.wav filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
examples/ami_two_speakers.wav filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,64 +1,35 @@
|
|
| 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
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
|
| 28 |
-
A2R is `Qwen/Qwen3-Omni-30B-A3B-Instruct`
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
overlap — rather than only what is said. It reasons before answering and closes
|
| 32 |
-
with a structured block:
|
| 33 |
|
| 34 |
-
``
|
| 35 |
-
|
| 36 |
-
```
|
| 37 |
|
| 38 |
-
##
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 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
|
|
|
|
| 1 |
---
|
| 2 |
+
title: A2R-30B-A3B 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 |
python_version: "3.12"
|
| 10 |
+
short_description: Ask who said what in a multi-speaker recording
|
| 11 |
startup_duration_timeout: 1h
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# A2R-30B-A3B — who said what?
|
| 15 |
|
| 16 |
+
Demo of [`PleasedPenguin/A2R-30B-A3B`](https://huggingface.co/PleasedPenguin/A2R-30B-A3B),
|
| 17 |
+
the model from **"HEAR Who Said What: Unlocking Speaker-Attributed Reasoning via
|
| 18 |
+
Counterfactual Voice Grounding"** ([paper](https://huggingface.co/papers/2608.29120),
|
| 19 |
+
[code](https://github.com/dwsmart32/HEAR),
|
| 20 |
+
[project page](https://attributetoreason.github.io/AttributeToReason/)).
|
| 21 |
|
| 22 |
+
A2R is `Qwen/Qwen3-Omni-30B-A3B-Instruct` post-trained with GRPO on counterfactual
|
| 23 |
+
audio containing speaker-level hard negatives, so it grounds answers in *vocal
|
| 24 |
+
identity* rather than in the transcript.
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
Prompt formatting, system prompt and sampling defaults (`temperature=0.6`,
|
| 27 |
+
`top_p=0.95`) follow the authors' evaluation harness.
|
|
|
|
| 28 |
|
| 29 |
+
## Example audio attribution
|
| 30 |
|
| 31 |
+
The bundled example clips are excerpts of the
|
| 32 |
+
[AMI Meeting Corpus](https://huggingface.co/datasets/diarizers-community/ami),
|
| 33 |
+
licensed **CC BY 4.0** — one of the source corpora behind the HEAR benchmark.
|
| 34 |
+
The HEAR benchmark audio itself is not redistributable and is therefore not
|
| 35 |
+
bundled here.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
CHANGED
|
@@ -1,365 +1,283 @@
|
|
| 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 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
import
|
| 24 |
-
import
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
import
|
| 28 |
-
|
| 29 |
-
import
|
| 30 |
-
import
|
| 31 |
-
import
|
| 32 |
-
from transformers import
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 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 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
#
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
print(f"
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
| 63 |
dtype=torch.bfloat16,
|
| 64 |
-
low_cpu_mem_usage=True,
|
| 65 |
attn_implementation="sdpa",
|
| 66 |
-
).
|
| 67 |
-
|
| 68 |
-
print("
|
| 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 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
{"type": "audio", "audio": audio},
|
| 118 |
-
{"type": "text", "text": instruction},
|
| 119 |
-
],
|
| 120 |
-
},
|
| 121 |
-
]
|
| 122 |
|
|
|
|
|
|
|
| 123 |
|
| 124 |
-
ANSWER_RE = re.compile(r"<answer>
|
| 125 |
-
REASONING_RE = re.compile(
|
| 126 |
-
r"<reasoning>\s*(.*?)\s*</reasoning>", re.DOTALL | re.IGNORECASE
|
| 127 |
-
)
|
| 128 |
|
| 129 |
|
| 130 |
-
def
|
| 131 |
-
"""Split
|
|
|
|
|
|
|
| 132 |
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
answer_letter = ""
|
| 142 |
-
if answer_raw:
|
| 143 |
try:
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
return
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
# --------------------------------------------------------------------------- #
|
| 161 |
-
# Inference
|
| 162 |
-
# --------------------------------------------------------------------------- #
|
| 163 |
-
def _estimate_duration(
|
| 164 |
-
audio_path: str | None,
|
| 165 |
-
question: str,
|
| 166 |
-
options: str = "",
|
| 167 |
-
max_new_tokens: int = 1024,
|
| 168 |
-
temperature: float = 0.6,
|
| 169 |
-
top_p: float = 0.95,
|
| 170 |
-
*_args,
|
| 171 |
-
**_kwargs,
|
| 172 |
-
) -> int:
|
| 173 |
-
"""ZeroGPU duration: weight streaming on cold start + A3B generation time."""
|
| 174 |
-
return min(420, 150 + int(max_new_tokens) // 6)
|
| 175 |
|
| 176 |
|
| 177 |
@spaces.GPU(duration=_estimate_duration, size="xlarge")
|
| 178 |
-
def
|
| 179 |
-
|
| 180 |
question: str,
|
| 181 |
-
|
| 182 |
-
max_new_tokens: int = 1024,
|
| 183 |
temperature: float = 0.6,
|
| 184 |
top_p: float = 0.95,
|
| 185 |
-
progress=gr.Progress(),
|
| 186 |
):
|
| 187 |
"""Answer a speaker-attribution question about a multi-speaker audio clip.
|
| 188 |
|
| 189 |
Args:
|
| 190 |
-
|
| 191 |
-
question: the question about who
|
| 192 |
-
|
| 193 |
max_new_tokens: maximum number of tokens A2R may generate.
|
| 194 |
-
temperature: sampling temperature
|
| 195 |
-
top_p: nucleus sampling
|
| 196 |
|
| 197 |
Returns:
|
| 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 |
-
inputs = {k: (v.to("cuda") if hasattr(v, "to") else v) for k, v in inputs.items()}
|
| 223 |
-
|
| 224 |
-
with torch.inference_mode():
|
| 225 |
-
out_ids = MODEL.generate(
|
| 226 |
-
**inputs,
|
| 227 |
-
max_new_tokens=int(max_new_tokens),
|
| 228 |
-
do_sample=float(temperature) > 0,
|
| 229 |
-
temperature=float(temperature) if float(temperature) > 0 else None,
|
| 230 |
-
top_p=float(top_p),
|
| 231 |
-
)
|
| 232 |
-
gen_ids = out_ids[:, inputs["input_ids"].shape[1]:]
|
| 233 |
-
response = PROCESSOR.batch_decode(
|
| 234 |
-
gen_ids, skip_special_tokens=True, clean_up_tokenization_spaces=False
|
| 235 |
-
)[0].strip()
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
|
| 244 |
-
# ---------------------------------------------------------------------------
|
| 245 |
# UI
|
| 246 |
-
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
| 247 |
EXAMPLES = [
|
| 248 |
[
|
| 249 |
-
|
| 250 |
-
"
|
| 251 |
-
""
|
|
|
|
| 252 |
],
|
| 253 |
[
|
| 254 |
-
|
| 255 |
-
"
|
| 256 |
-
"
|
|
|
|
| 257 |
],
|
| 258 |
[
|
| 259 |
-
|
| 260 |
-
"
|
| 261 |
-
"
|
|
|
|
|
|
|
|
|
|
| 262 |
],
|
| 263 |
]
|
| 264 |
|
| 265 |
CSS = """
|
| 266 |
-
#
|
| 267 |
-
|
| 268 |
-
.dark .gradio-container {color: var(--body-text-color);}
|
| 269 |
"""
|
| 270 |
|
| 271 |
-
with gr.Blocks(title="A2R
|
| 272 |
-
with gr.Column():
|
| 273 |
-
gr.
|
| 274 |
-
"""
|
| 275 |
-
<div id="hero">
|
| 276 |
-
<h1>🔊 A2R-30B-A3B · Speaker-Attributed Reasoning</h1>
|
| 277 |
-
<p style="font-size:1.05rem; opacity:.85;">
|
| 278 |
-
<i>HEAR Who Said What: Unlocking Speaker-Attributed Reasoning via
|
| 279 |
-
Counterfactual Voice Grounding</i> — ask <b>who</b> is speaking
|
| 280 |
-
in multi-party audio, not only what is said.
|
| 281 |
-
</p>
|
| 282 |
-
</div>
|
| 283 |
"""
|
| 284 |
-
|
| 285 |
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
sources=["upload", "microphone"],
|
| 291 |
-
type="filepath",
|
| 292 |
-
format="wav",
|
| 293 |
-
)
|
| 294 |
-
question_in = gr.Textbox(
|
| 295 |
-
label="Question about the speakers",
|
| 296 |
-
placeholder="e.g. Who speaks second? / Who spoke the longest? / How many speakers do you hear?",
|
| 297 |
-
lines=2,
|
| 298 |
-
value="Who speaks first, and who speaks immediately after them?",
|
| 299 |
-
)
|
| 300 |
-
options_in = gr.Textbox(
|
| 301 |
-
label="Answer options (optional, one per line)",
|
| 302 |
-
placeholder="(A) The first speaker\n(B) The second speaker\n(C) Someone else",
|
| 303 |
-
lines=3,
|
| 304 |
-
)
|
| 305 |
-
run_btn = gr.Button("Reason about the speakers", variant="primary")
|
| 306 |
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
|
| 314 |
-
with gr.Accordion("Advanced settings", open=False):
|
| 315 |
with gr.Row():
|
| 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 |
-
Example clips are individual-headset-mic segments from the
|
| 347 |
-
[AMI Meeting Corpus](https://groups.inf.ed.ac.uk/ami/corpus/) (CC BY 4.0),
|
| 348 |
-
the same source corpus family the HEAR benchmark is built on.
|
| 349 |
-
|
| 350 |
-
📄 [Paper](https://huggingface.co/papers/2608.29120) ·
|
| 351 |
-
🤗 [Model](https://huggingface.co/PleasedPenguin/A2R-30B-A3B) ·
|
| 352 |
-
💻 [Code](https://github.com/dwsmart32/HEAR) ·
|
| 353 |
-
📊 [HEAR benchmark](https://huggingface.co/datasets/PleasedPenguin/HEAR)
|
| 354 |
-
"""
|
| 355 |
-
)
|
| 356 |
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
inputs=[audio_in, question_in,
|
| 360 |
-
|
| 361 |
-
|
| 362 |
)
|
| 363 |
|
| 364 |
-
|
| 365 |
-
demo.launch(mcp_server=True, theme=gr.themes.Citrus(), css=CSS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
|
| 3 |
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 4 |
+
|
| 5 |
+
import spaces # noqa: E402 (must precede torch / CUDA-touching imports)
|
| 6 |
+
|
| 7 |
+
import json # noqa: E402
|
| 8 |
+
import re # noqa: E402
|
| 9 |
+
import shutil # noqa: E402
|
| 10 |
+
import threading # noqa: E402
|
| 11 |
+
import time # noqa: E402
|
| 12 |
+
|
| 13 |
+
import gradio as gr # noqa: E402
|
| 14 |
+
import librosa # noqa: E402
|
| 15 |
+
import torch # noqa: E402
|
| 16 |
+
from transformers import ( # noqa: E402
|
| 17 |
+
AutoProcessor,
|
| 18 |
+
Qwen3OmniMoeForConditionalGeneration,
|
| 19 |
+
TextIteratorStreamer,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
)
|
| 21 |
|
| 22 |
+
# --------------------------------------------------------------------------------------
|
| 23 |
+
# Model
|
| 24 |
+
# --------------------------------------------------------------------------------------
|
| 25 |
+
HUB_ID = "PleasedPenguin/A2R-30B-A3B"
|
| 26 |
+
# The 70 GB checkpoint is attached to this Space as a read-only volume so it never has to
|
| 27 |
+
# be copied onto the (small) ephemeral Space disk.
|
| 28 |
+
MOUNT = "/models/a2r"
|
| 29 |
+
MODEL_PATH = MOUNT if os.path.isfile(os.path.join(MOUNT, "config.json")) else HUB_ID
|
| 30 |
+
|
| 31 |
+
_total, _used, _free = shutil.disk_usage("/")
|
| 32 |
+
print(f"[boot] disk: total={_total / 2**30:.1f}GB used={_used / 2**30:.1f}GB free={_free / 2**30:.1f}GB")
|
| 33 |
+
print(f"[boot] loading {MODEL_PATH} ...")
|
| 34 |
+
|
| 35 |
+
_t0 = time.perf_counter()
|
| 36 |
+
processor = AutoProcessor.from_pretrained(MODEL_PATH)
|
| 37 |
+
model = Qwen3OmniMoeForConditionalGeneration.from_pretrained(
|
| 38 |
+
MODEL_PATH,
|
| 39 |
dtype=torch.bfloat16,
|
|
|
|
| 40 |
attn_implementation="sdpa",
|
| 41 |
+
).eval()
|
| 42 |
+
model = model.to("cuda")
|
| 43 |
+
print(f"[boot] model loaded in {time.perf_counter() - _t0:.1f}s (talker: {model.has_talker})")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
_total, _used, _free = shutil.disk_usage("/")
|
| 46 |
+
print(f"[boot] disk after load: used={_used / 2**30:.1f}GB free={_free / 2**30:.1f}GB")
|
| 47 |
|
| 48 |
+
# Qwen's official Omni system prompt. The paper's evaluation harness
|
| 49 |
+
# (github.com/dwsmart32/HEAR) applies exactly this to A2R.
|
| 50 |
+
SYSTEM_PROMPT = (
|
| 51 |
+
"You are Qwen, a virtual human developed by the Qwen Team, Alibaba Group, capable of "
|
| 52 |
+
"perceiving auditory and visual inputs, as well as generating text and speech."
|
| 53 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
+
MAX_AUDIO_SECONDS = 120
|
| 56 |
+
TARGET_SR = 16000
|
| 57 |
|
| 58 |
+
ANSWER_RE = re.compile(r"<answer>(.*?)</answer>", re.S)
|
| 59 |
+
REASONING_RE = re.compile(r"<reasoning>(.*?)(?:</reasoning>|$)", re.S)
|
|
|
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
+
def _split_output(text: str):
|
| 63 |
+
"""Split A2R's `<reasoning>...</reasoning><answer>{...}</answer>` output."""
|
| 64 |
+
reasoning_match = REASONING_RE.search(text)
|
| 65 |
+
reasoning = reasoning_match.group(1).strip() if reasoning_match else ""
|
| 66 |
|
| 67 |
+
answer_blocks = ANSWER_RE.findall(text)
|
| 68 |
+
answer = answer_blocks[-1].strip() if answer_blocks else ""
|
| 69 |
+
|
| 70 |
+
if not reasoning and not answer:
|
| 71 |
+
# Model answered without the structured wrapper (common for open-ended questions).
|
| 72 |
+
return "", text.strip()
|
| 73 |
+
|
| 74 |
+
if answer:
|
|
|
|
|
|
|
| 75 |
try:
|
| 76 |
+
parsed = json.loads(answer)
|
| 77 |
+
if isinstance(parsed, dict) and "Answer" in parsed:
|
| 78 |
+
answer = f"**{parsed['Answer']}**"
|
| 79 |
+
except Exception:
|
| 80 |
+
pass
|
| 81 |
+
return reasoning, answer
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _estimate_duration(audio, question, max_new_tokens=768, *args, **kwargs):
|
| 85 |
+
try:
|
| 86 |
+
n = int(max_new_tokens)
|
| 87 |
+
except Exception:
|
| 88 |
+
n = 768
|
| 89 |
+
return int(min(360, 60 + n * 0.3))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
@spaces.GPU(duration=_estimate_duration, size="xlarge")
|
| 93 |
+
def analyze(
|
| 94 |
+
audio: str,
|
| 95 |
question: str,
|
| 96 |
+
max_new_tokens: int = 768,
|
|
|
|
| 97 |
temperature: float = 0.6,
|
| 98 |
top_p: float = 0.95,
|
|
|
|
| 99 |
):
|
| 100 |
"""Answer a speaker-attribution question about a multi-speaker audio clip.
|
| 101 |
|
| 102 |
Args:
|
| 103 |
+
audio: path to an audio file containing one or more speakers.
|
| 104 |
+
question: the question to ask about who is speaking, optionally with
|
| 105 |
+
multiple-choice options.
|
| 106 |
max_new_tokens: maximum number of tokens A2R may generate.
|
| 107 |
+
temperature: sampling temperature.
|
| 108 |
+
top_p: nucleus sampling probability mass.
|
| 109 |
|
| 110 |
Returns:
|
| 111 |
+
The model's answer and its speaker-attribution reasoning trace.
|
| 112 |
"""
|
| 113 |
+
if not audio:
|
| 114 |
+
raise gr.Error("Please provide an audio clip.")
|
| 115 |
+
if not question or not question.strip():
|
| 116 |
+
raise gr.Error("Please ask a question about the audio.")
|
| 117 |
+
|
| 118 |
+
waveform, _ = librosa.load(audio, sr=TARGET_SR, mono=True)
|
| 119 |
+
if waveform.shape[0] > MAX_AUDIO_SECONDS * TARGET_SR:
|
| 120 |
+
waveform = waveform[: MAX_AUDIO_SECONDS * TARGET_SR]
|
| 121 |
+
gr.Info(f"Audio truncated to the first {MAX_AUDIO_SECONDS} seconds.")
|
| 122 |
+
|
| 123 |
+
messages = [
|
| 124 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 125 |
+
{
|
| 126 |
+
"role": "user",
|
| 127 |
+
"content": [
|
| 128 |
+
{"type": "audio", "audio": audio},
|
| 129 |
+
{"type": "text", "text": question.strip()},
|
| 130 |
+
],
|
| 131 |
+
},
|
| 132 |
+
]
|
| 133 |
+
text = processor.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
|
| 134 |
+
inputs = processor(
|
| 135 |
+
text=[text],
|
| 136 |
+
audio=[waveform],
|
| 137 |
+
sampling_rate=TARGET_SR,
|
| 138 |
+
return_tensors="pt",
|
| 139 |
+
).to("cuda")
|
| 140 |
+
|
| 141 |
+
streamer = TextIteratorStreamer(
|
| 142 |
+
processor.tokenizer, skip_prompt=True, skip_special_tokens=True
|
| 143 |
)
|
| 144 |
+
kwargs = dict(
|
| 145 |
+
**inputs,
|
| 146 |
+
streamer=streamer,
|
| 147 |
+
max_new_tokens=int(max_new_tokens),
|
| 148 |
+
do_sample=True,
|
| 149 |
+
temperature=float(temperature),
|
| 150 |
+
top_p=float(top_p),
|
| 151 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
+
thread = threading.Thread(target=model.thinker.generate, kwargs=kwargs, daemon=True)
|
| 154 |
+
t0 = time.perf_counter()
|
| 155 |
+
thread.start()
|
| 156 |
+
|
| 157 |
+
acc = ""
|
| 158 |
+
n_chunks = 0
|
| 159 |
+
yield gr.update(value="_Listening…_"), gr.update(value="", visible=True)
|
| 160 |
+
for chunk in streamer:
|
| 161 |
+
acc += chunk
|
| 162 |
+
n_chunks += 1
|
| 163 |
+
if n_chunks % 4 == 0:
|
| 164 |
+
reasoning, answer = _split_output(acc)
|
| 165 |
+
yield (
|
| 166 |
+
gr.update(value=answer or "_Reasoning…_"),
|
| 167 |
+
gr.update(value=reasoning or acc),
|
| 168 |
+
)
|
| 169 |
+
thread.join()
|
| 170 |
+
|
| 171 |
+
elapsed = time.perf_counter() - t0
|
| 172 |
+
reasoning, answer = _split_output(acc)
|
| 173 |
+
print(f"[infer] {n_chunks} chunks in {elapsed:.1f}s")
|
| 174 |
+
yield (
|
| 175 |
+
gr.update(value=answer or "_(no answer parsed)_"),
|
| 176 |
+
gr.update(value=reasoning or acc),
|
| 177 |
+
)
|
| 178 |
|
| 179 |
|
| 180 |
+
# --------------------------------------------------------------------------------------
|
| 181 |
# UI
|
| 182 |
+
# --------------------------------------------------------------------------------------
|
| 183 |
+
MCQ_TAIL = '\n\nRespond in JSON format, e.g. {"Answer": "A"}'
|
| 184 |
+
|
| 185 |
EXAMPLES = [
|
| 186 |
[
|
| 187 |
+
"examples/ami_two_speakers.wav",
|
| 188 |
+
"Listen to the main audio and answer the following multiple-choice question."
|
| 189 |
+
"\n\nHow many distinct speakers are there in the audio?"
|
| 190 |
+
"\n\nOptions:\n(A) 1\n(B) 2\n(C) 3\n(D) 4" + MCQ_TAIL,
|
| 191 |
],
|
| 192 |
[
|
| 193 |
+
"examples/ami_three_speakers.wav",
|
| 194 |
+
"Walk through this recording turn by turn. How many different people speak, and "
|
| 195 |
+
"in what order do they take turns? Base your answer on the voices you hear, not "
|
| 196 |
+
"on what is being said.",
|
| 197 |
],
|
| 198 |
[
|
| 199 |
+
"examples/ami_four_speakers.wav",
|
| 200 |
+
"Listen to the main audio and answer the following multiple-choice question."
|
| 201 |
+
"\n\nDoes the person who speaks first in the recording speak again later in the "
|
| 202 |
+
"recording?"
|
| 203 |
+
"\n\nOptions:\n(A) Yes, the first speaker's voice returns later\n"
|
| 204 |
+
"(B) No, the first speaker never speaks again" + MCQ_TAIL,
|
| 205 |
],
|
| 206 |
]
|
| 207 |
|
| 208 |
CSS = """
|
| 209 |
+
#col-container { max-width: 1100px; margin: 0 auto; }
|
| 210 |
+
.dark .gradio-container { color: var(--body-text-color); }
|
|
|
|
| 211 |
"""
|
| 212 |
|
| 213 |
+
with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="A2R — speaker-attributed reasoning") as demo:
|
| 214 |
+
with gr.Column(elem_id="col-container"):
|
| 215 |
+
gr.Markdown(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
"""
|
| 217 |
+
# A2R-30B-A3B — who said what?
|
| 218 |
|
| 219 |
+
Ask questions about **who** is speaking in a multi-party recording, not only about what is
|
| 220 |
+
said. [A2R](https://huggingface.co/PleasedPenguin/A2R-30B-A3B) is `Qwen3-Omni-30B-A3B-Instruct`
|
| 221 |
+
trained with GRPO on counterfactual audio with speaker-level hard negatives, so it grounds its
|
| 222 |
+
answer in vocal cues instead of the transcript.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
+
[Paper](https://huggingface.co/papers/2608.29120) ·
|
| 225 |
+
[Project page](https://attributetoreason.github.io/AttributeToReason/) ·
|
| 226 |
+
[Code](https://github.com/dwsmart32/HEAR) ·
|
| 227 |
+
[Model](https://huggingface.co/PleasedPenguin/A2R-30B-A3B)
|
| 228 |
+
"""
|
| 229 |
+
)
|
| 230 |
|
|
|
|
| 231 |
with gr.Row():
|
| 232 |
+
with gr.Column(scale=1):
|
| 233 |
+
audio_in = gr.Audio(
|
| 234 |
+
label="Multi-speaker audio", type="filepath", sources=["upload", "microphone"]
|
| 235 |
+
)
|
| 236 |
+
question_in = gr.Textbox(
|
| 237 |
+
label="Question",
|
| 238 |
+
lines=7,
|
| 239 |
+
placeholder=(
|
| 240 |
+
"e.g. How many different people speak in this recording, and in "
|
| 241 |
+
"what order do they take turns?"
|
| 242 |
+
),
|
| 243 |
+
)
|
| 244 |
+
run = gr.Button("Analyse", variant="primary")
|
| 245 |
+
with gr.Column(scale=1):
|
| 246 |
+
gr.Markdown("### Answer")
|
| 247 |
+
answer_out = gr.Markdown(value="", min_height=90)
|
| 248 |
+
reasoning_out = gr.Textbox(
|
| 249 |
+
label="Reasoning trace", lines=16, show_copy_button=True
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
with gr.Accordion("Advanced settings", open=False):
|
| 253 |
+
max_new_tokens = gr.Slider(
|
| 254 |
+
128, 2048, value=768, step=64, label="Max new tokens"
|
| 255 |
)
|
| 256 |
+
temperature = gr.Slider(0.0, 1.5, value=0.6, step=0.05, label="Temperature")
|
| 257 |
+
top_p = gr.Slider(0.1, 1.0, value=0.95, step=0.01, label="Top-p")
|
| 258 |
+
|
| 259 |
+
gr.Examples(
|
| 260 |
+
examples=EXAMPLES,
|
| 261 |
+
inputs=[audio_in, question_in],
|
| 262 |
+
outputs=[answer_out, reasoning_out],
|
| 263 |
+
fn=analyze,
|
| 264 |
+
cache_examples=True,
|
| 265 |
+
cache_mode="lazy",
|
| 266 |
+
label="Examples (AMI Meeting Corpus, CC BY 4.0)",
|
| 267 |
+
)
|
|
|
|
| 268 |
|
| 269 |
+
gr.Markdown(
|
| 270 |
+
"Example clips are excerpts of the "
|
| 271 |
+
"[AMI Meeting Corpus](https://huggingface.co/datasets/diarizers-community/ami) "
|
| 272 |
+
"(CC BY 4.0), one of the source corpora behind the HEAR benchmark. The HEAR "
|
| 273 |
+
"benchmark audio itself is not redistributable and is therefore not bundled here."
|
| 274 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
|
| 276 |
+
run.click(
|
| 277 |
+
analyze,
|
| 278 |
+
inputs=[audio_in, question_in, max_new_tokens, temperature, top_p],
|
| 279 |
+
outputs=[answer_out, reasoning_out],
|
| 280 |
+
api_name="analyze",
|
| 281 |
)
|
| 282 |
|
| 283 |
+
demo.queue(max_size=12).launch(mcp_server=True)
|
|
|
examples/ami_four_speakers.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f347b457d95437b44ff7a62a05666e076a4e36e36326e487a7acac8f843d6f30
|
| 3 |
+
size 984364
|
examples/ami_three_speakers.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:573d95505309e40c339080c78a3b0b02c44cdc1213e0cb1eaf20451caf295ffd
|
| 3 |
+
size 1093482
|
examples/ami_two_speakers.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d42c49e5f34393905bd3acc3e4870d66b3b2c777df950a0f1989fd253934dd6f
|
| 3 |
+
size 1090282
|
requirements.txt
CHANGED
|
@@ -1,8 +1,6 @@
|
|
| 1 |
transformers==5.16.1
|
| 2 |
-
accelerate
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
numpy
|
| 7 |
-
hf-transfer>=0.1.4
|
| 8 |
-
torchvision
|
|
|
|
| 1 |
transformers==5.16.1
|
| 2 |
+
accelerate
|
| 3 |
+
torchvision
|
| 4 |
+
librosa
|
| 5 |
+
soundfile
|
| 6 |
+
numpy
|
|
|
|
|
|