目标:在一台 Ubuntu Server + RTX 2080 的环境中,部署基于 Whisper large-v2 的语音转写服务,通过浏览器访问,仅保留两种交互模式:
1)一个按钮:点击开始录音;再次点击自动停止并上传识别。
2)一个按钮:上传本地音频文件识别。
设计重点:高准确率、整段识别、私有部署、无外网依赖、最少交互控件


1. 架构与工作流

1.1 架构概览

  • 前端(浏览器):纯 HTML + 原生 JavaScript。
    • 按钮 A:录音“切换键”(toggle):第一次点击开始录音,第二次点击停止并自动上传。
    • 按钮 B:文件选择器:上传本地音频进行识别。
  • 后端(Ubuntu Server):FastAPI 提供接口;faster-whisper 调用 Whisper large-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 ✅
  • 脱敏合规:不使用外部服务,不记录敏感日志 ✅

以上流程落地后,即可在浏览器侧以最少的交互完成高质量语音转写:点击开始录音→再次点击自动上传识别,或直接上传本地音频文件,两种模式同时具备,满足高准确率与完整语义的核心诉求。

Leave a Reply

Your email address will not be published. Required fields are marked *