Vue 3.5 + WangEditor 打造智能笔记编辑器:语音识别功能深度实现

在上篇文章中,我使用了 node.js 封装了豆包模型的语音识别接口,接下来就在前端调用该接口,实现语音识别完整。这篇文章讲拆解如何在 Vue 3.5 框架与 WangEditor 富文本编辑器的基础上,集成完整的语音识别功能,从音频采集、格式转换到实时识别结果插入,全方位呈现可落地的技术方案。

功能定位与技术选型

核心需求拆解

  • 实时语音转文字:支持麦克风采集音频,实时返回识别结果并插入编辑器
  • 良好的用户体验:提供录音状态可视化、快捷键控制、错误提示
  • 兼容性保障:适配主流浏览器,兼顾现代 API 与降级方案
  • 稳定性设计:包含连接诊断、错误处理、资源自动释放机制

技术栈选型

  • 框架:Vue 3.5 + Composition API(高效的组件逻辑组织)
  • 编辑器:WangEditor(轻量、可扩展的富文本编辑器)
  • 音频处理:AudioWorklet + ScriptProcessor(高效音频格式转换)
  • 通信方式:WebSocket(实时传输音频数据与识别结果)
  • UI 组件:Element Plus(错误提示、弹窗等交互组件)

选型核心考量:Vue 3.5 的响应式特性适合状态管理,WangEditor 开放的 API 便于扩展,WebSocket 确保流式数据传输的实时性,双重音频处理方案保障兼容性。

整体架构设计

语音识别功能采用分层设计,各模块职责清晰,便于维护与扩展:

UI交互层(NoteEditor.vue)→ 核心控制层(simpleSpeech.js)→ 音频处理层(audioPcmProcessor.js)→ 通信层(WebSocket) <math xmlns="http://www.w3.org/1998/Math/MathML"> ↓ \qquad\qquad\qquad\qquad\qquad\qquad\downarrow </math>↓ <math xmlns="http://www.w3.org/1998/Math/MathML"> \qquad\qquad\qquad\qquad </math>诊断工具层(speechConfigChecker.js)

  • UI 交互层:提供用户操作入口(录音按钮、状态展示)和结果插入逻辑
  • 核心控制层:封装录音启停、连接管理、消息处理等核心逻辑
  • 音频处理层:将麦克风采集的音频转换为识别服务支持的 PCM 格式
  • 通信层:通过 WebSocket 与后端语音识别服务实时交互
  • 诊断工具层:检测环境兼容性、服务可用性,辅助问题排查

核心模块实现详解

1. 音频处理层:PCM 格式转换(audioPcmProcessor.js)

语音识别服务通常要求输入 16kHz 采样率、16 位深度的 PCM 格式音频,而浏览器麦克风采集的是 Float32Array 格式(范围 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ − 1 , 1 ] [-1, 1] </math>[−1,1]),因此需要专门的格式转换模块。

采用 AudioWorklet 实现高效音频处理(浏览器不支持时降级为 ScriptProcessor):

javascript 复制代码
class AudioPcmProcessor extends AudioWorkletProcessor {
    constructor() {
        super()
        this.bufferSize = 4096 // 缓冲区大小,平衡延迟与性能
        this.buffer = new Float32Array(this.bufferSize)
        this.bufferIndex = 0
    }
    process(inputs, outputs, parameters) {
        const input = inputs[0]
        if (input.length > 0) {
            const inputChannel = input[0]
            // 填充缓冲区
            for (let i = 0; i < inputChannel.length; i++) {
                this.buffer[this.bufferIndex] = inputChannel[i]
                this.bufferIndex++
                // 缓冲区满时处理并发送
                if (this.bufferIndex >= this.bufferSize) {
                    this.processAndSendPcm()
                    this.bufferIndex = 0
                }
            }
        }
        return true
    }
    processAndSendPcm() {
        try {
            // Float32 → Int16 PCM 转换:[-1,1] → [-32768, 32767]
            const pcmData = new Int16Array(this.buffer.length)
            for (let i = 0; i < this.buffer.length; i++) {
                const sample = Math.max(-1, Math.min(1, this.buffer[i])) // 限制范围
                pcmData[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff
            }
            // 用 Transferable 对象传输,避免数据复制
            this.port.postMessage(
                { type: 'pcmData', data: pcmData.buffer, length: pcmData.length },
                [pcmData.buffer]
            )
        } catch (error) {
            this.port.postMessage({ type: 'error', error: error.message })
        }
    }
}
registerProcessor('audio-pcm-processor', AudioPcmProcessor)

核心亮点:

  • 采用缓冲区批量处理,减少通信开销
  • 使用 Transferable 对象 转移 ArrayBuffer 所有权,提升传输效率
  • 严格限制音频采样范围,避免失真

2. 核心控制层:语音识别封装(simpleSpeech.js)

该模块是语音识别功能的核心,封装了录音控制、WebSocket 连接、消息处理等逻辑,对外提供简洁的 API:

javascript 复制代码
export class SimpleSpeech {
    constructor(options = {}) {
        // 基础配置
        const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
        this.serverUrl = `${baseUrl.replace('http', 'ws')}/api/v1/ai/speech-recognition`
        this.sampleRate = options.sampleRate || 16000
        this.model = options.model || 'bigmodel'
        
        // 状态管理
        this.isRecording = false
        this.isConnected = false
        this.isReady = false
        
        // 音频组件与通信实例
        this.audioContext = null
        this.audioStream = null
        this.workletNode = null
        this.ws = null
        
        // 事件回调(解耦UI与核心逻辑)
        this.onReady = options.onReady || (() => {})
        this.onPartial = options.onPartial || (() => {}) // 中间结果回调
        this.onFinal = options.onFinal || (() => {})      // 最终结果回调
        this.onError = options.onError || (() => {})
    }
    // 建立WebSocket连接
    async connect() {
        try {
            const url = `${this.serverUrl}?sampleRate=${this.sampleRate}&model=${this.model}`
            this.ws = new WebSocket(url)
            
            this.ws.onopen = () => {
                this.isConnected = true
                this.onStatusChange('connected')
            }
            
            this.ws.onmessage = (event) => {
                const data = JSON.parse(event.data)
                this.handleMessage(data) // 处理服务端消息
            }
            
            this.ws.onclose = () => {
                this.isConnected = false
                this.isReady = false
                this.onStatusChange('disconnected')
            }
        } catch (error) {
            this.onError('连接失败: ' + error.message)
        }
    }
    // 处理服务端消息
    handleMessage(data) {
        switch (data.type) {
            case 'ready':
                this.isReady = true
                this.onReady() // 服务就绪回调
                break
            case 'incremental_result':
                // 实时返回中间识别结果
                this.onPartial({ text: data.text, timestamp: data.timestamp })
                break
            case 'final_result':
                // 语音片段识别完成,返回最终结果
                this.onFinal({ text: data.text, timestamp: data.timestamp })
                break
            case 'error':
                this.onError(this.formatErrorMsg(data.message))
                break
        }
    }
    // 开始录音
    async startRecording() {
        if (!this.isConnected || !this.isReady) {
            this.onError('请先连接语音识别服务')
            return
        }
        
        try {
            // 获取麦克风权限
            this.audioStream = await navigator.mediaDevices.getUserMedia({
                audio: {
                    sampleRate: this.sampleRate,
                    channelCount: 1, // 单声道
                    echoCancellation: true, // 回声消除
                    noiseSuppression: true, // 降噪
                    autoGainControl: true // 自动增益
                }
            })
            
            // 创建音频上下文
            this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
                sampleRate: this.sampleRate
            })
            const audioSource = this.audioContext.createMediaStreamSource(this.audioStream)
            
            // 优先使用AudioWorklet,失败则降级
            try {
                await this.audioContext.audioWorklet.addModule('/audioPcmProcessor.js')
                this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-pcm-processor')
                this.workletNode.port.onmessage = (event) => {
                    if (event.data.type === 'pcmData' && this.ws?.readyState === WebSocket.OPEN) {
                        this.ws.send(event.data.data) // 发送PCM数据
                    }
                }
                audioSource.connect(this.workletNode)
            } catch (error) {
                this.createScriptProcessor(audioSource) // 降级方案
            }
            
            this.isRecording = true
        } catch (error) {
            this.onError('录音启动失败: ' + error.message)
        }
    }
    // ScriptProcessor降级方案(兼容旧浏览器)
    createScriptProcessor(audioSource) {
        const scriptProcessor = this.audioContext.createScriptProcessor(4096, 1, 1)
        scriptProcessor.onaudioprocess = (event) => {
            if (this.ws?.readyState === WebSocket.OPEN) {
                const inputData = event.inputBuffer.getChannelData(0)
                // 同上的PCM格式转换逻辑
                const pcmData = new Int16Array(inputData.length)
                for (let i = 0; i < inputData.length; i++) {
                    const sample = Math.max(-1, Math.min(1, inputData[i]))
                    pcmData[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff
                }
                this.ws.send(pcmData.buffer)
            }
        }
        audioSource.connect(scriptProcessor)
        scriptProcessor.connect(this.audioContext.destination)
    }
    // 停止录音
    stopRecording() {
        if (!this.isRecording) return
        
        // 断开音频节点,释放资源
        if (this.workletNode) this.workletNode.disconnect()
        if (this.scriptProcessor) this.scriptProcessor.disconnect()
        
        // 关闭音频流和上下文
        this.audioStream.getTracks().forEach(track => track.stop())
        this.audioContext.close()
        
        this.isRecording = false
    }
    // 格式化错误信息
    formatErrorMsg(message) {
        if (message.includes('缺少配置')) {
            return '后端缺少配置,请设置ARK_APP_ID和ARK_ACCESS_TOKEN'
        } else if (message.includes('连接失败')) {
            return '无法连接语音识别服务,请检查网络'
        }
        return message
    }
}

核心设计思路:

  • 采用事件回调解耦 UI 与核心逻辑,提高复用性
  • 双重音频处理方案,兼顾现代浏览器性能与旧浏览器兼容性
  • 完善的状态管理,避免无效操作(如未连接时启动录音)
  • 错误信息格式化,提升用户可理解性

3. UI 交互层:编辑器集成(NoteEditor.vue)

在 Vue 组件中集成 WangEditor 与语音识别核心逻辑,提供用户交互入口和结果展示:

3.1 模板结构设计

html 复制代码
<template>
    <div class="note-editor">
        <div class="editor-toolbar">
            <Toolbar :editor="editorRef" :defaultConfig="toolbarConfig" :mode="mode" />
        </div>
        
        <div class="editor-content">
            <Editor v-model="valueHtml" :defaultConfig="editorConfig" @onCreated="handleCreated" />
        </div>
        
        <div v-if="isSpeechRecording" class="speech-recording-indicator">
            <div class="recording-animation">
                <div class="pulse-ring"></div>
                <div class="microphone-icon">🎤</div>
            </div>
            <div class="recording-text">正在录音中...</div>
            <button @click="stopSpeechRecognition" class="stop-btn">⏹️ 停止录音</button>
            <div class="esc-hint">按 ESC 键停止</div>
        </div>
    </div>
</template>

3.2 核心逻辑实现

javascript 复制代码
<script setup>
import { ref, shallowRef, onMounted, onBeforeUnmount } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { SimpleSpeech } from '@/utils/simpleSpeech.js'
import { SpeechConfigChecker } from '@/utils/speechConfigChecker.js'
import { ElMessage } from 'element-plus'
// 编辑器实例(WangEditor要求用shallowRef)
const editorRef = shallowRef(null)
// 语音识别相关状态
const speechRecognition = ref(null)
const isSpeechRecording = ref(false)
const speechStatus = ref('disconnected')
// 工具栏配置(添加语音识别菜单)
const toolbarConfig = {
    excludeKeys: ['group-video'],
    insertKeys: {
        index: 0,
        keys: ['voiceReadingMenu'] // 自定义语音识别菜单
    }
}
// 编辑器创建完成回调
const handleCreated = (editor) => {
    editorRef.value = editor
}
// 初始化语音识别实例
const initSpeechRecognition = () => {
    if (speechRecognition.value) return
    speechRecognition.value = new SimpleSpeech({
        sampleRate: 16000,
        model: 'bigmodel',
        onReady: () => {
            speechStatus.value = 'ready'
        },
        onPartial: (result) => {
            // 实时插入中间识别结果
            insertSpeechText(result.text)
        },
        onFinal: (result) => {
            // 插入最终结果(可覆盖中间结果或追加)
            insertSpeechText(result.text)
        },
        onError: (error) => {
            ElMessage.error(error)
            // 自动运行配置检查,辅助排查问题
            runConfigCheck()
        }
    })
}
// 开始语音识别
const startSpeechRecognitionDirect = async () => {
    try {
        if (!speechRecognition.value) {
            initSpeechRecognition()
        }
        await speechRecognition.value.connect()
        
        // 等待服务就绪(最多3秒超时)
        let retryCount = 0
        while (retryCount < 30 && speechStatus.value !== 'ready') {
            await new Promise(resolve => setTimeout(resolve, 100))
            retryCount++
        }
        
        if (speechStatus.value !== 'ready') {
            throw new Error('语音识别服务连接超时')
        }
        
        await speechRecognition.value.startRecording()
        isSpeechRecording.value = true
        ElMessage.success('🎤 开始语音识别')
    } catch (error) {
        ElMessage.error('启动失败: ' + error.message)
    }
}
// 停止语音识别
const stopSpeechRecognition = () => {
    if (speechRecognition.value && isSpeechRecording.value) {
        speechRecognition.value.stopRecording()
        isSpeechRecording.value = false
        ElMessage.info('⏹️ 语音识别已停止')
    }
}
// 插入识别结果到编辑器
const insertSpeechText = (text) => {
    const editor = editorRef.value
    if (!editor || !text.trim()) return
    
    editor.focus() // 聚焦编辑器
    editor.insertText(text) // 插入文本
}
// 全局快捷键监听(ESC停止录音)
const handleGlobalKeydown = (event) => {
    if (event.key === 'Escape' && isSpeechRecording.value) {
        event.preventDefault()
        stopSpeechRecognition()
    }
}
// 配置检查(诊断环境和服务问题)
const runConfigCheck = async () => {
    const checker = new SpeechConfigChecker()
    const results = await checker.runFullCheck()
    const report = checker.generateDiagnosticReport(results)
    console.log('语音识别配置诊断报告:', report)
}
// 生命周期钩子:绑定事件
onMounted(() => {
    // 绑定语音识别菜单点击事件
    document.addEventListener('voiceReadingClick', (e) => {
        const action = e.detail.action
        if (action === 'start') {
            startSpeechRecognitionDirect()
        } else if (action === 'stop') {
            stopSpeechRecognition()
        }
    })
    
    // 绑定全局快捷键
    document.addEventListener('keydown', handleGlobalKeydown)
})
// 生命周期钩子:释放资源
onBeforeUnmount(() => {
    // 清理事件监听
    document.removeEventListener('voiceReadingClick', handleVoiceReadingClick)
    document.removeEventListener('keydown', handleGlobalKeydown)
    
    // 销毁语音识别实例
    if (speechRecognition.value) {
        speechRecognition.value.disconnect()
        speechRecognition.value = null
    }
    
    // 销毁编辑器
    editorRef.value?.destroy()
})
</script>

3.3 样式优化(提升用户体验)

scss 复制代码
<style lang="scss" scoped>
// 录音状态指示器样式
.speech-recording-indicator {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 2000;
    background: rgba(255, 255, 255, 0.95);
    border-radius: 20px;
    padding: 30px;
    text-align: center;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
    
    .recording-animation {
        position: relative;
        width: 80px;
        height: 80px;
        margin: 0 auto 20px;
        
        .pulse-ring {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 60px;
            height: 60px;
            border: 3px solid #1976d2;
            border-radius: 50%;
            opacity: 0;
            animation: pulseRing 2s ease-out infinite;
        }
        
        .microphone-icon {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            font-size: 32px;
            z-index: 10;
        }
    }
    
    .stop-btn {
        background: #f44336;
        color: white;
        border: none;
        border-radius: 25px;
        padding: 12px 24px;
        font-size: 16px;
        cursor: pointer;
        transition: all 0.3s ease;
    }
}
// 脉冲动画
@keyframes pulseRing {
    0% {
        width: 60px;
        height: 60px;
        opacity: 1;
    }
    100% {
        width: 120px;
        height: 120px;
        opacity: 0;
    }
}
</style>

UI 层核心亮点:

  • 录音状态可视化:通过脉冲动画直观展示录音中状态
  • 快捷键支持:ESC 键快速停止录音,提升操作便捷性
  • 结果实时插入:识别结果即时插入编辑器,无需手动复制
  • 资源自动释放:组件卸载时清理事件监听和实例,避免内存泄漏

4. 诊断工具层:配置检查(speechConfigChecker.js)

为了解决用户环境差异导致的功能异常,提供配置检查工具,自动诊断问题:

javascript 复制代码
export class SpeechConfigChecker {
    constructor() {
        this.baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
    }
    // 检查后端服务状态
    async checkBackendService() {
        try {
            const response = await fetch(`${this.baseUrl}/health`)
            return {
                success: response.ok,
                message: response.ok ? '后端服务正常' : `状态码: ${response.status}`
            }
        } catch (error) {
            return { success: false, message: '无法连接后端: ' + error.message }
        }
    }
    // 检查语音识别服务可用性
    async checkSpeechService() {
        try {
            const response = await fetch(`${this.baseUrl}/api/v1/ai/speech-recognition`)
            return {
                success: response.ok,
                message: response.ok ? '语音服务可用' : await response.text()
            }
        } catch (error) {
            return { success: false, message: '语音服务检查失败: ' + error.message }
        }
    }
    // 测试WebSocket连接
    async testWebSocketConnection() {
        return new Promise((resolve) => {
            const wsUrl = this.baseUrl.replace('http', 'ws')
            const ws = new WebSocket(`${wsUrl}/api/v1/ai/speech-recognition?sampleRate=16000`)
            const timeout = setTimeout(() => {
                ws.close()
                resolve({ success: false, message: 'WebSocket连接超时' })
            }, 5000)
            ws.onopen = () => {
                clearTimeout(timeout)
                ws.close()
                resolve({ success: true, message: 'WebSocket连接成功' })
            }
            ws.onerror = () => {
                clearTimeout(timeout)
                resolve({ success: false, message: 'WebSocket连接错误' })
            }
        })
    }
    // 检查浏览器兼容性
    checkBrowserCompatibility() {
        const features = [
            { name: 'WebSocket', check: () => typeof WebSocket !== 'undefined' },
            { name: 'AudioContext', check: () => typeof AudioContext !== 'undefined' },
            { name: 'getUserMedia', check: () => navigator.mediaDevices?.getUserMedia },
            { name: 'AudioWorklet', check: () => typeof AudioWorkletNode !== 'undefined' }
        ]
        return features.map(item => ({
            feature: item.name,
            supported: item.check(),
            message: item.check() ? '支持' : '不支持'
        }))
    }
    // 生成诊断报告
    generateDiagnosticReport(results) {
        const report = [
            '=== 语音识别配置诊断报告 ===',
            `后端服务: ${results.backend.success ? '✅ 正常' : '❌ 异常'} - ${results.backend.message}`,
            `语音服务: ${results.speechService.success ? '✅ 正常' : '❌ 异常'} - ${results.speechService.message}`,
            `WebSocket: ${results.webSocket.success ? '✅ 正常' : '❌ 异常'} - ${results.webSocket.message}`,
            '浏览器兼容性:'
        ]
        results.browser.forEach(item => {
            report.push(`  ${item.feature}: ${item.supported ? '✅' : '❌'} ${item.message}`)
        })
        return report.join('\n')
    }
}

核心价值:

  • 自动化诊断环境问题,减少用户反馈与排查成本
  • 覆盖服务可用性、网络连接、浏览器兼容性等关键检查点
  • 生成结构化报告,便于开发者定位问题

关键技术难点与解决方案

1. 音频格式一致性问题

  • 问题:不同浏览器采集的音频参数差异,导致识别服务无法解析
  • 解决方案
    • 强制指定采样率为 16kHz、单声道
    • 启用浏览器内置的回声消除、降噪功能
    • 统一转换为 16 位 PCM 格式,确保数据标准化

2. 实时性与性能平衡

  • 问题:音频数据传输过频繁导致网络开销大,过慢则识别延迟高
  • 解决方案
    • 缓冲区大小设置为 4096 字节(16kHz 单声道约 256ms 数据)
    • 使用 Transferable 对象避免数据复制,提升传输效率
    • 中间结果实时返回,最终结果确认,平衡实时性与准确性

3. 浏览器兼容性问题

  • 问题:部分旧浏览器不支持 AudioWorklet 等现代 API
  • 解决方案
    • 实现 AudioWorklet + ScriptProcessor 双重方案
    • 提前检查浏览器特性,自动降级
    • 提供清晰的兼容性提示,引导用户升级浏览器

4. 资源泄漏风险

  • 问题:录音过程中组件卸载或异常退出,导致音频流、WebSocket 未关闭
  • 解决方案
    • 在 Vue 生命周期钩子中统一释放资源
    • 录音状态与组件生命周期绑定,卸载时强制停止
    • WebSocket 连接关闭时清理相关状态

功能测试与优化建议

测试场景覆盖

  • 浏览器兼容性测试:Chrome、Firefox、Edge 等主流浏览器
  • 网络环境测试:弱网、断网场景下的错误处理
  • 权限测试:麦克风权限拒绝、授予后的状态切换
  • 异常场景测试:服务不可用、配置错误等情况

优化方向

  1. 音频预处理:添加音量检测,过滤静音片段,减少无效数据传输
  2. 识别结果优化:支持结果编辑、纠错功能
  3. 多语言支持:根据用户配置切换识别语言
  4. 离线识别:集成 Web Speech API 作为离线降级方案
  5. 性能监控:统计识别延迟、成功率等指标,优化用户体验

总结

这篇文章基于 Vue 3.5 和 WangEditor 实现了一套完整的笔记编辑器语音识别功能,通过分层设计实现了模块解耦,兼顾了实时性、兼容性和稳定性。核心亮点包括:

  • 高效的音频格式转换方案,确保识别服务兼容性
  • 完善的状态管理与错误处理,提升用户体验
  • 自动化配置诊断工具,降低问题排查成本
  • 可扩展的架构设计,便于后续功能迭代

该方案不仅适用于笔记编辑器,也可迁移到聊天、文档协作等其他需要语音输入的场景。通过合理的技术选型和架构设计,能够有效降低语音识别功能的集成难度,为用户提供便捷、高效的输入体验。

相关推荐
非凡ghost3 小时前
BiliLive-tools(B站录播一站式工具) 中文绿色版
前端·javascript·后端
yi碗汤园3 小时前
【一文了解】八大排序-冒泡排序、选择排序
开发语言·前端·算法·unity·c#·1024程序员节
非凡ghost3 小时前
bkViewer小巧精悍数码照片浏览器 中文绿色版
前端·javascript·后端
三小河3 小时前
JS 自定义事件:从 CustomEvent 到 dispatchEvent
前端
西洼工作室3 小时前
前端监控:错误捕获与行为日志全解析
前端·javascript
huangql5203 小时前
JavaScript数据结构实战指南:从业务场景到性能优化
javascript·数据结构·性能优化
Glommer4 小时前
某易易盾验证码处理思路(下)
javascript·逆向
砺能4 小时前
window.postMessage与window.dispatchEvent
前端·javascript
雪中何以赠君别4 小时前
【框架】CLI 工具笔记
javascript·node.js