ASR-demo/realtime_asr_optimization_demo/model_service.py

121 lines
4.6 KiB
Python
Raw Normal View History

2026-09-10 05:47:09 +00:00
"""独立实时 Demo 使用的 OpenAI 兼容 VLLM 服务适配器。"""
from __future__ import annotations
import io
import wave
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from aiohttp import ClientSession, ClientTimeout, FormData
@dataclass(frozen=True)
class ModelServiceConfig:
"""一个独立 VLLM 端点所需的连接配置。"""
base_url: str = "http://127.0.0.1:9950/v1"
model: str = "Qwen/Qwen3-ASR-0.6B"
api_key: str = "EMPTY"
timeout_seconds: float = 45.0
def pcm16_to_wav(pcm_bytes: bytes, sample_rate: int = 16000) -> bytes:
"""将浏览器发送的 PCM16 单声道数据封装为 VLLM 可识别的 WAV 请求。"""
output = io.BytesIO()
with wave.open(output, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(sample_rate)
wav_file.writeframes(pcm_bytes)
return output.getvalue()
def wav_to_pcm16(audio_bytes: bytes) -> bytes:
"""从 WAV 缓冲区提取 PCM 帧,并兼容尚未完整的中间音频数据。"""
try:
with wave.open(io.BytesIO(audio_bytes), "rb") as wav_file:
return wav_file.readframes(wav_file.getnframes())
except (EOFError, wave.Error):
if audio_bytes[:4] == b"RIFF" and audio_bytes[8:12] == b"WAVE" and len(audio_bytes) > 44:
return audio_bytes[44:]
return audio_bytes
def prepare_audio_request(
audio_bytes: bytes,
source: str,
file_name: str,
partial: bool,
) -> tuple[bytes, str, str] | None:
"""将麦克风、PCM 或 WAV 数据转换为 WAV压缩格式的中间片段延迟到最终帧处理。"""
suffix = Path(file_name).suffix.lower()
if source == "mic" or suffix in {".pcm", ".wav"}:
pcm_bytes = wav_to_pcm16(audio_bytes) if suffix == ".wav" else audio_bytes
return pcm16_to_wav(pcm_bytes), "audio.wav", "audio/wav"
if partial:
# MP3/M4A/OGG 的不断增长前缀通常不是完整容器,不能安全解码,因此只在
# 最终阶段提交压缩文件,避免中间请求产生随机解码错误。
return None
content_type = {
".mp3": "audio/mpeg",
".m4a": "audio/mp4",
".ogg": "audio/ogg",
".opus": "audio/ogg",
}.get(suffix, "application/octet-stream")
return audio_bytes, Path(file_name).name or "audio.bin", content_type
class VLLMTranscriptionService:
"""只调用独立项目提供的 VLLM HTTP 接口,不导入原项目应用代码。"""
native_partial_supported = False
def __init__(self, config: ModelServiceConfig) -> None:
self.config = config
self._session: ClientSession | None = None
async def start(self) -> None:
"""创建可复用的 HTTP 会话,供所有中间和最终转写请求共享。"""
self._session = ClientSession(timeout=ClientTimeout(total=self.config.timeout_seconds))
async def close(self) -> None:
"""本地 Demo 退出时释放可复用的 HTTP 会话和底层连接。"""
if self._session is not None:
await self._session.close()
self._session = None
async def transcribe(
self,
audio_bytes: bytes,
source: str,
file_name: str,
partial: bool,
) -> str | None:
"""提交一次音频快照并返回文本;返回 None 表示当前格式不支持中间转写。"""
prepared = prepare_audio_request(audio_bytes, source, file_name, partial)
if prepared is None:
return None
payload, upload_name, content_type = prepared
if self._session is None:
raise RuntimeError("model service is not started")
form = FormData()
form.add_field("file", payload, filename=upload_name, content_type=content_type)
form.add_field("model", self.config.model)
form.add_field("response_format", "json")
headers = {"Authorization": f"Bearer {self.config.api_key}"}
endpoint = self.config.base_url.rstrip("/") + "/audio/transcriptions"
async with self._session.post(endpoint, data=form, headers=headers) as response:
body = await response.text()
if response.status >= 400:
raise RuntimeError(f"VLLM transcription failed ({response.status}): {body[:500]}")
try:
decoded: Any = await response.json(content_type=None)
except ValueError:
return body.strip()
if isinstance(decoded, dict):
return str(decoded.get("text") or decoded.get("transcript") or "").strip()
return str(decoded).strip()