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

922 lines
29 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, onMounted, ref } from 'vue'
import { ElMessage, ElNotification } from 'element-plus'
import { useRobotStore } from '@/stores/robot'
import {
getMicrophoneVolume,
getSpeakerVolume,
setMicrophoneVolume,
setSpeakerVolume
} from '@/api/audio'
// 机器人实际音频流为 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('')
const microphoneVolume = ref(50)
const speakerVolume = ref(50)
const isMicrophoneVolumeLoading = ref(false)
const isSpeakerVolumeLoading = ref(false)
let confirmedMicrophoneVolume = 50
let confirmedSpeakerVolume = 50
// 本次通话占用的浏览器资源。
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
})
function microphoneVolumeRequestParams() {
return {
ip: robotStore.ip,
deviceId: robotStore.micDeviceId
}
}
async function loadMicrophoneVolume({ silent = false } = {}) {
if (!robotStore.ip || !robotStore.micDeviceId) return
isMicrophoneVolumeLoading.value = true
try {
const response = await getMicrophoneVolume(microphoneVolumeRequestParams())
if (response.code !== 200) throw new Error(response.message || '获取机器人音量失败')
const volume = Math.max(0, Math.min(100, Number(response.data?.volume) || 0))
microphoneVolume.value = volume
confirmedMicrophoneVolume = volume
} catch (error) {
if (!silent) ElMessage.error(error.message || '获取机器人音量失败')
} finally {
isMicrophoneVolumeLoading.value = false
}
}
async function updateMicrophoneVolume(volume) {
const previousVolume = confirmedMicrophoneVolume
isMicrophoneVolumeLoading.value = true
try {
const response = await setMicrophoneVolume({
...microphoneVolumeRequestParams(),
volume: Math.round(volume)
})
if (response.code !== 200) throw new Error(response.message || '设置机器人音量失败')
microphoneVolume.value = response.data.volume
confirmedMicrophoneVolume = response.data.volume
ElMessage.success(`机器人麦克风音量已设置为 ${response.data.volume}%`)
} catch (error) {
microphoneVolume.value = previousVolume
ElMessage.error(error.message || '设置机器人音量失败')
} finally {
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
}
}
/**
* 统一显示通话异常。
* @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即 10ms、960 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。
onMounted(() => {
loadMicrophoneVolume()
loadSpeakerVolume()
})
onBeforeUnmount(cleanupResources)
</script>
<style lang="scss" scoped>
.voice-conversation {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 500px;
max-height: 500px;
min-height: 0;
padding: 30px 28px 16px;
overflow: hidden;
box-sizing: border-box;
color: $color-voice-white;
background:
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%);
&::before,
&::after {
position: absolute;
content: '';
pointer-events: none;
}
&::before {
inset: 0;
background: linear-gradient(90deg, $color-voice-grid-line 1px, $color-voice-transparent 1px),
linear-gradient($color-voice-grid-line 1px, $color-voice-transparent 1px);
background-size: 42px 42px;
mask-image: radial-gradient(circle at center, $color-voice-mask-black 12%, $color-voice-transparent 82%);
}
&::after {
width: 360px;
height: 360px;
border: 1px solid $color-voice-outer-ring;
border-radius: 50%;
box-shadow: 0 0 100px $color-voice-outer-ring-shadow;
}
.tech-grid {
position: absolute;
inset: 0;
background: repeating-linear-gradient(135deg, $color-voice-transparent 0 118px, $color-voice-grid-diagonal 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 $color-voice-title-shadow;
}
.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 $color-voice-heading-shadow;
}
p {
margin: 4px 0 0;
color: $color-voice-subtitle;
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 $color-voice-orbit-border;
border-radius: 50%;
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;
&::before {
position: absolute;
inset: 7px;
border: 1px solid $color-voice-orbit-inner-border;
border-radius: inherit;
box-shadow: inset 0 0 16px $color-voice-orbit-inner-shadow;
content: '';
}
.orbit-glow {
position: absolute;
inset: -13px;
border-radius: inherit;
background: conic-gradient(from 90deg, $color-voice-transparent, $color-voice-orbit-conic-blue, $color-voice-transparent, $color-voice-orbit-conic-purple, $color-voice-transparent);
filter: blur(12px);
}
}
.microphone-core {
position: relative;
display: grid;
place-items: center;
width: 62px;
height: 62px;
border-radius: 50%;
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;
svg {
width: 31px;
height: 31px;
overflow: visible;
fill: $color-voice-white;
stroke: $color-voice-white;
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, $color-voice-cyan, $color-voice-purple);
box-shadow: 0 0 10px $color-voice-wave-shadow;
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 $color-voice-status-border;
border-radius: 20px;
color: $color-voice-status-text;
background: $color-voice-status-background;
box-shadow: 0 7px 18px $color-voice-status-shadow;
font-size: 11px;
.status-light {
width: 7px;
height: 7px;
border-radius: 50%;
background: $color-voice-status-idle;
&.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; }
}
}
.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: $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;
}
}
:deep(.call-button) {
width: 178px;
height: 34px;
border: 1px solid $color-voice-call-border;
border-radius: 10px;
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;
font-size: 13px;
font-weight: 700;
letter-spacing: 2px;
&:hover,
&:focus {
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);
transform: translateY(-1px);
}
&.call-button--danger {
border-color: $color-voice-danger-border;
background: linear-gradient(100deg, $color-voice-danger-start, $color-voice-danger-middle, $color-voice-danger-end);
}
}
:deep(.mute-button) {
height: 30px;
border-color: $color-voice-panel-border;
color: $color-voice-control-text;
background: $color-voice-muted-background;
&:hover { color: $color-voice-white; border-color: $color-voice-cyan; background: $color-voice-muted-hover-background; }
}
.status-card {
width: min(100%, 550px);
margin-top: 20px;
padding: 8px 18px 8px;
border: 1px solid $color-voice-panel-border;
border-radius: 16px;
box-sizing: border-box;
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;
backdrop-filter: blur(16px);
.status-copy {
text-align: center;
p {
margin: 0;
color: $color-voice-card-primary-text;
font-size: 12px;
strong { color: $color-voice-white; font-weight: 600; }
}
span {
display: block;
margin-top: 2px;
color: $color-voice-card-secondary-text;
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 $color-voice-tag-divider;
span {
padding: 2px 8px;
border: 1px solid $color-voice-tag-border;
border-radius: 20px;
color: $color-voice-tag-text;
background: $color-voice-tag-background;
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>