基于Flask与Faster-Whisper的轻量级Web语音识别系统

在人工智能应用爆发的今天,语音识别(ASR)已成为许多Web应用的核心能力。然而,将OpenAI的Whisper模型直接集成到Web服务中往往面临模型加载慢、显存占用高、推理延迟大等挑战。

本文将介绍如何利用 Faster-Whisper(基于CTranslate2的优化版本)结合 Flask 框架,构建一个轻量级、高性能的Web语音识别API。该系统支持中文识别,具备语音活动检测(VAD)功能,且无需昂贵的API调用费用,完全本地化部署。

先上效果图:
在这里插入图片描述

💡 为什么选择本方案?

  • 零成本:完全免费,无需调用百度、阿里等收费API。
  • 高性能:比原版Whisper快4倍,CPU也能流畅运行。
  • 易部署:纯Python实现,代码拿来即用,无需复杂配置。
功能需求与项目目标

本项目旨在构建一个B/S架构的语音转文字服务,主要满足以下需求:

  1. Web端交互:用户通过浏览器访问页面,上传音频文件进行识别。
  2. 高性能推理:利用 faster-whisper 替代原版 Whisper,在CPU环境下也能实现快速推理,显著降低内存占用。
  3. 中文优化:强制指定中文语言模型,并结合正则处理,确保输出结果为简体中文。
  4. 智能降噪:集成 VAD 技术,自动过滤音频中的静音片段,提高识别准确率。
  5. 异步加载:服务启动时异步加载模型,避免阻塞主线程,提升用户体验。
开发思路与技术架构

为了实现上述目标,我们采用以下技术栈和设计模式:

  1. 核心引擎:Faster-Whisper
    相比原版,Faster-Whisper 通过量化技术(如 int8)将模型体积压缩,推理速度提升4倍以上。本方案选择 tiny 模型配合 int8 计算类型,确保在普通CPU设备上也能流畅运行。
    注意:faster-whisper 内部集成了音频处理模块,Python 代码层面无需额外调用系统命令。

  2. Web框架:Flask
    使用 Flask 作为轻量级Web服务器,处理HTTP请求。通过 threading 模块在后台预加载模型,实现“单例模式”管理模型实例,避免每次请求重复加载模型带来的巨大开销。

  3. 音频处理流程
    前端上传音频 -> Flask接收文件 -> 保存为临时文件 -> Faster-Whisper内部解码与转录 -> VAD过滤静音 -> 正则清洗文本 -> 返回JSON结果。

  4. 文本后处理
    针对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语音应用!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐