diff --git a/src/views/inspection/cockpit/components/ManualTakeover/VoiceConversation.vue b/src/views/inspection/cockpit/components/ManualTakeover/VoiceConversation.vue index 5299251..036ebe1 100644 --- a/src/views/inspection/cockpit/components/ManualTakeover/VoiceConversation.vue +++ b/src/views/inspection/cockpit/components/ManualTakeover/VoiceConversation.vue @@ -65,10 +65,7 @@ - - - - + @@ -76,268 +73,227 @@ import { computed, onBeforeUnmount, ref } from 'vue' import { ElMessage, ElNotification } from 'element-plus' -// 请替换为实际部署的信令服务器地址。 -const SIGNALING_SERVER_URL = 'ws://192.168.1.222:13080/ws/signaling' -// 后端创建语音会话所需的固定终端、音频设备和操作员标识。 +// WebSocket 只承载控制 JSON 和二进制 PCM,不再使用 WebRTC、SDP 或 ICE。 +const AUDIO_SERVER_URL = 'ws://192.168.1.222:13080/ws/audio' const TERMINAL_ID = 'c2f8a06826baf03843925c1a2a13bcfd' const DEVICE_ID = '1234' const OPERATOR_ID = 'user001' -const RTC_CONFIGURATION = { - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] -} -// 音频元素及通话核心资源:分别保存本地/远端媒体流、WebRTC 连接和信令连接。 -const localAudio = ref(null) -const remoteAudio = ref(null) -const localStream = ref(null) -const remoteStream = ref(null) -const peerConnection = ref(null) -const webSocket = ref(null) +// 后端约定的 PCM 格式:S16LE、48kHz、单声道、10ms。 +const PCM_SAMPLE_RATE = 48000 +const PCM_FRAME_SAMPLES = 480 +const PCM_FRAME_BYTES = 960 +const JOIN_TIMEOUT = 10000 -// 页面交互状态:用于控制按钮、状态文案、动画和错误提示。 +// 页面交互状态。 const isCalling = ref(false) const isStarting = ref(false) const isConnected = ref(false) const isMuted = ref(false) const connectionStatus = ref('idle') +const errorMessage = ref('') -// 音量分析和异步通话过程使用的非响应式资源。 +// 本次通话占用的浏览器资源。 +const localStream = ref(null) +const webSocket = ref(null) let audioContext = null -let analyser = null -let volumeAnimationFrame = null -// 后端返回 joined 后生成的业务会话 ID,后续 Offer、Candidate、心跳和停止消息都必须携带。 +let mediaStreamSource = null +let audioWorkletNode = null let signalingSessionId = '' -// 远端 SDP 设置完成前收到的 ICE 候选会暂存在这里,避免 addIceCandidate 调用失败。 -let pendingIceCandidates = [] -// 每次开始或清理通话都会更新会话编号,用于让旧会话的异步回调自动失效。 +let incomingRemainder = new Uint8Array(0) +let pendingPlaybackFrames = [] let callSessionId = 0 -// 将内部连接状态转换为页面展示所需的中文状态和引导文案。 const statusMeta = computed(() => { const statusMap = { idle: { label: '未连接', description: '点击按钮建立语音通道' }, - connecting: { label: '连接中', description: '正在建立安全语音通道,请稍候' }, - connected: { label: '通话中', description: '语音通道已建立,可以开始对讲' }, + connecting: { label: '连接中', description: '正在建立音频通道,请稍候' }, + connected: { label: '通话中', description: 'PCM 音频通道已建立,可以开始对讲' }, error: { label: '未连接', description: '连接出现异常,请检查后重试' } } return statusMap[connectionStatus.value] || statusMap.idle }) /** - * 统一处理通话过程中的异常。 - * 该方法会记录错误、更新页面状态,并使用 Element Plus 通知用户。 - * @param {string} message 展示给用户的错误信息 - * @param {Error} [error] 原始异常对象,主要用于控制台排查 + * 统一显示通话异常。 + * @param {string} message 用户可读的错误说明 + * @param {Error} [error] 原始异常 */ function showError(message, error) { - // 将完整错误输出到控制台,方便开发环境定位具体异常。 console.error(message, error || '') - // 将内部状态切换为错误状态,驱动状态灯和说明文字更新。 + errorMessage.value = message connectionStatus.value = 'error' - // 明确标记当前已经不处于 WebRTC 已连接状态。 isConnected.value = false - // 使用全局通知主动提醒用户本次通话发生异常。 ElNotification.error({ title: '语音对讲错误', message }) } /** - * 通过已经连接的 WebSocket 发送一条信令消息。 - * Offer、Answer 和 ICE 候选都通过此方法序列化为 JSON 后发送。 - * @param {Object} message 符合约定格式的信令消息 - * @throws {Error} WebSocket 未处于 OPEN 状态时抛出异常 + * 发送 WebSocket 控制消息。 + * 此方法只允许发送 JSON;PCM 音频由 sendPcmFrame 单独以二进制发送。 + * @param {Object} message 控制消息 */ -function sendSignalingMessage(message) { - // 读取当前 WebSocket 实例,避免后续多次访问响应式引用。 +function sendControlMessage(message) { const socket = webSocket.value - // 只有 WebSocket 完全打开后才允许发送信令数据。 if (!socket || socket.readyState !== WebSocket.OPEN) { - // 抛出异常交给上层统一显示和处理,避免静默丢失 Offer 或 ICE。 - throw new Error('信令服务器尚未连接') + throw new Error('音频 WebSocket 尚未连接') } - // 将信令对象序列化为 JSON 字符串后发送给服务器。 socket.send(JSON.stringify(message)) } /** - * 创建并初始化 RTCPeerConnection。 - * 作用包括:接收并播放远端音频、发送 ICE 候选, - * 以及监听连接状态来同步页面上的“连接中/通话中/未连接”状态。 - * @param {number} sessionId 本次通话的会话编号,用于忽略过期回调 - * @returns {RTCPeerConnection} 初始化完成的 WebRTC 连接实例 + * 将 Float32 PCM 转换成明确的小端 Int16 PCM。 + * 使用 DataView 的 littleEndian=true,避免依赖运行平台的原生字节序。 + * @param {Float32Array} float32 取值范围为 -1 到 1 的 PCM + * @returns {ArrayBuffer} PCM_S16LE 数据 */ -function setupPeerConnection(sessionId) { - // 使用预设的 STUN 配置创建本次通话的 WebRTC 点对点连接。 - const pc = new RTCPeerConnection(RTC_CONFIGURATION) - // 保存连接实例,供信令处理、挂断和组件卸载时使用。 - peerConnection.value = pc - // 创建独立的远端媒体流,用于汇总对端发送过来的音频轨道。 - remoteStream.value = new MediaStream() +function floatToInt16Pcm(float32) { + const buffer = new ArrayBuffer(float32.length * 2) + const view = new DataView(buffer) - // 将远端媒体流绑定到远端 audio 元素,轨道到达后即可播放。 - if (remoteAudio.value) remoteAudio.value.srcObject = remoteStream.value - - // 对端音频轨道到达后,将轨道加入远端媒体流并交给 audio 元素播放。 - pc.ontrack = (event) => { - // 如果回调属于已经结束的旧会话,则直接忽略,避免污染新通话。 - if (sessionId !== callSessionId) return - // 读取事件携带的第一个远端媒体流,并逐条处理其中的轨道。 - event.streams[0].getTracks().forEach((track) => { - // 根据轨道 ID 判断该轨道是否已经加入,防止重复添加。 - const exists = remoteStream.value.getTracks().some((item) => item.id === track.id) - // 仅把尚未存在的远端轨道加入用于播放的媒体流。 - if (!exists) remoteStream.value.addTrack(track) - }) - // 主动调用播放,以兼容不会仅凭 autoplay 自动启动的浏览器。 - remoteAudio.value?.play().catch(() => { - // 自动播放被浏览器策略阻止时,提示用户通过交互解除限制。 - ElMessage.warning('浏览器阻止了远端音频自动播放,请点击页面后重试') - }) + for (let index = 0; index < float32.length; index += 1) { + const sample = Math.max(-1, Math.min(1, float32[index])) + const int16 = sample < 0 ? Math.round(sample * 0x8000) : Math.round(sample * 0x7fff) + view.setInt16(index * 2, int16, true) } - // 浏览器发现新的本地 ICE 候选时,立即通过信令服务器发送给对端。 - pc.onicecandidate = (event) => { - // 空候选表示本轮收集结束;旧会话产生的候选也无需发送。 - if (!event.candidate || sessionId !== callSessionId) return - try { - // 按约定协议发送候选地址、媒体行索引和媒体标识。 - sendSignalingMessage({ - type: 'candidate', - sessionId: signalingSessionId, - candidate: { - candidate: event.candidate.candidate, - sdpMid: event.candidate.sdpMid, - sdpMLineIndex: event.candidate.sdpMLineIndex - } - }) - } catch (error) { - // 信令连接异常时进入统一错误处理流程。 - showError('ICE 候选发送失败', error) - } - } - - // WebRTC 连接状态变化时更新 UI,并对连接失败或断开进行统一报错。 - pc.onconnectionstatechange = () => { - // 忽略已经被清理的旧会话触发的状态变化。 - if (sessionId !== callSessionId) return - // connected 表示 ICE、DTLS 和媒体通道均已成功建立。 - if (pc.connectionState === 'connected') { - // 标记 WebRTC 已连接,供业务逻辑判断当前通话状态。 - isConnected.value = true - // 切换为通话中状态,启用页面声波和光晕动画。 - connectionStatus.value = 'connected' - // 向用户提示语音通道已经成功建立。 - ElMessage.success('实时语音连接已建立') - // failed 或 disconnected 表示媒体通道已经不可用。 - } else if (['failed', 'disconnected'].includes(pc.connectionState)) { - // 将连接中断交给统一错误处理逻辑更新页面并提示用户。 - showError('WebRTC 连接失败或已断开') - } - } - - // 返回实例,供 startCall 创建 Offer 和设置本地描述。 - return pc + return buffer } /** - * 将暂存的远端 ICE 候选依次加入 WebRTC 连接。 - * 必须在远端 SDP 设置完成后调用,否则部分浏览器会拒绝添加候选。 - * @param {RTCPeerConnection} pc 当前 WebRTC 连接 - * @returns {Promise} + * 将后端返回的小端 Int16 PCM 转成 AudioWorklet 使用的 Float32 PCM。 + * @param {ArrayBuffer} buffer 一帧 960 字节 PCM_S16LE + * @returns {Float32Array} 480 个浮点采样 */ -async function flushPendingIceCandidates(pc) { - // 一次性取出并清空候选队列,防止同一候选被重复添加。 - const candidates = pendingIceCandidates.splice(0) - // 按接收顺序逐一处理暂存的远端候选。 - for (const candidate of candidates) { - // 将候选加入连接,帮助浏览器尝试对应的网络传输路径。 - await pc.addIceCandidate(candidate) +function int16ToFloat32(buffer) { + const view = new DataView(buffer) + const samples = new Float32Array(buffer.byteLength / 2) + + for (let index = 0; index < samples.length; index += 1) { + samples[index] = view.getInt16(index * 2, true) / 32768 } + + return samples } /** - * 处理信令服务器发来的消息。 - * - answer:完成发起端的远端描述设置; - * - candidate:立即添加候选,或等待远端 SDP 就绪后再添加。 - * @param {MessageEvent} event WebSocket 消息事件 - * @param {number} sessionId 本次通话的会话编号 - * @returns {Promise} + * 发送一帧浏览器麦克风 PCM。 + * 每次只发送 480 samples,即 10ms、960 bytes。 + * @param {Float32Array} samples AudioWorklet 采集的一帧数据 */ -async function handleSignalingMessage(event, sessionId) { - // 旧会话的 WebSocket 消息不应影响当前正在进行的新会话。 - if (sessionId !== callSessionId) return - - // 先声明解析后的消息变量,供后续不同信令类型共用。 - let message - try { - // 将服务器发来的 JSON 字符串转换成可处理的信令对象。 - message = JSON.parse(event.data) - } catch (error) { - // 非法 JSON 无法参与信令协商,向用户报告协议数据异常。 - showError('收到的信令消息不是有效 JSON', error) - // 解析失败后立即结束本次消息处理。 +function sendPcmFrame(samples) { + const socket = webSocket.value + if ( + !isCalling.value || + !signalingSessionId || + !socket || + socket.readyState !== WebSocket.OPEN + ) { return } - // 后端消息携带其他会话 ID 时直接忽略,防止串话或旧会话消息污染当前连接。 - if (message.sessionId && signalingSessionId && message.sessionId !== signalingSessionId) return + if (samples.length !== PCM_FRAME_SAMPLES) { + console.warn('忽略长度异常的麦克风 PCM 帧:', samples.length) + return + } - // 获取当前 WebRTC 实例,后续 SDP 和 ICE 都需要写入该实例。 - const pc = peerConnection.value - // 通话可能已经被清理,没有连接实例时直接忽略消息。 - if (!pc) return + const pcmBuffer = floatToInt16Pcm(samples) + socket.send(pcmBuffer) +} - try { - // 当前协议固定由浏览器发送 Offer,后端只需返回对应的 Answer。 - if (message.type === 'answer') { - // 设置远端 Answer,完成 SDP Offer/Answer 协商闭环。 - await pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: message.sdp })) - // SDP 就绪后添加在 Answer 到达前收到的 ICE 候选。 - await flushPendingIceCandidates(pc) - // ICE 候选用于告知本端对端可能可用的网络地址和传输路径。 - } else if (message.type === 'candidate') { - // 使用协议字段还原浏览器可识别的 RTCIceCandidate 对象。 - const candidate = new RTCIceCandidate({ - candidate: message.candidate?.candidate, - sdpMLineIndex: message.candidate?.sdpMLineIndex, - sdpMid: message.candidate?.sdpMid - }) - // 已有远端描述时可以立即把候选加入 WebRTC 连接。 - if (pc.remoteDescription) await pc.addIceCandidate(candidate) - // 远端描述尚未到达时先缓存候选,等待 SDP 设置完成后处理。 - else pendingIceCandidates.push(candidate) - // 后端主动报告会话错误时停止继续协商并显示错误原因。 - } else if (message.type === 'error') { - showError(message.message || '语音会话发生异常') - // 后端确认会话停止后同步恢复页面状态。 - } else if (message.type === 'stopped' || message.type === 'stop') { - cleanupResources(false) - connectionStatus.value = 'idle' +/** + * 将机器人返回的二进制数据按 960 字节切帧后加入播放队列。 + * 支持后端将半帧或多帧放在一个 WebSocket 消息中的情况。 + * @param {ArrayBuffer} buffer WebSocket 二进制数据 + */ +function enqueueIncomingPcm(buffer) { + const received = new Uint8Array(buffer) + const merged = new Uint8Array(incomingRemainder.length + received.length) + merged.set(incomingRemainder) + merged.set(received, incomingRemainder.length) + + let offset = 0 + while (merged.length - offset >= PCM_FRAME_BYTES) { + const frameBytes = merged.slice(offset, offset + PCM_FRAME_BYTES) + const floatFrame = int16ToFloat32(frameBytes.buffer) + + if (audioWorkletNode) { + audioWorkletNode.port.postMessage( + { type: 'playback', samples: floatFrame.buffer }, + [floatFrame.buffer] + ) + } else { + // AudioWorklet 尚未初始化时暂存首批机器人音频,最多保留约 1 秒。 + if (pendingPlaybackFrames.length >= 100) pendingPlaybackFrames.shift() + pendingPlaybackFrames.push(floatFrame) } - } catch (error) { - // 捕获 SDP 设置、Answer 创建或候选添加过程中的所有异常。 - showError(`处理 ${message.type || '未知'} 信令失败`, error) + + offset += PCM_FRAME_BYTES + } + + incomingRemainder = merged.slice(offset) +} + +/** + * 处理后端文本控制消息。 + * @param {Object} message 已解析的 JSON 消息 + * @param {number} sessionToken 前端本地会话编号 + */ +function handleControlMessage(message, sessionToken) { + if (sessionToken !== callSessionId) return + if ( + message.sessionId && + signalingSessionId && + message.sessionId !== signalingSessionId + ) { + return + } + + if (message.type === 'error') { + cleanupResources(false) + showError(message.message || '音频服务返回错误') + } else if (message.type === 'stopped' || message.type === 'stop') { + cleanupResources(false) + connectionStatus.value = 'idle' } } /** - * 建立与信令服务器的 WebSocket 连接并注册消息、错误和关闭事件。 - * WebSocket 只负责交换连接信息,实际语音数据建立连接后由 WebRTC 点对点传输。 - * @param {number} sessionId 本次通话的会话编号 - * @returns {Promise} 连接成功后返回 WebSocket 实例 + * 连接音频 WebSocket,发送 join 并等待后端返回 joined。 + * 文本帧作为控制 JSON 处理,二进制帧作为机器人 PCM 处理。 + * @param {number} sessionToken 前端本地会话编号 + * @returns {Promise} joined 消息 */ -function connectSignalingServer(sessionId) { - // Promise 只有在后端返回 joined 后才完成,确保 Offer 一定携带有效 sessionId。 +function connectAudioServer(sessionToken) { return new Promise((resolve, reject) => { - // 直接连接后端提供的语音信令地址。 - const socket = new WebSocket(SIGNALING_SERVER_URL) - // 保存实例,供发送信令以及通话结束时关闭连接。 + const socket = new WebSocket(AUDIO_SERVER_URL) + socket.binaryType = 'arraybuffer' webSocket.value = socket - // 标记 joined 是否已经返回,用于区分建连失败和通话中断线。 - let hasJoined = false - // WebSocket 建立后必须先发送 join,由后端创建会话并打开机器人 gRPC 音频流。 + let joined = false + let settled = false + const timeout = window.setTimeout(() => { + if (!settled) { + settled = true + reject(new Error('等待音频服务 joined 响应超时')) + } + }, JOIN_TIMEOUT) + + const resolveJoin = (message) => { + if (settled) return + settled = true + window.clearTimeout(timeout) + resolve(message) + } + + const rejectJoin = (error) => { + if (settled) return + settled = true + window.clearTimeout(timeout) + reject(error) + } + socket.onopen = () => { - sendSignalingMessage({ + sendControlMessage({ type: 'join', terminalId: TERMINAL_ID, deviceId: DEVICE_ID, @@ -345,303 +301,319 @@ function connectSignalingServer(sessionId) { }) } - // joined 消息在此处完成启动流程,其他消息继续交给 WebRTC 信令处理方法。 - socket.onmessage = (event) => { - let message - try { - // 先解析一次消息,以识别建立后端会话所需的 joined 响应。 - message = JSON.parse(event.data) - } catch (error) { - // 非法消息交给统一处理方法展示协议错误。 - handleSignalingMessage(event, sessionId) - return - } + socket.onmessage = async (event) => { + if (sessionToken !== callSessionId) return - // 后端成功创建会话后保存业务 sessionId,并开始前端心跳。 - if (message.type === 'joined') { - // 缺少 sessionId 时无法发送后续信令,因此直接判定加入失败。 - if (!message.sessionId) { - reject(new Error('后端 joined 响应缺少 sessionId')) + if (typeof event.data === 'string') { + let message + try { + message = JSON.parse(event.data) + } catch (error) { + showError('收到的控制消息不是有效 JSON', error) return } - // 忽略旧通话延迟到达的 joined 响应。 - if (sessionId !== callSessionId) return - // 保存后端会话 ID,供本次通话的全部后续消息使用。 - signalingSessionId = message.sessionId - // 标记后端会话已经创建成功。 - hasJoined = true - // 将 joined 数据返回 startCall,允许继续创建并发送 Offer。 - resolve(message) + + if (message.type === 'joined') { + if (!message.sessionId) { + rejectJoin(new Error('joined 响应缺少 sessionId')) + return + } + signalingSessionId = message.sessionId + joined = true + resolveJoin(message) + return + } + + if (message.type === 'error' && !joined) { + rejectJoin(new Error(message.message || '后端创建音频会话失败')) + return + } + + handleControlMessage(message, sessionToken) return } - // join 阶段收到后端错误时立即终止等待,让 startCall 进入异常清理流程。 - if (message.type === 'error' && !hasJoined) { - reject(new Error(message.message || '后端创建语音会话失败')) - return + try { + const buffer = event.data instanceof Blob + ? await event.data.arrayBuffer() + : event.data + enqueueIncomingPcm(buffer) + } catch (error) { + showError('机器人 PCM 音频处理失败', error) } - - // joined 之外的 Answer、Candidate、错误等消息按 WebRTC 协议处理。 - handleSignalingMessage(event, sessionId) } - // WebSocket 底层异常时让尚未完成的启动流程失败。 socket.onerror = () => { - if (!hasJoined) reject(new Error('WebSocket 连接失败')) + if (!joined) rejectJoin(new Error('音频 WebSocket 连接失败')) } - // 监听信令连接关闭,识别加入过程中失败或通话期间意外断线。 socket.onclose = () => { - if (!hasJoined) { - reject(new Error('信令服务器在会话建立前已断开')) - } else if (sessionId === callSessionId && isCalling.value) { - showError('信令服务器连接已断开') + if (!joined) { + rejectJoin(new Error('音频服务在会话建立前已断开')) + } else if ( + sessionToken === callSessionId && + (isCalling.value || isStarting.value) + ) { + cleanupResources(false) + showError('音频 WebSocket 已断开') } } }) } /** - * 创建 Web Audio 音频分析器并持续读取麦克风频域数据。 - * 当前用于保留实时音量分析能力,后续可将分析结果绑定到声波高度或音量条。 - * @param {MediaStream} stream 本地麦克风媒体流 + * 返回单文件组件内使用的 AudioWorklet 源码。 + * Worklet 同时完成麦克风 480-sample 切帧和机器人 PCM 连续播放。 + * 播放队列预缓冲 3 帧(约 30ms),降低网络抖动造成的爆音和卡顿。 */ -function startVolumeMeter(stream) { - // 优先使用标准 AudioContext,并兼容旧版 Safari 的前缀实现。 - const AudioContextClass = window.AudioContext || window.webkitAudioContext - // 当前浏览器不支持 Web Audio API 时跳过分析,不影响正常通话。 - if (!AudioContextClass) return - - // 创建音频处理上下文,管理后续分析节点。 - audioContext = new AudioContextClass() - // 创建频域分析器,用来读取麦克风的实时强度数据。 - analyser = audioContext.createAnalyser() - // 设置 FFT 采样窗口;数值越小响应越快且计算开销越低。 - analyser.fftSize = 256 - // 将麦克风媒体流转换为 Web Audio 节点并连接到分析器。 - audioContext.createMediaStreamSource(stream).connect(analyser) - // 根据分析器频率桶数量创建复用的数据缓冲区。 - const samples = new Uint8Array(analyser.frequencyBinCount) - - // 定义逐帧读取音量数据的内部循环。 - const updateVolume = () => { - // 将当前频域强度写入 samples,后续可据此计算平均音量。 - analyser.getByteFrequencyData(samples) - // 保留分析循环,为声波动画接入实时音量数据预留能力。 - // 请求浏览器在下一次绘制前继续执行分析循环。 - volumeAnimationFrame = requestAnimationFrame(updateVolume) - } - // 立即启动第一次分析,后续由 requestAnimationFrame 持续调度。 - updateVolume() +function createAudioWorkletSource() { + return [ + 'class PcmDuplexProcessor extends AudioWorkletProcessor {', + ' constructor() {', + ' super();', + ' this.captureFrame = new Float32Array(480);', + ' this.captureOffset = 0;', + ' this.playbackQueue = [];', + ' this.playbackFrame = null;', + ' this.playbackOffset = 0;', + ' this.playing = false;', + ' this.port.onmessage = (event) => {', + " if (event.data && event.data.type === 'playback') {", + ' if (this.playbackQueue.length >= 100) this.playbackQueue.shift();', + ' this.playbackQueue.push(new Float32Array(event.data.samples));', + ' }', + ' };', + ' }', + ' capture(input) {', + ' if (!input) return;', + ' for (let index = 0; index < input.length; index += 1) {', + ' this.captureFrame[this.captureOffset++] = input[index];', + ' if (this.captureOffset === 480) {', + ' const frame = this.captureFrame.slice();', + " this.port.postMessage({ type: 'capture', samples: frame.buffer }, [frame.buffer]);", + ' this.captureOffset = 0;', + ' }', + ' }', + ' }', + ' render(output) {', + ' output.fill(0);', + ' if (!this.playing && this.playbackQueue.length >= 3) this.playing = true;', + ' if (!this.playing) return;', + ' for (let index = 0; index < output.length; index += 1) {', + ' if (!this.playbackFrame || this.playbackOffset >= this.playbackFrame.length) {', + ' this.playbackFrame = this.playbackQueue.shift() || null;', + ' this.playbackOffset = 0;', + ' if (!this.playbackFrame) {', + ' this.playing = false;', + ' return;', + ' }', + ' }', + ' output[index] = this.playbackFrame[this.playbackOffset++];', + ' }', + ' }', + ' process(inputs, outputs) {', + ' this.capture(inputs[0] && inputs[0][0]);', + ' this.render(outputs[0][0]);', + ' return true;', + ' }', + '}', + "registerProcessor('pcm-duplex-processor', PcmDuplexProcessor);" + ].join('\n') } /** - * 开始一次实时语音通话。 - * 完整流程:防止重复启动 → 请求麦克风权限 → 创建 WebRTC 连接 → - * 连接信令服务器 → 创建本地 Offer → 发送 Offer 等待对端应答。 - * 任一步骤失败都会显示错误并释放本次已创建的所有资源。 - * @returns {Promise} + * 创建 48kHz AudioContext 和双向 AudioWorklet。 + * 麦克风只进入 Worklet 采集端,不直接回放;Worklet 输出只播放机器人音频。 + * @param {MediaStream} stream 麦克风媒体流 */ -async function startCall() { - // 已在通话、正在启动或已有连接时禁止重复创建通话资源。 - if (isCalling.value || isStarting.value || peerConnection.value) return +async function setupAudioPipeline(stream) { + const AudioContextClass = window.AudioContext || window.webkitAudioContext + if (!AudioContextClass || !window.AudioWorkletNode) { + throw new Error('当前浏览器不支持 AudioWorklet') + } - // 生成本次会话唯一编号,使之前异步任务的结果自动失效。 - const sessionId = ++callSessionId - // 显示按钮加载状态,防止用户在初始化期间重复点击。 - isStarting.value = true - // 立即将 UI 更新为连接中状态。 - connectionStatus.value = 'connecting' + audioContext = new AudioContextClass({ + sampleRate: PCM_SAMPLE_RATE, + latencyHint: 'interactive' + }) + + if (audioContext.sampleRate !== PCM_SAMPLE_RATE) { + throw new Error('浏览器无法创建 48000Hz AudioContext') + } + + const workletSource = createAudioWorkletSource() + const workletUrl = URL.createObjectURL( + new Blob([workletSource], { type: 'application/javascript' }) + ) try { - // 第一步连接信令服务器、发送 join,并等待后端返回 joined 和业务 sessionId。 - await connectSignalingServer(sessionId) - // joined 成功后创建 WebRTC 连接,用于承载后续双向音频轨道。 - const pc = setupPeerConnection(sessionId) + await audioContext.audioWorklet.addModule(workletUrl) + } finally { + URL.revokeObjectURL(workletUrl) + } + + audioWorkletNode = new AudioWorkletNode( + audioContext, + 'pcm-duplex-processor', + { + numberOfInputs: 1, + numberOfOutputs: 1, + outputChannelCount: [1], + channelCount: 1, + channelCountMode: 'explicit' + } + ) + + audioWorkletNode.port.onmessage = (event) => { + if (event.data?.type !== 'capture') return + sendPcmFrame(new Float32Array(event.data.samples)) + } + + // 将 AudioWorklet 就绪前暂存的机器人音频依次送入播放队列。 + pendingPlaybackFrames.forEach((frame) => { + audioWorkletNode.port.postMessage( + { type: 'playback', samples: frame.buffer }, + [frame.buffer] + ) + }) + pendingPlaybackFrames = [] + + mediaStreamSource = audioContext.createMediaStreamSource(stream) + mediaStreamSource.connect(audioWorkletNode) + audioWorkletNode.connect(audioContext.destination) + await audioContext.resume() +} + +/** + * 开始实时语音通话。 + * 流程:连接 WS → join/joined → 获取麦克风 → 启动 AudioWorklet → 二进制 PCM 双向传输。 + */ +async function startCall() { + if (isCalling.value || isStarting.value || webSocket.value) return + + const sessionToken = ++callSessionId + isStarting.value = true + connectionStatus.value = 'connecting' + errorMessage.value = '' + + try { + await connectAudioServer(sessionToken) - // 请求用户授权并采集仅包含音频的本地媒体流。 const stream = await navigator.mediaDevices.getUserMedia({ audio: { - // 启用浏览器回声消除,降低扬声器声音再次进入麦克风的影响。 + channelCount: 1, + sampleRate: PCM_SAMPLE_RATE, echoCancellation: true, - // 启用环境噪声抑制,提高语音清晰度。 noiseSuppression: true, - // 启用自动增益,让不同音量的讲话声音更加稳定。 autoGainControl: true }, - // 明确禁止采集摄像头画面,本组件只进行语音通话。 video: false }) - // 等待权限期间用户可能已经离开或结束通话,因此需要再次校验会话编号。 - if (sessionId !== callSessionId) { - // 过期媒体流必须立即停止,否则浏览器会继续占用麦克风。 + + if (sessionToken !== callSessionId) { stream.getTracks().forEach((track) => track.stop()) - // 结束已失效的启动流程,不再创建连接。 return } - // 保存麦克风媒体流,供 WebRTC 发布、静音和资源清理使用。 localStream.value = stream - // 将本地流绑定到静音 audio 元素,用于保持本地音频预览链路。 - if (localAudio.value) localAudio.value.srcObject = stream - // 启动麦克风音量分析,为声波可视化提供数据基础。 - startVolumeMeter(stream) - // 将麦克风音轨加入 WebRTC;连接建立后浏览器会自动通过 WebRTC 发送音频。 - stream.getAudioTracks().forEach((track) => { - pc.addTrack(track, stream) - }) + await setupAudioPipeline(stream) - // 根据本地音频能力生成发起协商所需的 SDP Offer。 - const offer = await pc.createOffer({ offerToReceiveAudio: true }) - // 设置本地描述,正式启动 ICE 候选收集过程。 - await pc.setLocalDescription(offer) - // 按约定协议将 Offer 发送给同一房间的远端用户。 - sendSignalingMessage({ type: 'offer', sessionId: signalingSessionId, sdp: offer.sdp }) - // 标记通话已启动;最终媒体连通状态仍由 connectionstatechange 更新。 isCalling.value = true + isConnected.value = true + connectionStatus.value = 'connected' + ElMessage.success('实时语音通道已建立') } catch (error) { - // 单独识别权限拒绝错误,为用户提供更明确的解决方式。 const permissionDenied = ['NotAllowedError', 'PermissionDeniedError'].includes(error.name) - // 根据错误类型生成提示,并进入统一错误状态。 showError( permissionDenied ? '麦克风权限被拒绝,请在浏览器设置中允许访问' - : `开始通话失败:${error.message}`, + : '开始通话失败:' + error.message, error ) - // 启动失败时释放此前可能已经创建的媒体流、连接和分析器。 cleanupResources() } finally { - // 无论成功或失败都关闭按钮加载状态。 isStarting.value = false } } /** - * 切换本地麦克风的静音状态。 - * 通过修改音频轨道的 enabled 属性停止/恢复向对端发送声音,媒体流本身不会被销毁。 + * 切换麦克风静音状态。 + * 禁用媒体轨道后 Worklet 收到静音采样,不会发送真实麦克风声音。 */ function toggleMute() { - // 获取本地媒体流中的全部音频轨道;可选链避免尚未采集麦克风时报错。 const audioTracks = localStream.value?.getAudioTracks() || [] - // 没有可控制的麦克风轨道时无需继续执行。 if (!audioTracks.length) return - // 反转当前静音状态,用于驱动按钮文案和轨道状态。 isMuted.value = !isMuted.value - // 同步控制所有本地音频轨道是否继续向 WebRTC 发送采样数据。 audioTracks.forEach((track) => { - // 静音时禁用轨道,取消静音时重新启用轨道。 track.enabled = !isMuted.value }) - // 向用户反馈本次静音切换结果。 ElMessage.info(isMuted.value ? '麦克风已静音' : '麦克风已取消静音') } /** - * 释放一次通话占用的全部资源。 - * 包括停止音量分析、关闭 AudioContext/WebRTC/WebSocket、停止本地和远端轨道, - * 清空 audio 元素并重置通话相关状态。该方法可重复调用,供异常、挂断和卸载共同使用。 + * 释放 WebSocket、麦克风和 Web Audio 资源。 + * @param {boolean} notifyBackend 是否在关闭前发送 stop 控制消息 */ function cleanupResources(notifyBackend = true) { - // 主动挂断或启动失败时,先通知后端停止 gRPC 音频流并释放机器人会话。 - if (notifyBackend && signalingSessionId && webSocket.value?.readyState === WebSocket.OPEN) { + if ( + notifyBackend && + signalingSessionId && + webSocket.value?.readyState === WebSocket.OPEN + ) { try { - // 按后端协议发送停止消息;即使发送失败也必须继续释放本地资源。 - sendSignalingMessage({ type: 'stop', sessionId: signalingSessionId }) + sendControlMessage({ type: 'stop', sessionId: signalingSessionId }) } catch (error) { - // 仅记录停止消息的发送异常,避免它中断后续清理流程。 console.warn('停止会话消息发送失败', error) } } - // 更新会话编号,使当前会话尚未完成的所有异步回调立即失效。 + callSessionId += 1 - // 清空未处理 ICE 候选,防止带入下一次通话。 - pendingIceCandidates = [] + incomingRemainder = new Uint8Array(0) + pendingPlaybackFrames = [] - // 停止浏览器逐帧执行音量分析循环。 - if (volumeAnimationFrame) cancelAnimationFrame(volumeAnimationFrame) - // 清除动画帧编号,确保状态与实际资源一致。 - volumeAnimationFrame = null - // 断开分析节点与音频节点之间的连接。 - analyser?.disconnect() - // 释放分析器引用,便于垃圾回收。 - analyser = null - // 关闭 AudioContext;catch 用于忽略已经关闭导致的重复清理异常。 - audioContext?.close().catch(() => {}) - // 释放音频上下文引用。 - audioContext = null + mediaStreamSource?.disconnect() + mediaStreamSource = null - // 仅在 WebRTC 实例存在时执行事件解绑和关闭操作。 - if (peerConnection.value) { - // 解除远端轨道监听,避免关闭过程中继续处理事件。 - peerConnection.value.ontrack = null - // 解除 ICE 候选监听,避免关闭后继续发送信令。 - peerConnection.value.onicecandidate = null - // 解除连接状态监听,避免主动关闭被误判为异常断开。 - peerConnection.value.onconnectionstatechange = null - // 关闭底层 ICE、DTLS 和媒体传输连接。 - peerConnection.value.close() - // 清空 WebRTC 实例引用。 - peerConnection.value = null + if (audioWorkletNode) { + audioWorkletNode.port.onmessage = null + audioWorkletNode.disconnect() + audioWorkletNode = null } - // 读取当前 WebSocket,以便解绑事件并安全关闭。 + audioContext?.close().catch(() => {}) + audioContext = null + + localStream.value?.getTracks().forEach((track) => track.stop()) + localStream.value = null + const socket = webSocket.value - // 仅在信令连接实例存在时执行关闭操作。 if (socket) { - // 清除打开事件,避免延迟建连后继续执行旧流程。 socket.onopen = null - // 清除消息事件,避免关闭期间继续处理远端信令。 socket.onmessage = null - // 清除错误事件,主动关闭时不再显示异常。 socket.onerror = null - // 清除关闭事件,避免主动挂断触发“信令断开”提示。 socket.onclose = null - // 关闭与信令服务器的网络连接。 socket.close() - // 清空 WebSocket 实例引用。 webSocket.value = null } - // 停止所有本地媒体轨道,真正释放浏览器对麦克风的占用。 - localStream.value?.getTracks().forEach((track) => track.stop()) - // 停止远端媒体轨道,结束远端音频播放和解码。 - remoteStream.value?.getTracks().forEach((track) => track.stop()) - // 清空本地媒体流引用。 - localStream.value = null - // 清空远端媒体流引用。 - remoteStream.value = null - // 移除本地 audio 元素绑定的媒体流。 - if (localAudio.value) localAudio.value.srcObject = null - // 移除远端 audio 元素绑定的媒体流。 - if (remoteAudio.value) remoteAudio.value.srcObject = null - - // 恢复非通话状态,使页面重新显示开始通话按钮。 - isCalling.value = false - // 清除 WebRTC 已连接标记。 - isConnected.value = false - // 下次通话默认从非静音状态开始。 - isMuted.value = false - // 清空后端业务会话 ID,避免下一次通话误用旧 sessionId。 signalingSessionId = '' + isCalling.value = false + isConnected.value = false + isMuted.value = false } /** * 主动结束当前通话。 - * 在统一清理资源后,将页面恢复为“未连接”状态并向用户显示结束提示。 */ function endCall() { - // 统一关闭连接并释放麦克风、媒体流和音频分析资源。 cleanupResources() - // 将状态恢复为未连接,更新状态标签和说明文案。 connectionStatus.value = 'idle' - // 主动结束后清除错误信息,避免旧错误继续显示。 - // 向用户确认通话已经结束。 + errorMessage.value = '' ElMessage.success('通话已结束') } -// 组件离开页面时必须释放麦克风和网络连接,防止设备仍被占用或产生内存泄漏。 +// 离开页面时释放麦克风、AudioWorklet 和 WebSocket。 onBeforeUnmount(cleanupResources)