面向 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 字段 |
文件:
cfc-langgraph/app/audio/__init__.py(空文件)cfc-langgraph/app/audio/transcriber.pycfc-langgraph/app/api/audio.pycfc-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),
}
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
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()
在 cfc-langgraph/requirements.txt 末尾追加:
faster-whisper==1.2.1
av==18.1.0
在 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')"
文件:
修改: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 || '')))
}
})
})
}
文件:
cfc-frontend/pages/growth/diet-checkin.vuecfc-frontend/pages/growth/exercise-checkin.vuecfc-frontend/pages/growth/sleep-checkin.vue三页改动模式相同,以 diet-checkin.vue 为例。
在 <script> 顶部 import 区域添加:
import { sttTranscribe } from '../../utils/api.js'
在 data() 的 form 对象中增加:
form: {
// ... 现有字段
voiceText: '',
}
将 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)
})
})
在 doCheckin 方法的提交数据对象中增加 voiceText:
// 在 submitData 构建处
var submitData = {
// ... 现有字段
voiceText: this.form.voiceText || '',
}
同样模式:引入 sttTranscribe、voiceText 字段、onStop 回调加 STT、doCheckin 提交时包含。
说明: 对话页 pages/chat/chat.vue(或类似路径)新增语音按钮。此任务作为独立步骤,不阻塞打卡页功能。
在输入框旁添加 🎙️ 按钮,绑定 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'
文件:
cfc-backend/src/main/java/com/etotem/cfc/dto/DietCheckinDTO.javacfc-backend/src/main/java/com/etotem/cfc/dto/ExerciseCheckinDTO.javacfc-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
[ ] 步骤 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 页 → 录音 → 确认转写结果正常保存。