基于Flask与Faster-Whisper的轻量级Web语音识别系统
基于Flask与Faster-Whisper的轻量级Web语音识别系统
在人工智能应用爆发的今天,语音识别(ASR)已成为许多Web应用的核心能力。然而,将OpenAI的Whisper模型直接集成到Web服务中往往面临模型加载慢、显存占用高、推理延迟大等挑战。
本文将介绍如何利用 Faster-Whisper(基于CTranslate2的优化版本)结合 Flask 框架,构建一个轻量级、高性能的Web语音识别API。该系统支持中文识别,具备语音活动检测(VAD)功能,且无需昂贵的API调用费用,完全本地化部署。
先上效果图:
💡 为什么选择本方案?
- 零成本:完全免费,无需调用百度、阿里等收费API。
- 高性能:比原版Whisper快4倍,CPU也能流畅运行。
- 易部署:纯Python实现,代码拿来即用,无需复杂配置。
功能需求与项目目标
本项目旨在构建一个B/S架构的语音转文字服务,主要满足以下需求:
- Web端交互:用户通过浏览器访问页面,上传音频文件进行识别。
- 高性能推理:利用
faster-whisper替代原版 Whisper,在CPU环境下也能实现快速推理,显著降低内存占用。 - 中文优化:强制指定中文语言模型,并结合正则处理,确保输出结果为简体中文。
- 智能降噪:集成 VAD 技术,自动过滤音频中的静音片段,提高识别准确率。
- 异步加载:服务启动时异步加载模型,避免阻塞主线程,提升用户体验。
开发思路与技术架构
为了实现上述目标,我们采用以下技术栈和设计模式:
-
核心引擎:Faster-Whisper
相比原版,Faster-Whisper 通过量化技术(如int8)将模型体积压缩,推理速度提升4倍以上。本方案选择tiny模型配合int8计算类型,确保在普通CPU设备上也能流畅运行。
注意:faster-whisper内部集成了音频处理模块,Python 代码层面无需额外调用系统命令。 -
Web框架:Flask
使用 Flask 作为轻量级Web服务器,处理HTTP请求。通过threading模块在后台预加载模型,实现“单例模式”管理模型实例,避免每次请求重复加载模型带来的巨大开销。 -
音频处理流程
前端上传音频 -> Flask接收文件 -> 保存为临时文件 -> Faster-Whisper内部解码与转录 -> VAD过滤静音 -> 正则清洗文本 -> 返回JSON结果。 -
文本后处理
针对Whisper偶尔输出繁体字或标点混乱的问题,引入正则表达式re进行文本清洗,确保输出整洁。
环境准备与依赖安装
在开始之前,请确保你的开发环境满足以下条件:
- Python版本:Python 3.8 或更高版本
- 系统环境:Windows / Linux / macOS 均可
1. 安装 Python 依赖包
为了加快下载速度,请使用以下命令(使用清华大学镜像源)安装必要的库:
# 安装 Web 框架
pip install flask -i https://pypi.tuna.tsinghua.edu.cn/simple
# 安装 Faster-Whisper 及其依赖 (包含 torch, torchaudio 等)
# 该库已内置音频处理能力,无需单独安装 ffmpeg
pip install numpy scipy faster-whisper -i https://pypi.tuna.tsinghua.edu.cn/simple
# 音频处理与数值计算库
pip install numpy pyaudio -i https://pypi.tuna.tsinghua.edu.cn/simple
注意:如果在 Windows 上安装
pyaudio报错,建议下载对应的.whl文件进行安装,或使用pip install pipwin && pipwin install pyaudio。
项目目录结构
建议按照以下结构组织项目文件:
speech-app/
├── app.py # 主程序入口(后端逻辑)
├── templates/
└── index.html # 前端页面
完整源码解析
以下是后端核心代码 app.py 的完整实现。代码中包含了详细的注释,解释了模型加载、API路由及音频处理的关键步骤。
import os
import threading
import queue
import tempfile
import wave
import numpy as np
import pyaudio
from faster_whisper import WhisperModel
from flask import Flask, render_template, request, jsonify
import re
app = Flask(__name__)
# 全局变量
class AppState:
def __init__(self):
self.model = None
self.model_loaded = False
self.is_recording = False
self.audio_frames = []
self.stream = None
self.p_audio = None
self.transcription_queue = queue.Queue()
self.results = []
state = AppState()
def load_model():
"""加载模型"""
try:
model_size = "tiny"
device = "cpu"
compute_type = "int8"
state.model = WhisperModel(model_size, device=device, compute_type=compute_type)
state.model_loaded = True
print("✅ 模型加载成功")
except Exception as e:
print(f"❌ 模型加载失败:{e}")
@app.route('/')
def index():
"""主页"""
return render_template('index.html')
@app.route('/api/model_status')
def get_model_status():
"""获取模型状态"""
return jsonify({
'loaded': state.model_loaded
})
@app.route('/api/transcribe', methods=['POST'])
def transcribe():
"""转录音频"""
if not state.model_loaded:
return jsonify({
'success': False,
'error': '模型未加载'
}), 503
try:
audio_file = request.files['audio']
# 保存为临时文件
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as temp_file:
temp_filename = temp_file.name
audio_file.save(temp_filename)
# 执行转录
segments, info = state.model.transcribe(
temp_filename,
beam_size=5,
language="zh",
vad_filter=True
)
# 收集转录结果
text_parts = []
for segment in segments:
text = segment.text.strip()
text = ensure_simplified_chinese(text)
if text:
text_parts.append(text)
result_text = ' '.join(text_parts)
# 清理临时文件
try:
os.unlink(temp_filename)
except:
pass
if result_text:
return jsonify({
'success': True,
'text': result_text,
'language': info.language,
'probability': info.language_probability
})
else:
return jsonify({
'success': False,
'error': '未检测到语音'
})
except Exception as e:
print(f"转录错误:{e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
def ensure_simplified_chinese(text):
"""确保输出为简体中文或英文"""
if not text:
return ""
text = re.sub(r'\s+', ' ', text).strip()
return text
def main():
# 在后台线程中加载模型
model_thread = threading.Thread(target=load_model, daemon=True)
model_thread.start()
# 启动 Flask 应用
print("🚀 服务器启动中...")
print("📱 请在浏览器中打开:http://localhost:5000")
app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)
if __name__ == "__main__":
main()
以下是前端完整代码 index.html 的完整实现。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>实时语音转文字</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Arial', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 800px;
width: 100%;
padding: 30px;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.status-bar {
text-align: center;
padding: 15px;
margin: 20px 0;
border-radius: 10px;
font-weight: bold;
transition: all 0.3s;
}
.status-loading {
background-color: #fff3cd;
color: #856404;
}
.status-ready {
background-color: #d4edda;
color: #155724;
}
.status-recording {
background-color: #f8d7da;
color: #721c24;
}
.status-processing {
background-color: #d1ecf1;
color: #0c5460;
}
.text-area {
width: 100%;
height: 400px;
border: 2px solid #e0e0e0;
border-radius: 10px;
padding: 20px;
font-size: 16px;
line-height: 1.6;
overflow-y: auto;
background: #fafafa;
margin-bottom: 20px;
resize: vertical;
}
.button-container {
display: flex;
gap: 15px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 15px 40px;
font-size: 18px;
border: none;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s;
font-weight: bold;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.btn-record {
background: linear-gradient(135deg, #4CAF50, #45a049);
color: white;
}
.btn-record:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.4);
}
.btn-record:active {
transform: translateY(0);
}
.btn-record.recording {
background: linear-gradient(135deg, #f44336, #da190b);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 4px 15px rgba(244, 67, 54, 0.4);
}
50% {
box-shadow: 0 4px 25px rgba(244, 67, 54, 0.6);
}
}
.btn-clear {
background: linear-gradient(135deg, #9e9e9e, #757575);
color: white;
}
.btn-clear:hover {
background: linear-gradient(135deg, #757575, #616161);
}
.result-item {
background: white;
padding: 15px;
margin-bottom: 15px;
border-radius: 8px;
border-left: 4px solid #667eea;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.result-timestamp {
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.result-text {
font-size: 16px;
color: #333;
line-height: 1.5;
}
.divider {
border-top: 2px dashed #e0e0e0;
margin: 15px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>🎤 按住说话 - 实时语音转文字</h1>
<div id="status" class="status-bar status-loading">
正在加载模型...
</div>
<div id="textArea" class="text-area">
<!-- 转录结果将显示在这里 -->
</div>
<div class="button-container">
<button id="recordBtn" class="btn btn-record"
onmousedown="startRecording()"
onmouseup="stopRecording()"
onmouseleave="stopRecording()"
ontouchstart="startRecording()"
ontouchend="stopRecording()">
🎤 按住说话
</button>
<button onclick="clearText()" class="btn btn-clear">
🗑️ 清除
</button>
</div>
</div>
<script>
let mediaRecorder;
let audioChunks = [];
let isRecording = false;
async function startRecording() {
if (isRecording) return;
const status = document.getElementById('status');
const recordBtn = document.getElementById('recordBtn');
// 检查模型是否加载
const modelStatus = await fetch('/api/model_status');
const modelData = await modelStatus.json();
if (!modelData.loaded) {
alert('模型加载中,请稍候...');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true
}
});
mediaRecorder = new MediaRecorder(stream);
audioChunks = [];
mediaRecorder.ondataavailable = (event) => {
audioChunks.push(event.data);
};
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
await processAudio(audioBlob);
};
mediaRecorder.start();
isRecording = true;
status.textContent = '🔴 正在录音...';
status.className = 'status-bar status-recording';
recordBtn.classList.add('recording');
recordBtn.innerHTML = '🔴 录音中...';
} catch (err) {
console.error('录音错误:', err);
status.textContent = '❌ 无法访问麦克风:' + err.message;
status.className = 'status-bar';
status.style.backgroundColor = '#f8d7da';
status.style.color = '#721c24';
}
}
async function stopRecording() {
if (!isRecording) return;
const status = document.getElementById('status');
const recordBtn = document.getElementById('recordBtn');
mediaRecorder.stop();
mediaRecorder.stream.getTracks().forEach(track => track.stop());
isRecording = false;
status.textContent = '🟢 正在转录...';
status.className = 'status-bar status-processing';
recordBtn.classList.remove('recording');
recordBtn.innerHTML = '🎤 按住说话';
}
async function processAudio(audioBlob) {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.wav');
try {
const response = await fetch('/api/transcribe', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
displayResult(result);
} else {
const status = document.getElementById('status');
status.textContent = '⚠️ ' + result.error;
status.className = 'status-bar';
status.style.backgroundColor = '#fff3cd';
status.style.color = '#856404';
}
} catch (err) {
console.error('转录错误:', err);
const status = document.getElementById('status');
status.textContent = '❌ 转录错误:' + err.message;
status.className = 'status-bar';
status.style.backgroundColor = '#f8d7da';
status.style.color = '#721c24';
}
}
function displayResult(result) {
const textArea = document.getElementById('textArea');
const resultDiv = document.createElement('div');
resultDiv.className = 'result-item';
const timestamp = document.createElement('div');
timestamp.className = 'result-timestamp';
timestamp.textContent = `[语言:${result.language} (置信度:${result.probability.toFixed(2)})]`;
const text = document.createElement('div');
text.className = 'result-text';
text.textContent = result.text;
const divider = document.createElement('div');
divider.className = 'divider';
resultDiv.appendChild(timestamp);
resultDiv.appendChild(text);
resultDiv.appendChild(divider);
textArea.appendChild(resultDiv);
textArea.scrollTop = textArea.scrollHeight;
const status = document.getElementById('status');
status.textContent = '✅ 转录完成,请继续说话或松开按钮';
status.className = 'status-bar status-ready';
}
function clearText() {
document.getElementById('textArea').innerHTML = '';
}
// 定期检查模型状态
async function checkModelStatus() {
try {
const response = await fetch('/api/model_status');
const data = await response.json();
const status = document.getElementById('status');
if (data.loaded) {
status.textContent = '✅ 模型已就绪,请按住按钮说话';
status.className = 'status-bar status-ready';
} else {
status.textContent = '⏳ 正在加载模型...';
status.className = 'status-bar status-loading';
}
} catch (err) {
console.error('检查状态失败:', err);
}
}
// 每 2 秒检查一次模型状态
setInterval(checkModelStatus, 2000);
checkModelStatus();
</script>
</body>
</html>
总结与展望
通过上述代码,我们成功实现了一个基于 Flask 和 Faster-Whisper 的语音识别后端。
- 优势:代码结构清晰,利用
int8量化技术极大降低了硬件门槛,使得在没有独立显卡的普通笔记本上也能运行语音识别服务。 - 扩展性:你可以进一步开发
templates/index.html实现录音上传功能,或者对接前端麦克风实现实时流式识别。
🔥 觉得有用的话,请动动小手:
- 点赞:你的点赞是我持续输出高质量技术文章的最大动力!
- 关注:关注我,第一时间获取最新AI实战教程和Python开发技巧。
- 收藏:防止迷路,方便日后查阅代码。
👇 遇到问题?欢迎在评论区留言讨论,我会第一时间回复!
希望这篇教程能帮助你快速搭建属于自己的AI语音应用!
更多推荐
所有评论(0)