# 语音转文字(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.1`、`av==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`: ```python """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`: ```python """语音转写端点""" 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)` 之后添加: ```python from app.api import audio app.include_router(audio.router) ``` 在 `startup()` 函数中 `setup_logging` 之后、`retriever.initialize()` 之前添加: ```python 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/` 之前添加: ```dockerfile # 预下载 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 编译 & 启动** ```bash 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` 区域)添加: ```javascript /** * 语音转写:上传音频文件,返回转写文字 * @param {string} filePath - 录音临时路径 * @returns {Promise} 转写文字 */ 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** 在 `