feat: 语音对话

This commit is contained in:
zhanghao 2026-07-15 11:42:33 +08:00
parent 97ca74a225
commit 341c688991

View File

@ -65,10 +65,7 @@
</div>
</section>
</main>
<!-- 本地预览必须静音以免麦克风声音回放造成啸叫 -->
<audio ref="localAudio" autoplay muted playsinline></audio>
<audio ref="remoteAudio" autoplay playsinline></audio>
<!-- PCM 播放由 AudioWorklet 连续输出不需要额外 audio 元素 -->
</section>
</template>
@ -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使 WebRTCSDP 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 S16LE48kHz10ms
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 OfferCandidate
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 发送一条信令消息
* OfferAnswer ICE 候选都通过此方法序列化为 JSON 后发送
* @param {Object} message 符合约定格式的信令消息
* @throws {Error} WebSocket 未处于 OPEN 状态时抛出异常
* 发送 WebSocket 控制消息
* 此方法只允许发送 JSONPCM 音频由 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 ICEDTLS
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<void>}
* 将后端返回的小端 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<void>}
* 发送一帧浏览器麦克风 PCM
* 每次只发送 480 samples 10ms960 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 || '语音会话发生异常')
//
/**
* 将机器人返回的二进制数据按 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)
}
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'
}
} catch (error) {
// SDP Answer
showError(`处理 ${message.type || '未知'} 信令失败`, error)
}
}
/**
* 建立与信令服务器的 WebSocket 连接并注册消息错误和关闭事件
* WebSocket 只负责交换连接信息实际语音数据建立连接后由 WebRTC 点对点传输
* @param {number} sessionId 本次通话的会话编号
* @returns {Promise<WebSocket>} 连接成功后返回 WebSocket 实例
* 连接音频 WebSocket发送 join 并等待后端返回 joined
* 文本帧作为控制 JSON 处理二进制帧作为机器人 PCM 处理
* @param {number} sessionToken 前端本地会话编号
* @returns {Promise<Object>} 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) => {
socket.onmessage = async (event) => {
if (sessionToken !== callSessionId) return
if (typeof event.data === 'string') {
let message
try {
// joined
message = JSON.parse(event.data)
} catch (error) {
//
handleSignalingMessage(event, sessionId)
showError('收到的控制消息不是有效 JSON', error)
return
}
// sessionId
if (message.type === 'joined') {
// sessionId
if (!message.sessionId) {
reject(new Error('后端 joined 响应缺少 sessionId'))
rejectJoin(new Error('joined 响应缺少 sessionId'))
return
}
// joined
if (sessionId !== callSessionId) return
// ID使
signalingSessionId = message.sessionId
//
hasJoined = true
// joined startCall Offer
resolve(message)
joined = true
resolveJoin(message)
return
}
// join startCall
if (message.type === 'error' && !hasJoined) {
reject(new Error(message.message || '后端创建语音会话失败'))
if (message.type === 'error' && !joined) {
rejectJoin(new Error(message.message || '后端创建音频会话失败'))
return
}
// joined AnswerCandidate WebRTC
handleSignalingMessage(event, sessionId)
handleControlMessage(message, sessionToken)
return
}
// WebSocket
socket.onerror = () => {
if (!hasJoined) reject(new Error('WebSocket 连接失败'))
}
// 线
socket.onclose = () => {
if (!hasJoined) {
reject(new Error('信令服务器在会话建立前已断开'))
} else if (sessionId === callSessionId && isCalling.value) {
showError('信令服务器连接已断开')
}
}
})
}
/**
* 创建 Web Audio 音频分析器并持续读取麦克风频域数据
* 当前用于保留实时音量分析能力后续可将分析结果绑定到声波高度或音量条
* @param {MediaStream} stream 本地麦克风媒体流
*/
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()
}
/**
* 开始一次实时语音通话
* 完整流程防止重复启动 请求麦克风权限 创建 WebRTC 连接
* 连接信令服务器 创建本地 Offer 发送 Offer 等待对端应答
* 任一步骤失败都会显示错误并释放本次已创建的所有资源
* @returns {Promise<void>}
*/
async function startCall() {
//
if (isCalling.value || isStarting.value || peerConnection.value) return
// 使
const sessionId = ++callSessionId
//
isStarting.value = true
// UI
connectionStatus.value = 'connecting'
try {
// join joined sessionId
await connectSignalingServer(sessionId)
// joined WebRTC
const pc = setupPeerConnection(sessionId)
const buffer = event.data instanceof Blob
? await event.data.arrayBuffer()
: event.data
enqueueIncomingPcm(buffer)
} catch (error) {
showError('机器人 PCM 音频处理失败', error)
}
}
socket.onerror = () => {
if (!joined) rejectJoin(new Error('音频 WebSocket 连接失败'))
}
socket.onclose = () => {
if (!joined) {
rejectJoin(new Error('音频服务在会话建立前已断开'))
} else if (
sessionToken === callSessionId &&
(isCalling.value || isStarting.value)
) {
cleanupResources(false)
showError('音频 WebSocket 已断开')
}
}
})
}
/**
* 返回单文件组件内使用的 AudioWorklet 源码
* Worklet 同时完成麦克风 480-sample 切帧和机器人 PCM 连续播放
* 播放队列预缓冲 3 30ms降低网络抖动造成的爆音和卡顿
*/
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')
}
/**
* 创建 48kHz AudioContext 和双向 AudioWorklet
* 麦克风只进入 Worklet 采集端不直接回放Worklet 输出只播放机器人音频
* @param {MediaStream} stream 麦克风媒体流
*/
async function setupAudioPipeline(stream) {
const AudioContextClass = window.AudioContext || window.webkitAudioContext
if (!AudioContextClass || !window.AudioWorkletNode) {
throw new Error('当前浏览器不支持 AudioWorklet')
}
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 {
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
// AudioContextcatch
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
// ICEDTLS
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)
</script>