multimodalart HF Staff commited on
Commit
a5ff76e
·
verified ·
1 Parent(s): fad6dae

Upload folder using huggingface_hub

Browse files
.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: A2r Speaker Reasoning
3
- emoji: 👁
4
- colorFrom: pink
5
- colorTo: red
6
  sdk: gradio
7
  sdk_version: 6.26.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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