Skip to content

Commit c05abf1

Browse files
authored
feat(audio): batch Qwen3-ASR transcriptions (#5172)
1 parent c4c4ff2 commit c05abf1

4 files changed

Lines changed: 419 additions & 31 deletions

File tree

xinference/model/audio/qwen3_asr.py

Lines changed: 128 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,32 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import asyncio
1516
import logging
1617
import os
1718
import tempfile
19+
from collections import defaultdict
1820
from typing import TYPE_CHECKING, List, Optional, Tuple
1921

22+
from xoscar import extensible
23+
2024
from ...device_utils import (
2125
get_available_device,
2226
get_device_preferred_dtype,
2327
is_device_available,
2428
)
29+
from ...utils import make_hashable
30+
from ..batch import BatchMixin
2531

2632
if TYPE_CHECKING:
2733
from .core import AudioModelFamilyV2
2834

2935
logger = logging.getLogger(__name__)
3036

37+
QWEN3_ASR_DEFAULT_BATCH_INTERVAL = 0.1
38+
3139

32-
class Qwen3ASRModel:
40+
class Qwen3ASRModel(BatchMixin):
3341
def __init__(
3442
self,
3543
model_uid: str,
@@ -44,8 +52,22 @@ def __init__(
4452
self._model_spec = model_spec
4553
self._device = device
4654
self._model = None
55+
batch_size = kwargs.pop("batch_size", None)
56+
batch_interval = kwargs.pop("batch_interval", QWEN3_ASR_DEFAULT_BATCH_INTERVAL)
4757
self._kwargs = kwargs
4858

59+
batching_kwargs = {}
60+
max_inference_batch_size = kwargs.get("max_inference_batch_size")
61+
if batch_size is not None:
62+
batching_kwargs["batch_size"] = batch_size
63+
elif max_inference_batch_size is not None and int(max_inference_batch_size) > 0:
64+
# qwen_asr consumes this as a native from_pretrained argument;
65+
# reuse it as Xinference's request-batch limit when no override is set.
66+
batching_kwargs["batch_size"] = max_inference_batch_size
67+
if batch_interval is not None:
68+
batching_kwargs["batch_interval"] = batch_interval
69+
BatchMixin.__init__(self, self._batch_transcribe, **batching_kwargs)
70+
4971
@property
5072
def model_ability(self):
5173
return self._model_spec.model_ability
@@ -139,7 +161,108 @@ def _extract_text_and_language(self, result) -> Tuple[str, Optional[str]]:
139161

140162
return str(result), None
141163

142-
def transcriptions(
164+
def _format_transcription_result(self, result, response_format: str):
165+
text, detected_language = self._extract_text_and_language(result)
166+
if response_format == "json":
167+
return {"text": text}
168+
if response_format == "verbose_json":
169+
return {
170+
"task": "transcribe",
171+
"language": detected_language,
172+
"text": text,
173+
}
174+
raise ValueError(f"Unsupported response format: {response_format}")
175+
176+
def _run_transcription_batch(
177+
self,
178+
audios: List[bytes],
179+
languages: List[Optional[str]],
180+
response_formats: List[str],
181+
transcription_kwargs: dict,
182+
) -> List[dict]:
183+
assert self._model is not None
184+
with tempfile.TemporaryDirectory() as temp_dir:
185+
audio_paths = []
186+
for i, audio in enumerate(audios):
187+
audio_path = os.path.join(temp_dir, f"audio-{i}")
188+
with open(audio_path, "wb") as f:
189+
f.write(audio)
190+
audio_paths.append(audio_path)
191+
192+
results = self._model.transcribe(
193+
audio=audio_paths,
194+
language=languages,
195+
**transcription_kwargs,
196+
)
197+
198+
if not isinstance(results, list):
199+
raise RuntimeError(
200+
f"Qwen3-ASR returned an invalid batch result: {type(results)}"
201+
)
202+
if len(results) != len(audios):
203+
raise RuntimeError(
204+
"Qwen3-ASR returned a different number of results than inputs: "
205+
f"{len(results)} != {len(audios)}"
206+
)
207+
return [
208+
self._format_transcription_result(result, response_format)
209+
for result, response_format in zip(results, response_formats)
210+
]
211+
212+
@extensible
213+
def _batch_transcribe(
214+
self,
215+
audio: bytes,
216+
language: Optional[str],
217+
response_format: str,
218+
transcription_kwargs: dict,
219+
) -> dict:
220+
return self._run_transcription_batch(
221+
[audio], [language], [response_format], transcription_kwargs
222+
)[0]
223+
224+
@_batch_transcribe.batch # type: ignore
225+
async def _batch_transcribe(self, args_list, kwargs_list):
226+
grouped = defaultdict(
227+
lambda: {
228+
"audios": [],
229+
"languages": [],
230+
"response_formats": [],
231+
"transcription_kwargs": None,
232+
"indices": [],
233+
}
234+
)
235+
236+
for index, (args, kwargs) in enumerate(zip(args_list, kwargs_list)):
237+
if kwargs:
238+
raise TypeError("Qwen3-ASR internal batch call expects positional args")
239+
audio, language, response_format, transcription_kwargs = args
240+
key = make_hashable(transcription_kwargs)
241+
group = grouped[key]
242+
group["audios"].append(audio)
243+
group["languages"].append(language)
244+
group["response_formats"].append(response_format)
245+
group["transcription_kwargs"] = transcription_kwargs
246+
group["indices"].append(index)
247+
248+
results_with_indices = []
249+
for group in grouped.values():
250+
results = await asyncio.to_thread(
251+
self._run_transcription_batch,
252+
group["audios"],
253+
group["languages"],
254+
group["response_formats"],
255+
group["transcription_kwargs"],
256+
)
257+
results_with_indices.extend(zip(group["indices"], results))
258+
259+
results_with_indices.sort(key=lambda item: item[0])
260+
return [result for _, result in results_with_indices]
261+
262+
def _get_batch_size(self, *args, **kwargs) -> int:
263+
return 1
264+
265+
async def transcriptions(
143266
self,
144267
audio: bytes,
145268
language: Optional[str] = None,
@@ -163,25 +286,12 @@ def transcriptions(
163286
logger.warning(
164287
"Prompt for Qwen3-ASR transcriptions will be ignored: %s", prompt
165288
)
289+
if response_format not in ("json", "verbose_json"):
290+
raise ValueError(f"Unsupported response format: {response_format}")
166291

167292
kw = dict(getattr(self._model_spec, "default_transcription_config", None) or {})
168293
kw.update(kwargs)
169-
170-
with tempfile.NamedTemporaryFile(buffering=0) as f:
171-
f.write(audio)
172-
assert self._model is not None
173-
result = self._model.transcribe(audio=f.name, language=language, **kw)
174-
text, detected_language = self._extract_text_and_language(result)
175-
176-
if response_format == "json":
177-
return {"text": text}
178-
if response_format == "verbose_json":
179-
return {
180-
"task": "transcribe",
181-
"language": detected_language,
182-
"text": text,
183-
}
184-
raise ValueError(f"Unsupported response format: {response_format}")
294+
return await self._batch_transcribe(audio, language, response_format, kw)
185295

186296
def translations(
187297
self,
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# Copyright 2022-2026 Xinference Holdings Pte. Ltd
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import asyncio
16+
from contextlib import suppress
17+
from pathlib import Path
18+
from types import SimpleNamespace
19+
20+
import pytest
21+
22+
from ..qwen3_asr import Qwen3ASRModel
23+
24+
25+
class _FakeQwenASR:
26+
def __init__(self):
27+
self.calls = []
28+
29+
def transcribe(self, audio, language, **kwargs):
30+
payloads = [Path(path).read_bytes().decode() for path in audio]
31+
self.calls.append(
32+
{"payloads": payloads, "language": list(language), "kwargs": kwargs}
33+
)
34+
return [
35+
SimpleNamespace(text=payload, language=lang or "Detected")
36+
for payload, lang in zip(payloads, language)
37+
]
38+
39+
40+
def _new_model(batch_size=8, batch_interval=0.01, **kwargs):
41+
model_spec = SimpleNamespace(
42+
model_name="Qwen3-ASR-0.6B",
43+
model_ability=["audio2text"],
44+
default_transcription_config={},
45+
)
46+
model = Qwen3ASRModel(
47+
"qwen3-asr",
48+
"/unused",
49+
model_spec,
50+
batch_size=batch_size,
51+
batch_interval=batch_interval,
52+
**kwargs,
53+
)
54+
model._model = _FakeQwenASR()
55+
return model
56+
57+
58+
def test_qwen3_asr_uses_audio_batch_interval_default():
59+
model_spec = SimpleNamespace(
60+
model_name="Qwen3-ASR-0.6B",
61+
model_ability=["audio2text"],
62+
default_transcription_config={},
63+
)
64+
65+
model = Qwen3ASRModel("default", "/unused", model_spec)
66+
overridden = Qwen3ASRModel("overridden", "/unused", model_spec, batch_interval=0.02)
67+
68+
assert model.batch_interval == pytest.approx(0.1)
69+
assert overridden.batch_interval == pytest.approx(0.02)
70+
71+
72+
async def _shutdown_batch_processor(model):
73+
task = model._process_batch_task
74+
if task is not None:
75+
task.cancel()
76+
with suppress(asyncio.CancelledError):
77+
await task
78+
79+
80+
@pytest.mark.asyncio
81+
async def test_qwen3_asr_batches_requests_and_preserves_order():
82+
model = _new_model()
83+
try:
84+
results = await asyncio.gather(
85+
model.transcriptions(b"first", language="Chinese"),
86+
model.transcriptions(
87+
b"second", language="English", response_format="verbose_json"
88+
),
89+
model.transcriptions(b"third"),
90+
)
91+
92+
assert results == [
93+
{"text": "first"},
94+
{"task": "transcribe", "language": "English", "text": "second"},
95+
{"text": "third"},
96+
]
97+
assert model._model.calls == [
98+
{
99+
"payloads": ["first", "second", "third"],
100+
"language": ["Chinese", "English", None],
101+
"kwargs": {},
102+
}
103+
]
104+
finally:
105+
await _shutdown_batch_processor(model)
106+
107+
108+
@pytest.mark.asyncio
109+
async def test_qwen3_asr_respects_batch_size():
110+
model = _new_model(batch_size=2)
111+
try:
112+
results = await asyncio.gather(
113+
model.transcriptions(b"first"),
114+
model.transcriptions(b"second"),
115+
model.transcriptions(b"third"),
116+
)
117+
118+
assert results == [
119+
{"text": "first"},
120+
{"text": "second"},
121+
{"text": "third"},
122+
]
123+
assert [call["payloads"] for call in model._model.calls] == [
124+
["first", "second"],
125+
["third"],
126+
]
127+
finally:
128+
await _shutdown_batch_processor(model)
129+
130+
131+
@pytest.mark.asyncio
132+
async def test_qwen3_asr_groups_model_kwargs_and_survives_cancellation():
133+
model = _new_model()
134+
try:
135+
cancelled = asyncio.create_task(
136+
model.transcriptions(b"cancelled", language="Chinese", beam_size=1)
137+
)
138+
kept = asyncio.create_task(
139+
model.transcriptions(b"kept", language="English", beam_size=1)
140+
)
141+
other_group = asyncio.create_task(
142+
model.transcriptions(b"other", language="Japanese", beam_size=2)
143+
)
144+
await asyncio.sleep(0)
145+
cancelled.cancel()
146+
147+
with pytest.raises(asyncio.CancelledError):
148+
await cancelled
149+
assert await kept == {"text": "kept"}
150+
assert await other_group == {"text": "other"}
151+
152+
# A cancelled waiter must not stop the long-lived batch processor.
153+
assert await model.transcriptions(b"after") == {"text": "after"}
154+
155+
assert model._model.calls[:2] == [
156+
{
157+
"payloads": ["kept"],
158+
"language": ["English"],
159+
"kwargs": {"beam_size": 1},
160+
},
161+
{
162+
"payloads": ["other"],
163+
"language": ["Japanese"],
164+
"kwargs": {"beam_size": 2},
165+
},
166+
]
167+
assert model._model.calls[2]["payloads"] == ["after"]
168+
finally:
169+
await _shutdown_batch_processor(model)

0 commit comments

Comments
 (0)