forked from SmartBench/CMVR-IOT-UI
- 将terminalId替换为robotId以统一机器人标识 - 实现机器人设备动态加载和选择功能 - 添加设备类型过滤和状态显示 - 重构设备注册页面为机器人设备管理界面 - 优化API调用参数传递方式 - 添加设备自动匹配和锁定机制
1126 lines
32 KiB
Vue
1126 lines
32 KiB
Vue
<template>
|
||
<section class="voice-conversation">
|
||
<main class="voice-content">
|
||
<header class="hero-title">
|
||
<p>低延迟实时语音对讲</p>
|
||
</header>
|
||
|
||
<div class="voice-visual" :class="{ active: connectionStatus === 'connected' }">
|
||
<div class="sound-wave sound-wave--left" aria-hidden="true">
|
||
<i v-for="index in 7" :key="`left-${index}`"></i>
|
||
</div>
|
||
|
||
<div class="microphone-orbit">
|
||
<div class="orbit-glow"></div>
|
||
<div class="microphone-core">
|
||
<svg viewBox="0 0 48 48" aria-label="麦克风" role="img">
|
||
<path d="M24 30a8 8 0 0 0 8-8V12a8 8 0 0 0-16 0v10a8 8 0 0 0 8 8Z" />
|
||
<path d="M11 21v2a13 13 0 0 0 26 0v-2M24 36v8M17 44h14" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sound-wave sound-wave--right" aria-hidden="true">
|
||
<i v-for="index in 7" :key="`right-${index}`"></i>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="floating-status">
|
||
<span :class="['status-light', connectionStatus]"></span>
|
||
{{ statusMeta.label }}
|
||
</div>
|
||
|
||
<div class="call-controls">
|
||
<el-button
|
||
v-if="!isCalling"
|
||
class="call-button"
|
||
:loading="isStarting"
|
||
@click="startCall"
|
||
>
|
||
开始通话
|
||
</el-button>
|
||
<el-button v-else class="call-button call-button--danger" @click="endCall">
|
||
结束通话
|
||
</el-button>
|
||
<el-button
|
||
v-if="isCalling"
|
||
class="mute-button"
|
||
round
|
||
@click="toggleMute"
|
||
>
|
||
{{ isMuted ? '取消静音' : '静音' }}
|
||
</el-button>
|
||
</div>
|
||
|
||
<div class="robot-volume">
|
||
<span class="robot-volume__label">机器人麦克风音量</span>
|
||
<el-slider
|
||
v-model="microphoneVolume"
|
||
class="robot-volume__slider"
|
||
:min="0"
|
||
:max="100"
|
||
:disabled="isMicrophoneVolumeLoading"
|
||
:show-tooltip="true"
|
||
@change="updateMicrophoneVolume"
|
||
/>
|
||
<span class="robot-volume__value">{{ microphoneVolume }}%</span>
|
||
</div>
|
||
|
||
<div class="robot-volume">
|
||
<span class="robot-volume__label">机器人扬声器音量</span>
|
||
<el-slider
|
||
v-model="speakerVolume"
|
||
class="robot-volume__slider"
|
||
:min="0"
|
||
:max="100"
|
||
:disabled="isSpeakerVolumeLoading"
|
||
:show-tooltip="true"
|
||
@change="updateSpeakerVolume"
|
||
/>
|
||
<span class="robot-volume__value">{{ speakerVolume }}%</span>
|
||
</div>
|
||
|
||
<section class="status-card">
|
||
<div class="status-copy">
|
||
<p>当前状态:<strong>{{ statusMeta.label }}</strong></p>
|
||
<span>{{ statusMeta.description }}</span>
|
||
</div>
|
||
|
||
<div class="feature-tags">
|
||
<span>高清语音</span>
|
||
<span>低延迟</span>
|
||
<span>加密传输</span>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
<!-- PCM 播放由 AudioWorklet 连续输出,不需要额外 audio 元素。 -->
|
||
</section>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||
import { ElMessage, ElNotification } from 'element-plus'
|
||
import { setMicVolume, getMicVolume, setSpeakerVolume, getSpeakerVolume } from '@/api/inspection/cockpit'
|
||
|
||
const props = defineProps({
|
||
robotId: { type: String, default: '' },
|
||
speakerDeviceId: { type: String, default: '' },
|
||
microphoneDeviceId: { type: String, default: '' }
|
||
})
|
||
|
||
// WebSocket 只承载控制 JSON 和二进制 PCM,不再使用 WebRTC、SDP 或 ICE。
|
||
const AUDIO_SERVER_URL = import.meta.env.VITE_AUDIO_SERVER_URL
|
||
|
||
// 后端约定的 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 mediaStreamSource = null
|
||
let audioWorkletNode = null
|
||
let signalingSessionId = ''
|
||
let incomingRemainder = new Uint8Array(0)
|
||
let pendingPlaybackFrames = []
|
||
let callSessionId = 0
|
||
|
||
const statusMeta = computed(() => {
|
||
const statusMap = {
|
||
idle: { label: '未连接', description: '点击按钮建立语音通道' },
|
||
connecting: { label: '连接中', description: '正在建立音频通道,请稍候' },
|
||
connected: { label: '通话中', description: 'PCM 音频通道已建立,可以开始对讲' },
|
||
error: { label: '未连接', description: '连接出现异常,请检查后重试' }
|
||
}
|
||
return statusMap[connectionStatus.value] || statusMap.idle
|
||
})
|
||
|
||
/**
|
||
* 统一显示通话异常。
|
||
* @param {string} message 用户可读的错误说明
|
||
* @param {Error} [error] 原始异常
|
||
*/
|
||
function showError(message, error) {
|
||
console.error(message, error || '')
|
||
errorMessage.value = message
|
||
connectionStatus.value = 'error'
|
||
isConnected.value = false
|
||
ElNotification.error({ title: '语音对讲错误', message })
|
||
}
|
||
|
||
/**
|
||
* 发送 WebSocket 控制消息。
|
||
* 此方法只允许发送 JSON;PCM 音频由 sendPcmFrame 单独以二进制发送。
|
||
* @param {Object} message 控制消息
|
||
*/
|
||
function sendControlMessage(message) {
|
||
const socket = webSocket.value
|
||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||
throw new Error('音频 WebSocket 尚未连接')
|
||
}
|
||
socket.send(JSON.stringify(message))
|
||
}
|
||
|
||
/**
|
||
* 将 Float32 PCM 转换成明确的小端 Int16 PCM。
|
||
* 使用 DataView 的 littleEndian=true,避免依赖运行平台的原生字节序。
|
||
* @param {Float32Array} float32 取值范围为 -1 到 1 的 PCM
|
||
* @returns {ArrayBuffer} PCM_S16LE 数据
|
||
*/
|
||
function floatToInt16Pcm(float32) {
|
||
const buffer = new ArrayBuffer(float32.length * 2)
|
||
const view = new DataView(buffer)
|
||
|
||
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)
|
||
}
|
||
|
||
return buffer
|
||
}
|
||
|
||
/**
|
||
* 将后端返回的小端 Int16 PCM 转成 AudioWorklet 使用的 Float32 PCM。
|
||
* @param {ArrayBuffer} buffer 一帧 960 字节 PCM_S16LE
|
||
* @returns {Float32Array} 480 个浮点采样
|
||
*/
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 发送一帧浏览器麦克风 PCM。
|
||
* 每次只发送 480 samples,即 10ms、960 bytes。
|
||
* @param {Float32Array} samples AudioWorklet 采集的一帧数据
|
||
*/
|
||
function sendPcmFrame(samples) {
|
||
const socket = webSocket.value
|
||
if (
|
||
!isCalling.value ||
|
||
!signalingSessionId ||
|
||
!socket ||
|
||
socket.readyState !== WebSocket.OPEN
|
||
) {
|
||
return
|
||
}
|
||
|
||
if (samples.length !== PCM_FRAME_SAMPLES) {
|
||
console.warn('忽略长度异常的麦克风 PCM 帧:', samples.length)
|
||
return
|
||
}
|
||
|
||
const pcmBuffer = floatToInt16Pcm(samples)
|
||
socket.send(pcmBuffer)
|
||
}
|
||
|
||
/**
|
||
* 将机器人返回的二进制数据按 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'
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 连接音频 WebSocket,发送 join 并等待后端返回 joined。
|
||
* 文本帧作为控制 JSON 处理,二进制帧作为机器人 PCM 处理。
|
||
* @param {number} sessionToken 前端本地会话编号
|
||
* @returns {Promise<Object>} joined 消息
|
||
*/
|
||
function connectAudioServer(sessionToken) {
|
||
return new Promise((resolve, reject) => {
|
||
const socket = new WebSocket(AUDIO_SERVER_URL)
|
||
socket.binaryType = 'arraybuffer'
|
||
webSocket.value = socket
|
||
|
||
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 = () => {
|
||
sendControlMessage({
|
||
type: 'join',
|
||
robotId: props.robotId,
|
||
speakerDeviceId: props.speakerDeviceId,
|
||
micDeviceId: props.microphoneDeviceId
|
||
})
|
||
}
|
||
|
||
socket.onmessage = async (event) => {
|
||
if (sessionToken !== callSessionId) return
|
||
|
||
if (typeof event.data === 'string') {
|
||
let message
|
||
try {
|
||
message = JSON.parse(event.data)
|
||
} catch (error) {
|
||
showError('收到的控制消息不是有效 JSON', error)
|
||
return
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
try {
|
||
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
|
||
if (!props.robotId || !props.speakerDeviceId || !props.microphoneDeviceId) {
|
||
ElMessage.warning('当前机器人缺少在线麦克风或扬声器')
|
||
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 (sessionToken !== callSessionId) {
|
||
stream.getTracks().forEach((track) => track.stop())
|
||
return
|
||
}
|
||
|
||
localStream.value = stream
|
||
await setupAudioPipeline(stream)
|
||
|
||
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
|
||
)
|
||
cleanupResources()
|
||
} finally {
|
||
isStarting.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 切换麦克风静音状态。
|
||
* 禁用媒体轨道后 Worklet 收到静音采样,不会发送真实麦克风声音。
|
||
*/
|
||
function toggleMute() {
|
||
const audioTracks = localStream.value?.getAudioTracks() || []
|
||
if (!audioTracks.length) return
|
||
|
||
isMuted.value = !isMuted.value
|
||
audioTracks.forEach((track) => {
|
||
track.enabled = !isMuted.value
|
||
})
|
||
ElMessage.info(isMuted.value ? '麦克风已静音' : '麦克风已取消静音')
|
||
}
|
||
|
||
/**
|
||
* 释放 WebSocket、麦克风和 Web Audio 资源。
|
||
* @param {boolean} notifyBackend 是否在关闭前发送 stop 控制消息
|
||
*/
|
||
function cleanupResources(notifyBackend = true) {
|
||
if (
|
||
notifyBackend &&
|
||
signalingSessionId &&
|
||
webSocket.value?.readyState === WebSocket.OPEN
|
||
) {
|
||
try {
|
||
sendControlMessage({ type: 'stop', sessionId: signalingSessionId })
|
||
} catch (error) {
|
||
console.warn('停止会话消息发送失败', error)
|
||
}
|
||
}
|
||
|
||
callSessionId += 1
|
||
incomingRemainder = new Uint8Array(0)
|
||
pendingPlaybackFrames = []
|
||
|
||
mediaStreamSource?.disconnect()
|
||
mediaStreamSource = null
|
||
|
||
if (audioWorkletNode) {
|
||
audioWorkletNode.port.onmessage = null
|
||
audioWorkletNode.disconnect()
|
||
audioWorkletNode = null
|
||
}
|
||
|
||
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.value = null
|
||
}
|
||
|
||
signalingSessionId = ''
|
||
isCalling.value = false
|
||
isConnected.value = false
|
||
isMuted.value = false
|
||
}
|
||
|
||
/**
|
||
* 主动结束当前通话。
|
||
*/
|
||
function endCall() {
|
||
cleanupResources()
|
||
connectionStatus.value = 'idle'
|
||
errorMessage.value = ''
|
||
ElMessage.success('通话已结束')
|
||
}
|
||
|
||
const microphoneVolume = ref(50)
|
||
const speakerVolume = ref(50)
|
||
const isMicrophoneVolumeLoading = ref(false)
|
||
const isSpeakerVolumeLoading = ref(false)
|
||
let confirmedMicrophoneVolume = 50
|
||
let confirmedSpeakerVolume = 50
|
||
|
||
async function updateMicrophoneVolume(volume) {
|
||
const previousVolume = confirmedMicrophoneVolume
|
||
isMicrophoneVolumeLoading.value = true
|
||
try {
|
||
const response = await setMicVolume({
|
||
deviceId: props.microphoneDeviceId,
|
||
robotId: props.robotId,
|
||
volume: Math.round(volume)
|
||
})
|
||
if (response.code !== 200) throw new Error(response.message || '设置机器人音量失败')
|
||
microphoneVolume.value = volume
|
||
confirmedMicrophoneVolume = volume
|
||
ElMessage.success(`机器人麦克风音量已设置为 ${volume}%`)
|
||
} catch (error) {
|
||
microphoneVolume.value = previousVolume
|
||
ElMessage.error(error.message || '设置机器人音量失败')
|
||
} finally {
|
||
isMicrophoneVolumeLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function updateSpeakerVolume(volume) {
|
||
const previousVolume = confirmedSpeakerVolume
|
||
isSpeakerVolumeLoading.value = true
|
||
try {
|
||
const response = await setSpeakerVolume({
|
||
deviceId: props.speakerDeviceId,
|
||
robotId: props.robotId,
|
||
volume: Math.round(volume)
|
||
})
|
||
if (response.code !== 200) throw new Error(response.message || '设置扬声器音量失败')
|
||
speakerVolume.value = volume
|
||
confirmedSpeakerVolume = volume
|
||
ElMessage.success(`机器人扬声器音量已设置为 ${volume}%`)
|
||
} catch (error) {
|
||
speakerVolume.value = previousVolume
|
||
ElMessage.error(error.message || '设置扬声器音量失败')
|
||
} finally {
|
||
isSpeakerVolumeLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadMicrophoneVolume({ silent = false } = {}) {
|
||
isMicrophoneVolumeLoading.value = true
|
||
try {
|
||
const response = await getMicVolume({
|
||
deviceId: props.microphoneDeviceId,
|
||
robotId: props.robotId
|
||
})
|
||
if (response.code !== 200) throw new Error(response.message || '获取机器人音量失败')
|
||
const volume = Math.max(0, Math.min(100, Number(response.data) || 0))
|
||
microphoneVolume.value = volume
|
||
confirmedMicrophoneVolume = volume
|
||
} catch (error) {
|
||
if (!silent) ElMessage.error(error.message || '获取机器人音量失败')
|
||
} finally {
|
||
isMicrophoneVolumeLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadSpeakerVolume({ silent = false } = {}) {
|
||
isSpeakerVolumeLoading.value = true
|
||
try {
|
||
const response = await getSpeakerVolume({
|
||
deviceId: props.speakerDeviceId,
|
||
robotId: props.robotId
|
||
})
|
||
if (response.code !== 200) throw new Error(response.message || '获取扬声器音量失败')
|
||
const volume = Math.max(0, Math.min(100, Number(response.data) || 0))
|
||
speakerVolume.value = volume
|
||
confirmedSpeakerVolume = volume
|
||
} catch (error) {
|
||
if (!silent) ElMessage.error(error.message || '获取扬声器音量失败')
|
||
} finally {
|
||
isSpeakerVolumeLoading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadMicrophoneVolume()
|
||
loadSpeakerVolume()
|
||
})
|
||
|
||
// 离开页面时释放麦克风、AudioWorklet 和 WebSocket。
|
||
onBeforeUnmount(cleanupResources)
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
$cyan: #46c8ff;
|
||
$blue: #2478ff;
|
||
$purple: #936cff;
|
||
$panel-border: rgb(112 192 255 / 24%);
|
||
|
||
.voice-conversation {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 400px;
|
||
max-height: 400px;
|
||
min-height: 0;
|
||
padding: 30px 28px 16px;
|
||
overflow: hidden;
|
||
box-sizing: border-box;
|
||
color: #fff;
|
||
background:
|
||
radial-gradient(circle at 50% 38%, rgb(44 90 198 / 34%) 0%, transparent 33%),
|
||
radial-gradient(circle at 75% 20%, rgb(81 49 172 / 18%) 0%, transparent 30%),
|
||
linear-gradient(145deg, #071a3e 0%, #08152f 45%, #050c21 100%);
|
||
|
||
&::before,
|
||
&::after {
|
||
position: absolute;
|
||
content: '';
|
||
pointer-events: none;
|
||
}
|
||
|
||
&::before {
|
||
inset: 0;
|
||
background: linear-gradient(90deg, rgb(32 143 255 / 7%) 1px, transparent 1px),
|
||
linear-gradient(rgb(32 143 255 / 7%) 1px, transparent 1px);
|
||
background-size: 42px 42px;
|
||
mask-image: radial-gradient(circle at center, #000 12%, transparent 82%);
|
||
}
|
||
|
||
&::after {
|
||
width: 360px;
|
||
height: 360px;
|
||
border: 1px solid rgb(70 200 255 / 7%);
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 100px rgb(36 120 255 / 8%);
|
||
}
|
||
|
||
.tech-grid {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: repeating-linear-gradient(135deg, transparent 0 118px, rgb(89 138 255 / 3%) 119px 120px);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.corner-title {
|
||
position: absolute;
|
||
top: 16px;
|
||
left: 22px;
|
||
z-index: 2;
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
letter-spacing: 1px;
|
||
text-shadow: 0 0 18px rgb(74 168 255 / 30%);
|
||
}
|
||
|
||
.voice-content {
|
||
position: relative;
|
||
z-index: 2;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
width: min(100%, 660px);
|
||
}
|
||
|
||
.hero-title {
|
||
text-align: center;
|
||
|
||
h1 {
|
||
margin: 0;
|
||
font-size: clamp(23px, 3vw, 30px);
|
||
line-height: 1.2;
|
||
letter-spacing: 5px;
|
||
text-shadow: 0 0 25px rgb(79 159 255 / 25%);
|
||
}
|
||
|
||
p {
|
||
margin: 4px 0 0;
|
||
color: #86c6f4;
|
||
font-size: 12px;
|
||
letter-spacing: 3px;
|
||
}
|
||
}
|
||
|
||
.voice-visual {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: clamp(18px, 4vw, 42px);
|
||
width: 100%;
|
||
margin-top: 13px;
|
||
|
||
&.active {
|
||
.microphone-orbit { animation: orbit-breathe 2.4s ease-in-out infinite; }
|
||
.sound-wave i { animation-play-state: running; }
|
||
}
|
||
}
|
||
|
||
.microphone-orbit {
|
||
position: relative;
|
||
display: grid;
|
||
place-items: center;
|
||
flex: 0 0 auto;
|
||
width: 100px;
|
||
height: 100px;
|
||
border: 1px solid rgb(117 210 255 / 45%);
|
||
border-radius: 50%;
|
||
background: linear-gradient(145deg, rgb(41 146 255 / 20%), rgb(120 73 255 / 16%));
|
||
box-shadow: 0 0 26px rgb(35 158 255 / 35%), inset 0 0 28px rgb(117 83 255 / 24%);
|
||
|
||
&::before {
|
||
position: absolute;
|
||
inset: 7px;
|
||
border: 1px solid rgb(160 124 255 / 55%);
|
||
border-radius: inherit;
|
||
box-shadow: inset 0 0 16px rgb(55 186 255 / 22%);
|
||
content: '';
|
||
}
|
||
|
||
.orbit-glow {
|
||
position: absolute;
|
||
inset: -13px;
|
||
border-radius: inherit;
|
||
background: conic-gradient(from 90deg, transparent, rgb(58 190 255 / 35%), transparent, rgb(146 94 255 / 42%), transparent);
|
||
filter: blur(12px);
|
||
}
|
||
}
|
||
|
||
.microphone-core {
|
||
position: relative;
|
||
display: grid;
|
||
place-items: center;
|
||
width: 62px;
|
||
height: 62px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(145deg, #237bfa, #794be1);
|
||
box-shadow: 0 8px 30px rgb(34 111 255 / 48%), inset 0 1px 1px rgb(255 255 255 / 35%);
|
||
|
||
svg {
|
||
width: 31px;
|
||
height: 31px;
|
||
overflow: visible;
|
||
fill: #fff;
|
||
stroke: #fff;
|
||
stroke-width: 3;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
|
||
path:first-child { stroke: none; }
|
||
path:last-child { fill: none; }
|
||
}
|
||
}
|
||
|
||
.sound-wave {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
width: 86px;
|
||
height: 60px;
|
||
|
||
i {
|
||
width: 4px;
|
||
height: var(--wave-height, 30px);
|
||
border-radius: 10px;
|
||
background: linear-gradient(to bottom, $cyan, $purple);
|
||
box-shadow: 0 0 10px rgb(72 190 255 / 46%);
|
||
opacity: 0.74;
|
||
animation: wave-pulse 1.15s ease-in-out infinite alternate;
|
||
animation-delay: calc(var(--index) * -0.09s);
|
||
animation-play-state: paused;
|
||
|
||
&:nth-child(1), &:nth-child(7) { --wave-height: 12px; --index: 1; opacity: 0.32; }
|
||
&:nth-child(2), &:nth-child(6) { --wave-height: 22px; --index: 2; opacity: 0.48; }
|
||
&:nth-child(3), &:nth-child(5) { --wave-height: 35px; --index: 3; opacity: 0.62; }
|
||
&:nth-child(4) { --wave-height: 48px; --index: 4; }
|
||
}
|
||
|
||
&--left { justify-content: flex-end; }
|
||
&--right { justify-content: flex-start; }
|
||
}
|
||
|
||
.floating-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 7px;
|
||
padding: 4px 13px;
|
||
border: 1px solid rgb(255 255 255 / 9%);
|
||
border-radius: 20px;
|
||
color: #d5d9e4;
|
||
background: rgb(31 38 58 / 86%);
|
||
box-shadow: 0 7px 18px rgb(0 0 0 / 24%);
|
||
font-size: 11px;
|
||
|
||
.status-light {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
background: #8791a7;
|
||
|
||
&.connecting { background: #ffd166; box-shadow: 0 0 8px #ffd166; }
|
||
&.connected { background: #4ef5ae; box-shadow: 0 0 8px #4ef5ae; }
|
||
&.error { background: #ff6482; box-shadow: 0 0 8px #ff6482; }
|
||
}
|
||
}
|
||
|
||
.call-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
min-height: 34px;
|
||
margin-top: 9px;
|
||
|
||
:deep(.el-button + .el-button) { margin-left: 0; }
|
||
}
|
||
|
||
.robot-volume {
|
||
display: grid;
|
||
grid-template-columns: auto minmax(120px, 210px) 42px;
|
||
align-items: center;
|
||
gap: 12px;
|
||
width: min(100%, 430px);
|
||
margin-top: 10px;
|
||
color: #badeff;;
|
||
font-size: 11px;
|
||
|
||
&__label { white-space: nowrap; }
|
||
&__value { color: #fff; text-align: right; font-variant-numeric: tabular-nums; }
|
||
|
||
:deep(.el-slider__runway) { background-color: #dff7ff; }
|
||
:deep(.el-slider__bar) { background: linear-gradient(90deg, #46c8ff, #936cff); }
|
||
:deep(.el-slider__button) {
|
||
width: 14px;
|
||
height: 14px;
|
||
border-color: #46c8ff;
|
||
background: #dff7ff;
|
||
box-shadow: 0 0 10px rgb(70 200 255 / 65%);
|
||
}
|
||
}
|
||
|
||
:deep(.call-button) {
|
||
width: 178px;
|
||
height: 34px;
|
||
border: 1px solid rgb(125 207 255 / 52%);
|
||
border-radius: 10px;
|
||
color: #fff;
|
||
background: linear-gradient(100deg, #176ee9, #448eff 56%, #7657e9);
|
||
box-shadow: 0 10px 28px rgb(24 105 235 / 35%), inset 0 1px rgb(255 255 255 / 18%);
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
letter-spacing: 2px;
|
||
|
||
&:hover,
|
||
&:focus {
|
||
color: #fff;
|
||
border-color: #86d5ff;
|
||
background: linear-gradient(100deg, #2280ff, #54a0ff 56%, #896afa);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
&.call-button--danger {
|
||
border-color: rgb(255 126 157 / 55%);
|
||
background: linear-gradient(100deg, #cf3e67, #e4597e, #8554d9);
|
||
}
|
||
}
|
||
|
||
:deep(.mute-button) {
|
||
height: 30px;
|
||
border-color: $panel-border;
|
||
color: #badeff;
|
||
background: rgb(16 45 85 / 72%);
|
||
|
||
&:hover { color: #fff; border-color: $cyan; background: rgb(23 76 128 / 82%); }
|
||
}
|
||
|
||
.status-card {
|
||
width: min(100%, 550px);
|
||
margin-top: 10px;
|
||
padding: 8px 18px 8px;
|
||
border: 1px solid $panel-border;
|
||
border-radius: 16px;
|
||
box-sizing: border-box;
|
||
background: linear-gradient(135deg, rgb(14 49 91 / 67%), rgb(20 30 74 / 55%));
|
||
box-shadow: 0 16px 40px rgb(0 4 24 / 28%), inset 0 1px rgb(255 255 255 / 5%);
|
||
backdrop-filter: blur(16px);
|
||
|
||
.status-copy {
|
||
text-align: center;
|
||
|
||
p {
|
||
margin: 0;
|
||
color: #edf7ff;
|
||
font-size: 12px;
|
||
|
||
strong { color: #fff; font-weight: 600; }
|
||
}
|
||
|
||
span {
|
||
display: block;
|
||
margin-top: 2px;
|
||
color: #8eafd0;
|
||
font-size: 10px;
|
||
}
|
||
}
|
||
|
||
.error-alert { margin-top: 6px; }
|
||
}
|
||
|
||
.feature-tags {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 9px;
|
||
margin-top: 6px;
|
||
padding-top: 6px;
|
||
border-top: 1px solid rgb(116 186 255 / 13%);
|
||
|
||
span {
|
||
padding: 2px 8px;
|
||
border: 1px solid rgb(87 183 255 / 35%);
|
||
border-radius: 20px;
|
||
color: #a9d6f8;
|
||
background: rgb(31 104 166 / 13%);
|
||
text-align: center;
|
||
font-size: 10px;
|
||
}
|
||
}
|
||
|
||
audio { display: none; }
|
||
}
|
||
|
||
@keyframes orbit-breathe {
|
||
0%, 100% { transform: scale(1); filter: brightness(1); }
|
||
50% { transform: scale(1.035); filter: brightness(1.16); }
|
||
}
|
||
|
||
@keyframes wave-pulse {
|
||
from { transform: scaleY(0.58); opacity: 0.42; }
|
||
to { transform: scaleY(1.08); opacity: 0.9; }
|
||
}
|
||
|
||
@media (max-width: 680px) {
|
||
.voice-conversation {
|
||
height: 400px;
|
||
max-height: 400px;
|
||
min-height: 0;
|
||
padding: 38px 14px 12px;
|
||
|
||
.corner-title { top: 12px; left: 16px; font-size: 17px; }
|
||
.voice-visual { gap: 10px; }
|
||
.microphone-orbit { width: 90px; height: 90px; }
|
||
.microphone-core { width: 56px; height: 56px; }
|
||
.microphone-core svg { width: 28px; height: 28px; }
|
||
.sound-wave { width: 58px; gap: 4px; }
|
||
.sound-wave i { width: 3px; }
|
||
.call-controls { flex-wrap: wrap; }
|
||
}
|
||
}
|
||
|
||
@media (max-width: 420px) {
|
||
.voice-conversation {
|
||
.hero-title p { letter-spacing: 1px; }
|
||
.voice-visual { gap: 7px; }
|
||
.sound-wave { width: 48px; gap: 3px; }
|
||
.sound-wave i:nth-child(1),
|
||
.sound-wave i:nth-child(7) { display: none; }
|
||
.feature-tags { gap: 7px; }
|
||
.status-card { padding-inline: 15px; }
|
||
}
|
||
}
|
||
</style>
|