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