This commit is contained in:
hajimi
2026-06-11 16:22:12 +08:00
parent 96efa1d905
commit 21aa11bf17
368 changed files with 2741 additions and 554 deletions
+3 -1
View File
@@ -1,2 +1,4 @@
*
!.gitignore
!.gitignore
!whisper-asr/
!whisper-asr/**
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
.venv/
venv/
models/
.env
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
.venv/
venv/
models/
.env
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
ASR_MODEL_CACHE=/models
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
RUN useradd --create-home --shell /usr/sbin/nologin appuser \
&& mkdir -p /models \
&& chown -R appuser:appuser /app /models
USER appuser
EXPOSE 8791
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8791"]
+46
View File
@@ -0,0 +1,46 @@
# Docker Whisper ASR
本服务为私聊语音输入提供本地语音转文字能力。它只绑定服务器本机 `127.0.0.1:8791`,由 PHP 后端 `/api/chat/voiceToText` 代理访问,不直接暴露公网。
## 启动
```bash
cd server/extend/whisper-asr
docker compose up -d --build
```
首次启动会下载 Whisper 模型到 Docker volume `whisper_asr_models`,耗时取决于网络和模型大小。
## 配置
常用环境变量:
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `ASR_MODEL` | `small` | Whisper 模型名 |
| `ASR_DEVICE` | `cpu` | `cpu``cuda` |
| `ASR_COMPUTE_TYPE` | `int8` | CPU 默认建议 `int8` |
| `ASR_LANGUAGE` | 空 | 留空自动识别,中文可设为 `zh` |
| `ASR_MAX_FILE_MB` | `10` | 单文件最大大小 |
| `ASR_API_TOKEN` | 空 | 配置后需要 Bearer Token |
如果配置 `ASR_API_TOKEN`,需要同步更新后台 AI 配置 `whisper_asr_api_token`
## 验证
```bash
curl http://127.0.0.1:8791/health
curl -F "file=@sample.m4a" http://127.0.0.1:8791/v1/transcribe
```
接口成功时返回:
```json
{
"text": "识别到的文字",
"language": "zh",
"language_probability": 0.9,
"duration": 3.2,
"duration_ms": 1200
}
```
@@ -0,0 +1,28 @@
services:
whisper-asr:
build: .
image: sport-era/whisper-asr:latest
container_name: sport-era-whisper-asr
restart: unless-stopped
environment:
ASR_MODEL: ${ASR_MODEL:-small}
ASR_DEVICE: ${ASR_DEVICE:-cpu}
ASR_COMPUTE_TYPE: ${ASR_COMPUTE_TYPE:-int8}
ASR_LANGUAGE: ${ASR_LANGUAGE:-}
ASR_BEAM_SIZE: ${ASR_BEAM_SIZE:-5}
ASR_MAX_FILE_MB: ${ASR_MAX_FILE_MB:-10}
ASR_API_TOKEN: ${ASR_API_TOKEN:-}
ASR_MODEL_CACHE: /models
ports:
- "127.0.0.1:8791:8791"
volumes:
- whisper_asr_models:/models
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8791/health', timeout=3).read()"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
volumes:
whisper_asr_models:
+107
View File
@@ -0,0 +1,107 @@
import os
import tempfile
import time
from pathlib import Path
from typing import Optional
from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile
from faster_whisper import WhisperModel
APP_NAME = "Sport Era Whisper ASR"
ALLOWED_EXTENSIONS = {"mp3", "m4a", "aac", "wav", "amr", "mp4", "3gp"}
app = FastAPI(title=APP_NAME)
model: Optional[WhisperModel] = None
def env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)))
except ValueError:
return default
def get_model() -> WhisperModel:
global model
if model is None:
model_name = os.getenv("ASR_MODEL", "small")
device = os.getenv("ASR_DEVICE", "cpu")
compute_type = os.getenv("ASR_COMPUTE_TYPE", "int8")
cache_dir = os.getenv("ASR_MODEL_CACHE", "/models")
model = WhisperModel(
model_name,
device=device,
compute_type=compute_type,
download_root=cache_dir,
)
return model
def verify_token(authorization: str = Header(default="")) -> None:
token = os.getenv("ASR_API_TOKEN", "").strip()
if not token:
return
expected = f"Bearer {token}"
if authorization != expected:
raise HTTPException(status_code=401, detail="Unauthorized")
def file_extension(filename: str) -> str:
return Path(filename or "").suffix.lower().lstrip(".")
@app.get("/health")
def health() -> dict:
return {
"status": "ok",
"service": APP_NAME,
"model": os.getenv("ASR_MODEL", "small"),
"device": os.getenv("ASR_DEVICE", "cpu"),
"compute_type": os.getenv("ASR_COMPUTE_TYPE", "int8"),
}
@app.post("/v1/transcribe")
async def transcribe(file: UploadFile = File(...), _: None = Depends(verify_token)) -> dict:
extension = file_extension(file.filename)
if extension not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail="Unsupported audio format")
max_size_mb = max(1, env_int("ASR_MAX_FILE_MB", 10))
content = await file.read(max_size_mb * 1024 * 1024 + 1)
if not content:
raise HTTPException(status_code=400, detail="Empty audio file")
if len(content) > max_size_mb * 1024 * 1024:
raise HTTPException(status_code=413, detail=f"Audio file is larger than {max_size_mb}MB")
temp_path = ""
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=f".{extension}") as temp_file:
temp_file.write(content)
temp_path = temp_file.name
language = os.getenv("ASR_LANGUAGE", "").strip() or None
beam_size = max(1, env_int("ASR_BEAM_SIZE", 5))
start_time = time.perf_counter()
segments, info = get_model().transcribe(
temp_path,
language=language,
beam_size=beam_size,
vad_filter=True,
)
text = "".join(segment.text for segment in segments).strip()
duration_ms = int((time.perf_counter() - start_time) * 1000)
return {
"text": text,
"language": info.language,
"language_probability": info.language_probability,
"duration": info.duration,
"duration_ms": duration_ms,
}
finally:
if temp_path:
try:
os.unlink(temp_path)
except OSError:
pass
@@ -0,0 +1,4 @@
fastapi>=0.115,<1.0
uvicorn[standard]>=0.32,<1.0
python-multipart>=0.0.20,<1.0
faster-whisper>=1.1,<2.0