更新
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
*
|
||||
!.gitignore
|
||||
!.gitignore
|
||||
!whisper-asr/
|
||||
!whisper-asr/**
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
models/
|
||||
.env
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
models/
|
||||
.env
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user