CMVR-BID/src/views/inspection/cockpit/components/ManualTakeover/VoiceConversation.vue
lixiaolong 45b190ff02 feat(flow): 优化机器人设备绑定功能
- 将terminalId替换为robotId以统一机器人标识
- 实现机器人设备动态加载和选择功能
- 添加设备类型过滤和状态显示
- 重构设备注册页面为机器人设备管理界面
- 优化API调用参数传递方式
- 添加设备自动匹配和锁定机制
2026-08-05 09:27:55 +08:00

1126 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>