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

// WebSocket 只承载控制 JSON 和二进制 PCM，不再使用 WebRTC、SDP 或 ICE。
const AUDIO_SERVER_URL = import.meta.env.VITE_AUDIO_SERVER_URL
const TERMINAL_ID = 'c2f8a06826baf03843925c1a2a13bcfd'
const speakerDeviceId = import.meta.env.VITE_SPEAKER_DEVICE_ID
const micDeviceId = import.meta.env.VITE_MIC_DEVICE_ID

// 后端约定的 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',
        terminalId: TERMINAL_ID,
        speakerDeviceId: speakerDeviceId,
        micDeviceId: micDeviceId
      })
    }

    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

  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: micDeviceId,
      terminalId: TERMINAL_ID,
      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: speakerDeviceId,
      terminalId: TERMINAL_ID,
      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: micDeviceId,
      terminalId: TERMINAL_ID
    })
    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: speakerDeviceId,
      terminalId: TERMINAL_ID
    })
    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>
