174 lines
5.3 KiB
Python
174 lines
5.3 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
import uuid
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
|
from funasr import AutoModel
|
|
from pydantic import BaseModel
|
|
|
|
LOGGER = logging.getLogger("funasr-service")
|
|
MAX_AUDIO_BYTES = 15 * 1024 * 1024
|
|
CHUNK_BYTES = 1024 * 1024
|
|
SUPPORTED_FORMATS = {"mp3", "aac", "wav", "m4a", "amr"}
|
|
|
|
|
|
class Settings:
|
|
def __init__(self) -> None:
|
|
self.model = os.getenv("ASR_MODEL", "paraformer-zh")
|
|
self.vad_model = os.getenv("ASR_VAD_MODEL", "fsmn-vad")
|
|
self.punc_model = os.getenv("ASR_PUNC_MODEL", "ct-punc")
|
|
self.device = os.getenv("ASR_DEVICE", "cpu")
|
|
self.max_concurrency = positive_int("ASR_MAX_CONCURRENCY", 1)
|
|
self.batch_size_seconds = positive_int("ASR_BATCH_SIZE_SECONDS", 300)
|
|
|
|
|
|
class HealthResponse(BaseModel):
|
|
status: str
|
|
model: str
|
|
device: str
|
|
|
|
|
|
class TranscriptionResponse(BaseModel):
|
|
text: str
|
|
duration_ms: int
|
|
request_id: str
|
|
|
|
|
|
def positive_int(name: str, default: int) -> int:
|
|
raw = os.getenv(name, str(default))
|
|
try:
|
|
value = int(raw)
|
|
except ValueError as error:
|
|
raise RuntimeError(f"{name} must be an integer") from error
|
|
if value <= 0:
|
|
raise RuntimeError(f"{name} must be greater than 0")
|
|
return value
|
|
|
|
|
|
def load_model(settings: Settings) -> Any:
|
|
LOGGER.info(
|
|
"loading FunASR model model=%s vad=%s punc=%s device=%s",
|
|
settings.model,
|
|
settings.vad_model,
|
|
settings.punc_model,
|
|
settings.device,
|
|
)
|
|
model = AutoModel(
|
|
model=settings.model,
|
|
vad_model=settings.vad_model,
|
|
punc_model=settings.punc_model,
|
|
device=settings.device,
|
|
disable_update=True,
|
|
)
|
|
LOGGER.info("FunASR model is ready")
|
|
return model
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
settings = Settings()
|
|
app.state.settings = settings
|
|
app.state.model = await asyncio.to_thread(load_model, settings)
|
|
app.state.inference_slots = asyncio.Semaphore(settings.max_concurrency)
|
|
yield
|
|
|
|
|
|
app = FastAPI(
|
|
title="Teaching Feedback FunASR Service",
|
|
version="1.0.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
|
|
@app.get("/health", response_model=HealthResponse)
|
|
async def health() -> HealthResponse:
|
|
settings: Settings = app.state.settings
|
|
return HealthResponse(status="ok", model=settings.model, device=settings.device)
|
|
|
|
|
|
@app.post("/v1/transcriptions", response_model=TranscriptionResponse)
|
|
async def create_transcription(
|
|
file: UploadFile = File(...),
|
|
language: str = Form("zh"),
|
|
duration_ms: int = Form(0),
|
|
hotword: str = Form(""),
|
|
) -> TranscriptionResponse:
|
|
if language not in {"zh", "zh-cn", "cmn"}:
|
|
raise HTTPException(status_code=422, detail="this service is configured for Chinese audio")
|
|
audio_format = file_extension(file.filename)
|
|
temporary_path = await save_upload(file, audio_format)
|
|
request_id = str(uuid.uuid4())
|
|
try:
|
|
async with app.state.inference_slots:
|
|
text = await asyncio.to_thread(
|
|
run_inference,
|
|
app.state.model,
|
|
temporary_path,
|
|
app.state.settings.batch_size_seconds,
|
|
hotword.strip(),
|
|
)
|
|
except Exception as error:
|
|
LOGGER.exception("FunASR inference failed request_id=%s", request_id)
|
|
raise HTTPException(status_code=500, detail=f"speech recognition failed: {error}") from error
|
|
finally:
|
|
temporary_path.unlink(missing_ok=True)
|
|
|
|
if not text:
|
|
raise HTTPException(status_code=422, detail="no clear speech was recognized")
|
|
return TranscriptionResponse(
|
|
text=text,
|
|
duration_ms=max(0, duration_ms),
|
|
request_id=request_id,
|
|
)
|
|
|
|
|
|
def file_extension(filename: str | None) -> str:
|
|
suffix = Path(filename or "segment.mp3").suffix.lower().lstrip(".")
|
|
if suffix not in SUPPORTED_FORMATS:
|
|
raise HTTPException(status_code=415, detail="unsupported audio format")
|
|
return suffix
|
|
|
|
|
|
async def save_upload(file: UploadFile, audio_format: str) -> Path:
|
|
handle = tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False)
|
|
path = Path(handle.name)
|
|
total_bytes = 0
|
|
try:
|
|
with handle:
|
|
while chunk := await file.read(CHUNK_BYTES):
|
|
total_bytes += len(chunk)
|
|
if total_bytes > MAX_AUDIO_BYTES:
|
|
raise HTTPException(status_code=413, detail="audio file exceeds 15 MB")
|
|
handle.write(chunk)
|
|
except Exception:
|
|
path.unlink(missing_ok=True)
|
|
raise
|
|
finally:
|
|
await file.close()
|
|
if total_bytes == 0:
|
|
path.unlink(missing_ok=True)
|
|
raise HTTPException(status_code=422, detail="audio file is empty")
|
|
return path
|
|
|
|
|
|
def run_inference(model: Any, path: Path, batch_size_seconds: int, hotword: str) -> str:
|
|
options: dict[str, Any] = {
|
|
"input": str(path),
|
|
"batch_size_s": batch_size_seconds,
|
|
"use_itn": True,
|
|
}
|
|
if hotword:
|
|
options["hotword"] = hotword
|
|
result = model.generate(**options)
|
|
if not isinstance(result, list):
|
|
return ""
|
|
texts = [str(item.get("text", "")).strip() for item in result if isinstance(item, dict)]
|
|
return "\n".join(text for text in texts if text).strip()
|