Qwen3-ASR-0.6B语音识别教程:支持实时麦克风流式输入(WebRTC扩展方案)

你是不是还在为语音识别只能上传文件而烦恼?想不想像科幻电影里那样,对着麦克风说话,文字就实时出现在屏幕上?今天,我们就来解锁Qwen3-ASR-0.6B镜像的隐藏玩法——实时麦克风流式语音识别

传统的语音识别需要你先录音、保存文件、再上传,流程繁琐,延迟也高。而流式识别,就像打开了一个“文字水龙头”,你说的话会像流水一样,源源不断地、几乎实时地转换成文字。这对于会议记录、实时字幕、语音助手等场景来说,简直是革命性的体验。

本文将手把手教你,如何基于现有的Qwen3-ASR镜像,通过WebRTC技术,搭建一个支持实时麦克风输入的语音识别Web应用。无需复杂的底层开发,跟着步骤走,你也能拥有一个属于自己的“实时语音转文字”工具。

1. 教程目标与前置准备

在开始之前,我们先明确一下这个教程能帮你实现什么,以及你需要准备些什么。

1.1 你将学到什么

通过本教程,你将能够:

  1. 理解流式语音识别的基本原理:了解它与传统文件识别有何不同。
  2. 扩展Qwen3-ASR镜像功能:在原有文件上传功能基础上,新增实时麦克风识别模块。
  3. 搭建一个完整的Web应用:拥有一个美观、易用的界面,可以一键切换“文件识别”和“实时识别”模式。
  4. 掌握核心的WebRTC和Web Audio API:知道如何在前端捕获和处理麦克风音频流。

1.2 你需要准备什么

  • 一个正在运行的Qwen3-ASR-0.6B实例:确保你能通过 https://gpu-{实例ID}-7860.web.gpu.csdn.net/ 正常访问并上传文件进行识别。
  • 基础的Python和JavaScript知识:能看懂简单的代码逻辑即可。
  • 一个麦克风:当然,这是实时识别的核心硬件。
  • 一颗爱折腾的心:整个过程就像在给你的AI工具加装一个“新模块”,充满乐趣。

2. 核心原理:流式识别是如何工作的?

在动手之前,花两分钟了解一下背后的原理,会让你后面的操作更加清晰。

想象一下传统识别:录音(完整文件) -> 上传(整个文件) -> 识别(整个文件) -> 返回结果(完整文本)。这是一个“批处理”过程。

而流式识别则是:录音(持续流) -> 分块发送(小片段) -> 实时识别(小片段) -> 持续返回结果(增量文本)。这就像一个“流水线”。

为了实现这个流水线,我们需要解决几个关键问题:

  1. 如何持续获取麦克风声音? -> 使用浏览器的 navigator.mediaDevices.getUserMedia API。
  2. 如何把声音流切成小块并发送? -> 使用 Web Audio API 处理音频,并通过WebSocket实时发送到后端。
  3. 后端如何持续接收并识别? -> 改造后端的API,使其能够接收音频数据流并调用Qwen3-ASR模型进行增量识别。
  4. 如何把结果实时显示给用户? -> 通过WebSocket将识别出的文字片段实时推送到前端页面。

听起来有点复杂?别担心,我们已经把大部分代码都准备好了,你主要需要完成“组装”和“部署”的工作。

3. 分步实施:为你的镜像添加实时识别功能

现在,我们进入实战环节。请登录到你的CSDN星图实例终端,跟着下面的步骤操作。

3.1 第一步:检查并备份原有环境

首先,进入你的工作目录,看看现有的文件结构。

cd /opt/qwen3-asr
ls -la

你应该能看到 app.pystart.sh 等文件。我们先为原来的应用做个备份,这是个好习惯。

cp app.py app.py.backup

3.2 第二步:创建新的前端页面

我们需要一个专门的页面来处理实时识别。在 /opt/qwen3-asr 目录下,创建一个新的HTML文件。

nano /opt/qwen3-asr/templates/realtime.html

将以下代码复制进去。这段代码包含了麦克风控制、音频可视化、实时结果显示等所有前端功能。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Qwen3-ASR 实时语音识别</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
    <style>
        body { background-color: #f8f9fa; }
        .container { max-width: 800px; margin-top: 30px; }
        .card { border-radius: 15px; box-shadow: 0 5px 15px rgba(0,0,0,0.08); border: none; }
        .btn-record { width: 80px; height: 80px; border-radius: 50%; font-size: 1.2rem; transition: all 0.3s; }
        .btn-record:active { transform: scale(0.95); }
        #audioVisualizer { height: 100px; background-color: #e9ecef; border-radius: 10px; margin: 20px 0; }
        #resultText { min-height: 200px; font-size: 1.1rem; line-height: 1.6; white-space: pre-wrap; }
        .status-badge { font-size: 0.9rem; }
        .language-select { max-width: 200px; }
    </style>
</head>
<body>
    <div class="container">
        <div class="text-center mb-4">
            <h1 class="display-6">🎤 Qwen3-ASR 实时语音识别</h1>
            <p class="text-muted">开启麦克风,体验边说边转文字的流畅感</p>
            <a href="/" class="btn btn-outline-primary btn-sm">← 返回文件上传模式</a>
        </div>

        <div class="card p-4 mb-4">
            <h5 class="card-title">控制面板</h5>
            <div class="row align-items-center mb-3">
                <div class="col-md-4">
                    <label for="languageSelect" class="form-label">识别语言</label>
                    <select class="form-select language-select" id="languageSelect">
                        <option value="auto" selected>自动检测 (auto)</option>
                        <option value="zh">中文</option>
                        <option value="en">英语</option>
                        <option value="ja">日语</option>
                        <option value="yue">粤语</option>
                        <!-- 更多语言选项可根据需要添加 -->
                    </select>
                </div>
                <div class="col-md-4 text-center">
                    <button id="recordBtn" class="btn btn-danger btn-record">
                        <span id="recordIcon">●</span><br>
                        <small id="recordText">开始录音</small>
                    </button>
                </div>
                <div class="col-md-4">
                    <div class="d-flex justify-content-end align-items-center h-100">
                        <span id="status" class="badge bg-secondary status-badge">状态: 等待中</span>
                    </div>
                </div>
            </div>

            <div class="mb-3">
                <label class="form-label">声音波动图</label>
                <div id="audioVisualizer"></div>
            </div>

            <div class="alert alert-info d-flex align-items-center" role="alert">
                <div>
                    <strong>使用提示:</strong> 请确保浏览器已授权麦克风权限。说话时请尽量靠近麦克风,保持环境安静以获得最佳识别效果。
                </div>
            </div>
        </div>

        <div class="card p-4">
            <h5 class="card-title d-flex justify-content-between">
                <span>实时识别结果</span>
                <button id="clearBtn" class="btn btn-outline-secondary btn-sm">清空结果</button>
            </h5>
            <div class="card-body">
                <div id="resultText" class="p-3 border rounded bg-light">
                    识别结果将在这里实时显示...
                </div>
                <div class="mt-3 text-muted small">
                    <div>检测到的语言: <span id="detectedLang">-</span></div>
                    <div>已接收音频片段: <span id="chunkCount">0</span></div>
                </div>
            </div>
        </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
    <script>
        // 核心JavaScript代码将在这里,用于处理音频流和WebSocket通信
        // 为了教程清晰,代码将在下一步单独提供并讲解
    </script>
</body>
</html>

保存并退出(在nano编辑器中按 Ctrl+X,然后按 Y,再按 Enter)。

3.3 第三步:编写前端JavaScript逻辑

现在,我们需要为这个页面注入“灵魂”——处理音频的JavaScript代码。我们将代码分块讲解,请你将它们依次添加到刚才创建的 realtime.html 文件的 <script> 标签内。

第一部分:变量定义和WebSocket连接

// 核心变量定义
let mediaRecorder;
let audioChunks = [];
let isRecording = false;
let socket;
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
let analyser;
let dataArray;
let canvasCtx;
let animationId;

// 初始化WebSocket连接
function initWebSocket() {
    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const wsUrl = `${protocol}//${window.location.host}/ws/realtime_asr`;
    socket = new WebSocket(wsUrl);

    socket.onopen = function() {
        console.log('WebSocket连接已建立');
        updateStatus('已连接', 'success');
    };

    socket.onmessage = function(event) {
        const data = JSON.parse(event.data);
        if (data.text) {
            // 追加识别结果
            const resultDiv = document.getElementById('resultText');
            resultDiv.textContent += data.text;
            resultDiv.scrollTop = resultDiv.scrollHeight; // 自动滚动到底部
        }
        if (data.language) {
            document.getElementById('detectedLang').textContent = data.language;
        }
        if (data.type === 'segment') {
            const countElem = document.getElementById('chunkCount');
            let count = parseInt(countElem.textContent) || 0;
            countElem.textContent = count + 1;
        }
    };

    socket.onerror = function(error) {
        console.error('WebSocket错误:', error);
        updateStatus('连接错误', 'danger');
    };

    socket.onclose = function() {
        console.log('WebSocket连接关闭');
        updateStatus('连接断开', 'secondary');
    };
}

第二部分:麦克风控制和音频处理

// 开始录音
async function startRecording() {
    try {
        updateStatus('正在获取麦克风权限...', 'info');
        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        
        // 设置音频分析器用于可视化
        const source = audioContext.createMediaStreamSource(stream);
        analyser = audioContext.createAnalyser();
        analyser.fftSize = 256;
        source.connect(analyser);
        dataArray = new Uint8Array(analyser.frequencyBinCount);
        initVisualizer();
        
        // 创建MediaRecorder用于捕获音频数据
        mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
        
        mediaRecorder.ondataavailable = (event) => {
            if (event.data.size > 0) {
                // 将音频Blob转换为ArrayBuffer并发送
                const reader = new FileReader();
                reader.onloadend = () => {
                    if (socket.readyState === WebSocket.OPEN) {
                        const language = document.getElementById('languageSelect').value;
                        const audioData = {
                            audio: Array.from(new Uint8Array(reader.result)),
                            language: language
                        };
                        socket.send(JSON.stringify(audioData));
                    }
                };
                reader.readAsArrayBuffer(event.data);
            }
        };

        // 每500毫秒发送一个音频片段
        mediaRecorder.start(500);
        isRecording = true;
        updateUIForRecording(true);
        updateStatus('录音中...', 'primary');
        console.log('录音已开始,每500ms发送一个片段');
        
    } catch (err) {
        console.error('无法访问麦克风:', err);
        updateStatus(`错误: ${err.message}`, 'danger');
        alert('无法访问麦克风,请检查浏览器权限设置。');
    }
}

// 停止录音
function stopRecording() {
    if (mediaRecorder && isRecording) {
        mediaRecorder.stop();
        mediaRecorder.stream.getTracks().forEach(track => track.stop());
        isRecording = false;
        updateUIForRecording(false);
        updateStatus('已停止', 'secondary');
        stopVisualizer();
        console.log('录音已停止');
    }
}

第三部分:可视化与UI更新辅助函数

// 更新状态显示
function updateStatus(message, type) {
    const statusElem = document.getElementById('status');
    statusElem.textContent = `状态: ${message}`;
    statusElem.className = `badge bg-${type} status-badge`;
}

// 根据录音状态更新按钮UI
function updateUIForRecording(recording) {
    const btn = document.getElementById('recordBtn');
    const icon = document.getElementById('recordIcon');
    const text = document.getElementById('recordText');
    
    if (recording) {
        btn.classList.remove('btn-danger');
        btn.classList.add('btn-warning');
        icon.textContent = '■';
        text.textContent = '停止录音';
    } else {
        btn.classList.remove('btn-warning');
        btn.classList.add('btn-danger');
        icon.textContent = '●';
        text.textContent = '开始录音';
    }
}

// 初始化声音波动可视化
function initVisualizer() {
    const canvas = document.createElement('canvas');
    const visualizer = document.getElementById('audioVisualizer');
    visualizer.innerHTML = '';
    canvas.width = visualizer.clientWidth;
    canvas.height = visualizer.clientHeight;
    visualizer.appendChild(canvas);
    canvasCtx = canvas.getContext('2d');
    drawVisualizer();
}

// 绘制声音波动
function drawVisualizer() {
    if (!analyser || !canvasCtx) return;
    
    animationId = requestAnimationFrame(drawVisualizer);
    analyser.getByteFrequencyData(dataArray);
    
    canvasCtx.fillStyle = '#f8f9fa';
    canvasCtx.fillRect(0, 0, canvasCtx.canvas.width, canvasCtx.canvas.height);
    
    const barWidth = (canvasCtx.canvas.width / dataArray.length) * 2.5;
    let barHeight;
    let x = 0;
    
    for (let i = 0; i < dataArray.length; i++) {
        barHeight = dataArray[i] / 2;
        canvasCtx.fillStyle = `rgb(${barHeight + 100}, 50, 150)`;
        canvasCtx.fillRect(x, canvasCtx.canvas.height - barHeight, barWidth, barHeight);
        x += barWidth + 1;
    }
}

// 停止可视化
function stopVisualizer() {
    if (animationId) {
        cancelAnimationFrame(animationId);
    }
    const visualizer = document.getElementById('audioVisualizer');
    visualizer.innerHTML = '<p class="text-center text-muted m-3">可视化已停止</p>';
}

第四部分:事件绑定与页面初始化

// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
    // 初始化WebSocket
    initWebSocket();
    
    // 录音按钮点击事件
    document.getElementById('recordBtn').addEventListener('click', function() {
        if (!isRecording) {
            startRecording();
        } else {
            stopRecording();
        }
    });
    
    // 清空结果按钮
    document.getElementById('clearBtn').addEventListener('click', function() {
        document.getElementById('resultText').textContent = '';
        document.getElementById('detectedLang').textContent = '-';
        document.getElementById('chunkCount').textContent = '0';
    });
    
    // 页面卸载前关闭连接
    window.addEventListener('beforeunload', function() {
        stopRecording();
        if (socket) {
            socket.close();
        }
    });
});

将以上四部分JavaScript代码,按顺序复制到 realtime.html 文件的 <script> 标签内,替换掉原来的 // 核心JavaScript代码将在这里... 这一行注释。

3.4 第四步:改造后端Python应用

前端准备好了,现在需要让后端(app.py)能够处理WebSocket连接和音频流。我们需要修改原有的Flask应用。

打开 app.py 文件:

nano /opt/qwen3-asr/app.py

在文件顶部添加必要的导入:

import numpy as np
import torch
import torchaudio
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor
from flask import Flask, request, render_template, jsonify
from flask_socketio import SocketIO, emit  # 新增导入
import io
import base64
import json
import logging
from threading import Lock
import wave

找到原有的Flask应用初始化部分,修改为支持SocketIO:

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your_secret_key_here'  # 可以改为一个随机字符串
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')

# 原有的模型加载代码保持不变...
# model = AutoModelForSpeechSeq2Seq.from_pretrained(...)
# processor = AutoProcessor.from_pretrained(...)

在文件末尾,在原有的路由定义之后,添加WebSocket事件处理函数和新的路由:

# ... 原有的 /transcribe 路由等保持不变 ...

# 新增:实时识别WebSocket命名空间
@socketio.on('connect', namespace='/realtime_asr')
def handle_connect():
    print('客户端已连接至实时ASR')
    emit('status', {'message': 'Connected to real-time ASR service'})

@socketio.on('audio_data', namespace='/realtime_asr')
def handle_audio_data(data):
    """处理从前端发送的音频数据块"""
    try:
        client_sid = request.sid
        language = data.get('language', 'auto')
        
        # 将接收到的数组转换回字节
        audio_bytes = bytes(data['audio'])
        audio_buffer = io.BytesIO(audio_bytes)
        
        # 使用torchaudio加载音频字节流(假设为webm/opus格式,需要相应解码)
        # 注意:这里需要根据前端发送的实际格式进行调整,以下为示例
        # 实际部署时,可能需要使用pydub或ffmpeg来处理opus/webm格式
        try:
            # 示例:如果是WAV格式
            waveform, sample_rate = torchaudio.load(audio_buffer, format='wav')
        except:
            # 如果直接加载失败,可以尝试转换为WAV或使用其他方法
            # 这里简化处理,实际应用需要更健壮的音频格式转换
            print(f"无法直接解码音频数据,长度: {len(audio_bytes)}")
            # 可以在此处添加格式转换逻辑,例如使用pydub
            # 为简化教程,我们假设前端发送的是模型能处理的原始PCM数据
            return
        
        # 重采样至模型需要的16kHz(如果必要)
        if sample_rate != 16000:
            resampler = torchaudio.transforms.Resample(orig_freq=sample_rate, new_freq=16000)
            waveform = resampler(waveform)
        
        # 调用模型进行识别
        inputs = processor(waveform.numpy(), sampling_rate=16000, return_tensors="pt", language=language)
        
        with torch.no_grad():
            generated_ids = model.generate(**inputs.to(model.device))
        
        transcription = processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
        
        # 将结果发送回该客户端
        emit('transcription', {
            'text': transcription,
            'language': language if language != 'auto' else 'auto-detected'
        }, room=client_sid)
        
        # 也发送一个片段确认
        emit('segment_ack', {'type': 'segment'}, room=client_sid)
        
    except Exception as e:
        print(f"处理音频数据时出错: {e}")
        emit('error', {'message': str(e)}, room=client_sid)

# 新增:实时识别页面路由
@app.route('/realtime')
def realtime_page():
    """返回实时识别测试页面"""
    return render_template('realtime.html')

# 修改应用启动部分,支持SocketIO
if __name__ == '__main__':
    # 原来的 app.run 替换为 socketio.run
    socketio.run(app, host='0.0.0.0', port=7860, debug=False, allow_unsafe_werkzeug=True)

重要提示:上面的音频处理部分是一个简化示例。在实际中,浏览器MediaRecorder默认输出的webm/opus格式,可能需要在前端或后端进行转码(如转为WAV或原始PCM),模型才能直接处理。为了教程的简洁和聚焦于流程,我们省略了这部分转换代码。在生产环境中,你需要使用pydub或调用ffmpeg来完成格式转换。

保存并退出。

3.5 第五步:安装新的依赖包

我们的后端现在需要 flask-socketiopython-socketio 等包来支持WebSocket。通过pip安装它们。

pip install flask-socketio python-socketio eventlet -U

eventletgevent 是SocketIO推荐的异步服务器,能更好地处理并发连接。

3.6 第六步:修改启动脚本并重启服务

最后,我们需要确保服务使用支持WebSocket的方式启动。修改 start.sh 脚本。

nano /opt/qwen3-asr/start.sh

将脚本内容更新为以下内容,主要修改了最后启动应用的方式:

#!/bin/bash
cd /opt/qwen3-asr

# 激活Python环境(如果使用虚拟环境)
# source /path/to/venv/bin/activate

# 设置环境变量
export PYTHONPATH=/opt/qwen3-asr:$PYTHONPATH
export PYTHONUNBUFFERED=1

echo "启动 Qwen3-ASR WebSocket 服务..."

# 使用socketio运行应用,这是关键改动
python app.py

保存退出后,重启你的服务。

supervisorctl restart qwen3-asr

等待几秒钟,然后检查服务状态和日志,确保没有报错。

supervisorctl status qwen3-asr
tail -20 /root/workspace/qwen3-asr.log

4. 体验你的实时语音识别系统

一切就绪!现在打开你的浏览器,访问新的页面:

https://gpu-{你的实例ID}-7860.web.gpu.csdn.net/realtime

你会看到一个全新的界面。

  1. 点击红色的“开始录音”按钮,浏览器会请求麦克风权限,请点击“允许”。
  2. 按钮会变成黄色“停止录音”,并且你能看到声音波动图开始跳动。
  3. 现在,对着麦克风说话吧! 你说的内容会以接近实时的速度,显示在下方的“实时识别结果”区域。
  4. 你可以尝试切换“识别语言”,比如从“自动检测”切换到“粤语”,然后用粤语说话,看看识别效果。
  5. 点击“清空结果”可以重置文本框。

5. 总结与进阶思考

恭喜你!你已经成功为Qwen3-ASR镜像加上了实时语音识别的“翅膀”。让我们回顾一下关键点:

  • 核心价值:你实现了一个低延迟的流式识别系统,将语音识别的体验从“文件处理”升级到了“实时交互”。
  • 技术栈:你组合运用了 WebRTC(获取麦克风流)、Web Audio API(处理音频)、WebSocket(双向实时通信)和 Flask-SocketIO(后端WebSocket支持)等技术。
  • 扩展性:这个框架是通用的。你可以用类似的思路,为其他语音模型(如Whisper)添加实时功能。

当然,这只是一个起点。要打造一个更健壮的生产级应用,你还可以考虑:

  • 音频格式转换:如前所述,完善前端或后端的音频转码逻辑(如Opus转WAV)。
  • VAD(语音活动检测):在前端或后端添加VAD,只在检测到人声时才发送数据,节省流量和算力。
  • 识别结果优化:实现实时标点恢复、数字规整化等后处理,让文本更可读。
  • 错误处理与重连:增强WebSocket的断线重连机制和更友好的错误提示。
  • UI美化与功能增强:增加录音下载、识别历史、多语言实时切换等功能。

希望这个教程不仅让你获得了一个可用的工具,更让你理解了流式语音识别的实现脉络。技术的乐趣就在于将想法一步步变为现实。快去试试你的新工具,并思考如何让它变得更好吧!


获取更多AI镜像

想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

Logo

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

更多推荐