ASR-demo/realtime_asr_optimization_demo/model_service.py

121 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""独立实时 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()