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> </div>
</section> </section>
</main> </main>
<!-- PCM 播放由 AudioWorklet 连续输出不需要额外 audio 元素 -->
<!-- 本地预览必须静音以免麦克风声音回放造成啸叫 -->
<audio ref="localAudio" autoplay muted playsinline></audio>
<audio ref="remoteAudio" autoplay playsinline></audio>
</section> </section>
</template> </template>
@ -76,268 +73,227 @@
import { computed, onBeforeUnmount, ref } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import { ElMessage, ElNotification } from 'element-plus' import { ElMessage, ElNotification } from 'element-plus'
// // WebSocket JSON PCM使 WebRTCSDP ICE
const SIGNALING_SERVER_URL = 'ws://192.168.1.222:13080/ws/signaling' const AUDIO_SERVER_URL = 'ws://192.168.1.222:13080/ws/audio'
//
const TERMINAL_ID = 'c2f8a06826baf03843925c1a2a13bcfd' const TERMINAL_ID = 'c2f8a06826baf03843925c1a2a13bcfd'
const DEVICE_ID = '1234' const DEVICE_ID = '1234'
const OPERATOR_ID = 'user001' const OPERATOR_ID = 'user001'
const RTC_CONFIGURATION = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
}
// /WebRTC // PCM S16LE48kHz10ms
const localAudio = ref(null) const PCM_SAMPLE_RATE = 48000
const remoteAudio = ref(null) const PCM_FRAME_SAMPLES = 480
const localStream = ref(null) const PCM_FRAME_BYTES = 960
const remoteStream = ref(null) const JOIN_TIMEOUT = 10000
const peerConnection = ref(null)
const webSocket = ref(null)
// //
const isCalling = ref(false) const isCalling = ref(false)
const isStarting = ref(false) const isStarting = ref(false)
const isConnected = ref(false) const isConnected = ref(false)
const isMuted = ref(false) const isMuted = ref(false)
const connectionStatus = ref('idle') const connectionStatus = ref('idle')
const errorMessage = ref('')
// 使 //
const localStream = ref(null)
const webSocket = ref(null)
let audioContext = null let audioContext = null
let analyser = null let mediaStreamSource = null
let volumeAnimationFrame = null let audioWorkletNode = null
// joined ID OfferCandidate
let signalingSessionId = '' let signalingSessionId = ''
// SDP ICE addIceCandidate let incomingRemainder = new Uint8Array(0)
let pendingIceCandidates = [] let pendingPlaybackFrames = []
//
let callSessionId = 0 let callSessionId = 0
//
const statusMeta = computed(() => { const statusMeta = computed(() => {
const statusMap = { const statusMap = {
idle: { label: '未连接', description: '点击按钮建立语音通道' }, idle: { label: '未连接', description: '点击按钮建立语音通道' },
connecting: { label: '连接中', description: '正在建立安全语音通道,请稍候' }, connecting: { label: '连接中', description: '正在建立通道,请稍候' },
connected: { label: '通话中', description: '语音通道已建立,可以开始对讲' }, connected: { label: '通话中', description: 'PCM 音频通道已建立,可以开始对讲' },
error: { label: '未连接', description: '连接出现异常,请检查后重试' } error: { label: '未连接', description: '连接出现异常,请检查后重试' }
} }
return statusMap[connectionStatus.value] || statusMap.idle return statusMap[connectionStatus.value] || statusMap.idle
}) })
/** /**
* 统一处理通话过程中的异常 * 统一显示通话异常
* 该方法会记录错误更新页面状态并使用 Element Plus 通知用户 * @param {string} message 用户可读的错误说明
* @param {string} message 展示给用户的错误信息 * @param {Error} [error] 原始异常
* @param {Error} [error] 原始异常对象主要用于控制台排查
*/ */
function showError(message, error) { function showError(message, error) {
// 便
console.error(message, error || '') console.error(message, error || '')
// errorMessage.value = message
connectionStatus.value = 'error' connectionStatus.value = 'error'
// WebRTC
isConnected.value = false isConnected.value = false
// 使
ElNotification.error({ title: '语音对讲错误', message }) ElNotification.error({ title: '语音对讲错误', message })
} }
/** /**
* 通过已经连接的 WebSocket 发送一条信令消息 * 发送 WebSocket 控制消息
* OfferAnswer ICE 候选都通过此方法序列化为 JSON 后发送 * 此方法只允许发送 JSONPCM 音频由 sendPcmFrame 单独以二进制发送
* @param {Object} message 符合约定格式的信令消息 * @param {Object} message 控制消息
* @throws {Error} WebSocket 未处于 OPEN 状态时抛出异常
*/ */
function sendSignalingMessage(message) { function sendControlMessage(message) {
// WebSocket 访
const socket = webSocket.value const socket = webSocket.value
// WebSocket
if (!socket || socket.readyState !== WebSocket.OPEN) { if (!socket || socket.readyState !== WebSocket.OPEN) {
// Offer ICE throw new Error('音频 WebSocket 尚未连接')
throw new Error('信令服务器尚未连接')
} }
// JSON
socket.send(JSON.stringify(message)) socket.send(JSON.stringify(message))
} }
/** /**
* 创建并初始化 RTCPeerConnection * Float32 PCM 转换成明确的小端 Int16 PCM
* 作用包括接收并播放远端音频发送 ICE 候选 * 使用 DataView littleEndian=true避免依赖运行平台的原生字节序
* 以及监听连接状态来同步页面上的连接中/通话中/未连接状态 * @param {Float32Array} float32 取值范围为 -1 1 PCM
* @param {number} sessionId 本次通话的会话编号用于忽略过期回调 * @returns {ArrayBuffer} PCM_S16LE 数据
* @returns {RTCPeerConnection} 初始化完成的 WebRTC 连接实例
*/ */
function setupPeerConnection(sessionId) { function floatToInt16Pcm(float32) {
// 使 STUN WebRTC const buffer = new ArrayBuffer(float32.length * 2)
const pc = new RTCPeerConnection(RTC_CONFIGURATION) const view = new DataView(buffer)
// 使
peerConnection.value = pc
//
remoteStream.value = new MediaStream()
// audio for (let index = 0; index < float32.length; index += 1) {
if (remoteAudio.value) remoteAudio.value.srcObject = remoteStream.value const sample = Math.max(-1, Math.min(1, float32[index]))
const int16 = sample < 0 ? Math.round(sample * 0x8000) : Math.round(sample * 0x7fff)
// audio view.setInt16(index * 2, int16, true)
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('浏览器阻止了远端音频自动播放,请点击页面后重试')
})
} }
// ICE return buffer
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
} }
/** /**
* 将暂存的远端 ICE 候选依次加入 WebRTC 连接 * 将后端返回的小端 Int16 PCM 转成 AudioWorklet 使用的 Float32 PCM
* 必须在远端 SDP 设置完成后调用否则部分浏览器会拒绝添加候选 * @param {ArrayBuffer} buffer 一帧 960 字节 PCM_S16LE
* @param {RTCPeerConnection} pc 当前 WebRTC 连接 * @returns {Float32Array} 480 个浮点采样
* @returns {Promise<void>}
*/ */
async function flushPendingIceCandidates(pc) { function int16ToFloat32(buffer) {
// const view = new DataView(buffer)
const candidates = pendingIceCandidates.splice(0) const samples = new Float32Array(buffer.byteLength / 2)
//
for (const candidate of candidates) { for (let index = 0; index < samples.length; index += 1) {
// samples[index] = view.getInt16(index * 2, true) / 32768
await pc.addIceCandidate(candidate)
} }
return samples
} }
/** /**
* 处理信令服务器发来的消息 * 发送一帧浏览器麦克风 PCM
* - answer完成发起端的远端描述设置 * 每次只发送 480 samples 10ms960 bytes
* - candidate立即添加候选或等待远端 SDP 就绪后再添加 * @param {Float32Array} samples AudioWorklet 采集的一帧数据
* @param {MessageEvent} event WebSocket 消息事件
* @param {number} sessionId 本次通话的会话编号
* @returns {Promise<void>}
*/ */
async function handleSignalingMessage(event, sessionId) { function sendPcmFrame(samples) {
// WebSocket const socket = webSocket.value
if (sessionId !== callSessionId) return if (
!isCalling.value ||
// !signalingSessionId ||
let message !socket ||
try { socket.readyState !== WebSocket.OPEN
// JSON ) {
message = JSON.parse(event.data)
} catch (error) {
// JSON
showError('收到的信令消息不是有效 JSON', error)
//
return return
} }
// ID if (samples.length !== PCM_FRAME_SAMPLES) {
if (message.sessionId && signalingSessionId && message.sessionId !== signalingSessionId) return console.warn('忽略长度异常的麦克风 PCM 帧:', samples.length)
return
}
// WebRTC SDP ICE const pcmBuffer = floatToInt16Pcm(samples)
const pc = peerConnection.value socket.send(pcmBuffer)
// }
if (!pc) return
try { /**
// Offer Answer * 将机器人返回的二进制数据按 960 字节切帧后加入播放队列
if (message.type === 'answer') { * 支持后端将半帧或多帧放在一个 WebSocket 消息中的情况
// Answer SDP Offer/Answer * @param {ArrayBuffer} buffer WebSocket 二进制数据
await pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: message.sdp })) */
// SDP Answer ICE function enqueueIncomingPcm(buffer) {
await flushPendingIceCandidates(pc) const received = new Uint8Array(buffer)
// ICE const merged = new Uint8Array(incomingRemainder.length + received.length)
} else if (message.type === 'candidate') { merged.set(incomingRemainder)
// 使 RTCIceCandidate merged.set(received, incomingRemainder.length)
const candidate = new RTCIceCandidate({
candidate: message.candidate?.candidate, let offset = 0
sdpMLineIndex: message.candidate?.sdpMLineIndex, while (merged.length - offset >= PCM_FRAME_BYTES) {
sdpMid: message.candidate?.sdpMid const frameBytes = merged.slice(offset, offset + PCM_FRAME_BYTES)
}) const floatFrame = int16ToFloat32(frameBytes.buffer)
// WebRTC
if (pc.remoteDescription) await pc.addIceCandidate(candidate) if (audioWorkletNode) {
// SDP audioWorkletNode.port.postMessage(
else pendingIceCandidates.push(candidate) { type: 'playback', samples: floatFrame.buffer },
// [floatFrame.buffer]
} else if (message.type === 'error') { )
showError(message.message || '语音会话发生异常') } 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') { } else if (message.type === 'stopped' || message.type === 'stop') {
cleanupResources(false) cleanupResources(false)
connectionStatus.value = 'idle' connectionStatus.value = 'idle'
} }
} catch (error) {
// SDP Answer
showError(`处理 ${message.type || '未知'} 信令失败`, error)
}
} }
/** /**
* 建立与信令服务器的 WebSocket 连接并注册消息错误和关闭事件 * 连接音频 WebSocket发送 join 并等待后端返回 joined
* WebSocket 只负责交换连接信息实际语音数据建立连接后由 WebRTC 点对点传输 * 文本帧作为控制 JSON 处理二进制帧作为机器人 PCM 处理
* @param {number} sessionId 本次通话的会话编号 * @param {number} sessionToken 前端本地会话编号
* @returns {Promise<WebSocket>} 连接成功后返回 WebSocket 实例 * @returns {Promise<Object>} joined 消息
*/ */
function connectSignalingServer(sessionId) { function connectAudioServer(sessionToken) {
// Promise joined Offer sessionId
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// const socket = new WebSocket(AUDIO_SERVER_URL)
const socket = new WebSocket(SIGNALING_SERVER_URL) socket.binaryType = 'arraybuffer'
//
webSocket.value = socket 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 = () => { socket.onopen = () => {
sendSignalingMessage({ sendControlMessage({
type: 'join', type: 'join',
terminalId: TERMINAL_ID, terminalId: TERMINAL_ID,
deviceId: DEVICE_ID, deviceId: DEVICE_ID,
@ -345,303 +301,319 @@ function connectSignalingServer(sessionId) {
}) })
} }
// joined WebRTC socket.onmessage = async (event) => {
socket.onmessage = (event) => { if (sessionToken !== callSessionId) return
if (typeof event.data === 'string') {
let message let message
try { try {
// joined
message = JSON.parse(event.data) message = JSON.parse(event.data)
} catch (error) { } catch (error) {
// showError('收到的控制消息不是有效 JSON', error)
handleSignalingMessage(event, sessionId)
return return
} }
// sessionId
if (message.type === 'joined') { if (message.type === 'joined') {
// sessionId
if (!message.sessionId) { if (!message.sessionId) {
reject(new Error('后端 joined 响应缺少 sessionId')) rejectJoin(new Error('joined 响应缺少 sessionId'))
return return
} }
// joined
if (sessionId !== callSessionId) return
// ID使
signalingSessionId = message.sessionId signalingSessionId = message.sessionId
// joined = true
hasJoined = true resolveJoin(message)
// joined startCall Offer
resolve(message)
return return
} }
// join startCall if (message.type === 'error' && !joined) {
if (message.type === 'error' && !hasJoined) { rejectJoin(new Error(message.message || '后端创建音频会话失败'))
reject(new Error(message.message || '后端创建语音会话失败'))
return return
} }
// joined AnswerCandidate WebRTC handleControlMessage(message, sessionToken)
handleSignalingMessage(event, sessionId) 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 { try {
// join joined sessionId const buffer = event.data instanceof Blob
await connectSignalingServer(sessionId) ? await event.data.arrayBuffer()
// joined WebRTC : event.data
const pc = setupPeerConnection(sessionId) 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({ const stream = await navigator.mediaDevices.getUserMedia({
audio: { audio: {
// channelCount: 1,
sampleRate: PCM_SAMPLE_RATE,
echoCancellation: true, echoCancellation: true,
//
noiseSuppression: true, noiseSuppression: true,
//
autoGainControl: true autoGainControl: true
}, },
//
video: false video: false
}) })
//
if (sessionId !== callSessionId) { if (sessionToken !== callSessionId) {
//
stream.getTracks().forEach((track) => track.stop()) stream.getTracks().forEach((track) => track.stop())
//
return return
} }
// WebRTC 使
localStream.value = stream localStream.value = stream
// audio await setupAudioPipeline(stream)
if (localAudio.value) localAudio.value.srcObject = stream
//
startVolumeMeter(stream)
// WebRTC WebRTC
stream.getAudioTracks().forEach((track) => {
pc.addTrack(track, 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 isCalling.value = true
isConnected.value = true
connectionStatus.value = 'connected'
ElMessage.success('实时语音通道已建立')
} catch (error) { } catch (error) {
//
const permissionDenied = ['NotAllowedError', 'PermissionDeniedError'].includes(error.name) const permissionDenied = ['NotAllowedError', 'PermissionDeniedError'].includes(error.name)
//
showError( showError(
permissionDenied permissionDenied
? '麦克风权限被拒绝,请在浏览器设置中允许访问' ? '麦克风权限被拒绝,请在浏览器设置中允许访问'
: `开始通话失败:${error.message}`, : '开始通话失败:' + error.message,
error error
) )
//
cleanupResources() cleanupResources()
} finally { } finally {
//
isStarting.value = false isStarting.value = false
} }
} }
/** /**
* 切换本地麦克风的静音状态 * 切换麦克风静音状态
* 通过修改音频轨道的 enabled 属性停止/恢复向对端发送声音媒体流本身不会被销毁 * 禁用媒体轨道后 Worklet 收到静音采样不会发送真实麦克风声音
*/ */
function toggleMute() { function toggleMute() {
//
const audioTracks = localStream.value?.getAudioTracks() || [] const audioTracks = localStream.value?.getAudioTracks() || []
//
if (!audioTracks.length) return if (!audioTracks.length) return
//
isMuted.value = !isMuted.value isMuted.value = !isMuted.value
// WebRTC
audioTracks.forEach((track) => { audioTracks.forEach((track) => {
//
track.enabled = !isMuted.value track.enabled = !isMuted.value
}) })
//
ElMessage.info(isMuted.value ? '麦克风已静音' : '麦克风已取消静音') ElMessage.info(isMuted.value ? '麦克风已静音' : '麦克风已取消静音')
} }
/** /**
* 释放一次通话占用的全部资源 * 释放 WebSocket麦克风和 Web Audio 资源
* 包括停止音量分析关闭 AudioContext/WebRTC/WebSocket停止本地和远端轨道 * @param {boolean} notifyBackend 是否在关闭前发送 stop 控制消息
* 清空 audio 元素并重置通话相关状态该方法可重复调用供异常挂断和卸载共同使用
*/ */
function cleanupResources(notifyBackend = true) { function cleanupResources(notifyBackend = true) {
// gRPC if (
if (notifyBackend && signalingSessionId && webSocket.value?.readyState === WebSocket.OPEN) { notifyBackend &&
signalingSessionId &&
webSocket.value?.readyState === WebSocket.OPEN
) {
try { try {
// 使 sendControlMessage({ type: 'stop', sessionId: signalingSessionId })
sendSignalingMessage({ type: 'stop', sessionId: signalingSessionId })
} catch (error) { } catch (error) {
//
console.warn('停止会话消息发送失败', error) console.warn('停止会话消息发送失败', error)
} }
} }
// 使
callSessionId += 1 callSessionId += 1
// ICE incomingRemainder = new Uint8Array(0)
pendingIceCandidates = [] pendingPlaybackFrames = []
// mediaStreamSource?.disconnect()
if (volumeAnimationFrame) cancelAnimationFrame(volumeAnimationFrame) mediaStreamSource = null
//
volumeAnimationFrame = null
//
analyser?.disconnect()
// 便
analyser = null
// AudioContextcatch
audioContext?.close().catch(() => {})
//
audioContext = null
// WebRTC if (audioWorkletNode) {
if (peerConnection.value) { audioWorkletNode.port.onmessage = null
// audioWorkletNode.disconnect()
peerConnection.value.ontrack = null audioWorkletNode = null
// ICE
peerConnection.value.onicecandidate = null
//
peerConnection.value.onconnectionstatechange = null
// ICEDTLS
peerConnection.value.close()
// WebRTC
peerConnection.value = null
} }
// WebSocket便 audioContext?.close().catch(() => {})
audioContext = null
localStream.value?.getTracks().forEach((track) => track.stop())
localStream.value = null
const socket = webSocket.value const socket = webSocket.value
//
if (socket) { if (socket) {
//
socket.onopen = null socket.onopen = null
//
socket.onmessage = null socket.onmessage = null
//
socket.onerror = null socket.onerror = null
//
socket.onclose = null socket.onclose = null
//
socket.close() socket.close()
// WebSocket
webSocket.value = null 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 = '' signalingSessionId = ''
isCalling.value = false
isConnected.value = false
isMuted.value = false
} }
/** /**
* 主动结束当前通话 * 主动结束当前通话
* 在统一清理资源后将页面恢复为未连接状态并向用户显示结束提示
*/ */
function endCall() { function endCall() {
//
cleanupResources() cleanupResources()
//
connectionStatus.value = 'idle' connectionStatus.value = 'idle'
// errorMessage.value = ''
//
ElMessage.success('通话已结束') ElMessage.success('通话已结束')
} }
// // AudioWorklet WebSocket
onBeforeUnmount(cleanupResources) onBeforeUnmount(cleanupResources)
</script> </script>