multimodalart HF Staff commited on
Commit
cf35ff7
·
verified ·
1 Parent(s): ce524d4

A2R-30B-A3B speaker-attributed reasoning demo

Browse files
.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 · 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
 
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
- 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
 
133
- Mirrors the HEAR harness's extraction: prefer the JSON `{"Answer": "X"}`
134
- inside the last `<answer>` block; fall back to the raw block text, then to
135
- a standalone-letter scan of the whole response.
136
- """
137
- reasoning_matches = REASONING_RE.findall(text)
138
- answer_matches = ANSWER_RE.findall(text)
139
- reasoning = (reasoning_matches[-1] if reasoning_matches else "").strip()
140
- answer_raw = (answer_matches[-1] if answer_matches else "").strip()
141
- answer_letter = ""
142
- if answer_raw:
143
  try:
144
- obj = json.loads(answer_raw)
145
- answer_letter = str(obj.get("Answer", "")).strip()
146
- except json.JSONDecodeError:
147
- answer_letter = answer_raw.strip()
148
- if not answer_letter:
149
- m = re.search(r'"Answer"\s*:\s*"?([^",\}]+)"?', text)
150
- if m:
151
- answer_letter = m.group(1).strip()
152
- if not answer_letter:
153
- # MCQ fallback: a parenthesized or bolded option letter outside tags.
154
- m = re.search(r"[((]([A-E])[))]|\*\*([A-E])\*\*", text)
155
- if m:
156
- answer_letter = m.group(1) or m.group(2)
157
- return reasoning, answer_letter, answer_raw
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 answer_question(
179
- audio_path: str,
180
  question: str,
181
- options: str = "",
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
- audio_path: path to the audio clip (multi-party speech works best).
191
- question: the question about who speaks / speaker identity.
192
- options: optional answer options, one per line, e.g. "(A) Alice".
193
  max_new_tokens: maximum number of tokens A2R may generate.
194
- temperature: sampling temperature (the paper uses 0.6).
195
- top_p: nucleus sampling threshold (the paper uses 0.95).
196
 
197
  Returns:
198
- (answer, reasoning, raw_output)
199
  """
200
- t0 = time.time()
201
- if not audio_path:
202
- raise gr.Error("Please upload or record an audio clip first.")
203
- if not (question or "").strip():
204
- raise gr.Error("Please enter a question about the speakers.")
205
-
206
- audio, sr = load_audio(audio_path)
207
- dur = _seconds(audio)
208
- if dur < 0.5:
209
- raise gr.Error("The audio clip is too short to reason about speakers.")
210
- if dur > MAX_AUDIO_SECONDS:
211
- audio = audio[: MAX_AUDIO_SECONDS * SAMPLE_RATE]
212
- dur = MAX_AUDIO_SECONDS
213
-
214
- instruction = build_instruction(question, options)
215
- conversation = build_conversation(audio, instruction)
216
- text = PROCESSOR.apply_chat_template(
217
- conversation, add_generation_prompt=True, tokenize=False
 
 
 
 
 
 
 
 
 
 
 
 
218
  )
219
- inputs = PROCESSOR(
220
- text=text, audio=[audio], return_tensors="pt", padding=True
 
 
 
 
 
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
- reasoning, answer_letter, answer_raw = parse_output(response)
238
- elapsed = time.time() - t0
239
- stats = f"⏱ {elapsed:.1f}s · {dur:.0f}s audio · {len(gen_ids[0])} tokens"
240
- display_answer = answer_letter if answer_letter else response
241
- return display_answer, reasoning, response, stats
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
 
243
 
244
- # --------------------------------------------------------------------------- #
245
  # UI
246
- # --------------------------------------------------------------------------- #
 
 
247
  EXAMPLES = [
248
  [
249
- str(ASSET_DIR / "ami_meeting_4speakers.wav"),
250
- "Four people are talking. Who speaks first, and who speaks immediately after them?",
251
- "",
 
252
  ],
253
  [
254
- str(ASSET_DIR / "ami_meeting_4speakers.wav"),
255
- "How many distinct speakers can you hear in this recording?",
256
- "",
 
257
  ],
258
  [
259
- str(ASSET_DIR / "ami_meeting_2speakers.wav"),
260
- "Two speakers alternate in this clip. Who spoke last — the same person who spoke first, or the other one?",
261
- "(A) The same person who spoke first\n(B) The other speaker",
 
 
 
262
  ],
263
  ]
264
 
265
  CSS = """
266
- #hero {text-align: center; margin-bottom: 6px;}
267
- #hero h1 {font-size: 2rem; margin-bottom: 4px;}
268
- .dark .gradio-container {color: var(--body-text-color);}
269
  """
270
 
271
- with gr.Blocks(title="A2R Speaker-Attributed Reasoning") as demo:
272
- with gr.Column():
273
- gr.HTML(
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
- with gr.Row(equal_height=False):
287
- with gr.Column(scale=5):
288
- audio_in = gr.Audio(
289
- label="Multi-speaker audio clip",
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
- with gr.Column(scale=4):
308
- answer_out = gr.Textbox(label="Answer", buttons=["copy"])
309
- reasoning_out = gr.Markdown(label="Reasoning trace")
310
- stats_out = gr.Markdown()
311
- with gr.Accordion("Raw model output", open=False):
312
- raw_box = gr.Textbox(label="Raw output", lines=6, buttons=["copy"])
313
 
314
- with gr.Accordion("Advanced settings", open=False):
315
  with gr.Row():
316
- max_new_tokens_in = gr.Slider(
317
- 128, 2048, value=1024, step=128,
318
- label="Max new tokens",
319
- info="A2R reasons before answering; 1024 covers most questions.",
320
- )
321
- temperature_in = gr.Slider(
322
- 0.0, 1.5, value=0.6, step=0.05,
323
- label="Temperature",
324
- info="The paper's evaluation uses 0.6.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  )
326
- top_p_in = gr.Slider(
327
- 0.1, 1.0, value=0.95, step=0.05, label="Top-p"
328
- )
329
-
330
- gr.Examples(
331
- examples=EXAMPLES,
332
- inputs=[audio_in, question_in, options_in],
333
- fn=answer_question,
334
- outputs=[answer_out, reasoning_out, raw_box, stats_out],
335
- cache_examples=True,
336
- cache_mode="lazy",
337
- label="Try an example (real meeting audio from the AMI corpus, CC BY 4.0)",
338
- )
339
 
340
- gr.Markdown(
341
- f"""
342
- **About.** A2R is `Qwen/Qwen3-Omni-30B-A3B-Instruct` fine-tuned with GRPO on
343
- CASH-60K counterfactual voice-grounding queries, so it answers *who* said
344
- what rather than just transcribing. It emits a reasoning trace followed by
345
- `<answer>{{"Answer": "…"}}</answer>` — both are shown above.
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
- run_btn.click(
358
- fn=answer_question,
359
- inputs=[audio_in, question_in, options_in,
360
- max_new_tokens_in, temperature_in, top_p_in],
361
- outputs=[answer_out, reasoning_out, raw_box, stats_out],
362
  )
363
 
364
- if __name__ == "__main__":
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>=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
 
1
  transformers==5.16.1
2
+ accelerate
3
+ torchvision
4
+ librosa
5
+ soundfile
6
+ numpy