inspection-host-computer/src/views/camera/VoiceConversation.vue

922 lines
29 KiB
Vue
Raw Normal View History

2026-07-22 13:33:59 +08:00
<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>
2026-07-22 13:53:44 +08:00
<div class="robot-volume">
<span class="robot-volume__label">机器人麦克风音量</span>
<el-slider
2026-07-22 14:01:38 +08:00
v-model="microphoneVolume"
2026-07-22 13:53:44 +08:00
class="robot-volume__slider"
:min="0"
:max="100"
2026-07-22 14:01:38 +08:00
:disabled="isMicrophoneVolumeLoading"
2026-07-22 13:53:44 +08:00
:show-tooltip="true"
2026-07-22 14:01:38 +08:00
@change="updateMicrophoneVolume"
2026-07-22 13:53:44 +08:00
/>
2026-07-22 14:01:38 +08:00
<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>
2026-07-22 13:53:44 +08:00
</div>
2026-07-22 13:33:59 +08:00
<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>
2026-07-22 13:53:44 +08:00
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
2026-07-22 13:33:59 +08:00
import { ElMessage, ElNotification } from 'element-plus'
import { useRobotStore } from '@/stores/robot'
2026-07-22 14:01:38 +08:00
import {
getMicrophoneVolume,
getSpeakerVolume,
setMicrophoneVolume,
setSpeakerVolume
} from '@/api/audio'
2026-07-22 13:33:59 +08:00
// 机器人实际音频流为 48kHz因此浏览器上传也统一为 48kHz。
const TARGET_SAMPLE_RATE = 48000
const TARGET_CHANNELS = 2
const JOIN_TIMEOUT = 10000
const robotStore = useRobotStore()
// 页面交互状态。
const isCalling = ref(false)
const isStarting = ref(false)
const isMuted = ref(false)
const connectionStatus = ref('idle')
const errorMessage = ref('')
2026-07-22 14:01:38 +08:00
const microphoneVolume = ref(50)
const speakerVolume = ref(50)
const isMicrophoneVolumeLoading = ref(false)
const isSpeakerVolumeLoading = ref(false)
let confirmedMicrophoneVolume = 50
let confirmedSpeakerVolume = 50
2026-07-22 13:33:59 +08:00
// 本次通话占用的浏览器资源。
const localStream = ref(null)
const webSocket = ref(null)
let audioContext
let mediaStreamSource
let captureNode
let silentGain
let nextPlaybackTime = 0
let resampleFraction = 0
let remoteSampleRate = TARGET_SAMPLE_RATE
let remoteChannels = 1
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
})
2026-07-22 14:01:38 +08:00
function microphoneVolumeRequestParams() {
2026-07-22 13:53:44 +08:00
return {
ip: robotStore.ip,
deviceId: robotStore.micDeviceId
}
}
2026-07-22 14:01:38 +08:00
async function loadMicrophoneVolume({ silent = false } = {}) {
2026-07-22 13:53:44 +08:00
if (!robotStore.ip || !robotStore.micDeviceId) return
2026-07-22 14:01:38 +08:00
isMicrophoneVolumeLoading.value = true
2026-07-22 13:53:44 +08:00
try {
2026-07-22 14:01:38 +08:00
const response = await getMicrophoneVolume(microphoneVolumeRequestParams())
2026-07-22 13:53:44 +08:00
if (response.code !== 200) throw new Error(response.message || '获取机器人音量失败')
const volume = Math.max(0, Math.min(100, Number(response.data?.volume) || 0))
2026-07-22 14:01:38 +08:00
microphoneVolume.value = volume
confirmedMicrophoneVolume = volume
2026-07-22 13:53:44 +08:00
} catch (error) {
if (!silent) ElMessage.error(error.message || '获取机器人音量失败')
} finally {
2026-07-22 14:01:38 +08:00
isMicrophoneVolumeLoading.value = false
2026-07-22 13:53:44 +08:00
}
}
2026-07-22 14:01:38 +08:00
async function updateMicrophoneVolume(volume) {
const previousVolume = confirmedMicrophoneVolume
isMicrophoneVolumeLoading.value = true
2026-07-22 13:53:44 +08:00
try {
const response = await setMicrophoneVolume({
2026-07-22 14:01:38 +08:00
...microphoneVolumeRequestParams(),
2026-07-22 13:53:44 +08:00
volume: Math.round(volume)
})
if (response.code !== 200) throw new Error(response.message || '设置机器人音量失败')
2026-07-22 14:01:38 +08:00
microphoneVolume.value = response.data.volume
confirmedMicrophoneVolume = response.data.volume
2026-07-22 13:53:44 +08:00
ElMessage.success(`机器人麦克风音量已设置为 ${response.data.volume}%`)
} catch (error) {
2026-07-22 14:01:38 +08:00
microphoneVolume.value = previousVolume
2026-07-22 13:53:44 +08:00
ElMessage.error(error.message || '设置机器人音量失败')
} finally {
2026-07-22 14:01:38 +08:00
isMicrophoneVolumeLoading.value = false
}
}
function speakerVolumeRequestParams() {
return {
ip: robotStore.ip,
deviceId: robotStore.spkDeviceId
}
}
async function loadSpeakerVolume({ silent = false } = {}) {
if (!robotStore.ip || !robotStore.spkDeviceId) return
isSpeakerVolumeLoading.value = true
try {
const response = await getSpeakerVolume(speakerVolumeRequestParams())
if (response.code !== 200) throw new Error(response.message || '获取扬声器音量失败')
const volume = Math.max(0, Math.min(100, Number(response.data?.volume) || 0))
speakerVolume.value = volume
confirmedSpeakerVolume = volume
} catch (error) {
if (!silent) ElMessage.error(error.message || '获取扬声器音量失败')
} finally {
isSpeakerVolumeLoading.value = false
}
}
async function updateSpeakerVolume(volume) {
const previousVolume = confirmedSpeakerVolume
isSpeakerVolumeLoading.value = true
try {
const response = await setSpeakerVolume({
...speakerVolumeRequestParams(),
volume: Math.round(volume)
})
if (response.code !== 200) throw new Error(response.message || '设置扬声器音量失败')
speakerVolume.value = response.data.volume
confirmedSpeakerVolume = response.data.volume
ElMessage.success(`机器人扬声器音量已设置为 ${response.data.volume}%`)
} catch (error) {
speakerVolume.value = previousVolume
ElMessage.error(error.message || '设置扬声器音量失败')
} finally {
isSpeakerVolumeLoading.value = false
2026-07-22 13:53:44 +08:00
}
}
2026-07-22 13:33:59 +08:00
/**
* 统一显示通话异常
* @param {string} message 用户可读的错误说明
* @param {Error} [error] 原始异常
*/
function showError(message, error) {
console.error(message, error)
errorMessage.value = message
connectionStatus.value = 'error'
ElNotification.error({ title: '语音通话异常', message })
}
/**
* 发送 WebSocket 控制消息
* 此方法只允许发送 JSONPCM 音频由 sendPcmFrame 单独以二进制发送
* @param {Object} message 控制消息
*/
function sendControlMessage(message) {
if (webSocket.value?.readyState === WebSocket.OPEN) {
webSocket.value.send(JSON.stringify(message))
}
}
/**
* Float32 PCM 转换成明确的小端 Int16 PCM
* 使用 DataView littleEndian=true避免依赖运行平台的原生字节序
* @param {Float32Array} float32 取值范围为 -1 1 PCM
* @returns {ArrayBuffer} PCM_S16LE 数据
*/
function floatToInt16Pcm(float32, channels = 1) {
const buffer = new ArrayBuffer(float32.length * channels * 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 ? sample * 0x8000 : sample * 0x7fff
// 单声道麦克风复制到左右声道,生成交错 S16LEL0,R0,L1,R1...
for (let channel = 0; channel < channels; channel += 1) {
view.setInt16((index * channels + channel) * 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 result = new Float32Array(Math.floor(buffer.byteLength / 2))
for (let index = 0; index < result.length; index += 1) {
const value = view.getInt16(index * 2, true)
result[index] = value < 0 ? value / 0x8000 : value / 0x7fff
}
return result
}
/** 当浏览器实际采样率不是 48kHz 时,通过线性插值转换为机器人采样率。 */
function resample(input, fromRate, toRate) {
if (fromRate === toRate) return new Float32Array(input)
// 保留每个回调产生的小数样本,避免逐块四舍五入造成长期采样率漂移。
const exactLength = input.length * toRate / fromRate + resampleFraction
const outputLength = Math.max(1, Math.floor(exactLength))
resampleFraction = exactLength - outputLength
const output = new Float32Array(outputLength)
const ratio = fromRate / toRate
for (let index = 0; index < outputLength; index += 1) {
const position = index * ratio
const left = Math.floor(position)
const right = Math.min(left + 1, input.length - 1)
const fraction = position - left
output[index] = input[left] * (1 - fraction) + input[right] * fraction
}
return output
}
/**
* 发送一帧浏览器麦克风 PCM
* 每次只发送 480 samples 10ms960 bytes
* @param {Float32Array} samples AudioWorklet 采集的一帧数据
*/
function sendPcmFrame(samples) {
const socket = webSocket.value
if (isMuted.value || socket?.readyState !== WebSocket.OPEN) return
socket.send(floatToInt16Pcm(
resample(samples, audioContext.sampleRate, TARGET_SAMPLE_RATE),
TARGET_CHANNELS
))
}
/**
* 将机器人返回的二进制数据按 960 字节切帧后加入播放队列
* 支持后端将半帧或多帧放在一个 WebSocket 消息中的情况
* @param {ArrayBuffer} buffer WebSocket 二进制数据
*/
function enqueueIncomingPcm(buffer) {
if (!audioContext || audioContext.state === 'closed' || buffer.byteLength < 2) return
const interleavedSamples = int16ToFloat32(buffer)
const frameCount = Math.floor(interleavedSamples.length / remoteChannels)
if (!frameCount) return
// 机器人返回交错 PCM按声道拆分后再交给 AudioBuffer 播放。
const audioBuffer = audioContext.createBuffer(remoteChannels, frameCount, remoteSampleRate)
for (let channel = 0; channel < remoteChannels; channel += 1) {
const channelData = audioBuffer.getChannelData(channel)
for (let frame = 0; frame < frameCount; frame += 1) {
channelData[frame] = interleavedSamples[frame * remoteChannels + channel]
}
}
const source = audioContext.createBufferSource()
source.buffer = audioBuffer
source.connect(audioContext.destination)
const now = audioContext.currentTime
if (nextPlaybackTime < now || nextPlaybackTime > now + 1) nextPlaybackTime = now + 0.03
source.start(nextPlaybackTime)
nextPlaybackTime += audioBuffer.duration
}
/**
* 处理后端文本控制消息
* @param {Object} message 已解析的 JSON 消息
* @param {number} sessionToken 前端本地会话编号
*/
function handleControlMessage(message, sessionToken) {
if (sessionToken !== callSessionId) return
if (message.type === 'audio-format') {
const sampleRate = Number(message.sampleRate)
const channels = Number(message.channels)
if (sampleRate >= 8000 && sampleRate <= 192000) remoteSampleRate = sampleRate
if (Number.isInteger(channels) && channels >= 1 && channels <= 8) remoteChannels = channels
} else if (message.type === 'error' || message.type === 'remote-ended') {
showError(message.message || '机器人已结束音频流')
cleanupResources(false)
}
}
/**
* 连接音频 WebSocket发送 join 并等待后端返回 joined
* 文本帧作为控制 JSON 处理二进制帧作为机器人 PCM 处理
* @param {number} sessionToken 前端本地会话编号
* @returns {Promise<Object>} joined 消息
*/
function connectAudioServer(sessionToken) {
return new Promise((resolve, reject) => {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const devHost = `${location.hostname}:${import.meta.env.VITE_SERVICE_PORT || 3000}`
const url = import.meta.env.VITE_AUDIO_SERVER_URL
|| `${protocol}//${import.meta.env.DEV ? devHost : location.host}/audio`
const socket = new WebSocket(url)
socket.binaryType = 'arraybuffer'
webSocket.value = socket
const timeout = window.setTimeout(() => reject(new Error('连接音频服务超时')), JOIN_TIMEOUT)
socket.onopen = () => sendControlMessage({
type: 'init',
microphoneId: robotStore.micDeviceId,
speakerId: robotStore.spkDeviceId,
robotAddress: robotStore.ip
})
socket.onmessage = (event) => {
if (typeof event.data !== 'string') {
enqueueIncomingPcm(event.data)
return
}
try {
const message = JSON.parse(event.data)
if (message.type === 'ready') {
window.clearTimeout(timeout)
resolve(message)
} else if (message.type === 'connection-failed') {
window.clearTimeout(timeout)
reject(new Error(message.message || '机器人音频服务连接失败'))
} else {
handleControlMessage(message, sessionToken)
}
} catch (error) {
reject(new Error('音频服务返回了无效消息', { cause: error }))
}
}
socket.onerror = () => reject(new Error('无法连接音频 WebSocket 服务'))
socket.onclose = () => {
window.clearTimeout(timeout)
if (sessionToken === callSessionId && isCalling.value) {
showError('音频连接已断开')
cleanupResources(false)
}
}
})
}
/**
* 创建浏览器 AudioContext 和麦克风采集节点
* 麦克风只进入静音采集支路不会在本地直接回放
* @param {MediaStream} stream 麦克风媒体流
*/
async function setupAudioPipeline(stream) {
await audioContext.resume()
mediaStreamSource = audioContext.createMediaStreamSource(stream)
// ScriptProcessor 兼容旧浏览器;回调中根据 audioContext.sampleRate 手动重采样。
captureNode = audioContext.createScriptProcessor(2048, 1, 1)
silentGain = audioContext.createGain()
silentGain.gain.value = 0
captureNode.onaudioprocess = (event) => sendPcmFrame(event.inputBuffer.getChannelData(0))
mediaStreamSource.connect(captureNode)
captureNode.connect(silentGain)
silentGain.connect(audioContext.destination)
}
/**
* 开始实时语音通话
* 流程连接 WS join/joined 获取麦克风 启动 AudioWorklet 二进制 PCM 双向传输
*/
async function startCall() {
if (isStarting.value || isCalling.value) return
isStarting.value = true
connectionStatus.value = 'connecting'
errorMessage.value = ''
const sessionToken = ++callSessionId
try {
if (!robotStore.ip || !robotStore.micDeviceId || !robotStore.spkDeviceId) {
throw new Error('请先配置机器人地址、麦克风 ID 和扬声器 ID')
}
// 必须在按钮点击的用户手势中创建,以符合浏览器自动播放策略。
audioContext = new AudioContext({ latencyHint: 'interactive' })
await connectAudioServer(sessionToken)
if (sessionToken !== callSessionId) return
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
})
localStream.value = stream
await setupAudioPipeline(stream)
isCalling.value = true
connectionStatus.value = 'connected'
ElMessage.success('语音通话已建立')
} catch (error) {
cleanupResources(false)
showError(error.message || '无法开始语音通话', error)
} 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) {
callSessionId += 1
if (notifyBackend) sendControlMessage({ type: 'stop' })
const socket = webSocket.value
webSocket.value = null
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, 'call ended')
localStream.value?.getTracks().forEach((track) => track.stop())
localStream.value = null
captureNode?.disconnect()
mediaStreamSource?.disconnect()
silentGain?.disconnect()
captureNode = undefined
mediaStreamSource = undefined
silentGain = undefined
if (audioContext && audioContext.state !== 'closed') audioContext.close()
audioContext = undefined
nextPlaybackTime = 0
resampleFraction = 0
remoteSampleRate = TARGET_SAMPLE_RATE
remoteChannels = 1
isCalling.value = false
isMuted.value = false
}
/**
* 主动结束当前通话
*/
function endCall() {
cleanupResources()
connectionStatus.value = 'idle'
errorMessage.value = ''
ElMessage.success('通话已结束')
}
// 离开页面时释放麦克风、AudioWorklet 和 WebSocket。
2026-07-22 14:01:38 +08:00
onMounted(() => {
loadMicrophoneVolume()
loadSpeakerVolume()
})
2026-07-22 13:33:59 +08:00
onBeforeUnmount(cleanupResources)
</script>
<style lang="scss" scoped>
.voice-conversation {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
2026-07-22 13:53:44 +08:00
height: 500px;
max-height: 500px;
2026-07-22 13:33:59 +08:00
min-height: 0;
padding: 30px 28px 16px;
overflow: hidden;
box-sizing: border-box;
2026-07-22 13:53:44 +08:00
color: $color-voice-white;
2026-07-22 13:33:59 +08:00
background:
2026-07-22 13:53:44 +08:00
radial-gradient(circle at 50% 38%, $color-voice-background-blue-glow 0%, $color-voice-transparent 33%),
radial-gradient(circle at 75% 20%, $color-voice-background-purple-glow 0%, $color-voice-transparent 30%),
linear-gradient(145deg, $color-voice-background-start 0%, $color-voice-background-middle 45%, $color-voice-background-end 100%);
2026-07-22 13:33:59 +08:00
&::before,
&::after {
position: absolute;
content: '';
pointer-events: none;
}
&::before {
inset: 0;
2026-07-22 13:53:44 +08:00
background: linear-gradient(90deg, $color-voice-grid-line 1px, $color-voice-transparent 1px),
linear-gradient($color-voice-grid-line 1px, $color-voice-transparent 1px);
2026-07-22 13:33:59 +08:00
background-size: 42px 42px;
2026-07-22 13:53:44 +08:00
mask-image: radial-gradient(circle at center, $color-voice-mask-black 12%, $color-voice-transparent 82%);
2026-07-22 13:33:59 +08:00
}
&::after {
width: 360px;
height: 360px;
2026-07-22 13:53:44 +08:00
border: 1px solid $color-voice-outer-ring;
2026-07-22 13:33:59 +08:00
border-radius: 50%;
2026-07-22 13:53:44 +08:00
box-shadow: 0 0 100px $color-voice-outer-ring-shadow;
2026-07-22 13:33:59 +08:00
}
.tech-grid {
position: absolute;
inset: 0;
2026-07-22 13:53:44 +08:00
background: repeating-linear-gradient(135deg, $color-voice-transparent 0 118px, $color-voice-grid-diagonal 119px 120px);
2026-07-22 13:33:59 +08:00
pointer-events: none;
}
.corner-title {
position: absolute;
top: 16px;
left: 22px;
z-index: 2;
font-size: 20px;
font-weight: 700;
letter-spacing: 1px;
2026-07-22 13:53:44 +08:00
text-shadow: 0 0 18px $color-voice-title-shadow;
2026-07-22 13:33:59 +08:00
}
.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;
2026-07-22 13:53:44 +08:00
text-shadow: 0 0 25px $color-voice-heading-shadow;
2026-07-22 13:33:59 +08:00
}
p {
margin: 4px 0 0;
2026-07-22 13:53:44 +08:00
color: $color-voice-subtitle;
2026-07-22 13:33:59 +08:00
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;
2026-07-22 13:53:44 +08:00
border: 1px solid $color-voice-orbit-border;
2026-07-22 13:33:59 +08:00
border-radius: 50%;
2026-07-22 13:53:44 +08:00
background: linear-gradient(145deg, $color-voice-orbit-blue-fill, $color-voice-orbit-purple-fill);
box-shadow: 0 0 26px $color-voice-orbit-blue-shadow, inset 0 0 28px $color-voice-orbit-purple-shadow;
2026-07-22 13:33:59 +08:00
&::before {
position: absolute;
inset: 7px;
2026-07-22 13:53:44 +08:00
border: 1px solid $color-voice-orbit-inner-border;
2026-07-22 13:33:59 +08:00
border-radius: inherit;
2026-07-22 13:53:44 +08:00
box-shadow: inset 0 0 16px $color-voice-orbit-inner-shadow;
2026-07-22 13:33:59 +08:00
content: '';
}
.orbit-glow {
position: absolute;
inset: -13px;
border-radius: inherit;
2026-07-22 13:53:44 +08:00
background: conic-gradient(from 90deg, $color-voice-transparent, $color-voice-orbit-conic-blue, $color-voice-transparent, $color-voice-orbit-conic-purple, $color-voice-transparent);
2026-07-22 13:33:59 +08:00
filter: blur(12px);
}
}
.microphone-core {
position: relative;
display: grid;
place-items: center;
width: 62px;
height: 62px;
border-radius: 50%;
2026-07-22 13:53:44 +08:00
background: linear-gradient(145deg, $color-voice-microphone-blue, $color-voice-microphone-purple);
box-shadow: 0 8px 30px $color-voice-microphone-shadow, inset 0 1px 1px $color-voice-highlight-strong;
2026-07-22 13:33:59 +08:00
svg {
width: 31px;
height: 31px;
overflow: visible;
2026-07-22 13:53:44 +08:00
fill: $color-voice-white;
stroke: $color-voice-white;
2026-07-22 13:33:59 +08:00
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;
2026-07-22 13:53:44 +08:00
background: linear-gradient(to bottom, $color-voice-cyan, $color-voice-purple);
box-shadow: 0 0 10px $color-voice-wave-shadow;
2026-07-22 13:33:59 +08:00
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;
2026-07-22 13:53:44 +08:00
border: 1px solid $color-voice-status-border;
2026-07-22 13:33:59 +08:00
border-radius: 20px;
2026-07-22 13:53:44 +08:00
color: $color-voice-status-text;
background: $color-voice-status-background;
box-shadow: 0 7px 18px $color-voice-status-shadow;
2026-07-22 13:33:59 +08:00
font-size: 11px;
.status-light {
width: 7px;
height: 7px;
border-radius: 50%;
2026-07-22 13:53:44 +08:00
background: $color-voice-status-idle;
2026-07-22 13:33:59 +08:00
2026-07-22 13:53:44 +08:00
&.connecting { background: $color-voice-status-connecting; box-shadow: 0 0 8px $color-voice-status-connecting; }
&.connected { background: $color-voice-status-connected; box-shadow: 0 0 8px $color-voice-status-connected; }
&.error { background: $color-voice-status-error; box-shadow: 0 0 8px $color-voice-status-error; }
2026-07-22 13:33:59 +08:00
}
}
.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; }
}
2026-07-22 13:53:44 +08:00
.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: $color-voice-control-text;
font-size: 11px;
&__label { white-space: nowrap; }
&__value { color: $color-voice-white; text-align: right; font-variant-numeric: tabular-nums; }
:deep(.el-slider__runway) { background-color: $color-voice-slider-track; }
:deep(.el-slider__bar) { background: linear-gradient(90deg, $color-voice-cyan, $color-voice-purple); }
:deep(.el-slider__button) {
width: 14px;
height: 14px;
border-color: $color-voice-cyan;
background: $color-voice-slider-thumb;
box-shadow: 0 0 10px $color-voice-slider-glow;
}
}
2026-07-22 13:33:59 +08:00
:deep(.call-button) {
width: 178px;
height: 34px;
2026-07-22 13:53:44 +08:00
border: 1px solid $color-voice-call-border;
2026-07-22 13:33:59 +08:00
border-radius: 10px;
2026-07-22 13:53:44 +08:00
color: $color-voice-white;
background: linear-gradient(100deg, $color-voice-call-start, $color-voice-call-middle 56%, $color-voice-call-end);
box-shadow: 0 10px 28px $color-voice-call-shadow, inset 0 1px $color-voice-highlight-soft;
2026-07-22 13:33:59 +08:00
font-size: 13px;
font-weight: 700;
letter-spacing: 2px;
&:hover,
&:focus {
2026-07-22 13:53:44 +08:00
color: $color-voice-white;
border-color: $color-voice-call-hover-border;
background: linear-gradient(100deg, $color-voice-call-hover-start, $color-voice-call-hover-middle 56%, $color-voice-call-hover-end);
2026-07-22 13:33:59 +08:00
transform: translateY(-1px);
}
&.call-button--danger {
2026-07-22 13:53:44 +08:00
border-color: $color-voice-danger-border;
background: linear-gradient(100deg, $color-voice-danger-start, $color-voice-danger-middle, $color-voice-danger-end);
2026-07-22 13:33:59 +08:00
}
}
:deep(.mute-button) {
height: 30px;
2026-07-22 13:53:44 +08:00
border-color: $color-voice-panel-border;
color: $color-voice-control-text;
background: $color-voice-muted-background;
2026-07-22 13:33:59 +08:00
2026-07-22 13:53:44 +08:00
&:hover { color: $color-voice-white; border-color: $color-voice-cyan; background: $color-voice-muted-hover-background; }
2026-07-22 13:33:59 +08:00
}
.status-card {
width: min(100%, 550px);
margin-top: 20px;
padding: 8px 18px 8px;
2026-07-22 13:53:44 +08:00
border: 1px solid $color-voice-panel-border;
2026-07-22 13:33:59 +08:00
border-radius: 16px;
box-sizing: border-box;
2026-07-22 13:53:44 +08:00
background: linear-gradient(135deg, $color-voice-card-start, $color-voice-card-end);
box-shadow: 0 16px 40px $color-voice-card-shadow, inset 0 1px $color-voice-card-highlight;
2026-07-22 13:33:59 +08:00
backdrop-filter: blur(16px);
.status-copy {
text-align: center;
p {
margin: 0;
2026-07-22 13:53:44 +08:00
color: $color-voice-card-primary-text;
2026-07-22 13:33:59 +08:00
font-size: 12px;
2026-07-22 13:53:44 +08:00
strong { color: $color-voice-white; font-weight: 600; }
2026-07-22 13:33:59 +08:00
}
span {
display: block;
margin-top: 2px;
2026-07-22 13:53:44 +08:00
color: $color-voice-card-secondary-text;
2026-07-22 13:33:59 +08:00
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;
2026-07-22 13:53:44 +08:00
border-top: 1px solid $color-voice-tag-divider;
2026-07-22 13:33:59 +08:00
span {
padding: 2px 8px;
2026-07-22 13:53:44 +08:00
border: 1px solid $color-voice-tag-border;
2026-07-22 13:33:59 +08:00
border-radius: 20px;
2026-07-22 13:53:44 +08:00
color: $color-voice-tag-text;
background: $color-voice-tag-background;
2026-07-22 13:33:59 +08:00
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>