feat: 语音对话
This commit is contained in:
parent
c53faef9fe
commit
4540dfb3d7
@ -17,7 +17,7 @@ const service = axios.create({
|
|||||||
// axios中请求配置有baseURL选项,表示请求URL公共部分
|
// axios中请求配置有baseURL选项,表示请求URL公共部分
|
||||||
baseURL: import.meta.env.VITE_APP_BASE_API,
|
baseURL: import.meta.env.VITE_APP_BASE_API,
|
||||||
// 超时
|
// 超时
|
||||||
timeout: 100000
|
timeout: 10000
|
||||||
})
|
})
|
||||||
|
|
||||||
// request拦截器
|
// request拦截器
|
||||||
@ -117,7 +117,7 @@ service.interceptors.response.use(res => {
|
|||||||
} else if (message.includes("Request failed with status code")) {
|
} else if (message.includes("Request failed with status code")) {
|
||||||
message = "系统接口" + message.substr(message.length - 3) + "异常";
|
message = "系统接口" + message.substr(message.length - 3) + "异常";
|
||||||
}
|
}
|
||||||
ElMessage({ message: message, type: 'error', duration: 5 * 1000 })
|
ElMessage({ message: message, grouping: true, type: 'error', duration: 5 * 1000 })
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,966 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<section class="status-card">
|
||||||
|
<div class="status-copy">
|
||||||
|
<p>当前状态:<strong>{{ statusMeta.label }}</strong></p>
|
||||||
|
<span>{{ statusMeta.description }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="error-alert"
|
||||||
|
:title="errorMessage"
|
||||||
|
type="error"
|
||||||
|
:closable="true"
|
||||||
|
show-icon
|
||||||
|
@close="errorMessage = ''"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="feature-tags">
|
||||||
|
<span>高清语音</span>
|
||||||
|
<span>低延迟</span>
|
||||||
|
<span>加密传输</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 本地预览必须静音,以免麦克风声音回放造成啸叫。 -->
|
||||||
|
<audio ref="localAudio" autoplay muted playsinline></audio>
|
||||||
|
<audio ref="remoteAudio" autoplay playsinline></audio>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import { ElMessage, ElNotification } from 'element-plus'
|
||||||
|
|
||||||
|
// 请替换为实际部署的信令服务器地址。
|
||||||
|
const SIGNALING_SERVER_URL = 'wss://your-signaling-server.com'
|
||||||
|
const ROOM_NAME = 'voice-room'
|
||||||
|
const RTC_CONFIGURATION = {
|
||||||
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 音频元素及通话核心资源:分别保存本地/远端媒体流、WebRTC 连接和信令连接。
|
||||||
|
const localAudio = ref(null)
|
||||||
|
const remoteAudio = ref(null)
|
||||||
|
const localStream = ref(null)
|
||||||
|
const remoteStream = ref(null)
|
||||||
|
const peerConnection = ref(null)
|
||||||
|
const webSocket = ref(null)
|
||||||
|
|
||||||
|
// 页面交互状态:用于控制按钮、状态文案、动画和错误提示。
|
||||||
|
const isCalling = ref(false)
|
||||||
|
const isStarting = ref(false)
|
||||||
|
const isConnected = ref(false)
|
||||||
|
const isMuted = ref(false)
|
||||||
|
const connectionStatus = ref('idle')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
// 音量分析和异步通话过程使用的非响应式资源。
|
||||||
|
let audioContext = null
|
||||||
|
let analyser = null
|
||||||
|
let volumeAnimationFrame = null
|
||||||
|
// 远端 SDP 设置完成前收到的 ICE 候选会暂存在这里,避免 addIceCandidate 调用失败。
|
||||||
|
let pendingIceCandidates = []
|
||||||
|
// 每次开始或清理通话都会更新会话编号,用于让旧会话的异步回调自动失效。
|
||||||
|
let callSessionId = 0
|
||||||
|
|
||||||
|
// 将内部连接状态转换为页面展示所需的中文状态和引导文案。
|
||||||
|
const statusMeta = computed(() => {
|
||||||
|
const statusMap = {
|
||||||
|
idle: { label: '未连接', description: '点击按钮建立语音通道' },
|
||||||
|
connecting: { label: '连接中', description: '正在建立安全语音通道,请稍候' },
|
||||||
|
connected: { label: '通话中', description: '语音通道已建立,可以开始对讲' },
|
||||||
|
error: { label: '未连接', description: '连接出现异常,请检查后重试' }
|
||||||
|
}
|
||||||
|
return statusMap[connectionStatus.value] || statusMap.idle
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成当前房间的 WebSocket 信令地址。
|
||||||
|
* 将固定房间名放入 URL 查询参数,使信令服务器可以把消息转发给同房间的另一端。
|
||||||
|
* @returns {string} 带 room 参数的完整 WebSocket 地址
|
||||||
|
*/
|
||||||
|
function getSignalingUrl() {
|
||||||
|
// 如果原地址已经带有查询参数,则使用 & 继续拼接,否则使用 ? 开始查询参数。
|
||||||
|
const separator = SIGNALING_SERVER_URL.includes('?') ? '&' : '?'
|
||||||
|
// 对房间名进行 URL 编码,避免特殊字符破坏 WebSocket 地址。
|
||||||
|
return `${SIGNALING_SERVER_URL}${separator}room=${encodeURIComponent(ROOM_NAME)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一处理通话过程中的异常。
|
||||||
|
* 该方法会记录错误、更新页面状态,并使用 Element Plus 通知用户。
|
||||||
|
* @param {string} message 展示给用户的错误信息
|
||||||
|
* @param {Error} [error] 原始异常对象,主要用于控制台排查
|
||||||
|
*/
|
||||||
|
function showError(message, error) {
|
||||||
|
// 将完整错误输出到控制台,方便开发环境定位具体异常。
|
||||||
|
console.error(message, error || '')
|
||||||
|
// 保存用户可读的错误文案,以便状态卡显示错误提示。
|
||||||
|
errorMessage.value = message
|
||||||
|
// 将内部状态切换为错误状态,驱动状态灯和说明文字更新。
|
||||||
|
connectionStatus.value = 'error'
|
||||||
|
// 明确标记当前已经不处于 WebRTC 已连接状态。
|
||||||
|
isConnected.value = false
|
||||||
|
// 使用全局通知主动提醒用户本次通话发生异常。
|
||||||
|
ElNotification.error({ title: '语音对讲错误', message })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过已经连接的 WebSocket 发送一条信令消息。
|
||||||
|
* Offer、Answer 和 ICE 候选都通过此方法序列化为 JSON 后发送。
|
||||||
|
* @param {Object} message 符合约定格式的信令消息
|
||||||
|
* @throws {Error} WebSocket 未处于 OPEN 状态时抛出异常
|
||||||
|
*/
|
||||||
|
function sendSignalingMessage(message) {
|
||||||
|
// 读取当前 WebSocket 实例,避免后续多次访问响应式引用。
|
||||||
|
const socket = webSocket.value
|
||||||
|
// 只有 WebSocket 完全打开后才允许发送信令数据。
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
// 抛出异常交给上层统一显示和处理,避免静默丢失 Offer 或 ICE。
|
||||||
|
throw new Error('信令服务器尚未连接')
|
||||||
|
}
|
||||||
|
// 将信令对象序列化为 JSON 字符串后发送给服务器。
|
||||||
|
socket.send(JSON.stringify(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建并初始化 RTCPeerConnection。
|
||||||
|
* 作用包括:加入本地麦克风轨道、接收并播放远端音频、发送 ICE 候选,
|
||||||
|
* 以及监听连接状态来同步页面上的“连接中/通话中/未连接”状态。
|
||||||
|
* @param {number} sessionId 本次通话的会话编号,用于忽略过期回调
|
||||||
|
* @returns {RTCPeerConnection} 初始化完成的 WebRTC 连接实例
|
||||||
|
*/
|
||||||
|
function setupPeerConnection(sessionId) {
|
||||||
|
// 使用预设的 STUN 配置创建本次通话的 WebRTC 点对点连接。
|
||||||
|
const pc = new RTCPeerConnection(RTC_CONFIGURATION)
|
||||||
|
// 保存连接实例,供信令处理、挂断和组件卸载时使用。
|
||||||
|
peerConnection.value = pc
|
||||||
|
// 创建独立的远端媒体流,用于汇总对端发送过来的音频轨道。
|
||||||
|
remoteStream.value = new MediaStream()
|
||||||
|
|
||||||
|
// 将远端媒体流绑定到远端 audio 元素,轨道到达后即可播放。
|
||||||
|
if (remoteAudio.value) remoteAudio.value.srcObject = remoteStream.value
|
||||||
|
|
||||||
|
// 遍历本地麦克风流中的所有轨道,并将其发布到 WebRTC 连接中。
|
||||||
|
localStream.value.getTracks().forEach((track) => {
|
||||||
|
// addTrack 会触发 SDP 中相应媒体能力的协商。
|
||||||
|
pc.addTrack(track, localStream.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 对端音频轨道到达后,将轨道加入远端媒体流并交给 audio 元素播放。
|
||||||
|
pc.ontrack = (event) => {
|
||||||
|
// 如果回调属于已经结束的旧会话,则直接忽略,避免污染新通话。
|
||||||
|
if (sessionId !== callSessionId) return
|
||||||
|
// 读取事件携带的第一个远端媒体流,并逐条处理其中的轨道。
|
||||||
|
event.streams[0].getTracks().forEach((track) => {
|
||||||
|
// 根据轨道 ID 判断该轨道是否已经加入,防止重复添加。
|
||||||
|
const exists = remoteStream.value.getTracks().some((item) => item.id === track.id)
|
||||||
|
// 仅把尚未存在的远端轨道加入用于播放的媒体流。
|
||||||
|
if (!exists) remoteStream.value.addTrack(track)
|
||||||
|
})
|
||||||
|
// 主动调用播放,以兼容不会仅凭 autoplay 自动启动的浏览器。
|
||||||
|
remoteAudio.value?.play().catch(() => {
|
||||||
|
// 自动播放被浏览器策略阻止时,提示用户通过交互解除限制。
|
||||||
|
ElMessage.warning('浏览器阻止了远端音频自动播放,请点击页面后重试')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 浏览器发现新的本地 ICE 候选时,立即通过信令服务器发送给对端。
|
||||||
|
pc.onicecandidate = (event) => {
|
||||||
|
// 空候选表示本轮收集结束;旧会话产生的候选也无需发送。
|
||||||
|
if (!event.candidate || sessionId !== callSessionId) return
|
||||||
|
try {
|
||||||
|
// 按约定协议发送候选地址、媒体行索引和媒体标识。
|
||||||
|
sendSignalingMessage({
|
||||||
|
type: 'ice-candidate',
|
||||||
|
candidate: event.candidate.candidate,
|
||||||
|
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
||||||
|
sdpMid: event.candidate.sdpMid
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// 信令连接异常时进入统一错误处理流程。
|
||||||
|
showError('ICE 候选发送失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebRTC 连接状态变化时更新 UI,并对连接失败或断开进行统一报错。
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
// 忽略已经被清理的旧会话触发的状态变化。
|
||||||
|
if (sessionId !== callSessionId) return
|
||||||
|
// connected 表示 ICE、DTLS 和媒体通道均已成功建立。
|
||||||
|
if (pc.connectionState === 'connected') {
|
||||||
|
// 标记 WebRTC 已连接,供业务逻辑判断当前通话状态。
|
||||||
|
isConnected.value = true
|
||||||
|
// 切换为通话中状态,启用页面声波和光晕动画。
|
||||||
|
connectionStatus.value = 'connected'
|
||||||
|
// 成功连接后清除之前可能存在的错误信息。
|
||||||
|
errorMessage.value = ''
|
||||||
|
// 向用户提示语音通道已经成功建立。
|
||||||
|
ElMessage.success('实时语音连接已建立')
|
||||||
|
// failed 或 disconnected 表示媒体通道已经不可用。
|
||||||
|
} else if (['failed', 'disconnected'].includes(pc.connectionState)) {
|
||||||
|
// 将连接中断交给统一错误处理逻辑更新页面并提示用户。
|
||||||
|
showError('WebRTC 连接失败或已断开')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回实例,供 startCall 创建 Offer 和设置本地描述。
|
||||||
|
return pc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将暂存的远端 ICE 候选依次加入 WebRTC 连接。
|
||||||
|
* 必须在远端 SDP 设置完成后调用,否则部分浏览器会拒绝添加候选。
|
||||||
|
* @param {RTCPeerConnection} pc 当前 WebRTC 连接
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function flushPendingIceCandidates(pc) {
|
||||||
|
// 一次性取出并清空候选队列,防止同一候选被重复添加。
|
||||||
|
const candidates = pendingIceCandidates.splice(0)
|
||||||
|
// 按接收顺序逐一处理暂存的远端候选。
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
// 将候选加入连接,帮助浏览器尝试对应的网络传输路径。
|
||||||
|
await pc.addIceCandidate(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理信令服务器发来的消息。
|
||||||
|
* - offer:设置远端描述,创建并返回 answer;
|
||||||
|
* - answer:完成发起端的远端描述设置;
|
||||||
|
* - ice-candidate:立即添加候选,或等待远端 SDP 就绪后再添加。
|
||||||
|
* @param {MessageEvent} event WebSocket 消息事件
|
||||||
|
* @param {number} sessionId 本次通话的会话编号
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function handleSignalingMessage(event, sessionId) {
|
||||||
|
// 旧会话的 WebSocket 消息不应影响当前正在进行的新会话。
|
||||||
|
if (sessionId !== callSessionId) return
|
||||||
|
|
||||||
|
// 先声明解析后的消息变量,供后续不同信令类型共用。
|
||||||
|
let message
|
||||||
|
try {
|
||||||
|
// 将服务器发来的 JSON 字符串转换成可处理的信令对象。
|
||||||
|
message = JSON.parse(event.data)
|
||||||
|
} catch (error) {
|
||||||
|
// 非法 JSON 无法参与信令协商,向用户报告协议数据异常。
|
||||||
|
showError('收到的信令消息不是有效 JSON', error)
|
||||||
|
// 解析失败后立即结束本次消息处理。
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前 WebRTC 实例,后续 SDP 和 ICE 都需要写入该实例。
|
||||||
|
const pc = peerConnection.value
|
||||||
|
// 通话可能已经被清理,没有连接实例时直接忽略消息。
|
||||||
|
if (!pc) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 收到 Offer 表示对端希望发起或重新进行媒体能力协商。
|
||||||
|
if (message.type === 'offer') {
|
||||||
|
// 将对端 Offer 设置为远端描述,使浏览器了解对端媒体能力。
|
||||||
|
await pc.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp: message.sdp }))
|
||||||
|
// 远端描述就绪后,补充此前因时序原因暂存的 ICE 候选。
|
||||||
|
await flushPendingIceCandidates(pc)
|
||||||
|
// 根据远端 Offer 和本地能力生成应答 SDP。
|
||||||
|
const answer = await pc.createAnswer()
|
||||||
|
// 保存本地 Answer,使 WebRTC 连接进入正确的信令状态。
|
||||||
|
await pc.setLocalDescription(answer)
|
||||||
|
// 将 Answer 通过信令服务器返回给发起方。
|
||||||
|
sendSignalingMessage({ type: 'answer', sdp: answer.sdp })
|
||||||
|
// 收到 Answer 表示对端已经响应了本地发送的 Offer。
|
||||||
|
} else if (message.type === 'answer') {
|
||||||
|
// 设置远端 Answer,完成 SDP Offer/Answer 协商闭环。
|
||||||
|
await pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: message.sdp }))
|
||||||
|
// SDP 就绪后添加在 Answer 到达前收到的 ICE 候选。
|
||||||
|
await flushPendingIceCandidates(pc)
|
||||||
|
// ICE 候选用于告知本端对端可能可用的网络地址和传输路径。
|
||||||
|
} else if (message.type === 'ice-candidate') {
|
||||||
|
// 使用协议字段还原浏览器可识别的 RTCIceCandidate 对象。
|
||||||
|
const candidate = new RTCIceCandidate({
|
||||||
|
candidate: message.candidate,
|
||||||
|
sdpMLineIndex: message.sdpMLineIndex,
|
||||||
|
sdpMid: message.sdpMid
|
||||||
|
})
|
||||||
|
// 已有远端描述时可以立即把候选加入 WebRTC 连接。
|
||||||
|
if (pc.remoteDescription) await pc.addIceCandidate(candidate)
|
||||||
|
// 远端描述尚未到达时先缓存候选,等待 SDP 设置完成后处理。
|
||||||
|
else pendingIceCandidates.push(candidate)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 捕获 SDP 设置、Answer 创建或候选添加过程中的所有异常。
|
||||||
|
showError(`处理 ${message.type || '未知'} 信令失败`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立与信令服务器的 WebSocket 连接并注册消息、错误和关闭事件。
|
||||||
|
* WebSocket 只负责交换连接信息,实际语音数据建立连接后由 WebRTC 点对点传输。
|
||||||
|
* @param {number} sessionId 本次通话的会话编号
|
||||||
|
* @returns {Promise<WebSocket>} 连接成功后返回 WebSocket 实例
|
||||||
|
*/
|
||||||
|
function connectSignalingServer(sessionId) {
|
||||||
|
// 返回 Promise,让 startCall 可以等待 WebSocket 真正连接成功后再发送 Offer。
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 使用带房间参数的地址创建信令 WebSocket。
|
||||||
|
const socket = new WebSocket(getSignalingUrl())
|
||||||
|
// 保存实例,供发送信令及结束通话时关闭连接。
|
||||||
|
webSocket.value = socket
|
||||||
|
|
||||||
|
// 连接成功时兑现 Promise,允许后续 SDP 协商继续执行。
|
||||||
|
socket.onopen = () => resolve(socket)
|
||||||
|
// 每条服务端消息都交由统一的信令处理方法按 type 分发。
|
||||||
|
socket.onmessage = (event) => handleSignalingMessage(event, sessionId)
|
||||||
|
// 建连阶段发生错误时拒绝 Promise,由 startCall 捕获并清理资源。
|
||||||
|
socket.onerror = () => reject(new Error('WebSocket 连接失败'))
|
||||||
|
// 监听连接关闭,识别通话过程中意外断开信令服务器的情况。
|
||||||
|
socket.onclose = () => {
|
||||||
|
// 仅当前会话仍处于通话状态时才提示异常,主动挂断不应报错。
|
||||||
|
if (sessionId === callSessionId && isCalling.value) {
|
||||||
|
// 信令连接意外关闭时更新错误状态并提醒用户。
|
||||||
|
showError('信令服务器连接已断开')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Web Audio 音频分析器并持续读取麦克风频域数据。
|
||||||
|
* 当前用于保留实时音量分析能力,后续可将分析结果绑定到声波高度或音量条。
|
||||||
|
* @param {MediaStream} stream 本地麦克风媒体流
|
||||||
|
*/
|
||||||
|
function startVolumeMeter(stream) {
|
||||||
|
// 优先使用标准 AudioContext,并兼容旧版 Safari 的前缀实现。
|
||||||
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext
|
||||||
|
// 当前浏览器不支持 Web Audio API 时跳过分析,不影响正常通话。
|
||||||
|
if (!AudioContextClass) return
|
||||||
|
|
||||||
|
// 创建音频处理上下文,管理后续分析节点。
|
||||||
|
audioContext = new AudioContextClass()
|
||||||
|
// 创建频域分析器,用来读取麦克风的实时强度数据。
|
||||||
|
analyser = audioContext.createAnalyser()
|
||||||
|
// 设置 FFT 采样窗口;数值越小响应越快且计算开销越低。
|
||||||
|
analyser.fftSize = 256
|
||||||
|
// 将麦克风媒体流转换为 Web Audio 节点并连接到分析器。
|
||||||
|
audioContext.createMediaStreamSource(stream).connect(analyser)
|
||||||
|
// 根据分析器频率桶数量创建复用的数据缓冲区。
|
||||||
|
const samples = new Uint8Array(analyser.frequencyBinCount)
|
||||||
|
|
||||||
|
// 定义逐帧读取音量数据的内部循环。
|
||||||
|
const updateVolume = () => {
|
||||||
|
// 将当前频域强度写入 samples,后续可据此计算平均音量。
|
||||||
|
analyser.getByteFrequencyData(samples)
|
||||||
|
// 保留分析循环,为声波动画接入实时音量数据预留能力。
|
||||||
|
// 请求浏览器在下一次绘制前继续执行分析循环。
|
||||||
|
volumeAnimationFrame = requestAnimationFrame(updateVolume)
|
||||||
|
}
|
||||||
|
// 立即启动第一次分析,后续由 requestAnimationFrame 持续调度。
|
||||||
|
updateVolume()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始一次实时语音通话。
|
||||||
|
* 完整流程:防止重复启动 → 请求麦克风权限 → 创建 WebRTC 连接 →
|
||||||
|
* 连接信令服务器 → 创建本地 Offer → 发送 Offer 等待对端应答。
|
||||||
|
* 任一步骤失败都会显示错误并释放本次已创建的所有资源。
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function startCall() {
|
||||||
|
// 已在通话、正在启动或已有连接时禁止重复创建通话资源。
|
||||||
|
if (isCalling.value || isStarting.value || peerConnection.value) return
|
||||||
|
|
||||||
|
// 生成本次会话唯一编号,使之前异步任务的结果自动失效。
|
||||||
|
const sessionId = ++callSessionId
|
||||||
|
// 显示按钮加载状态,防止用户在初始化期间重复点击。
|
||||||
|
isStarting.value = true
|
||||||
|
// 立即将 UI 更新为连接中状态。
|
||||||
|
connectionStatus.value = 'connecting'
|
||||||
|
// 新一轮连接开始前清空上一次的错误提示。
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 请求用户授权并采集仅包含音频的本地媒体流。
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
// 启用浏览器回声消除,降低扬声器声音再次进入麦克风的影响。
|
||||||
|
echoCancellation: true,
|
||||||
|
// 启用环境噪声抑制,提高语音清晰度。
|
||||||
|
noiseSuppression: true,
|
||||||
|
// 启用自动增益,让不同音量的讲话声音更加稳定。
|
||||||
|
autoGainControl: true
|
||||||
|
},
|
||||||
|
// 明确禁止采集摄像头画面,本组件只进行语音通话。
|
||||||
|
video: false
|
||||||
|
})
|
||||||
|
// 等待权限期间用户可能已经离开或结束通话,因此需要再次校验会话编号。
|
||||||
|
if (sessionId !== callSessionId) {
|
||||||
|
// 过期媒体流必须立即停止,否则浏览器会继续占用麦克风。
|
||||||
|
stream.getTracks().forEach((track) => track.stop())
|
||||||
|
// 结束已失效的启动流程,不再创建连接。
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存麦克风媒体流,供 WebRTC 发布、静音和资源清理使用。
|
||||||
|
localStream.value = stream
|
||||||
|
// 将本地流绑定到静音 audio 元素,用于保持本地音频预览链路。
|
||||||
|
if (localAudio.value) localAudio.value.srcObject = stream
|
||||||
|
// 启动麦克风音量分析,为声波可视化提供数据基础。
|
||||||
|
startVolumeMeter(stream)
|
||||||
|
// 创建 WebRTC 实例并把本地音频轨道加入连接。
|
||||||
|
const pc = setupPeerConnection(sessionId)
|
||||||
|
// 等待信令服务器连接成功,确保随后的 Offer 不会发送失败。
|
||||||
|
await connectSignalingServer(sessionId)
|
||||||
|
|
||||||
|
// 根据本地音频能力生成发起协商所需的 SDP Offer。
|
||||||
|
const offer = await pc.createOffer()
|
||||||
|
// 设置本地描述,正式启动 ICE 候选收集过程。
|
||||||
|
await pc.setLocalDescription(offer)
|
||||||
|
// 按约定协议将 Offer 发送给同一房间的远端用户。
|
||||||
|
sendSignalingMessage({ type: 'offer', sdp: offer.sdp })
|
||||||
|
// 标记通话已启动;最终媒体连通状态仍由 connectionstatechange 更新。
|
||||||
|
isCalling.value = true
|
||||||
|
} catch (error) {
|
||||||
|
// 单独识别权限拒绝错误,为用户提供更明确的解决方式。
|
||||||
|
const permissionDenied = ['NotAllowedError', 'PermissionDeniedError'].includes(error.name)
|
||||||
|
// 根据错误类型生成提示,并进入统一错误状态。
|
||||||
|
showError(
|
||||||
|
permissionDenied
|
||||||
|
? '麦克风权限被拒绝,请在浏览器设置中允许访问'
|
||||||
|
: `开始通话失败:${error.message}`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
// 启动失败时释放此前可能已经创建的媒体流、连接和分析器。
|
||||||
|
cleanupResources()
|
||||||
|
} finally {
|
||||||
|
// 无论成功或失败都关闭按钮加载状态。
|
||||||
|
isStarting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换本地麦克风的静音状态。
|
||||||
|
* 通过修改音频轨道的 enabled 属性停止/恢复向对端发送声音,媒体流本身不会被销毁。
|
||||||
|
*/
|
||||||
|
function toggleMute() {
|
||||||
|
// 获取本地媒体流中的全部音频轨道;可选链避免尚未采集麦克风时报错。
|
||||||
|
const audioTracks = localStream.value?.getAudioTracks() || []
|
||||||
|
// 没有可控制的麦克风轨道时无需继续执行。
|
||||||
|
if (!audioTracks.length) return
|
||||||
|
|
||||||
|
// 反转当前静音状态,用于驱动按钮文案和轨道状态。
|
||||||
|
isMuted.value = !isMuted.value
|
||||||
|
// 同步控制所有本地音频轨道是否继续向 WebRTC 发送采样数据。
|
||||||
|
audioTracks.forEach((track) => {
|
||||||
|
// 静音时禁用轨道,取消静音时重新启用轨道。
|
||||||
|
track.enabled = !isMuted.value
|
||||||
|
})
|
||||||
|
// 向用户反馈本次静音切换结果。
|
||||||
|
ElMessage.info(isMuted.value ? '麦克风已静音' : '麦克风已取消静音')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放一次通话占用的全部资源。
|
||||||
|
* 包括停止音量分析、关闭 AudioContext/WebRTC/WebSocket、停止本地和远端轨道,
|
||||||
|
* 清空 audio 元素并重置通话相关状态。该方法可重复调用,供异常、挂断和卸载共同使用。
|
||||||
|
*/
|
||||||
|
function cleanupResources() {
|
||||||
|
// 更新会话编号,使当前会话尚未完成的所有异步回调立即失效。
|
||||||
|
callSessionId += 1
|
||||||
|
// 清空未处理 ICE 候选,防止带入下一次通话。
|
||||||
|
pendingIceCandidates = []
|
||||||
|
|
||||||
|
// 停止浏览器逐帧执行音量分析循环。
|
||||||
|
if (volumeAnimationFrame) cancelAnimationFrame(volumeAnimationFrame)
|
||||||
|
// 清除动画帧编号,确保状态与实际资源一致。
|
||||||
|
volumeAnimationFrame = null
|
||||||
|
// 断开分析节点与音频节点之间的连接。
|
||||||
|
analyser?.disconnect()
|
||||||
|
// 释放分析器引用,便于垃圾回收。
|
||||||
|
analyser = null
|
||||||
|
// 关闭 AudioContext;catch 用于忽略已经关闭导致的重复清理异常。
|
||||||
|
audioContext?.close().catch(() => {})
|
||||||
|
// 释放音频上下文引用。
|
||||||
|
audioContext = null
|
||||||
|
|
||||||
|
// 仅在 WebRTC 实例存在时执行事件解绑和关闭操作。
|
||||||
|
if (peerConnection.value) {
|
||||||
|
// 解除远端轨道监听,避免关闭过程中继续处理事件。
|
||||||
|
peerConnection.value.ontrack = null
|
||||||
|
// 解除 ICE 候选监听,避免关闭后继续发送信令。
|
||||||
|
peerConnection.value.onicecandidate = null
|
||||||
|
// 解除连接状态监听,避免主动关闭被误判为异常断开。
|
||||||
|
peerConnection.value.onconnectionstatechange = null
|
||||||
|
// 关闭底层 ICE、DTLS 和媒体传输连接。
|
||||||
|
peerConnection.value.close()
|
||||||
|
// 清空 WebRTC 实例引用。
|
||||||
|
peerConnection.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取当前 WebSocket,以便解绑事件并安全关闭。
|
||||||
|
const socket = webSocket.value
|
||||||
|
// 仅在信令连接实例存在时执行关闭操作。
|
||||||
|
if (socket) {
|
||||||
|
// 清除打开事件,避免延迟建连后继续执行旧流程。
|
||||||
|
socket.onopen = null
|
||||||
|
// 清除消息事件,避免关闭期间继续处理远端信令。
|
||||||
|
socket.onmessage = null
|
||||||
|
// 清除错误事件,主动关闭时不再显示异常。
|
||||||
|
socket.onerror = null
|
||||||
|
// 清除关闭事件,避免主动挂断触发“信令断开”提示。
|
||||||
|
socket.onclose = null
|
||||||
|
// 关闭与信令服务器的网络连接。
|
||||||
|
socket.close()
|
||||||
|
// 清空 WebSocket 实例引用。
|
||||||
|
webSocket.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止所有本地媒体轨道,真正释放浏览器对麦克风的占用。
|
||||||
|
localStream.value?.getTracks().forEach((track) => track.stop())
|
||||||
|
// 停止远端媒体轨道,结束远端音频播放和解码。
|
||||||
|
remoteStream.value?.getTracks().forEach((track) => track.stop())
|
||||||
|
// 清空本地媒体流引用。
|
||||||
|
localStream.value = null
|
||||||
|
// 清空远端媒体流引用。
|
||||||
|
remoteStream.value = null
|
||||||
|
// 移除本地 audio 元素绑定的媒体流。
|
||||||
|
if (localAudio.value) localAudio.value.srcObject = null
|
||||||
|
// 移除远端 audio 元素绑定的媒体流。
|
||||||
|
if (remoteAudio.value) remoteAudio.value.srcObject = null
|
||||||
|
|
||||||
|
// 恢复非通话状态,使页面重新显示开始通话按钮。
|
||||||
|
isCalling.value = false
|
||||||
|
// 清除 WebRTC 已连接标记。
|
||||||
|
isConnected.value = false
|
||||||
|
// 下次通话默认从非静音状态开始。
|
||||||
|
isMuted.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动结束当前通话。
|
||||||
|
* 在统一清理资源后,将页面恢复为“未连接”状态并向用户显示结束提示。
|
||||||
|
*/
|
||||||
|
function endCall() {
|
||||||
|
// 统一关闭连接并释放麦克风、媒体流和音频分析资源。
|
||||||
|
cleanupResources()
|
||||||
|
// 将状态恢复为未连接,更新状态标签和说明文案。
|
||||||
|
connectionStatus.value = 'idle'
|
||||||
|
// 主动结束后清除错误信息,避免旧错误继续显示。
|
||||||
|
errorMessage.value = ''
|
||||||
|
// 向用户确认通话已经结束。
|
||||||
|
ElMessage.success('通话已结束')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件离开页面时必须释放麦克风和网络连接,防止设备仍被占用或产生内存泄漏。
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
: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: 20px;
|
||||||
|
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>
|
||||||
@ -102,6 +102,9 @@
|
|||||||
<div class="monitor-container"></div>
|
<div class="monitor-container"></div>
|
||||||
<div class="split-line"></div>
|
<div class="split-line"></div>
|
||||||
<div class="container-title">语音对话</div>
|
<div class="container-title">语音对话</div>
|
||||||
|
<div class="voice-container">
|
||||||
|
<VoiceConversation />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -114,6 +117,7 @@ import RoboticArm from "./RoboticArm.vue";
|
|||||||
import { getRobotList } from '@/api/inspection/robot'
|
import { getRobotList } from '@/api/inspection/robot'
|
||||||
import { getJointStateApi, moveJApi, torqueOnApi, speedJApi, stopMotionApi } from '@/api/inspection/cockpit'
|
import { getJointStateApi, moveJApi, torqueOnApi, speedJApi, stopMotionApi } from '@/api/inspection/cockpit'
|
||||||
import { Remove, CirclePlus } from '@element-plus/icons-vue'
|
import { Remove, CirclePlus } from '@element-plus/icons-vue'
|
||||||
|
import VoiceConversation from "./VoiceConversation.vue";
|
||||||
|
|
||||||
const robotList = ref([])
|
const robotList = ref([])
|
||||||
|
|
||||||
@ -191,6 +195,8 @@ const jointNamsMap = {
|
|||||||
'joint_6': 'elfin_joint6',
|
'joint_6': 'elfin_joint6',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const errCount = ref(0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取关节状态
|
* 获取关节状态
|
||||||
* @param robot
|
* @param robot
|
||||||
@ -210,9 +216,19 @@ const getJointState = async (robot) => {
|
|||||||
})
|
})
|
||||||
}, 500)
|
}, 500)
|
||||||
} else {
|
} else {
|
||||||
|
errCount.value += 1
|
||||||
|
if (errCount.value > 2) {
|
||||||
|
clearInterval(animationId.value)
|
||||||
|
errCount.value = 0
|
||||||
|
}
|
||||||
console.error('获取关节状态失败:', res.message)
|
console.error('获取关节状态失败:', res.message)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
errCount.value += 1
|
||||||
|
if (errCount.value > 2) {
|
||||||
|
clearInterval(animationId.value)
|
||||||
|
errCount.value = 0
|
||||||
|
}
|
||||||
console.error('获取关节状态失败:', error)
|
console.error('获取关节状态失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -440,6 +456,11 @@ onUnmounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 360px;
|
height: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voice-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user