feat: add voice transcription feedback workflow

This commit is contained in:
2026-07-20 10:49:49 +08:00
parent 0a07538dce
commit bed80aa807
17 changed files with 1691 additions and 19 deletions

View File

@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.venv/
.pytest_cache/

20
asr-service/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM python:3.10-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg libsndfile1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt ./
RUN pip install --upgrade pip \
&& pip install torch==2.8.0 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cpu \
&& pip install -r requirements.txt
COPY app.py ./
EXPOSE 10095
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "10095", "--workers", "1", "--no-access-log"]

38
asr-service/README.md Normal file
View File

@@ -0,0 +1,38 @@
# 中文录音转写服务
该服务使用 FunASR 的中文 Paraformer、FSMN-VAD 和标点模型,为 Rust API 提供本地录音转写。模型首次启动时会下载约 2.1 GB 到持久化缓存,完成后 `/health` 才会可用。
## Docker 启动
在项目根目录执行:
```bash
docker compose -f compose.asr.yml up --build -d
curl http://127.0.0.1:10095/health
```
默认使用 CPU 且只允许一个并发推理任务。可通过 `ASR_DEVICE``ASR_MAX_CONCURRENCY``ASR_MODEL``ASR_VAD_MODEL``ASR_PUNC_MODEL` 调整。GPU 部署需要使用带 CUDA 的 PyTorch 基础环境,不能只把 CPU 镜像中的 `ASR_DEVICE` 改为 `cuda`
## 本机 Python 启动
支持 Python 3.9 及以上版本,推荐 Python 3.10
```bash
cd asr-service
python3.10 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
MODELSCOPE_CACHE=.models uvicorn app:app --host 127.0.0.1 --port 10095 --no-access-log
```
## 接口
Rust API 调用 `POST /v1/transcriptions`,以 multipart 上传 `file``language``duration_ms`,响应格式为:
```json
{
"text": "识别后的中文课堂内容。",
"duration_ms": 480000,
"request_id": "b2f6fc1c-62c1-4a99-8ab4-6f933f1bd252"
}
```

173
asr-service/app.py Normal file
View File

@@ -0,0 +1,173 @@
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()

View File

@@ -0,0 +1,7 @@
fastapi==0.128.8
funasr==1.3.14
modelscope==1.37.1
python-multipart==0.0.20
torch==2.8.0
torchaudio==2.8.0
uvicorn[standard]==0.39.0