目标:在一台 Ubuntu Server + RTX 2080 的环境中,部署基于 Whisper
large-v2的语音转写服务,通过浏览器访问,仅保留两种交互模式:
1)一个按钮:点击开始录音;再次点击自动停止并上传识别。
2)一个按钮:上传本地音频文件识别。
设计重点:高准确率、整段识别、私有部署、无外网依赖、最少交互控件。
1. 架构与工作流
1.1 架构概览
- 前端(浏览器):纯 HTML + 原生 JavaScript。
- 按钮 A:录音“切换键”(toggle):第一次点击开始录音,第二次点击停止并自动上传。
- 按钮 B:文件选择器:上传本地音频进行识别。
- 后端(Ubuntu Server):FastAPI 提供接口;
faster-whisper调用 Whisperlarge-v2进行转写;FFmpeg 用于音频转码。 - GPU 推理:RTX 2080(8GB)+ FP16 精度;只运行单一服务,无桌面环境占用显存。
1.2 工作过程(与需求对齐)
- 模式一:录音识别
点击按钮开始录音 → 自然表达(中途停顿不触发切分)→ 再次点击结束 → 一次性上传整段音频 → 后端转写 → 返回完整文本(含自动断句与标点)。 - 模式二:文件识别
选择本地音频 → 上传 → 后端转写 → 返回完整文本。
2. 环境准备
2.1 系统与驱动(Ubuntu 20.04/22.04/24.04 皆可)
sudo apt update
sudo apt -y upgrade
sudo apt -y install build-essential pkg-config git curl wget unzip ffmpeg python3 python3-venv python3-pip
2.2 NVIDIA 驱动与 CUDA(RTX 2080 / Turing)
- 推荐安装官方驱动(525+),CUDA 11/12 任一版本均可;本项目只需通过 PyTorch/CTranslate2 使用 CUDA,不强依赖特定 CUDA 版本。
- 驱动安装略(按企业既有流程或发行版推荐方式);安装完成后验证:
nvidia-smi
出现显卡与驱动信息即为正常。
3. 项目结构
whisper_tool/
├── app.py # FastAPI 入口
├── transcribe.py # 转写逻辑(faster-whisper 封装)
├── static/
│ └── index.html # 前端页面(两按钮)
├── uploads/ # 临时音频保存
└── requirements.txt # Python 依赖
4. Python 依赖与模型
4.1 创建虚拟环境并安装依赖
cd /opt
sudo mkdir -p whisper_tool && sudo chown $USER:$USER whisper_tool
cd whisper_tool
python3 -m venv venv
source venv/bin/activate
cat > requirements.txt << 'EOF'
fastapi==0.110.0
uvicorn[standard]==0.29.0
pydub==0.25.1
faster-whisper==1.0.0
python-multipart==0.0.9
EOF
pip install -U pip
pip install -r requirements.txt
4.2 说明:为何选择 faster-whisper
- 使用相同的 Whisper 权重(
large-v2),转写准确率与原生基本一致。 - FP16 下显存占用更低,RTX 2080(8GB)可稳定运行
large-v2。 - 推理速度与资源使用更优,适合无桌面服务端长期运行。
5. 后端代码
5.1 transcribe.py
# transcribe.py
import os
import uuid
from faster_whisper import WhisperModel
# 运行参数:中文可显式指定 language="zh";混合场景可省略语言自动识别
MODEL_NAME = "large-v2"
COMPUTE_TYPE = "float16" # 在 RTX 2080 上使用 FP16 节省显存
BEAM_SIZE = 5 # 提升准确率(相对速度折中)
LANGUAGE = None # 指定 "zh" 或 None(自动);按需修改
# 模型常驻内存,避免重复加载
model = WhisperModel(MODEL_NAME, compute_type=COMPUTE_TYPE)
UPLOAD_DIR = os.path.join(os.path.dirname(__file__), "uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
def run_transcribe(file_path: str):
# 这里不做细碎切分,整段识别,充分利用上下文,获得更自然的断句和标点
segments, info = model.transcribe(
file_path,
beam_size=BEAM_SIZE,
language=LANGUAGE,
vad_filter=False # 满足“整段上传整段识别”的诉求;若需额外稳健可 True
)
text = "".join(seg.text for seg in segments)
return {
"text": text.strip(),
"duration": getattr(info, "duration", None),
"language": getattr(info, "language", None),
}
def make_temp_path(suffix=".wav"):
return os.path.join(UPLOAD_DIR, f"{uuid.uuid4().hex}{suffix}")
5.2 app.py
# app.py
import os
import shutil
import subprocess
from fastapi import FastAPI, UploadFile, Response
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from transcribe import run_transcribe, make_temp_path
app = FastAPI(title="Whisper Tool (Two-Button Edition)")
# 如需跨域,按需配置允许的来源
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 内网或受控环境可放开;公网建议限制具体域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 静态页面
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
# 简单的转码函数:确保统一为 wav / pcm_s16le,便于模型解析
def ensure_wav(input_path: str) -> str:
if input_path.lower().endswith(".wav"):
return input_path
out_path = input_path.rsplit(".", 1)[0] + ".wav"
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", out_path
]
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
return out_path
@app.post("/api/transcribe/record")
async def transcribe_record(file: UploadFile):
# 录音模式上传的 Blob(webm / mp3 / wav 等)
raw_path = make_temp_path("." + (file.filename.split(".")[-1] if "." in file.filename else "webm"))
with open(raw_path, "wb") as f:
shutil.copyfileobj(file.file, f)
wav_path = ensure_wav(raw_path)
result = run_transcribe(wav_path)
# 清理临时文件(按需保留以便审计/排错)
for p in {raw_path, wav_path}:
try:
if os.path.exists(p):
os.remove(p)
except Exception:
pass
return result
@app.post("/api/transcribe/upload")
async def transcribe_upload(file: UploadFile):
# 本地音频文件识别
raw_path = make_temp_path("." + (file.filename.split(".")[-1] if "." in file.filename else "bin"))
with open(raw_path, "wb") as f:
shutil.copyfileobj(file.file, f)
wav_path = ensure_wav(raw_path)
result = run_transcribe(wav_path)
for p in {raw_path, wav_path}:
try:
if os.path.exists(p):
os.remove(p)
except Exception:
pass
return result
@app.get("/healthz")
def healthz():
return {"ok": True}
6. 前端页面(两按钮极简 UI)
6.1 static/index.html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>语音转写(两按钮版)</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 24px; }
.btn { padding: 10px 16px; margin-right: 12px; cursor: pointer; border: 1px solid #333; border-radius: 6px; }
.btn.rec { background: #f2f2f2; }
.btn.upload { background: #e8f5ff; }
#result { white-space: pre-wrap; margin-top: 16px; padding: 12px; border: 1px dashed #aaa; border-radius: 6px; min-height: 120px; }
#status { margin-top: 8px; color: #555; }
</style>
</head>
<body>
<h2>语音转写(两按钮版)</h2>
<button id="recBtn" class="btn rec">🎤 点击开始录音</button>
<label class="btn upload">
📁 选择音频文件
<input id="fileInput" type="file" accept="audio/*" style="display:none;" />
</label>
<div id="status">状态:空闲</div>
<div id="result" placeholder="识别结果将在此显示…"></div>
<script>
const recBtn = document.getElementById('recBtn');
const fileInput = document.getElementById('fileInput');
const statusEl = document.getElementById('status');
const resultEl = document.getElementById('result');
let mediaRecorder;
let chunks = [];
let recording = false;
// 切换录音:第一次点击开始;第二次点击停止并上传
recBtn.addEventListener('click', async () => {
if (!recording) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
chunks = [];
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };
mediaRecorder.onstop = async () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
await uploadBlob(blob, '/api/transcribe/record');
};
mediaRecorder.start();
recording = true;
recBtn.textContent = '⏹️ 点击停止并上传';
statusEl.textContent = '状态:录音中…';
} catch (err) {
statusEl.textContent = '状态:无法访问麦克风(请检查权限或设备)';
}
} else {
mediaRecorder.stop();
recording = false;
recBtn.textContent = '🎤 点击开始录音';
statusEl.textContent = '状态:处理中…';
}
});
// 文件上传识别
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
statusEl.textContent = '状态:处理中…';
await uploadBlob(file, '/api/transcribe/upload');
fileInput.value = '';
});
async function uploadBlob(blob, endpoint) {
const form = new FormData();
form.append('file', blob, 'audio.webm');
try {
const res = await fetch(endpoint, { method: 'POST', body: form });
const data = await res.json();
if (data && data.text !== undefined) {
resultEl.textContent += (resultEl.textContent ? '\n' : '') + '▶ ' + data.text;
statusEl.textContent = '状态:完成';
} else {
statusEl.textContent = '状态:解析失败';
}
} catch (e) {
statusEl.textContent = '状态:上传或识别失败';
}
}
</script>
</body>
</html>
7. 启动与访问
7.1 启动服务
cd /opt/whisper_tool
source venv/bin/activate
uvicorn app:app --host 0.0.0.0 --port 8000
浏览器访问:http://<服务器IP>:8000/
(例如内网 http://192.168.1.50:8000/)
8. 服务常驻与反向代理(可选)
8.1 使用 systemd 常驻
sudo tee /etc/systemd/system/whisper-tool.service >/dev/null <<'EOF'
[Unit]
Description=Whisper Tool Service
After=network.target
[Service]
User=%i
WorkingDirectory=/opt/whisper_tool
ExecStart=/opt/whisper_tool/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8000
Restart=always
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable whisper-tool
sudo systemctl start whisper-tool
sudo systemctl status whisper-tool --no-pager
若以指定用户运行,将
%i替换为该用户名,或直接写User=youruser。
8.2 反向代理与 HTTPS(可选)
- 生产环境可通过 Nginx/Caddy 做反向代理与 TLS;仅内网使用可忽略。
- 若公网暴露,建议:
- 限制访问来源 IP
- 基本认证或简单登录页
- 请求体大小限制(防止超大文件占满磁盘/显存)
9. 参数与性能建议
- 模型:
large-v2(最高准确率场景) - 精度:
float16(RTX 2080 的 8GB 显存足够) - beam_size:5(准确率优先;可在 1~5 间调)
- 语言:中文为主可设
language="zh";多语混合可设None(自动检测)。 - 并发:单 GPU 下建议串行处理或排队,避免显存紧张。
- 文件大小:前端录音建议不超过数分钟一段;超长文件可在后端分段处理再拼合文本。
- 资源回收:上传处理后清理临时文件夹,保留期与合规策略视环境决定。
10. 安全与隐私(私有部署要点)
- 全程本地推理,不将音频或文本上传第三方。
- 默认不落盘保存音频与文本,仅做临时缓存;如需留存,先评估合规与配额。
- 公网暴露时务必上 HTTPS、鉴权与速率限制。
- 日志中不记录原始音频路径与文本内容(或采用脱敏/可配置开关)。
11. 常见问题与排障
| 问题 | 处理建议 |
|---|---|
CUDA out of memory | 确认无桌面/其他占用;保持单任务;必要时将 beam_size 调小或切 base/small/medium 临时排障。 |
| 上传 mp3/webm 无法识别 | 确保已安装 ffmpeg,并使用统一转码到 16k 单声道 PCM WAV。 |
| 浏览器无麦克风权限 | 检查 HTTPS(部分浏览器对非 HTTPS 的麦克风权限更严格)或站点权限设置。 |
| 结果延迟较长 | large-v2 精度更高但更重;若追求更快可改 medium,或使用更强显卡。 |
| 中英文混说断句不理想 | 取消固定语言,使用自动语言检测;或启用 beam_size、保持整段识别。 |
12. 成品特性回顾(与需求一致性)
- 两个按钮:“录音/停止并上传” 与 “上传文件识别” ✅
- 整段识别:不按秒切分,更自然的断句与标点 ✅
- 高准确率:使用
large-v2+ beam search ✅ - 私有部署:Ubuntu Server 本地 GPU 推理 ✅
- 简单可用:纯静态前端 + 简洁 API ✅
- 脱敏合规:不使用外部服务,不记录敏感日志 ✅
以上流程落地后,即可在浏览器侧以最少的交互完成高质量语音转写:点击开始录音→再次点击自动上传识别,或直接上传本地音频文件,两种模式同时具备,满足高准确率与完整语义的核心诉求。