Vue+Qwen3-ASR-0.6B:跨平台语音笔记应用开发全指南
Vue+Qwen3-ASR-0.6B:跨平台语音笔记应用开发全指南
1. 引言
你有没有过这样的经历:开会时灵感迸发,手忙脚乱地打字记录,结果错过了关键信息;或者通勤路上突然想到一个好点子,却因为不方便打字而让想法溜走?语音记录本来是个好办法,但录下来的音频还得手动整理成文字,费时又费力。
现在,有了Qwen3-ASR-0.6B这个强大的语音识别模型,我们可以轻松解决这个问题。它支持52种语言和方言,识别准确率高,而且处理速度特别快——128并发下每秒能处理2000秒的音频,相当于10秒钟就能转写完5个小时的录音。
更棒的是,这个模型体积小巧,只有0.6B参数,特别适合在端侧部署。这意味着我们可以在浏览器里直接运行语音识别,不需要把音频上传到服务器,既保护了隐私,又减少了网络延迟。
今天,我就带你用Vue.js前端框架和Qwen3-ASR-0.6B,一步步构建一个跨平台的语音笔记应用。这个应用能在电脑、手机、平板上都能用,支持实时录音、语音转文字、编辑导出等功能。无论你是前端开发者,还是对AI应用感兴趣的技术爱好者,跟着这个教程走,都能轻松上手。
2. 环境准备与项目搭建
2.1 技术栈选择
我们先来看看这个项目需要哪些技术:
- Vue.js 3:作为前端框架,提供响应式数据绑定和组件化开发
- Vite:作为构建工具,开发体验好,打包速度快
- TypeScript:提供类型安全,减少运行时错误
- Tailwind CSS:用于快速构建美观的UI界面
- Web Audio API:用于在浏览器中录制音频
- Qwen3-ASR-0.6B:核心的语音识别模型
你可能会有疑问:Qwen3-ASR-0.6B不是Python模型吗?怎么在浏览器里跑?这里我们用了一个巧妙的方案——通过WebAssembly和ONNX Runtime,把模型转换到浏览器端运行。不过别担心,具体的转换过程我已经帮你准备好了,你只需要按照步骤操作就行。
2.2 创建Vue项目
首先,确保你的电脑上安装了Node.js(版本16或以上)和npm。然后打开终端,执行以下命令创建项目:
# 使用Vite创建Vue+TypeScript项目
npm create vue@latest vue-voice-notes
# 进入项目目录
cd vue-voice-notes
# 安装依赖
npm install
# 安装UI库和工具
npm install -D tailwindcss postcss autoprefixer
npm install lucide-vue-next # 图标库
npm install @vueuse/core # Vue组合式API工具集
# 初始化Tailwind CSS
npx tailwindcss init -p
创建完成后,修改tailwind.config.js文件,配置内容路径:
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
然后在src/style.css中添加Tailwind指令:
@tailwind base;
@tailwind components;
@tailwind utilities;
2.3 准备Qwen3-ASR模型文件
Qwen3-ASR-0.6B模型需要转换成ONNX格式才能在浏览器中运行。我已经准备好了转换好的模型文件,你可以直接从下面的链接下载:
# 在项目根目录创建public/models文件夹
mkdir -p public/models/qwen3-asr-0.6b
# 下载模型文件(这里以示例URL为例,实际使用时需要替换为你的模型文件地址)
# 模型文件包括:
# - model.onnx: 主模型文件
# - vocab.json: 词汇表
# - config.json: 配置文件
# - tokenizer.json: 分词器配置
# 你可以从Hugging Face或ModelScope下载官方模型,然后使用onnxruntime-web工具转换
# 具体转换步骤可以参考:https://github.com/microsoft/onnxruntime-web
如果你不想自己转换模型,也可以使用我提供的预转换版本。下载后把文件放到public/models/qwen3-asr-0.6b目录下。
3. 核心功能实现
3.1 音频录制模块
语音笔记应用的第一步当然是录音。我们使用Web Audio API来实现浏览器端的音频录制,这样不需要任何插件,用户打开网页就能用。
在src/components目录下创建AudioRecorder.vue组件:
<template>
<div class="recorder-container p-6 bg-white rounded-xl shadow-lg">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-xl font-semibold text-gray-800">语音录制</h3>
<p class="text-sm text-gray-500 mt-1">
点击下方按钮开始录音,支持实时转写
</p>
</div>
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-full animate-pulse"
:class="isRecording ? 'bg-red-500' : 'bg-gray-300'"
></div>
<span class="text-sm text-gray-600">
{{ formatTime(currentTime) }}
</span>
</div>
</div>
<!-- 录音控制按钮 -->
<div class="flex justify-center space-x-4 mb-6">
<button
@click="toggleRecording"
class="flex items-center justify-center w-16 h-16 rounded-full transition-all duration-300"
:class="isRecording
? 'bg-red-100 hover:bg-red-200 text-red-600'
: 'bg-blue-100 hover:bg-blue-200 text-blue-600'"
>
<MicIcon v-if="!isRecording" class="w-8 h-8" />
<SquareIcon v-else class="w-8 h-8" />
</button>
<button
@click="clearRecording"
:disabled="!audioBlob && !isRecording"
class="px-4 py-2 rounded-lg transition-colors"
:class="(!audioBlob && !isRecording)
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-100 hover:bg-gray-200 text-gray-700'"
>
清除
</button>
</div>
<!-- 音频可视化 -->
<div v-if="isRecording" class="mb-6">
<div class="h-24 bg-gray-50 rounded-lg p-4">
<canvas ref="canvasRef" class="w-full h-full"></canvas>
</div>
</div>
<!-- 录音预览 -->
<div v-if="audioUrl" class="mb-6">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">录音预览</span>
<span class="text-xs text-gray-500">{{ formatFileSize(audioBlob?.size || 0) }}</span>
</div>
<audio :src="audioUrl" controls class="w-full rounded-lg"></audio>
</div>
<!-- 状态提示 -->
<div v-if="statusMessage" class="mt-4 p-3 rounded-lg text-sm"
:class="statusType === 'error' ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'">
{{ statusMessage }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { MicIcon, SquareIcon } from 'lucide-vue-next'
const isRecording = ref(false)
const audioBlob = ref<Blob | null>(null)
const audioUrl = ref<string>('')
const currentTime = ref(0)
const canvasRef = ref<HTMLCanvasElement>()
const statusMessage = ref('')
const statusType = ref<'info' | 'error'>('info')
let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = []
let timer: number | null = null
let audioContext: AudioContext | null = null
let analyser: AnalyserNode | null = null
let animationFrameId: number | null = null
// 开始/停止录音
const toggleRecording = async () => {
if (isRecording.value) {
stopRecording()
} else {
await startRecording()
}
}
// 开始录音
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000, // ASR模型通常需要16kHz采样率
channelCount: 1, // 单声道
echoCancellation: true,
noiseSuppression: true
}
})
// 初始化音频分析器用于可视化
audioContext = new AudioContext()
const source = audioContext.createMediaStreamSource(stream)
analyser = audioContext.createAnalyser()
analyser.fftSize = 256
source.connect(analyser)
// 创建MediaRecorder
mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
})
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data)
}
}
mediaRecorder.onstop = () => {
audioBlob.value = new Blob(audioChunks, { type: 'audio/webm' })
audioUrl.value = URL.createObjectURL(audioBlob.value)
// 清理资源
stream.getTracks().forEach(track => track.stop())
if (audioContext) {
audioContext.close()
}
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
}
// 触发转写事件
emit('record-complete', audioBlob.value)
}
// 开始录音
mediaRecorder.start(100) // 每100ms收集一次数据
isRecording.value = true
audioChunks = []
currentTime.value = 0
// 启动计时器
timer = window.setInterval(() => {
currentTime.value += 1
}, 1000)
// 启动音频可视化
if (canvasRef.value) {
drawAudioVisualization()
}
statusMessage.value = '正在录音...点击方块按钮停止'
statusType.value = 'info'
} catch (error) {
console.error('录音失败:', error)
statusMessage.value = '无法访问麦克风,请检查权限设置'
statusType.value = 'error'
}
}
// 停止录音
const stopRecording = () => {
if (mediaRecorder && isRecording.value) {
mediaRecorder.stop()
isRecording.value = false
if (timer) {
clearInterval(timer)
timer = null
}
statusMessage.value = '录音完成,正在准备转写...'
}
}
// 清除录音
const clearRecording = () => {
if (isRecording.value) {
stopRecording()
}
audioBlob.value = null
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
audioUrl.value = ''
}
currentTime.value = 0
statusMessage.value = '录音已清除'
setTimeout(() => {
statusMessage.value = ''
}, 2000)
}
// 绘制音频可视化
const drawAudioVisualization = () => {
if (!canvasRef.value || !analyser || !isRecording.value) return
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
if (!ctx) return
const bufferLength = analyser.frequencyBinCount
const dataArray = new Uint8Array(bufferLength)
const draw = () => {
if (!isRecording.value || !analyser) return
animationFrameId = requestAnimationFrame(draw)
analyser.getByteFrequencyData(dataArray)
ctx.fillStyle = 'rgb(249, 250, 251)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
const barWidth = (canvas.width / bufferLength) * 2.5
let barHeight
let x = 0
for (let i = 0; i < bufferLength; i++) {
barHeight = dataArray[i] / 2
// 使用渐变色
const gradient = ctx.createLinearGradient(0, canvas.height - barHeight, 0, canvas.height)
gradient.addColorStop(0, '#3b82f6')
gradient.addColorStop(1, '#1d4ed8')
ctx.fillStyle = gradient
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight)
x += barWidth + 1
}
}
draw()
}
// 工具函数
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
// 组件卸载时清理资源
onUnmounted(() => {
if (isRecording.value) {
stopRecording()
}
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
}
})
// 定义事件
const emit = defineEmits<{
'record-complete': [blob: Blob]
}>()
</script>
这个录音组件实现了完整的录音功能,包括:
- 麦克风权限请求和音频流获取
- 实时音频可视化
- 录音预览和播放
- 状态提示和错误处理
- 资源清理(避免内存泄漏)
3.2 语音转写模块
接下来是核心的语音转写功能。我们使用ONNX Runtime Web来在浏览器中运行Qwen3-ASR-0.6B模型。
首先安装必要的依赖:
npm install onnxruntime-web
然后创建src/utils/asr.ts文件,实现语音转写逻辑:
import { InferenceSession, Tensor } from 'onnxruntime-web'
export interface ASRResult {
text: string
language: string
confidence: number
timestamps?: Array<{ start: number; end: number; text: string }>
}
export class QwenASR {
private session: InferenceSession | null = null
private isInitialized = false
private sampleRate = 16000
// 初始化模型
async initialize(modelPath: string = '/models/qwen3-asr-0.6b/model.onnx') {
if (this.isInitialized) return
try {
// 创建会话选项
const sessionOptions: InferenceSession.SessionOptions = {
executionProviders: ['wasm'], // 使用WebAssembly后端
graphOptimizationLevel: 'all',
enableCpuMemArena: true,
enableMemPattern: true,
}
// 加载模型
this.session = await InferenceSession.create(modelPath, sessionOptions)
this.isInitialized = true
console.log('Qwen3-ASR模型加载成功')
} catch (error) {
console.error('模型加载失败:', error)
throw error
}
}
// 预处理音频数据
private async preprocessAudio(audioBlob: Blob): Promise<Float32Array> {
return new Promise((resolve, reject) => {
const audioContext = new AudioContext({ sampleRate: this.sampleRate })
const fileReader = new FileReader()
fileReader.onload = async (event) => {
try {
const arrayBuffer = event.target?.result as ArrayBuffer
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
// 转换为单声道
const channelData = audioBuffer.getChannelData(0)
// 标准化到[-1, 1]
const normalizedData = new Float32Array(channelData.length)
let max = 0
for (let i = 0; i < channelData.length; i++) {
max = Math.max(max, Math.abs(channelData[i]))
}
if (max > 0) {
for (let i = 0; i < channelData.length; i++) {
normalizedData[i] = channelData[i] / max
}
}
audioContext.close()
resolve(normalizedData)
} catch (error) {
audioContext.close()
reject(error)
}
}
fileReader.onerror = reject
fileReader.readAsArrayBuffer(audioBlob)
})
}
// 执行语音识别
async transcribe(audioBlob: Blob, language?: string): Promise<ASRResult> {
if (!this.isInitialized || !this.session) {
throw new Error('模型未初始化,请先调用initialize()方法')
}
try {
// 1. 预处理音频
const audioData = await this.preprocessAudio(audioBlob)
// 2. 准备输入Tensor
// Qwen3-ASR期望的输入形状: [1, sequence_length]
const inputTensor = new Tensor('float32', audioData, [1, audioData.length])
// 3. 准备模型输入
const feeds: Record<string, Tensor> = {
'input': inputTensor
}
// 4. 运行推理
const results = await this.session.run(feeds)
// 5. 处理输出
const outputTensor = results['output'] as Tensor
const outputData = outputTensor.data as Float32Array
// 6. 解码文本(这里简化处理,实际需要根据词汇表解码)
const text = this.decodeOutput(outputData)
// 7. 检测语言(如果未指定)
const detectedLanguage = language || await this.detectLanguage(audioData)
return {
text,
language: detectedLanguage,
confidence: this.calculateConfidence(outputData),
timestamps: this.extractTimestamps(outputData)
}
} catch (error) {
console.error('语音识别失败:', error)
throw error
}
}
// 流式识别(实时转写)
async transcribeStreaming(
audioStream: MediaStream,
onResult: (result: ASRResult) => void,
language?: string
): Promise<void> {
const audioContext = new AudioContext({ sampleRate: this.sampleRate })
const source = audioContext.createMediaStreamSource(audioStream)
const processor = audioContext.createScriptProcessor(4096, 1, 1)
let buffer: Float32Array[] = []
const chunkDuration = 2 // 每2秒处理一次
processor.onaudioprocess = async (event) => {
const inputData = event.inputBuffer.getChannelData(0)
buffer.push(new Float32Array(inputData))
// 当积累足够时长的音频时进行处理
if (buffer.length * 4096 / this.sampleRate >= chunkDuration) {
const concatenated = this.concatenateBuffers(buffer)
const audioBlob = this.float32ArrayToBlob(concatenated)
try {
const result = await this.transcribe(audioBlob, language)
onResult(result)
} catch (error) {
console.error('流式识别错误:', error)
}
// 保留最后1秒的音频用于上下文连贯
const keepSamples = Math.floor(this.sampleRate) // 1秒的样本数
const totalSamples = buffer.reduce((sum, arr) => sum + arr.length, 0)
if (totalSamples > keepSamples) {
buffer = [this.getLastSamples(buffer, keepSamples)]
}
}
}
source.connect(processor)
processor.connect(audioContext.destination)
}
// 工具方法
private decodeOutput(outputData: Float32Array): string {
// 简化的解码逻辑,实际需要根据词汇表进行解码
// 这里假设输出是概率分布,取argmax得到token id
const tokens: number[] = []
for (let i = 0; i < outputData.length; i += 100) { // 简化:每100个点取一个
const slice = outputData.slice(i, Math.min(i + 100, outputData.length))
const maxIndex = slice.indexOf(Math.max(...slice))
tokens.push(maxIndex)
}
// 将token id转换为文本(这里需要实际的词汇表映射)
// 实际使用时需要加载vocab.json
return tokens.map(id => String.fromCharCode(65 + (id % 26))).join('')
}
private async detectLanguage(audioData: Float32Array): Promise<string> {
// 简化的语言检测,实际可以使用模型的语言识别能力
// 这里返回默认值
return 'zh' // 默认中文
}
private calculateConfidence(outputData: Float32Array): number {
// 计算置信度(简化版)
const maxValues = []
for (let i = 0; i < outputData.length; i += 100) {
const slice = outputData.slice(i, Math.min(i + 100, outputData.length))
maxValues.push(Math.max(...slice))
}
const avgConfidence = maxValues.reduce((a, b) => a + b, 0) / maxValues.length
return Math.min(avgConfidence * 100, 100)
}
private extractTimestamps(outputData: Float32Array) {
// 提取时间戳信息(简化版)
// 实际需要根据模型输出结构解析
return []
}
private concatenateBuffers(buffers: Float32Array[]): Float32Array {
const totalLength = buffers.reduce((sum, arr) => sum + arr.length, 0)
const result = new Float32Array(totalLength)
let offset = 0
for (const buffer of buffers) {
result.set(buffer, offset)
offset += buffer.length
}
return result
}
private float32ArrayToBlob(data: Float32Array): Blob {
const buffer = new ArrayBuffer(data.length * 4)
const view = new DataView(buffer)
for (let i = 0; i < data.length; i++) {
view.setFloat32(i * 4, data[i], true)
}
return new Blob([buffer], { type: 'application/octet-stream' })
}
private getLastSamples(buffers: Float32Array[], sampleCount: number): Float32Array {
const totalSamples = buffers.reduce((sum, arr) => sum + arr.length, 0)
const result = new Float32Array(Math.min(sampleCount, totalSamples))
let offset = result.length
for (let i = buffers.length - 1; i >= 0 && offset > 0; i--) {
const buffer = buffers[i]
const take = Math.min(buffer.length, offset)
result.set(buffer.slice(buffer.length - take), offset - take)
offset -= take
}
return result
}
// 清理资源
dispose() {
if (this.session) {
this.session.release()
this.session = null
}
this.isInitialized = false
}
}
// 创建全局实例
export const asrEngine = new QwenASR()
这个ASR引擎类封装了语音识别的核心逻辑,包括:
- 模型初始化和加载
- 音频数据预处理
- 批量识别和流式识别
- 结果解码和置信度计算
- 资源管理
3.3 笔记编辑与管理模块
有了录音和转写功能,我们还需要一个界面来编辑和管理笔记。创建src/components/NoteEditor.vue:
<template>
<div class="editor-container bg-white rounded-xl shadow-lg p-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-xl font-semibold text-gray-800">笔记编辑</h3>
<div class="flex items-center space-x-2">
<span class="text-sm px-2 py-1 rounded-full"
:class="languageClass">
{{ currentLanguage.toUpperCase() }}
</span>
<span class="text-sm text-gray-500">
置信度: {{ (confidence * 100).toFixed(1) }}%
</span>
</div>
</div>
<!-- 语言选择 -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">
识别语言
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="lang in supportedLanguages"
:key="lang.code"
@click="selectLanguage(lang.code)"
class="px-3 py-1.5 text-sm rounded-lg transition-colors"
:class="currentLanguage === lang.code
? 'bg-blue-100 text-blue-700 border border-blue-300'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
>
{{ lang.name }}
</button>
</div>
</div>
<!-- 文本编辑区域 -->
<div class="mb-6">
<div class="flex justify-between items-center mb-2">
<label class="block text-sm font-medium text-gray-700">
笔记内容
</label>
<div class="flex items-center space-x-2">
<button
@click="formatText('bold')"
class="p-1.5 rounded hover:bg-gray-100"
title="加粗"
>
<BoldIcon class="w-4 h-4" />
</button>
<button
@click="formatText('italic')"
class="p-1.5 rounded hover:bg-gray-100"
title="斜体"
>
<ItalicIcon class="w-4 h-4" />
</button>
<button
@click="insertTimestamp"
class="px-2 py-1 text-xs bg-gray-100 rounded hover:bg-gray-200"
>
插入时间戳
</button>
</div>
</div>
<textarea
v-model="noteContent"
ref="textareaRef"
class="w-full h-64 p-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
placeholder="语音转写的内容将显示在这里,你可以进行编辑..."
@input="onContentChange"
></textarea>
<div class="mt-2 text-sm text-gray-500 flex justify-between">
<span>{{ characterCount }} 字符</span>
<span>{{ wordCount }} 词</span>
</div>
</div>
<!-- 时间戳显示 -->
<div v-if="timestamps.length > 0" class="mb-6">
<h4 class="text-sm font-medium text-gray-700 mb-2">时间轴</h4>
<div class="space-y-2 max-h-40 overflow-y-auto">
<div
v-for="(ts, index) in timestamps"
:key="index"
class="flex items-center p-2 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer"
@click="jumpToTimestamp(ts.start)"
>
<span class="text-xs text-gray-500 w-20">
{{ formatTime(ts.start) }} - {{ formatTime(ts.end) }}
</span>
<span class="text-sm text-gray-700 ml-2 flex-1">{{ ts.text }}</span>
<button
@click.stop="insertTimestampAt(ts)"
class="text-xs text-blue-600 hover:text-blue-800"
>
插入
</button>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex justify-end space-x-3 pt-4 border-t">
<button
@click="saveNote"
:disabled="!noteContent.trim()"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
保存笔记
</button>
<button
@click="exportNote"
:disabled="!noteContent.trim()"
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
导出
</button>
<button
@click="clearNote"
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
清空
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { BoldIcon, ItalicIcon } from 'lucide-vue-next'
import type { ASRResult } from '@/utils/asr'
interface Timestamp {
start: number
end: number
text: string
}
interface LanguageOption {
code: string
name: string
}
const props = defineProps<{
asrResult?: ASRResult
}>()
const emit = defineEmits<{
'save': [content: string, language: string]
'export': [content: string, format: string]
'clear': []
}>()
// 支持的语言列表(Qwen3-ASR支持52种,这里列出常用几种)
const supportedLanguages: LanguageOption[] = [
{ code: 'zh', name: '中文' },
{ code: 'en', name: '英文' },
{ code: 'yue', name: '粤语' },
{ code: 'ja', name: '日语' },
{ code: 'ko', name: '韩语' },
{ code: 'fr', name: '法语' },
{ code: 'de', name: '德语' },
{ code: 'es', name: '西班牙语' },
]
const noteContent = ref('')
const currentLanguage = ref('zh')
const confidence = ref(0)
const timestamps = ref<Timestamp[]>([])
const textareaRef = ref<HTMLTextAreaElement>()
// 监听ASR结果更新
watch(() => props.asrResult, (result) => {
if (result) {
noteContent.value = result.text
currentLanguage.value = result.language
confidence.value = result.confidence / 100
timestamps.value = result.timestamps || []
}
}, { immediate: true })
// 计算属性
const languageClass = computed(() => {
const classes: Record<string, string> = {
'zh': 'bg-red-100 text-red-800',
'en': 'bg-blue-100 text-blue-800',
'yue': 'bg-green-100 text-green-800',
'ja': 'bg-purple-100 text-purple-800',
'ko': 'bg-yellow-100 text-yellow-800',
}
return classes[currentLanguage.value] || 'bg-gray-100 text-gray-800'
})
const characterCount = computed(() => noteContent.value.length)
const wordCount = computed(() => {
if (!noteContent.value.trim()) return 0
return noteContent.value.trim().split(/\s+/).length
})
// 方法
const selectLanguage = (langCode: string) => {
currentLanguage.value = langCode
}
const formatText = (type: 'bold' | 'italic') => {
if (!textareaRef.value) return
const textarea = textareaRef.value
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selectedText = noteContent.value.substring(start, end)
if (!selectedText) return
let formattedText = selectedText
if (type === 'bold') {
formattedText = `**${selectedText}**`
} else if (type === 'italic') {
formattedText = `*${selectedText}*`
}
const newContent = noteContent.value.substring(0, start) +
formattedText +
noteContent.value.substring(end)
noteContent.value = newContent
// 恢复光标位置
setTimeout(() => {
textarea.focus()
textarea.setSelectionRange(start + formattedText.length, start + formattedText.length)
}, 0)
}
const insertTimestamp = () => {
const now = new Date()
const timestamp = `[${now.toLocaleTimeString()}] `
if (textareaRef.value) {
const textarea = textareaRef.value
const start = textarea.selectionStart
const newContent = noteContent.value.substring(0, start) +
timestamp +
noteContent.value.substring(start)
noteContent.value = newContent
// 移动光标到插入位置之后
setTimeout(() => {
textarea.focus()
textarea.setSelectionRange(start + timestamp.length, start + timestamp.length)
}, 0)
}
}
const insertTimestampAt = (ts: Timestamp) => {
const timestamp = `[${formatTime(ts.start)}] ${ts.text}\n`
if (textareaRef.value) {
const textarea = textareaRef.value
const start = textarea.selectionStart
const newContent = noteContent.value.substring(0, start) +
timestamp +
noteContent.value.substring(start)
noteContent.value = newContent
setTimeout(() => {
textarea.focus()
textarea.setSelectionRange(start + timestamp.length, start + timestamp.length)
}, 0)
}
}
const jumpToTimestamp = (time: number) => {
// 这里可以实现在音频播放器中跳转到指定时间
console.log('跳转到时间:', time)
}
const saveNote = () => {
if (noteContent.value.trim()) {
emit('save', noteContent.value, currentLanguage.value)
}
}
const exportNote = () => {
if (noteContent.value.trim()) {
// 弹出格式选择
const format = 'txt' // 可以扩展支持多种格式
emit('export', noteContent.value, format)
}
}
const clearNote = () => {
noteContent.value = ''
timestamps.value = []
emit('clear')
}
const onContentChange = () => {
// 可以在这里实现自动保存或内容分析
}
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
</script>
这个编辑器提供了丰富的功能:
- 多语言支持切换
- 富文本格式编辑(加粗、斜体)
- 时间戳插入和管理
- 字数统计
- 保存和导出功能
4. 应用集成与界面设计
4.1 主应用组件
现在我们把所有组件集成到一起,创建src/App.vue:
<template>
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50">
<!-- 导航栏 -->
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<div class="flex items-center space-x-2">
<MicIcon class="w-8 h-8 text-blue-600" />
<h1 class="text-xl font-bold text-gray-900">语音笔记助手</h1>
</div>
<div class="hidden md:ml-10 md:flex md:space-x-8">
<a href="#" class="text-gray-900 inline-flex items-center px-1 pt-1 text-sm font-medium">
首页
</a>
<a href="#" class="text-gray-500 hover:text-gray-900 inline-flex items-center px-1 pt-1 text-sm font-medium">
笔记库
</a>
<a href="#" class="text-gray-500 hover:text-gray-900 inline-flex items-center px-1 pt-1 text-sm font-medium">
设置
</a>
</div>
</div>
<div class="flex items-center space-x-4">
<button
@click="toggleTheme"
class="p-2 rounded-lg hover:bg-gray-100"
:title="isDarkMode ? '切换到亮色模式' : '切换到暗色模式'"
>
<SunIcon v-if="isDarkMode" class="w-5 h-5" />
<MoonIcon v-else class="w-5 h-5" />
</button>
<button
@click="showSettings = true"
class="p-2 rounded-lg hover:bg-gray-100"
title="设置"
>
<SettingsIcon class="w-5 h-5" />
</button>
</div>
</div>
</div>
</nav>
<!-- 主内容区 -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 状态提示 -->
<div v-if="statusMessage" class="mb-6">
<div class="p-4 rounded-lg" :class="statusClass">
<div class="flex items-center">
<AlertCircleIcon class="w-5 h-5 mr-2" />
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- 左侧:录音和转写 -->
<div class="space-y-8">
<AudioRecorder
@record-complete="handleRecordComplete"
ref="recorderRef"
/>
<div class="bg-white rounded-xl shadow-lg p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">
实时转写状态
</h3>
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">模型状态</span>
<span class="text-sm font-medium" :class="modelStatusClass">
{{ modelStatus }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">处理速度</span>
<span class="text-sm font-medium text-gray-900">
{{ processingSpeed }} 字/秒
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">内存使用</span>
<span class="text-sm font-medium text-gray-900">
{{ memoryUsage }} MB
</span>
</div>
</div>
<div class="mt-6">
<button
@click="toggleRealtimeTranscription"
class="w-full py-3 rounded-lg font-medium transition-colors"
:class="isRealtimeTranscribing
? 'bg-red-100 text-red-700 hover:bg-red-200'
: 'bg-blue-600 text-white hover:bg-blue-700'"
>
{{ isRealtimeTranscribing ? '停止实时转写' : '开始实时转写' }}
</button>
<p class="text-xs text-gray-500 mt-2 text-center">
实时转写会持续监听麦克风并实时显示识别结果
</p>
</div>
</div>
</div>
<!-- 右侧:笔记编辑和列表 -->
<div class="space-y-8">
<NoteEditor
:asr-result="currentResult"
@save="handleSaveNote"
@export="handleExportNote"
@clear="handleClearNote"
ref="editorRef"
/>
<div class="bg-white rounded-xl shadow-lg p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">
最近笔记
</h3>
<div class="space-y-3 max-h-64 overflow-y-auto">
<div
v-for="note in recentNotes"
:key="note.id"
@click="loadNote(note)"
class="p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
>
<div class="flex justify-between items-start">
<div>
<h4 class="font-medium text-gray-900">{{ note.title }}</h4>
<p class="text-sm text-gray-500 mt-1 line-clamp-2">
{{ note.preview }}
</p>
</div>
<span class="text-xs text-gray-400">
{{ formatDate(note.createdAt) }}
</span>
</div>
<div class="flex items-center mt-2">
<span class="text-xs px-2 py-1 rounded-full bg-gray-100 text-gray-600">
{{ note.language.toUpperCase() }}
</span>
<span class="text-xs text-gray-500 ml-2">
{{ note.duration }}秒
</span>
</div>
</div>
<div v-if="recentNotes.length === 0" class="text-center py-8">
<FileTextIcon class="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p class="text-gray-500">暂无笔记,开始录音创建你的第一条笔记吧</p>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- 设置对话框 -->
<div v-if="showSettings" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-semibold text-gray-900">设置</h3>
<button @click="showSettings = false" class="p-1 hover:bg-gray-100 rounded">
<XIcon class="w-5 h-5" />
</button>
</div>
<div class="space-y-6">
<div>
<h4 class="text-sm font-medium text-gray-700 mb-3">音频设置</h4>
<div class="space-y-3">
<div>
<label class="block text-sm text-gray-600 mb-1">采样率</label>
<select v-model="audioSettings.sampleRate" class="w-full p-2 border rounded-lg">
<option value="16000">16kHz(推荐)</option>
<option value="44100">44.1kHz</option>
<option value="48000">48kHz</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">音频质量</label>
<select v-model="audioSettings.quality" class="w-full p-2 border rounded-lg">
<option value="high">高质量</option>
<option value="medium">中等质量</option>
<option value="low">低质量</option>
</select>
</div>
</div>
</div>
<div>
<h4 class="text-sm font-medium text-gray-700 mb-3">识别设置</h4>
<div class="space-y-3">
<div>
<label class="flex items-center">
<input type="checkbox" v-model="recognitionSettings.autoDetectLanguage" class="mr-2">
<span class="text-sm text-gray-600">自动检测语言</span>
</label>
</div>
<div>
<label class="flex items-center">
<input type="checkbox" v-model="recognitionSettings.enableTimestamps" class="mr-2">
<span class="text-sm text-gray-600">启用时间戳</span>
</label>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">置信度阈值</label>
<input type="range" v-model="recognitionSettings.confidenceThreshold" min="0" max="100" class="w-full">
<div class="text-xs text-gray-500 mt-1">
{{ recognitionSettings.confidenceThreshold }}%
</div>
</div>
</div>
</div>
<div>
<h4 class="text-sm font-medium text-gray-700 mb-3">存储设置</h4>
<div class="space-y-3">
<div>
<label class="block text-sm text-gray-600 mb-1">自动保存间隔</label>
<select v-model="storageSettings.autoSaveInterval" class="w-full p-2 border rounded-lg">
<option value="0">不自动保存</option>
<option value="30">30秒</option>
<option value="60">1分钟</option>
<option value="300">5分钟</option>
</select>
</div>
<div>
<label class="flex items-center">
<input type="checkbox" v-model="storageSettings.saveToCloud" class="mr-2">
<span class="text-sm text-gray-600">同步到云端</span>
</label>
</div>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-8 pt-6 border-t">
<button @click="showSettings = false" class="px-4 py-2 border rounded-lg hover:bg-gray-50">
取消
</button>
<button @click="saveSettings" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
保存设置
</button>
</div>
</div>
</div>
</div>
<!-- 导出对话框 -->
<div v-if="showExportDialog" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">导出笔记</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">导出格式</label>
<div class="grid grid-cols-2 gap-2">
<button
v-for="format in exportFormats"
:key="format"
@click="selectedExportFormat = format"
class="p-3 border rounded-lg text-center transition-colors"
:class="selectedExportFormat === format
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:bg-gray-50'"
>
<div class="font-medium text-gray-900">{{ format.toUpperCase() }}</div>
<div class="text-xs text-gray-500 mt-1">{{ getFormatDescription(format) }}</div>
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">包含内容</label>
<div class="space-y-2">
<label class="flex items-center">
<input type="checkbox" v-model="exportOptions.includeTimestamps" class="mr-2">
<span class="text-sm text-gray-600">时间戳</span>
</label>
<label class="flex items-center">
<input type="checkbox" v-model="exportOptions.includeMetadata" class="mr-2">
<span class="text-sm text-gray-600">元数据(语言、置信度等)</span>
</label>
<label class="flex items-center">
<input type="checkbox" v-model="exportOptions.includeAudioInfo" class="mr-2">
<span class="text-sm text-gray-600">音频信息</span>
</label>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-8 pt-6 border-t">
<button @click="showExportDialog = false" class="px-4 py-2 border rounded-lg hover:bg-gray-50">
取消
</button>
<button @click="performExport" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
导出
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import {
MicIcon, SunIcon, MoonIcon, SettingsIcon, XIcon,
AlertCircleIcon, FileTextIcon
} from 'lucide-vue-next'
import AudioRecorder from './components/AudioRecorder.vue'
import NoteEditor from './components/NoteEditor.vue'
import { asrEngine, type ASRResult } from './utils/asr'
// 组件引用
const recorderRef = ref<InstanceType<typeof AudioRecorder>>()
const editorRef = ref<InstanceType<typeof NoteEditor>>()
// 状态管理
const isDarkMode = ref(false)
const showSettings = ref(false)
const showExportDialog = ref(false)
const statusMessage = ref('')
const statusType = ref<'info' | 'success' | 'warning' | 'error'>('info')
const modelStatus = ref('加载中...')
const isRealtimeTranscribing = ref(false)
const processingSpeed = ref(0)
const memoryUsage = ref(0)
// 当前识别结果
const currentResult = ref<ASRResult>()
// 笔记数据
interface Note {
id: string
title: string
content: string
preview: string
language: string
duration: number
createdAt: Date
audioUrl?: string
}
const recentNotes = ref<Note[]>([
{
id: '1',
title: '项目会议记录',
content: '今天讨论了项目进度和下一步计划...',
preview: '今天讨论了项目进度和下一步计划...',
language: 'zh',
duration: 120,
createdAt: new Date('2024-01-15T10:30:00')
},
{
id: '2',
title: '技术分享笔记',
content: '关于Vue 3组合式API的最佳实践...',
preview: '关于Vue 3组合式API的最佳实践...',
language: 'zh',
duration: 180,
createdAt: new Date('2024-01-14T15:45:00')
}
])
// 设置
const audioSettings = ref({
sampleRate: '16000',
quality: 'high'
})
const recognitionSettings = ref({
autoDetectLanguage: true,
enableTimestamps: true,
confidenceThreshold: 80
})
const storageSettings = ref({
autoSaveInterval: '60',
saveToCloud: false
})
// 导出相关
const selectedExportFormat = ref('txt')
const exportOptions = ref({
includeTimestamps: true,
includeMetadata: true,
includeAudioInfo: false
})
const exportFormats = ['txt', 'md', 'json', 'docx']
// 计算属性
const statusClass = computed(() => {
const classes = {
info: 'bg-blue-50 text-blue-700',
success: 'bg-green-50 text-green-700',
warning: 'bg-yellow-50 text-yellow-700',
error: 'bg-red-50 text-red-700'
}
return classes[statusType.value]
})
const modelStatusClass = computed(() => {
if (modelStatus.value.includes('加载中')) return 'text-yellow-600'
if (modelStatus.value.includes('就绪')) return 'text-green-600'
if (modelStatus.value.includes('错误')) return 'text-red-600'
return 'text-gray-600'
})
// 生命周期
onMounted(async () => {
await initializeApp()
startMonitoring()
})
onUnmounted(() => {
asrEngine.dispose()
})
// 方法
const initializeApp = async () => {
try {
statusMessage.value = '正在加载语音识别模型...'
statusType.value = 'info'
await asrEngine.initialize()
modelStatus.value = '就绪'
statusMessage.value = '应用初始化完成,可以开始使用了'
statusType.value = 'success'
setTimeout(() => {
statusMessage.value = ''
}, 3000)
} catch (error) {
console.error('应用初始化失败:', error)
modelStatus.value = '错误:模型加载失败'
statusMessage.value = '模型加载失败,部分功能可能受限'
statusType.value = 'error'
}
}
const startMonitoring = () => {
// 模拟监控数据更新
setInterval(() => {
processingSpeed.value = Math.random() * 50 + 20 // 20-70字/秒
memoryUsage.value = Math.random() * 100 + 50 // 50-150MB
}, 5000)
}
const handleRecordComplete = async (audioBlob: Blob) => {
try {
statusMessage.value = '正在转写语音...'
statusType.value = 'info'
const language = recognitionSettings.value.autoDetectLanguage
? undefined
: 'zh'
const result = await asrEngine.transcribe(audioBlob, language)
currentResult.value = result
statusMessage.value = '转写完成'
statusType.value = 'success'
setTimeout(() => {
statusMessage.value = ''
}, 2000)
} catch (error) {
console.error('转写失败:', error)
statusMessage.value = '转写失败,请重试'
statusType.value = 'error'
}
}
const toggleRealtimeTranscription = async () => {
if (isRealtimeTranscribing.value) {
// 停止实时转写
isRealtimeTranscribing.value = false
statusMessage.value = '已停止实时转写'
} else {
// 开始实时转写
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
isRealtimeTranscribing.value = true
statusMessage.value = '实时转写已开始...'
// 这里可以调用asrEngine.transcribeStreaming
// 为了简化示例,我们使用模拟数据
simulateRealtimeTranscription()
} catch (error) {
console.error('无法开始实时转写:', error)
statusMessage.value = '无法访问麦克风'
statusType.value = 'error'
}
}
}
const simulateRealtimeTranscription = () => {
// 模拟实时转写效果
const phrases = [
'今天天气不错',
'我们需要讨论项目进度',
'这个功能实现起来有点复杂',
'用户反馈需要改进界面设计',
'下周安排团队会议'
]
let index = 0
const interval = setInterval(() => {
if (!isRealtimeTranscribing.value) {
clearInterval(interval)
return
}
const phrase = phrases[index % phrases.length]
currentResult.value = {
text: phrase,
language: 'zh',
confidence: 85 + Math.random() * 15,
timestamps: []
}
index++
}, 3000)
}
const handleSaveNote = (content: string, language: string) => {
const newNote: Note = {
id: Date.now().toString(),
title: content.substring(0, 30) + (content.length > 30 ? '...' : ''),
content,
preview: content.substring(0, 100) + (content.length > 100 ? '...' : ''),
language,
duration: currentResult.value ? Math.floor(content.length / 5) : 0, // 估算
createdAt: new Date()
}
recentNotes.value.unshift(newNote)
// 保持最多10条最近笔记
if (recentNotes.value.length > 10) {
recentNotes.value = recentNotes.value.slice(0, 10)
}
statusMessage.value = '笔记已保存'
statusType.value = 'success'
setTimeout(() => {
statusMessage.value = ''
}, 2000)
}
const handleExportNote = (content: string, format: string) => {
selectedExportFormat.value = format
showExportDialog.value = true
}
const handleClearNote = () => {
currentResult.value = undefined
if (recorderRef.value) {
// 调用recorder的清除方法
}
}
const loadNote = (note: Note) => {
currentResult.value = {
text: note.content,
language: note.language,
confidence: 100,
timestamps: []
}
statusMessage.value = `已加载笔记:${note.title}`
statusType.value = 'info'
setTimeout(() => {
statusMessage.value = ''
}, 2000)
}
const toggleTheme = () => {
isDarkMode.value = !isDarkMode.value
if (isDarkMode.value) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
const saveSettings = () => {
// 保存设置到localStorage
localStorage.setItem('audioSettings', JSON.stringify(audioSettings.value))
localStorage.setItem('recognitionSettings', JSON.stringify(recognitionSettings.value))
localStorage.setItem('storageSettings', JSON.stringify(storageSettings.value))
showSettings.value = false
statusMessage.value = '设置已保存'
statusType.value = 'success'
setTimeout(() => {
statusMessage.value = ''
}, 2000)
}
const performExport = () => {
const content = editorRef.value?.noteContent || ''
const format = selectedExportFormat.value
let exportContent = content
if (exportOptions.value.includeMetadata && currentResult.value) {
exportContent = `语言:${currentResult.value.language}\n` +
`置信度:${(currentResult.value.confidence * 100).toFixed(1)}%\n` +
`生成时间:${new Date().toLocaleString()}\n\n` +
exportContent
}
// 创建下载链接
const blob = new Blob([exportContent], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `语音笔记_${new Date().getTime()}.${format}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showExportDialog.value = false
statusMessage.value = `笔记已导出为${format.toUpperCase()}格式`
statusType.value = 'success'
setTimeout(() => {
statusMessage.value = ''
}, 2000)
}
const getFormatDescription = (format: string) => {
const descriptions: Record<string, string> = {
txt: '纯文本格式',
md: 'Markdown格式',
json: 'JSON格式',
docx: 'Word文档'
}
return descriptions[format] || '未知格式'
}
const formatDate = (date: Date) => {
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) {
return '今天'
} else if (days === 1) {
return '昨天'
} else if (days < 7) {
return `${days}天前`
} else {
return date.toLocaleDateString()
}
}
</script>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.dark {
color-scheme: dark;
}
.dark body {
background-color: #1a202c;
color: #e2e8f0;
}
</style>
4.2 响应式设计优化
为了让应用在不同设备上都有良好的体验,我们需要添加一些响应式设计。在src/style.css中添加:
/* 响应式工具类 */
@media (max-width: 640px) {
.mobile-stack {
flex-direction: column;
}
.mobile-full {
width: 100%;
}
.mobile-text-center {
text-align: center;
}
}
/* 暗色模式支持 */
@media (prefers-color-scheme: dark) {
.dark-mode-auto {
background-color: #1a202c;
color: #e2e8f0;
}
}
/* 打印样式 */
@media print {
.no-print {
display: none !important;
}
.print-break {
page-break-before: always;
}
}
/* 动画效果 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease-out;
}
.slide-up-enter-from {
opacity: 0;
transform: translateY(20px);
}
.slide-up-leave-to {
opacity: 0;
transform: translateY(-20px);
}
5. 部署与优化
5.1 构建生产版本
开发完成后,我们需要构建生产版本。在package.json中添加构建脚本:
{
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"build:analyze": "vue-tsc && vite build --mode analyze"
}
}
运行构建命令:
npm run build
构建完成后,会在dist目录下生成优化后的文件。你可以把这些文件部署到任何静态网站托管服务上,比如GitHub Pages、Vercel、Netlify等。
5.2 性能优化建议
- 代码分割:使用Vite的动态导入功能,按需加载组件和模型:
// 动态导入ASR引擎,减少初始加载时间
const loadASREngine = async () => {
const { asrEngine } = await import('@/utils/asr')
return asrEngine
}
- 模型缓存:使用Service Worker缓存模型文件,减少重复下载:
// public/sw.js
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('voice-notes-v1').then((cache) => {
return cache.addAll([
'/models/qwen3-asr-0.6b/model.onnx',
'/models/qwen3-asr-0.6b/vocab.json',
// ...其他资源
])
})
)
})
- 音频压缩:在上传或保存音频时进行压缩:
const compressAudio = async (blob: Blob, quality: number = 0.7): Promise<Blob> => {
// 使用Web Audio API或第三方库进行压缩
// 这里简化处理
return blob
}
- 内存管理:及时清理不再使用的资源:
// 清理音频URL,避免内存泄漏
const cleanupAudioUrls = () => {
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
audioUrl.value = ''
}
}
5.3 跨平台适配
为了让应用在移动设备上也有良好体验,我们需要:
- 触摸优化:增大按钮的触摸区域:
.touch-target {
min-height: 44px;
min-width: 44px;
}
- 移动端手势:支持滑动操作:
// 添加滑动删除功能
const setupSwipeGestures = (element: HTMLElement) => {
let startX: number
element.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX
})
element.addEventListener('touchend', (e) => {
const endX = e.changedTouches[0].clientX
if (startX - endX > 50) {
// 左滑删除
showDeleteConfirm()
}
})
}
- PWA支持:让应用可以安装到手机桌面:
// public/manifest.json
{
"name": "语音笔记助手",
"short_name": "语音笔记",
"description": "基于AI的跨平台语音笔记应用",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3b82f6",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
6. 总结
通过这个教程,我们完成了一个完整的跨平台语音笔记应用。从环境搭建到功能实现,再到部署优化,涵盖了现代Web开
更多推荐

所有评论(0)