2026-08-19-stt-implementation.md 15 KB

语音转文字(STT)实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 在 cfc-langgraph 中新增 faster-whisper 本地 STT 端点,改造小程序打卡页录音后自动转写,同时为 AI 对话页添加语音输入按钮。

架构: faster-whisper base 模型(140MB,CPU,int8)作为 langgraph 服务的内联模块启动时加载。POST /api/v1/audio/transcribe 接收 mp3 → 解码为 float32 → 转写 → 返回文字。小程序三页分享同一录音逻辑,直接保存转写结果不预览。

技术栈: faster-whisper==1.2.1, ctranslate2==4.8.1, av==18.1.0, Python 3.12, Vue 2 Options API, uni-app


文件结构

新增文件

文件 职责
cfc-langgraph/app/audio/__init__.py 空包
cfc-langgraph/app/audio/transcriber.py faster-whisper 封装:加载模型、转写 mp3、返回文字
cfc-langgraph/app/api/audio.py FastAPI 路由:POST /api/v1/audio/transcribe,接收 multipart file → 调 transcriber → 返回 JSON

修改文件

文件 修改内容
cfc-langgraph/app/main.py 注册 audio router;startup 事件中初始化全局 transcriber
cfc-langgraph/requirements.txt 新增 faster-whisper==1.2.1av==18.1.0
cfc-langgraph/Dockerfile 预下载 whisper base 模型(HF_ENDPOINT=https://hf-mirror.com 构建时下载缓存)
cfc-langgraph/.env.production 无需修改
cfc-frontend/utils/api.js 新增 sttTranscribe(filePath) 方法
cfc-frontend/pages/growth/diet-checkin.vue 录音 onStop 后调 STT,voiceText 自动填入表单
cfc-frontend/pages/growth/exercise-checkin.vue 同上
cfc-frontend/pages/growth/sleep-checkin.vue 同上
cfc-backend DTO 文件 打卡 DTO 增加 voiceText 字段

任务 1:langgraph — STT 转写器(transcriber.py)

文件:

  • 创建:cfc-langgraph/app/audio/__init__.py(空文件)
  • 创建:cfc-langgraph/app/audio/transcriber.py
  • 创建:cfc-langgraph/app/api/audio.py
  • 修改:cfc-langgraph/app/main.py
  • 修改:cfc-langgraph/requirements.txt

  • [ ] 步骤 1:创建 audio 包和 transcriber 模块

cfc-langgraph/app/audio/__init__.py — 空文件

cfc-langgraph/app/audio/transcriber.py:

"""faster-whisper 本地 STT 引擎封装"""
import os
import logging
import numpy as np
import av
from faster_whisper import WhisperModel

logger = logging.getLogger(__name__)

# 模型实例(全局单例,startup 时初始化)
_model: "WhisperModel | None" = None
_loaded = False


def init_model(model_size: str = "base", device: str = "cpu", compute_type: str = "int8"):
    """初始化 faster-whisper 模型(启动时调用)"""
    global _model, _loaded
    if _loaded:
        return
    # 优先使用国内镜像
    os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
    logger.info("加载 faster-whisper 模型 %s (device=%s, compute=%s)...", model_size, device, compute_type)
    _model = WhisperModel(model_size, device=device, compute_type=compute_type)
    _loaded = True
    logger.info("faster-whisper 模型加载完成")


def transcribe_audio(file_path: str, language: str = "zh") -> dict:
    """转写音频文件,返回 {text, language, language_probability, duration}"""
    if not _model:
        raise RuntimeError("faster-whisper 模型未初始化")

    # 1. 解码 mp3 → float32 16kHz 单声道
    duration_sec = 0.0
    try:
        with av.open(file_path) as container:
            stream = container.streams.audio[0]
            total_frames = 0
            samples = []
            for frame in container.decode(audio=0):
                if frame.pts is not None:
                    total_frames = frame.pts
                arr = frame.to_ndarray()
                # 混音为单声道
                if arr.ndim > 1 and arr.shape[0] > 1:
                    arr = np.mean(arr, axis=0)
                samples.append(arr)
            if not samples:
                raise ValueError("音频文件无有效帧")
            audio = np.concatenate(samples).astype(np.float32)
            # 归一化到 [-1, 1]
            max_val = np.max(np.abs(audio))
            if max_val > 0:
                audio = audio / max_val
            # 重采样到 16kHz (whisper 要求)
            # av 解码后可能是 44100/48000 Hz,需要重采样
            orig_rate = stream.rate if stream.rate else 44100
            if orig_rate != 16000:
                import scipy.signal  # 需要 scipy 处理重采样
                # 使用线性插值重采样
                new_len = int(len(audio) * 16000 / orig_rate)
                audio = np.interp(
                    np.linspace(0, len(audio) - 1, new_len),
                    np.arange(len(audio)),
                    audio
                ).astype(np.float32)
            duration_sec = len(audio) / 16000.0
    except Exception as e:
        logger.error("音频解码失败 %s: %s", file_path, e)
        raise

    # 2. 转写
    segments, info = _model.transcribe(audio, language=language, beam_size=5)
    text = "".join(seg.text for seg in segments).strip()
    return {
        "text": text,
        "language": info.language,
        "language_probability": round(float(info.language_probability), 4),
        "duration": round(duration_sec, 2),
    }
  • 步骤 2:创建 STT 端点

cfc-langgraph/app/api/audio.py:

"""语音转写端点"""
import os
import uuid
import logging
from fastapi import APIRouter, UploadFile, File, HTTPException
from pydantic import BaseModel
from typing import Optional
from app.audio.transcriber import transcribe_audio

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["audio"])

MAX_FILE_SIZE = 5 * 1024 * 1024  # 5MB
UPLOAD_DIR = "/tmp/stt_uploads"


class TranscribeResponse(BaseModel):
    code: int = 200
    message: str = "ok"
    data: Optional[dict] = None


@router.post("/audio/transcribe", response_model=TranscribeResponse)
async def transcribe(file: UploadFile = File(...)):
    # 验证文件类型
    if not file.filename or not file.filename.lower().endswith((".mp3", ".wav", ".m4a", ".flac", ".ogg")):
        raise HTTPException(status_code=400, detail="不支持的音频格式,仅支持 mp3/wav/m4a/flac/ogg")
    # 读取内容
    content = await file.read()
    if len(content) > MAX_FILE_SIZE:
        raise HTTPException(status_code=413, detail="音频文件过大(最大 5MB)")
    if len(content) < 1000:
        raise HTTPException(status_code=400, detail="音频文件为空或过短")

    # 保存到临时文件(faster-whisper 需要文件路径或 ndarray)
    os.makedirs(UPLOAD_DIR, exist_ok=True)
    ext = file.filename.rsplit(".", 1)[-1]
    tmp_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4().hex}.{ext}")
    try:
        with open(tmp_path, "wb") as f:
            f.write(content)
        result = transcribe_audio(tmp_path)
        return TranscribeResponse(data=result)
    except Exception as e:
        logger.error("转写失败: %s", e, exc_info=True)
        return TranscribeResponse(code=500, message=f"转写失败: {str(e)}", data={})
    finally:
        try:
            os.remove(tmp_path)
        except OSError:
            pass
  • 步骤 3:注册路由到 main.py

cfc-langgraph/app/main.py

app.include_router(logs.router) 之后添加:

from app.api import audio
app.include_router(audio.router)

startup() 函数中 setup_logging 之后、retriever.initialize() 之前添加:

from app.audio.transcriber import init_model
init_model()
  • 步骤 4:更新 requirements.txt

cfc-langgraph/requirements.txt 末尾追加:

faster-whisper==1.2.1
av==18.1.0
  • 步骤 5:更新 Dockerfile(预下载模型缓存)

cfc-langgraph/Dockerfile 中,COPY app/ app/ 之前添加:

# 预下载 faster-whisper base 模型(避免启动时慢下载)
RUN pip install --no-cache-dir --index-url https://pypi.tuna.tsinghua.edu.cn/simple faster-whisper==1.2.1 av==18.1.0 && \
    HF_ENDPOINT=https://hf-mirror.com python3 -c "from faster_whisper import WhisperModel; WhisperModel('base', device='cpu', compute_type='int8')"
  • [ ] 步骤 6:验证 langgraph 编译 & 启动

    cd /app/cfc/cfc-langgraph
    .venv/bin/python -m py_compile app/audio/transcriber.py app/api/audio.py
    # 验证 import 无报错
    .venv/bin/python -c "from app.audio.transcriber import init_model, transcribe_audio; print('import OK')"
    

任务 2:frontend — API 工具

文件:

  • 修改:cfc-frontend/utils/api.js

  • [ ] 步骤 1:新增 sttTranscribe 方法

cfc-frontend/utils/api.js 中(uploadFile 相关段落附近,例如 export const 区域)添加:

/**
 * 语音转写:上传音频文件,返回转写文字
 * @param {string} filePath - 录音临时路径
 * @returns {Promise<string>} 转写文字
 */
export const sttTranscribe = (filePath) => {
  return new Promise((resolve, reject) => {
    uni.uploadFile({
      url: BASE_URL + '/api/v1/audio/transcribe',
      filePath: filePath,
      name: 'file',
      header: { 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
      success: (res) => {
        try {
          var data = JSON.parse(res.data)
          if (data.code === 200 && data.data && data.data.text) {
            resolve(data.data.text)
          } else {
            reject(new Error(data.message || '转写失败'))
          }
        } catch (e) {
          reject(new Error('解析响应失败'))
        }
      },
      fail: (err) => {
        reject(new Error('上传失败: ' + (err.errMsg || '')))
      }
    })
  })
}

任务 3:frontend — 打卡页录音后自动转写

文件:

  • 修改:cfc-frontend/pages/growth/diet-checkin.vue
  • 修改:cfc-frontend/pages/growth/exercise-checkin.vue
  • 修改:cfc-frontend/pages/growth/sleep-checkin.vue

三页改动模式相同,以 diet-checkin.vue 为例。

  • 步骤 1:diet-checkin 引入 sttTranscribe

<script> 顶部 import 区域添加:

import { sttTranscribe } from '../../utils/api.js'
  • 步骤 2:data 中增加 voiceText 字段

data()form 对象中增加:

form: {
  // ... 现有字段
  voiceText: '',
}
  • 步骤 3:onStop 回调中增加 STT 调用

recorderManager.onStop 回调从:

this.recorderManager.onStop((res) => {
  this.isRecording = false
  if (this.recordTimer) { clearInterval(this.recordTimer); this.recordTimer = null }
  this.form.voicePath = res.tempFilePath
  this.form.voiceDuration = res.duration
})

改为:

this.recorderManager.onStop((res) => {
  this.isRecording = false
  if (this.recordTimer) { clearInterval(this.recordTimer); this.recordTimer = null }
  this.form.voicePath = res.tempFilePath
  this.form.voiceDuration = res.duration
  // 自动转写
  var self = this
  uni.showLoading({ title: '语音识别中...', mask: true })
  sttTranscribe(res.tempFilePath).then(function(text) {
    self.form.voiceText = text
    uni.hideLoading()
  }).catch(function(err) {
    uni.hideLoading()
    console.warn('STT 转写失败:', err)
  })
})
  • 步骤 4:doCheckin 提交时包含 voiceText

doCheckin 方法的提交数据对象中增加 voiceText

// 在 submitData 构建处
var submitData = {
  // ... 现有字段
  voiceText: this.form.voiceText || '',
}
  • 步骤 5:exercise-checkin.vue 与 sleep-checkin.vue 同步修改

同样模式:引入 sttTranscribevoiceText 字段、onStop 回调加 STT、doCheckin 提交时包含。


任务 4:frontend — AI 对话语音输入按钮(后续)

说明: 对话页 pages/chat/chat.vue(或类似路径)新增语音按钮。此任务作为独立步骤,不阻塞打卡页功能。

  • 步骤 1:对话页增加录音按钮

在输入框旁添加 🎙️ 按钮,绑定 toggleRecord 事件:

<view class="voice-btn" @touchstart="startVoiceRecord" @touchend="stopVoiceRecord">
  <text>🎙️</text>
</view>
  • [ ] 步骤 2:录音逻辑(与打卡页复用相同模式)

    startVoiceRecord: function() {
    this.recorderManager = uni.getRecorderManager()
    this.recorderManager.onStop((res) => {
    var self = this
    uni.showLoading({ title: '语音识别中...', mask: true })
    sttTranscribe(res.tempFilePath).then(function(text) {
      self.inputText = text  // 填入输入框
      uni.hideLoading()
    }).catch(function(err) {
      uni.hideLoading()
      uni.showToast({ title: '语音识别失败', icon: 'none' })
    })
    })
    this.recorderManager.start({ duration: 60000, format: 'mp3' })
    },
    stopVoiceRecord: function() {
    if (this.recorderManager) this.recorderManager.stop()
    },
    
  • [ ] 步骤 3:引入 sttTranscribe

    import { sttTranscribe } from '../../utils/api.js'
    

任务 5:backend — DTO 增加 voiceText 字段

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/dto/DietCheckinDTO.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/dto/ExerciseCheckinDTO.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/dto/SleepCheckinDTO.java

DietCheckinDTO 为例,三处改动相同。

  • [ ] 步骤 1:DTO 增加字段

    private String voiceText;  // 语音转写文字
    
    public String getVoiceText() { return voiceText; }
    public void setVoiceText(String voiceText) { this.voiceText = voiceText; }
    
  • [ ] 步骤 2:Entity 增加字段(若 DTO 映射到实体)

    private String voiceText;
    // getter/setter
    
  • [ ] 步骤 3:mvn compile 验证

    cd /app/cfc/cfc-backend && mvn clean compile -q
    

任务 6:镜像构建 & 部署验证

  • [ ] 步骤 1:重建镜像

    cd /app/cfc/cfc-langgraph && docker compose build langgraph-svc
    
  • [ ] 步骤 2:重启容器

    docker compose up -d langgraph-svc
    
  • [ ] 步骤 3:验证 STT 端点

    # 等待 healthcheck 通过
    curl -s http://localhost:9000/health
    # 用一段测试音频验证
    # 生成测试音频(静音 3 秒)
    python3 -c "
    import numpy as np
    import wave
    sr = 16000
    with wave.open('/tmp/test_stt.wav', 'w') as w:
    w.setnchannels(1)
    w.setsampwidth(2)
    w.setframerate(sr)
    w.writeframes((np.zeros(sr*3).astype(np.int16)).tobytes())
    "
    curl -s -X POST http://localhost:9000/api/v1/audio/transcribe \
    -F "file=@/tmp/test_stt.wav" | python3 -m json.tool
    
  • [ ] 步骤 4:打卡页录音验证(小程序端手动测试)

打开 diet-checkin 页 → 录音 → 确认转写结果正常保存。