Qwen3-ASR-1.7B在Vue前端项目中的实时语音识别实现
Qwen3-ASR-1.7B在Vue前端项目中的实时语音识别实现
想象一下,你正在开发一个在线会议应用,用户需要实时将语音转换成文字,支持多种语言,还要能处理带背景噪音的环境。传统的语音识别方案要么太贵,要么识别不准,要么部署复杂。现在,有了Qwen3-ASR-1.7B这个开源模型,情况就完全不同了。
Qwen3-ASR-1.7B是阿里开源的一个语音识别模型,别看它只有17亿参数,但能力可不小。它能识别52种语言和方言,包括各种中文方言,甚至在嘈杂环境下也能保持不错的准确率。最重要的是,它完全开源免费,我们可以把它部署在自己的服务器上,不用担心API调用费用和隐私问题。
今天我就来分享一下,怎么在Vue前端项目里集成这个模型,实现一个实时语音识别功能。我会从最基础的搭建开始,一步步带你完成整个流程,让你也能在自己的项目里用上这个强大的语音识别能力。
1. 项目环境准备与后端服务搭建
在开始前端集成之前,我们需要先搭建一个后端服务来处理语音识别。Qwen3-ASR-1.7B模型比较大,不适合直接在前端运行,所以我们需要一个后端服务来承载它。
1.1 后端服务环境搭建
首先,我们来创建一个简单的Python后端服务。我推荐使用FastAPI,因为它轻量、快速,而且对异步支持很好,特别适合处理语音识别这种IO密集型任务。
# requirements.txt
fastapi==0.104.1
uvicorn==0.24.0
pydantic==2.5.0
torch==2.1.0
transformers==4.36.0
soundfile==0.12.1
numpy==1.24.3
python-multipart==0.0.6
安装好依赖后,我们创建一个简单的后端服务:
# main.py
from fastapi import FastAPI, File, UploadFile, WebSocket
from fastapi.middleware.cors import CORSMiddleware
import torch
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor
import soundfile as sf
import numpy as np
import io
import asyncio
import logging
app = FastAPI(title="Qwen3-ASR语音识别服务")
# 配置CORS,允许前端访问
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境建议指定具体域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# 全局变量,用于缓存模型和处理器
model = None
processor = None
device = None
@app.on_event("startup")
async def load_model():
"""启动时加载模型"""
global model, processor, device
logger.info("开始加载Qwen3-ASR模型...")
# 检查是否有GPU可用
device = "cuda:0" if torch.cuda.is_available() else "cpu"
torch_dtype = torch.float16 if torch.cuda.is_available() else torch.float32
# 加载模型和处理器
model_id = "Qwen/Qwen3-ASR-1.7B"
try:
model = AutoModelForSpeechSeq2Seq.from_pretrained(
model_id,
torch_dtype=torch_dtype,
low_cpu_mem_usage=True,
use_safetensors=True
)
model.to(device)
processor = AutoProcessor.from_pretrained(model_id)
logger.info(f"模型加载完成,运行在 {device} 上")
except Exception as e:
logger.error(f"模型加载失败: {e}")
raise
@app.get("/")
async def root():
return {"status": "ok", "message": "Qwen3-ASR语音识别服务已启动"}
@app.post("/transcribe")
async def transcribe_audio(file: UploadFile = File(...)):
"""处理上传的音频文件"""
try:
# 读取音频文件
audio_bytes = await file.read()
# 使用soundfile读取音频
audio_data, sample_rate = sf.read(io.BytesIO(audio_bytes))
# 如果音频是立体声,转换为单声道
if len(audio_data.shape) > 1:
audio_data = np.mean(audio_data, axis=1)
# 预处理音频
inputs = processor(
audio_data,
sampling_rate=sample_rate,
return_tensors="pt",
padding=True
)
# 将输入移动到设备
inputs = {k: v.to(device) for k, v in inputs.items()}
# 生成转录
with torch.no_grad():
generated_ids = model.generate(**inputs)
# 解码结果
transcription = processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
return {
"status": "success",
"text": transcription,
"language": "auto", # 模型会自动检测语言
"duration": len(audio_data) / sample_rate
}
except Exception as e:
logger.error(f"转录失败: {e}")
return {"status": "error", "message": str(e)}
@app.websocket("/ws/transcribe")
async def websocket_transcribe(websocket: WebSocket):
"""WebSocket接口,用于实时语音识别"""
await websocket.accept()
try:
while True:
# 接收音频数据
data = await websocket.receive_bytes()
# 这里可以添加实时音频处理逻辑
# 由于实时处理比较复杂,我们先返回一个简单的响应
await websocket.send_json({
"status": "processing",
"message": "实时语音识别功能开发中..."
})
except Exception as e:
logger.error(f"WebSocket连接错误: {e}")
await websocket.close()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
这个后端服务提供了两个主要接口:一个HTTP接口用于处理上传的音频文件,一个WebSocket接口用于实时语音识别(后续会完善)。
1.2 启动后端服务
保存上面的代码为main.py,然后运行:
# 安装依赖
pip install -r requirements.txt
# 启动服务
python main.py
服务启动后,访问 http://localhost:8000/docs 可以看到自动生成的API文档。
2. Vue前端项目搭建
现在我们来创建Vue前端项目。我假设你已经有了Node.js环境,如果没有的话,需要先安装一下。
2.1 创建Vue项目
# 使用Vue CLI创建项目
npm create vue@latest qwen-asr-demo
# 按照提示选择配置
# ✔ Project name: qwen-asr-demo
# ✔ Add TypeScript? No
# ✔ Add JSX Support? No
# ✔ Add Vue Router for Single Page Application development? No
# ✔ Add Pinia for state management? No
# ✔ Add Vitest for Unit Testing? No
# ✔ Add an End-to-End Testing Solution? No
# ✔ Add ESLint for code quality? No
# 进入项目目录
cd qwen-asr-demo
# 安装依赖
npm install
# 安装额外的依赖
npm install axios recorder-js
2.2 创建语音识别组件
接下来,我们创建一个语音识别组件:
<!-- src/components/SpeechRecognition.vue -->
<template>
<div class="speech-recognition">
<div class="container">
<h1>Qwen3-ASR 语音识别演示</h1>
<div class="card">
<h2>实时语音识别</h2>
<div class="status">
<div class="status-indicator" :class="recordingStatus"></div>
<span>{{ statusText }}</span>
</div>
<div class="controls">
<button
@click="toggleRecording"
:class="['record-btn', isRecording ? 'recording' : '']"
:disabled="isProcessing"
>
{{ isRecording ? '停止录音' : '开始录音' }}
</button>
<button
@click="uploadAudio"
class="upload-btn"
:disabled="!audioBlob || isProcessing"
>
上传识别
</button>
<input
type="file"
ref="fileInput"
@change="handleFileUpload"
accept="audio/*"
style="display: none"
/>
<button
@click="triggerFileInput"
class="file-btn"
>
选择音频文件
</button>
</div>
<div v-if="audioUrl" class="audio-preview">
<h3>录音预览</h3>
<audio :src="audioUrl" controls></audio>
<button @click="clearAudio" class="clear-btn">清除</button>
</div>
<div v-if="selectedFile" class="file-info">
<h3>已选择文件</h3>
<p>{{ selectedFile.name }} ({{ formatFileSize(selectedFile.size) }})</p>
<button @click="clearFile" class="clear-btn">清除</button>
</div>
</div>
<div class="card">
<h2>识别结果</h2>
<div v-if="isProcessing" class="loading">
<div class="spinner"></div>
<p>正在识别中...</p>
</div>
<div v-else-if="transcription" class="result">
<div class="result-header">
<h3>识别文本</h3>
<button @click="copyToClipboard" class="copy-btn">复制</button>
</div>
<div class="result-text">{{ transcription }}</div>
<div v-if="resultInfo" class="result-info">
<p>语言: {{ resultInfo.language || '自动检测' }}</p>
<p>时长: {{ resultInfo.duration ? resultInfo.duration.toFixed(2) + '秒' : '未知' }}</p>
<p>识别时间: {{ new Date().toLocaleTimeString() }}</p>
</div>
</div>
<div v-else class="empty-result">
<p>暂无识别结果,请先录音或上传音频文件</p>
</div>
</div>
<div class="card">
<h2>使用说明</h2>
<div class="instructions">
<h3>方法一:实时录音识别</h3>
<ol>
<li>点击"开始录音"按钮</li>
<li>对着麦克风说话</li>
<li>点击"停止录音"按钮</li>
<li>点击"上传识别"按钮</li>
</ol>
<h3>方法二:上传音频文件</h3>
<ol>
<li>点击"选择音频文件"按钮</li>
<li>选择要识别的音频文件(支持mp3、wav等格式)</li>
<li>系统会自动上传并识别</li>
</ol>
<div class="tips">
<p><strong>提示:</strong></p>
<ul>
<li>支持52种语言和方言识别</li>
<li>建议在相对安静的环境下录音</li>
<li>单次录音建议不超过60秒</li>
<li>识别结果可以复制到剪贴板</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import Recorder from 'recorder-js';
export default {
name: 'SpeechRecognition',
data() {
return {
isRecording: false,
isProcessing: false,
recorder: null,
audioBlob: null,
audioUrl: null,
selectedFile: null,
transcription: '',
resultInfo: null,
mediaStream: null
};
},
computed: {
recordingStatus() {
return this.isRecording ? 'recording' : 'idle';
},
statusText() {
if (this.isRecording) return '录音中...';
if (this.audioBlob) return '已录音,准备识别';
return '准备就绪';
}
},
methods: {
async toggleRecording() {
if (this.isRecording) {
await this.stopRecording();
} else {
await this.startRecording();
}
},
async startRecording() {
try {
// 请求麦克风权限
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
this.mediaStream = stream;
// 初始化录音器
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.recorder = new Recorder(audioContext);
await this.recorder.init(stream);
this.recorder.start();
this.isRecording = true;
this.audioBlob = null;
this.audioUrl = null;
this.transcription = '';
} catch (error) {
console.error('开始录音失败:', error);
alert('无法访问麦克风,请检查权限设置');
}
},
async stopRecording() {
if (!this.recorder || !this.isRecording) return;
try {
const { blob } = await this.recorder.stop();
this.audioBlob = blob;
this.audioUrl = URL.createObjectURL(blob);
this.isRecording = false;
// 停止媒体流
if (this.mediaStream) {
this.mediaStream.getTracks().forEach(track => track.stop());
this.mediaStream = null;
}
} catch (error) {
console.error('停止录音失败:', error);
this.isRecording = false;
}
},
async uploadAudio() {
if (!this.audioBlob && !this.selectedFile) {
alert('请先录音或选择音频文件');
return;
}
this.isProcessing = true;
try {
const formData = new FormData();
if (this.audioBlob) {
// 使用录音数据
formData.append('file', this.audioBlob, 'recording.wav');
} else if (this.selectedFile) {
// 使用上传的文件
formData.append('file', this.selectedFile);
}
// 发送到后端服务
const response = await axios.post('http://localhost:8000/transcribe', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (response.data.status === 'success') {
this.transcription = response.data.text;
this.resultInfo = {
language: response.data.language,
duration: response.data.duration
};
} else {
throw new Error(response.data.message || '识别失败');
}
} catch (error) {
console.error('识别失败:', error);
alert(`识别失败: ${error.message}`);
} finally {
this.isProcessing = false;
}
},
triggerFileInput() {
this.$refs.fileInput.click();
},
handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
// 检查文件类型
if (!file.type.startsWith('audio/')) {
alert('请选择音频文件');
return;
}
// 检查文件大小(限制为10MB)
if (file.size > 10 * 1024 * 1024) {
alert('文件大小不能超过10MB');
return;
}
this.selectedFile = file;
this.audioBlob = null;
this.audioUrl = null;
this.transcription = '';
// 自动上传识别
this.uploadAudio();
},
clearAudio() {
this.audioBlob = null;
this.audioUrl = null;
if (this.audioUrl) {
URL.revokeObjectURL(this.audioUrl);
}
},
clearFile() {
this.selectedFile = null;
this.$refs.fileInput.value = '';
},
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
async copyToClipboard() {
if (!this.transcription) return;
try {
await navigator.clipboard.writeText(this.transcription);
alert('已复制到剪贴板');
} catch (error) {
console.error('复制失败:', error);
// 降级方案
const textArea = document.createElement('textarea');
textArea.value = this.transcription;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
alert('已复制到剪贴板');
}
}
},
beforeUnmount() {
// 清理资源
if (this.audioUrl) {
URL.revokeObjectURL(this.audioUrl);
}
if (this.mediaStream) {
this.mediaStream.getTracks().forEach(track => track.stop());
}
}
};
</script>
<style scoped>
.speech-recognition {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
text-align: center;
color: white;
margin-bottom: 30px;
font-size: 2.5rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.card {
background: white;
border-radius: 15px;
padding: 25px;
margin-bottom: 25px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
h2 {
color: #333;
margin-top: 0;
margin-bottom: 20px;
font-size: 1.5rem;
}
.status {
display: flex;
align-items: center;
margin-bottom: 20px;
padding: 10px;
background: #f5f5f5;
border-radius: 8px;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 10px;
}
.status-indicator.idle {
background: #ccc;
}
.status-indicator.recording {
background: #ff4757;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.controls {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin-bottom: 20px;
}
button {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.record-btn {
background: #4CAF50;
color: white;
flex: 1;
min-width: 150px;
}
.record-btn.recording {
background: #ff4757;
}
.record-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
.upload-btn, .file-btn {
background: #2196F3;
color: white;
flex: 1;
min-width: 150px;
}
.upload-btn:hover:not(:disabled),
.file-btn:hover:not(:disabled) {
background: #1976D2;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
.clear-btn {
background: #ff9800;
color: white;
padding: 8px 16px;
font-size: 14px;
margin-left: 10px;
}
.audio-preview, .file-info {
margin-top: 20px;
padding: 15px;
background: #f9f9f9;
border-radius: 8px;
border-left: 4px solid #2196F3;
}
.audio-preview h3, .file-info h3 {
margin-top: 0;
color: #2196F3;
}
audio {
width: 100%;
margin-top: 10px;
}
.loading {
text-align: center;
padding: 40px;
}
.spinner {
width: 50px;
height: 50px;
border: 5px solid #f3f3f3;
border-top: 5px solid #2196F3;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.result {
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.copy-btn {
background: #9C27B0;
color: white;
padding: 8px 16px;
font-size: 14px;
}
.copy-btn:hover {
background: #7B1FA2;
}
.result-text {
font-size: 18px;
line-height: 1.6;
color: #333;
padding: 15px;
background: white;
border-radius: 8px;
border: 1px solid #e0e0e0;
min-height: 100px;
white-space: pre-wrap;
word-break: break-word;
}
.result-info {
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #e0e0e0;
color: #666;
font-size: 14px;
}
.result-info p {
margin: 5px 0;
}
.empty-result {
text-align: center;
padding: 40px;
color: #999;
font-style: italic;
}
.instructions h3 {
color: #333;
margin-top: 20px;
margin-bottom: 10px;
}
.instructions ol {
margin-left: 20px;
margin-bottom: 20px;
}
.instructions li {
margin-bottom: 5px;
line-height: 1.6;
}
.tips {
background: #e8f5e9;
padding: 15px;
border-radius: 8px;
border-left: 4px solid #4CAF50;
}
.tips p {
margin-top: 0;
font-weight: bold;
color: #2e7d32;
}
.tips ul {
margin-bottom: 0;
}
.tips li {
line-height: 1.6;
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
.controls {
flex-direction: column;
}
button {
width: 100%;
}
h1 {
font-size: 2rem;
}
}
</style>
2.3 修改主应用组件
现在我们来修改主应用组件,使用我们刚刚创建的语音识别组件:
<!-- src/App.vue -->
<template>
<div id="app">
<SpeechRecognition />
</div>
</template>
<script>
import SpeechRecognition from './components/SpeechRecognition.vue'
export default {
name: 'App',
components: {
SpeechRecognition
}
}
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
}
#app {
min-height: 100vh;
}
</style>
2.4 运行前端应用
现在我们可以运行前端应用了:
# 开发模式运行
npm run dev
访问 http://localhost:5173 就可以看到我们的语音识别应用了。
3. 功能扩展与优化
基本的语音识别功能已经实现了,但我们可以做得更好。下面我们来添加一些高级功能。
3.1 实时语音识别(WebSocket)
前面的实现是录音后上传识别,现在我们来添加真正的实时语音识别功能。我们需要修改后端服务,支持WebSocket实时流式识别。
首先,更新后端服务:
# websocket_handler.py
import asyncio
import numpy as np
import torch
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor
import logging
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
@dataclass
class AudioBuffer:
"""音频缓冲区,用于存储实时音频数据"""
data: np.ndarray = np.array([], dtype=np.float32)
sample_rate: int = 16000
max_duration: float = 30.0 # 最大缓存30秒音频
def add_chunk(self, chunk: np.ndarray):
"""添加音频块"""
self.data = np.concatenate([self.data, chunk])
# 如果超过最大时长,截断旧数据
max_samples = int(self.sample_rate * self.max_duration)
if len(self.data) > max_samples:
self.data = self.data[-max_samples:]
def clear(self):
"""清空缓冲区"""
self.data = np.array([], dtype=np.float32)
@property
def duration(self) -> float:
"""获取当前音频时长"""
return len(self.data) / self.sample_rate
class RealTimeASR:
"""实时语音识别处理器"""
def __init__(self, model, processor, device):
self.model = model
self.processor = processor
self.device = device
self.audio_buffer = AudioBuffer()
self.is_processing = False
async def process_chunk(self, audio_chunk: bytes) -> Optional[str]:
"""处理音频块并返回识别结果"""
try:
# 将字节数据转换为numpy数组
# 这里假设音频是16kHz、16位、单声道的PCM数据
audio_array = np.frombuffer(audio_chunk, dtype=np.int16).astype(np.float32) / 32768.0
# 添加到缓冲区
self.audio_buffer.add_chunk(audio_array)
# 如果缓冲区有足够的数据(比如1秒以上),开始处理
if self.audio_buffer.duration >= 1.0 and not self.is_processing:
self.is_processing = True
try:
# 处理音频
inputs = self.processor(
self.audio_buffer.data,
sampling_rate=self.audio_buffer.sample_rate,
return_tensors="pt",
padding=True
)
inputs = {k: v.to(self.device) for k, v in inputs.items()}
with torch.no_grad():
generated_ids = self.model.generate(**inputs)
transcription = self.processor.batch_decode(
generated_ids,
skip_special_tokens=True
)[0]
# 清空已处理的缓冲区(保留最后0.5秒用于上下文)
keep_samples = int(self.audio_buffer.sample_rate * 0.5)
if len(self.audio_buffer.data) > keep_samples:
self.audio_buffer.data = self.audio_buffer.data[-keep_samples:]
else:
self.audio_buffer.clear()
return transcription
finally:
self.is_processing = False
except Exception as e:
logger.error(f"实时处理失败: {e}")
return None
return None
# 在main.py中添加
from websocket_handler import RealTimeASR
# 在load_model函数后添加
realtime_asr = None
@app.on_event("startup")
async def load_model():
# ... 原有的加载代码 ...
global realtime_asr
realtime_asr = RealTimeASR(model, processor, device)
@app.websocket("/ws/realtime")
async def websocket_realtime(websocket: WebSocket):
"""实时语音识别WebSocket接口"""
await websocket.accept()
try:
while True:
# 接收音频数据
data = await websocket.receive_bytes()
# 处理音频块
transcription = await realtime_asr.process_chunk(data)
if transcription:
# 发送识别结果
await websocket.send_json({
"type": "transcription",
"text": transcription,
"timestamp": time.time()
})
else:
# 发送处理状态
await websocket.send_json({
"type": "status",
"message": "processing",
"buffer_duration": realtime_asr.audio_buffer.duration
})
except Exception as e:
logger.error(f"WebSocket错误: {e}")
await websocket.close()
然后,在前端添加实时语音识别功能:
<!-- 在SpeechRecognition.vue中添加 -->
<template>
<!-- 在原有模板中添加实时识别部分 -->
<div class="card">
<h2>实时语音识别</h2>
<div class="realtime-controls">
<button
@click="toggleRealtime"
:class="['realtime-btn', isRealtimeRecording ? 'recording' : '']"
>
{{ isRealtimeRecording ? '停止实时识别' : '开始实时识别' }}
</button>
<div v-if="isRealtimeRecording" class="realtime-status">
<div class="visualizer">
<canvas ref="visualizerCanvas"></canvas>
</div>
<div class="realtime-result">
<h4>实时识别结果:</h4>
<p>{{ realtimeText }}</p>
</div>
</div>
</div>
</div>
</template>
<script>
// 在data中添加
data() {
return {
// ... 原有数据 ...
isRealtimeRecording: false,
realtimeSocket: null,
audioContext: null,
mediaStreamSource: null,
scriptProcessor: null,
realtimeText: '',
visualizerAnimationId: null
};
},
// 添加方法
methods: {
async toggleRealtime() {
if (this.isRealtimeRecording) {
await this.stopRealtime();
} else {
await this.startRealtime();
}
},
async startRealtime() {
try {
// 连接WebSocket
this.realtimeSocket = new WebSocket('ws://localhost:8000/ws/realtime');
this.realtimeSocket.onopen = () => {
console.log('WebSocket连接已建立');
};
this.realtimeSocket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'transcription') {
this.realtimeText += data.text + ' ';
}
};
this.realtimeSocket.onerror = (error) => {
console.error('WebSocket错误:', error);
};
this.realtimeSocket.onclose = () => {
console.log('WebSocket连接已关闭');
};
// 获取麦克风权限
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 16000
}
});
// 设置音频处理
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 16000
});
this.mediaStreamSource = this.audioContext.createMediaStreamSource(stream);
// 创建ScriptProcessorNode处理音频数据
this.scriptProcessor = this.audioContext.createScriptProcessor(4096, 1, 1);
this.scriptProcessor.onaudioprocess = (audioProcessingEvent) => {
const inputBuffer = audioProcessingEvent.inputBuffer;
const channelData = inputBuffer.getChannelData(0);
// 转换为16位PCM
const pcmData = new Int16Array(channelData.length);
for (let i = 0; i < channelData.length; i++) {
pcmData[i] = Math.max(-32768, Math.min(32767, channelData[i] * 32768));
}
// 通过WebSocket发送音频数据
if (this.realtimeSocket && this.realtimeSocket.readyState === WebSocket.OPEN) {
this.realtimeSocket.send(pcmData.buffer);
}
// 更新可视化
this.updateVisualizer(channelData);
};
// 连接节点
this.mediaStreamSource.connect(this.scriptProcessor);
this.scriptProcessor.connect(this.audioContext.destination);
this.isRealtimeRecording = true;
this.realtimeText = '';
// 初始化可视化
this.initVisualizer();
} catch (error) {
console.error('启动实时识别失败:', error);
alert('无法启动实时识别: ' + error.message);
}
},
async stopRealtime() {
if (this.scriptProcessor) {
this.scriptProcessor.disconnect();
this.scriptProcessor = null;
}
if (this.mediaStreamSource) {
this.mediaStreamSource.disconnect();
this.mediaStreamSource = null;
}
if (this.audioContext) {
await this.audioContext.close();
this.audioContext = null;
}
if (this.realtimeSocket) {
this.realtimeSocket.close();
this.realtimeSocket = null;
}
if (this.visualizerAnimationId) {
cancelAnimationFrame(this.visualizerAnimationId);
this.visualizerAnimationId = null;
}
this.isRealtimeRecording = false;
},
initVisualizer() {
const canvas = this.$refs.visualizerCanvas;
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth;
canvas.height = 100;
this.visualizerAnimationId = requestAnimationFrame(() => this.drawVisualizer());
},
drawVisualizer() {
// 这里可以添加音频可视化的绘制逻辑
// 为了简化,我们先画一个简单的动画
const canvas = this.$refs.visualizerCanvas;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 画一个简单的波形
ctx.fillStyle = '#2196F3';
const barWidth = 4;
const barSpacing = 2;
const maxBars = Math.floor(canvas.width / (barWidth + barSpacing));
for (let i = 0; i < maxBars; i++) {
const height = Math.random() * 60 + 20;
const x = i * (barWidth + barSpacing);
ctx.fillRect(x, canvas.height - height, barWidth, height);
}
if (this.isRealtimeRecording) {
this.visualizerAnimationId = requestAnimationFrame(() => this.drawVisualizer());
}
},
updateVisualizer(audioData) {
// 这里可以更新可视化数据
// 简化处理,直接重绘
}
},
// 在beforeUnmount中添加清理
beforeUnmount() {
// ... 原有清理代码 ...
if (this.isRealtimeRecording) {
this.stopRealtime();
}
}
</script>
<style scoped>
/* 添加实时识别样式 */
.realtime-controls {
margin-top: 20px;
}
.realtime-btn {
background: #FF9800;
color: white;
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
width: 100%;
}
.realtime-btn.recording {
background: #F44336;
}
.realtime-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
.realtime-status {
margin-top: 20px;
padding: 20px;
background: #f5f5f5;
border-radius: 8px;
}
.visualizer {
width: 100%;
height: 100px;
background: #333;
border-radius: 4px;
margin-bottom: 15px;
overflow: hidden;
}
.visualizer canvas {
width: 100%;
height: 100%;
}
.realtime-result h4 {
margin-top: 0;
color: #333;
}
.realtime-result p {
font-size: 16px;
line-height: 1.6;
color: #666;
min-height: 60px;
padding: 10px;
background: white;
border-radius: 4px;
border: 1px solid #ddd;
}
</style>
3.2 多语言支持
Qwen3-ASR支持52种语言,我们可以让用户选择识别的语言:
<!-- 在SpeechRecognition.vue中添加语言选择 -->
<template>
<div class="card">
<h2>识别设置</h2>
<div class="settings">
<div class="setting-item">
<label for="language">识别语言:</label>
<select
id="language"
v-model="selectedLanguage"
class="language-select"
>
<option value="auto">自动检测</option>
<option value="zh">中文</option>
<option value="en">英语</option>
<option value="ja">日语</option>
<option value="ko">韩语</option>
<option value="fr">法语</option>
<option value="de">德语</option>
<option value="es">西班牙语</option>
<option value="yue">粤语</option>
<option value="wuu">吴语</option>
<!-- 可以添加更多语言选项 -->
</select>
</div>
<div class="setting-item">
<label>
<input
type="checkbox"
v-model="enablePunctuation"
>
自动添加标点
</label>
</div>
<div class="setting-item">
<label>
<input
type="checkbox"
v-model="enableTimestamp"
>
生成时间戳
</label>
</div>
</div>
</div>
</template>
<script>
// 在data中添加
data() {
return {
// ... 原有数据 ...
selectedLanguage: 'auto',
enablePunctuation: true,
enableTimestamp: false
};
},
// 修改uploadAudio方法
async uploadAudio() {
// ... 原有代码 ...
try {
const formData = new FormData();
if (this.audioBlob) {
formData.append('file', this.audioBlob, 'recording.wav');
} else if (this.selectedFile) {
formData.append('file', this.selectedFile);
}
// 添加语言参数
if (this.selectedLanguage !== 'auto') {
formData.append('language', this.selectedLanguage);
}
formData.append('punctuation', this.enablePunctuation);
formData.append('timestamp', this.enableTimestamp);
// ... 发送请求 ...
}
// ... 其余代码 ...
}
</script>
<style scoped>
.settings {
display: flex;
flex-direction: column;
gap: 15px;
}
.setting-item {
display: flex;
align-items: center;
gap: 10px;
}
.language-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 150px;
}
.setting-item label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.setting-item input[type="checkbox"] {
width: 18px;
height: 18px;
}
</style>
同时,需要修改后端服务来支持这些参数:
# 在main.py中修改transcribe_audio函数
@app.post("/transcribe")
async def transcribe_audio(
file: UploadFile = File(...),
language: str = Form("auto"),
punctuation: bool = Form(True),
timestamp: bool = Form(False)
):
"""处理上传的音频文件"""
try:
# 读取音频文件
audio_bytes = await file.read()
audio_data, sample_rate = sf.read(io.BytesIO(audio_bytes))
if len(audio_data.shape) > 1:
audio_data = np.mean(audio_data, axis=1)
# 预处理音频,添加语言提示
if language != "auto":
# 添加语言提示到处理器
forced_decoder_ids = processor.get_decoder_prompt_ids(
language=language,
task="transcribe"
)
else:
forced_decoder_ids = None
inputs = processor(
audio_data,
sampling_rate=sample_rate,
return_tensors="pt",
padding=True
)
inputs = {k: v.to(device) for k, v in inputs.items()}
# 生成参数
generate_kwargs = {
"max_new_tokens": 256,
"language": language if language != "auto" else None,
}
if forced_decoder_ids:
generate_kwargs["forced_decoder_ids"] = forced_decoder_ids
with torch.no_grad():
generated_ids = model.generate(**inputs, **generate_kwargs)
transcription = processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
# 如果需要时间戳,使用强制对齐模型
result = {
"status": "success",
"text": transcription,
"language": language,
"duration": len(audio_data) / sample_rate
}
if timestamp:
# 这里可以添加时间戳生成逻辑
# 需要加载Qwen3-ForcedAligner模型
result["timestamps"] = [] # 时间戳数据
return result
except Exception as e:
logger.error(f"转录失败: {e}")
return {"status": "error", "message": str(e)}
3.3 历史记录功能
添加一个历史记录功能,让用户可以查看之前的识别记录:
<!-- 在SpeechRecognition.vue中添加历史记录 -->
<template>
<div class="card">
<h2>识别历史</h2>
<div v-if="history.length === 0" class="empty-history">
<p>暂无历史记录</p>
</div>
<div v-else class="history-list">
<div
v-for="(item, index) in history"
:key="index"
class="history-item"
:class="{ active: activeHistoryIndex === index }"
@click="selectHistory(index)"
>
<div class="history-header">
<span class="history-time">{{ formatTime(item.timestamp) }}</span>
<span class="history-language">{{ getLanguageName(item.language) }}</span>
<span class="history-duration">{{ item.duration.toFixed(1) }}s</span>
</div>
<div class="history-preview">
{{ truncateText(item.text, 80) }}
</div>
<button
@click.stop="deleteHistory(index)"
class="delete-btn"
>
删除
</button>
</div>
</div>
<div v-if="history.length > 0" class="history-actions">
<button @click="clearHistory" class="clear-all-btn">
清空历史
</button>
<button @click="exportHistory" class="export-btn">
导出历史
</button>
</div>
</div>
</template>
<script>
// 在data中添加
data() {
return {
// ... 原有数据 ...
history: [],
activeHistoryIndex: -1
};
},
// 添加方法
methods: {
// 保存到历史记录
saveToHistory(result) {
const historyItem = {
text: result.text,
language: result.language || 'auto',
duration: result.duration || 0,
timestamp: new Date().getTime()
};
this.history.unshift(historyItem); // 添加到开头
// 限制历史记录数量
if (this.history.length > 50) {
this.history = this.history.slice(0, 50);
}
// 保存到localStorage
this.saveHistoryToStorage();
},
selectHistory(index) {
this.activeHistoryIndex = index;
const item = this.history[index];
this.transcription = item.text;
this.resultInfo = {
language: item.language,
duration: item.duration
};
},
deleteHistory(index) {
if (confirm('确定要删除这条记录吗?')) {
this.history.splice(index, 1);
if (this.activeHistoryIndex === index) {
this.activeHistoryIndex = -1;
this.transcription = '';
}
this.saveHistoryToStorage();
}
},
clearHistory() {
if (confirm('确定要清空所有历史记录吗?')) {
this.history = [];
this.activeHistoryIndex = -1;
this.transcription = '';
localStorage.removeItem('asr_history');
}
},
exportHistory() {
const data = {
exportTime: new Date().toISOString(),
records: this.history
};
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `asr_history_${new Date().getTime()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
saveHistoryToStorage() {
try {
localStorage.setItem('asr_history', JSON.stringify(this.history));
} catch (e) {
console.error('保存历史记录失败:', e);
}
},
loadHistoryFromStorage() {
try {
const saved = localStorage.getItem('asr_history');
if (saved) {
this.history = JSON.parse(saved);
}
} catch (e) {
console.error('加载历史记录失败:', e);
}
},
formatTime(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
},
getLanguageName(code) {
const languages = {
'auto': '自动',
'zh': '中文',
'en': '英语',
'ja': '日语',
'ko': '韩语',
'fr': '法语',
'de': '德语',
'es': '西班牙语',
'yue': '粤语',
'wuu': '吴语'
};
return languages[code] || code;
},
truncateText(text, maxLength) {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
},
// 在uploadAudio成功时保存历史
async uploadAudio() {
// ... 原有代码 ...
if (response.data.status === 'success') {
this.transcription = response.data.text;
this.resultInfo = {
language: response.data.language,
duration: response.data.duration
};
// 保存到历史记录
this.saveToHistory(response.data);
}
// ... 其余代码 ...
},
// 在mounted中加载历史记录
mounted() {
this.loadHistoryFromStorage();
}
</script>
<style scoped>
.history-list {
max-height: 300px;
overflow-y: auto;
margin-bottom: 15px;
}
.history-item {
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 6px;
margin-bottom: 10px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.history-item:hover {
background: #f9f9f9;
border-color: #2196F3;
}
.history-item.active {
background: #e3f2fd;
border-color: #2196F3;
}
.history-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
color: #666;
}
.history-preview {
font-size: 14px;
line-height: 1.4;
color: #333;
}
.delete-btn {
position: absolute;
top: 10px;
right: 10px;
background: #ff5252;
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
}
.history-item:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
background: #ff1744;
}
.history-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.clear-all-btn {
background: #ff9800;
color: white;
padding: 8px 16px;
font-size: 14px;
}
.export-btn {
background: #4CAF50;
color: white;
padding: 8px 16px;
font-size: 14px;
}
.empty-history {
text-align: center;
padding: 30px;
color: #999;
font-style: italic;
}
</style>
4. 部署与优化建议
4.1 生产环境部署
在开发环境跑起来后,我们需要考虑生产环境的部署。这里有一些建议:
后端部署优化:
# production_config.py
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
import torch
# 环境变量配置
MODEL_CACHE_DIR = os.getenv("MODEL_CACHE_DIR", "./models")
MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE", 10 * 1024 * 1024)) # 10MB
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "").split(",")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""生命周期管理"""
# 启动时加载模型
print("正在加载模型...")
# 设置GPU内存优化
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.backends.cudnn.benchmark = True
yield
# 关闭时清理
print("正在清理资源...")
if torch.cuda.is_available():
torch.cuda.empty_cache()
app = FastAPI(
title="Qwen3-ASR生产服务",
description="基于Qwen3-ASR-1.7B的语音识别服务",
version="1.0.0",
lifespan=lifespan
)
# 添加生产环境中间件
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["example.com", "*.example.com"] # 替换为你的域名
)
# 添加速率限制(需要安装slowapi)
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.get("/health")
@limiter.limit("10/minute")
async def health_check():
"""健康检查接口"""
return {
"status": "healthy",
"model_loaded": model is not None,
"gpu_available": torch.cuda.is_available(),
"timestamp": time.time()
}
Docker部署:
# Dockerfile
FROM pytorch/pytorch:2.1.0-cuda11.8-cudnn8-runtime
WORKDIR /app
# 安装系统依赖
RUN apt-get update && apt-get install -y \
ffmpeg \
libsndfile1 \
&& rm -rf /var/lib/apt/lists/*
# 复制依赖文件
COPY requirements.txt .
# 安装Python依赖
RUN pip install --no-cache-dir -r requirements.txt
# 复制应用代码
COPY . .
# 创建模型缓存目录
RUN mkdir -p /app/models
# 设置环境变量
ENV MODEL_CACHE_DIR=/app/models
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app
# 暴露端口
EXPOSE 8000
# 启动命令
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
Docker Compose配置:
# docker-compose.yml
version: '3.8'
services:
asr-backend:
build: .
ports:
- "8000:8000"
environment:
- MODEL_CACHE_DIR=/app/models
- MAX_FILE_SIZE=10485760
- ALLOWED_ORIGINS=https://your-frontend.com
volumes:
- ./models:/app/models
- ./logs:/app/logs
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
restart: unless-stopped
# 可以添加Nginx反向代理
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./ssl:/etc/nginx/ssl
depends_on:
- asr-backend
restart: unless-stopped
4.2 前端优化建议
- 代码分割:
// 使用Vue的异步组件
const SpeechRecognition = defineAsyncComponent(() =>
import('./components/SpeechRecognition.vue')
)
- 错误处理增强:
// 统一的错误处理
class ASRErrorHandler {
static handleError(error, context) {
console.error(`[ASR Error] ${context}:`, error);
// 根据错误类型显示不同的提示
if (error.code === 'PERMISSION_DENIED') {
return '请允许访问麦克风权限';
} else if (error.code === 'NOT_SUPPORTED') {
return '您的浏览器不支持录音功能';
} else if (error.response?.status === 413) {
return '文件太大,请选择小于10MB的文件';
} else {
return '识别失败,请重试';
}
}
}
- 性能监控:
// 添加性能监控
const performanceMonitor = {
startTime: 0,
start() {
this.startTime = performance.now();
},
end(operation) {
const duration = performance.now() - this.startTime;
console.log(`[Performance] ${operation}: ${duration.toFixed(2)}ms`);
// 可以发送到监控服务
if (duration > 5000) {
console.warn(`[Performance Warning] ${operation} took too long`);
}
}
};
4.3 安全考虑
- API密钥管理: 如果使用云服务,不要在前端硬编码API密钥
- 文件大小限制: 防止大文件攻击
- 输入验证: 验证上传的文件类型和内容
- CORS配置: 生产环境不要使用
allow_origins: ["*"] - 速率限制: 防止滥用
5. 实际应用场景
这个语音识别系统可以在很多场景下使用:
5.1 在线教育平台
- 实时生成课程字幕
- 学生语音提问转文字
- 多语言课程翻译
5.2 会议系统
- 实时会议记录
- 多语言实时翻译
- 会议纪要自动生成
5.3 内容创作
- 视频字幕自动生成
- 播客文字稿转换
- 采访录音整理
5.4 无障碍服务
- 为听障人士提供实时字幕
- 语音控制界面
- 语音转文字通信
6. 总结
通过这篇文章,我们完成了一个完整的Vue前端项目集成Qwen3-ASR-1.7B语音识别模型的实践。从后端服务搭建到前端界面开发,从基础功能到高级特性,我们一步步实现了:
- 基础语音识别:支持录音和文件上传识别
- 实时语音识别:通过WebSocket实现流式识别
- 多语言支持:利用Qwen3-ASR的52种语言能力
- 历史记录:本地存储和管理识别历史
- 生产部署:提供了Docker和优化建议
Qwen3-ASR-1.7B的优势在于它的开源免费、多语言支持、以及不错的识别准确率。虽然1.7B的模型大小对于某些场景可能还有优化空间,但对于大多数应用来说已经足够用了。
在实际使用中,你可能会遇到一些挑战,比如模型加载时间、GPU内存占用、网络延迟等。但通过合理的优化和架构设计,这些问题都是可以解决的。
这个项目只是一个起点,你可以根据自己的需求继续扩展,比如添加语音合成、情感分析、说话人识别等功能。希望这篇文章能帮助你快速上手语音识别技术的集成,为你的项目增添智能语音交互的能力。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。
更多推荐


所有评论(0)