feat: 双向通话
This commit is contained in:
parent
d1e139ad71
commit
96ba1adb73
@ -28,6 +28,7 @@
|
||||
"urdf-loader": "^0.12.6",
|
||||
"vue": "^3.5.38",
|
||||
"vue-router": "^5.1.0",
|
||||
"ws": "^8.18.3",
|
||||
"xacro-parser": "^0.3.11",
|
||||
"xgplayer": "^3.0.24"
|
||||
},
|
||||
|
||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@ -56,6 +56,9 @@ importers:
|
||||
vue-router:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0(@vue/compiler-sfc@3.5.38)(pinia@3.0.4(vue@3.5.38))(vite@8.1.0(@types/node@26.0.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.38)
|
||||
ws:
|
||||
specifier: ^8.18.3
|
||||
version: 8.21.1
|
||||
xacro-parser:
|
||||
specifier: ^0.3.11
|
||||
version: 0.3.11
|
||||
@ -2373,6 +2376,18 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
ws@8.21.1:
|
||||
resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xacro-parser@0.3.11:
|
||||
resolution: {integrity: sha512-zyRzHmf6/BySLZQP77zp6iEtdryq1g7tWtfwj/28GOPyf9ijtPvouivOAeaA/yAu0/Bwh20DtA4x+BrYiV6qKQ==}
|
||||
|
||||
@ -4948,6 +4963,8 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.21.1: {}
|
||||
|
||||
xacro-parser@0.3.11:
|
||||
dependencies:
|
||||
expr-eval-fork: 3.0.3
|
||||
|
||||
170
server/audioWebSocket.js
Normal file
170
server/audioWebSocket.js
Normal file
@ -0,0 +1,170 @@
|
||||
import { WebSocketServer, WebSocket } from 'ws'
|
||||
import { createMicrophoneGrpcClient, createSpeakerGrpcClient } from './grpcClient.js'
|
||||
|
||||
// 设备实际返回 48kHz;上下行统一使用 48kHz,避免设备按固定采样率解释 44.1kHz 数据。
|
||||
const SAMPLE_RATE = 48000
|
||||
const CHANNELS = 2
|
||||
const MAX_BUFFERED_BYTES = SAMPLE_RATE * CHANNELS * 2 * 2
|
||||
|
||||
function sendJson(socket, payload) {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
function errorText(error) {
|
||||
return error?.details || error?.message || '机器人音频服务异常'
|
||||
}
|
||||
|
||||
function waitForGrpcReady(client, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.waitForReady(Date.now() + timeoutMs, (error) => {
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function attachAudioWebSocket(httpServer) {
|
||||
const wss = new WebSocketServer({ server: httpServer, path: '/audio' })
|
||||
|
||||
wss.on('connection', (socket) => {
|
||||
let initialized = false
|
||||
let speakerId = ''
|
||||
let microphoneStream
|
||||
let speakerStream
|
||||
let microphoneClient
|
||||
let speakerClient
|
||||
let remoteSampleRate = SAMPLE_RATE
|
||||
// 设为 0,确保首帧一定向浏览器发送真实声道数。
|
||||
let remoteChannels = 0
|
||||
let hasLoggedRemoteFormat = false
|
||||
let grpcReady = false
|
||||
let hasFailed = false
|
||||
let closing = false
|
||||
|
||||
const closeGrpc = () => {
|
||||
closing = true
|
||||
microphoneStream?.cancel()
|
||||
microphoneStream = undefined
|
||||
speakerStream?.end()
|
||||
speakerStream = undefined
|
||||
microphoneClient?.close()
|
||||
speakerClient?.close()
|
||||
microphoneClient = undefined
|
||||
speakerClient = undefined
|
||||
}
|
||||
|
||||
const fail = (error) => {
|
||||
if (hasFailed || closing) return
|
||||
hasFailed = true
|
||||
console.error('[audio] gRPC error:', error)
|
||||
sendJson(socket, {
|
||||
type: grpcReady ? 'error' : 'connection-failed',
|
||||
message: grpcReady
|
||||
? errorText(error)
|
||||
: `无法连接机器人音频服务:${errorText(error)}`
|
||||
})
|
||||
closeGrpc()
|
||||
}
|
||||
|
||||
socket.on('message', (data, isBinary) => {
|
||||
if (isBinary) {
|
||||
if (!initialized || !speakerStream || speakerStream.destroyed) return
|
||||
const pcm = Buffer.from(data)
|
||||
if (!pcm.length || pcm.length % 2 !== 0) return
|
||||
speakerStream.write({
|
||||
header: { device_id: speakerId },
|
||||
audio: {
|
||||
data: pcm,
|
||||
sample_rate: SAMPLE_RATE,
|
||||
channels: CHANNELS,
|
||||
format: 'PCM',
|
||||
codec: 'pcm_s16le',
|
||||
// nb_samples 表示每个声道的采样帧数,不是所有声道样本总数。
|
||||
nb_samples: pcm.length / (2 * CHANNELS)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let message
|
||||
try {
|
||||
message = JSON.parse(data.toString())
|
||||
} catch {
|
||||
sendJson(socket, { type: 'error', message: '无效的控制消息' })
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'stop') {
|
||||
closeGrpc()
|
||||
socket.close(1000, 'call ended')
|
||||
return
|
||||
}
|
||||
if (message.type !== 'init' || initialized) return
|
||||
|
||||
const { microphoneId, robotAddress } = message
|
||||
speakerId = message.speakerId?.trim()
|
||||
if (![microphoneId, speakerId, robotAddress].every((value) => typeof value === 'string' && value.trim())) {
|
||||
sendJson(socket, { type: 'error', message: '机器人地址、麦克风 ID 和扬声器 ID 不能为空' })
|
||||
socket.close(1008, 'invalid init')
|
||||
return
|
||||
}
|
||||
|
||||
initialized = true
|
||||
microphoneClient = createMicrophoneGrpcClient(robotAddress.trim())
|
||||
speakerClient = createSpeakerGrpcClient(robotAddress.trim())
|
||||
|
||||
// 只有两条 gRPC 通道都真正可用后,前端才进入“通话中”状态。
|
||||
Promise.all([
|
||||
waitForGrpcReady(microphoneClient),
|
||||
waitForGrpcReady(speakerClient)
|
||||
]).then(() => {
|
||||
if (hasFailed || socket.readyState !== WebSocket.OPEN) return
|
||||
grpcReady = true
|
||||
sendJson(socket, { type: 'ready', sampleRate: SAMPLE_RATE, channels: CHANNELS })
|
||||
}).catch(fail)
|
||||
|
||||
// 现有 proto 用一条服务端流和一条客户端流共同组成全双工音频通道。
|
||||
microphoneStream = microphoneClient.streamAudio({
|
||||
header: { device_id: microphoneId.trim() }
|
||||
})
|
||||
speakerStream = speakerClient.streamAudio((error, response) => {
|
||||
if (error) fail(error)
|
||||
else if (response?.header?.success === false) {
|
||||
fail(new Error(response.header.error_message || '机器人扬声器拒绝音频流'))
|
||||
}
|
||||
})
|
||||
|
||||
microphoneStream.on('data', (frame) => {
|
||||
const audio = frame?.audio
|
||||
if (!audio?.data?.length || socket.readyState !== WebSocket.OPEN) return
|
||||
if (!hasLoggedRemoteFormat) {
|
||||
hasLoggedRemoteFormat = true
|
||||
}
|
||||
const frameSampleRate = Number(audio.sample_rate) || SAMPLE_RATE
|
||||
const frameChannels = Number(audio.channels) || 1
|
||||
if (frameSampleRate !== remoteSampleRate || frameChannels !== remoteChannels) {
|
||||
remoteSampleRate = frameSampleRate
|
||||
remoteChannels = frameChannels
|
||||
// WebSocket 保证消息顺序,浏览器会先收到格式通知,再收到对应二进制帧。
|
||||
sendJson(socket, {
|
||||
type: 'audio-format',
|
||||
sampleRate: remoteSampleRate,
|
||||
channels: remoteChannels
|
||||
})
|
||||
}
|
||||
// 浏览器播放跟不上时丢弃新帧,防止内存持续增长。
|
||||
if (socket.bufferedAmount <= MAX_BUFFERED_BYTES) socket.send(audio.data, { binary: true })
|
||||
})
|
||||
microphoneStream.on('error', (error) => {
|
||||
if (error.code !== 1) fail(error) // CANCELLED(1) 是正常清理
|
||||
})
|
||||
microphoneStream.on('end', () => sendJson(socket, { type: 'remote-ended' }))
|
||||
speakerStream.on('error', fail)
|
||||
})
|
||||
|
||||
socket.on('close', closeGrpc)
|
||||
socket.on('error', (error) => console.error('[audio] WebSocket error:', error))
|
||||
})
|
||||
|
||||
return wss
|
||||
}
|
||||
@ -11,6 +11,17 @@ export const __dirname = path.dirname(__filename)
|
||||
const PROTO_ROOT = path.resolve(__dirname, './proto') // 指向 server/proto/
|
||||
const AGV_PROTO_ENTRY = path.join(PROTO_ROOT, 'cmvr/api/agv_service.proto')
|
||||
const ARM_PROTO_ENTRY = path.join(PROTO_ROOT, 'cmvr/api/arm_service.proto')
|
||||
const MICROPHONE_PROTO_ENTRY = path.join(PROTO_ROOT, 'cmvr/api/microphone_service.proto')
|
||||
const SPEAKER_PROTO_ENTRY = path.join(PROTO_ROOT, 'cmvr/api/speaker_service.proto')
|
||||
|
||||
const audioLoaderOptions = {
|
||||
includeDirs: [PROTO_ROOT, path.join(PROTO_ROOT, 'cmvr/api')],
|
||||
keepCase: true,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true
|
||||
}
|
||||
|
||||
const agvPackageDefinition = protoLoader.loadSync(
|
||||
AGV_PROTO_ENTRY,
|
||||
@ -36,8 +47,13 @@ const armPackageDefinition = protoLoader.loadSync(
|
||||
}
|
||||
)
|
||||
|
||||
const microphonePackageDefinition = protoLoader.loadSync(MICROPHONE_PROTO_ENTRY, audioLoaderOptions)
|
||||
const speakerPackageDefinition = protoLoader.loadSync(SPEAKER_PROTO_ENTRY, audioLoaderOptions)
|
||||
|
||||
const agvProto = grpc.loadPackageDefinition(agvPackageDefinition).cmvr.api
|
||||
const armProto = grpc.loadPackageDefinition(armPackageDefinition).cmvr.api
|
||||
const microphoneProto = grpc.loadPackageDefinition(microphonePackageDefinition).cmvr.api
|
||||
const speakerProto = grpc.loadPackageDefinition(speakerPackageDefinition).cmvr.api
|
||||
|
||||
export const createAGVGrpcClient = (address) => {
|
||||
const grpcClient = new agvProto.AgvService(
|
||||
@ -53,4 +69,14 @@ export const createARMGrpcClient = (address) => {
|
||||
grpc.credentials.createInsecure()
|
||||
)
|
||||
return grpcClient
|
||||
}
|
||||
}
|
||||
|
||||
export const createMicrophoneGrpcClient = (address) => new microphoneProto.MicPhoneService(
|
||||
address,
|
||||
grpc.credentials.createInsecure()
|
||||
)
|
||||
|
||||
export const createSpeakerGrpcClient = (address) => new speakerProto.SpeakerService(
|
||||
address,
|
||||
grpc.credentials.createInsecure()
|
||||
)
|
||||
|
||||
@ -5,6 +5,7 @@ import path from 'path';
|
||||
|
||||
import 'dotenv/config';
|
||||
import { createAGVGrpcClient, createARMGrpcClient, __dirname } from './grpcClient.js';
|
||||
import { attachAudioWebSocket } from './audioWebSocket.js';
|
||||
|
||||
const app = express()
|
||||
app.use(express.static(path.join(__dirname, '../dist')));
|
||||
@ -157,7 +158,6 @@ app.post('/api/agv/stopRobot', async (req, res) => {
|
||||
|
||||
// 获取机械臂末端位姿
|
||||
app.post('/api/arm/getPose', async (req, res) => {
|
||||
console.log('ssssssss')
|
||||
const request = {
|
||||
header: {
|
||||
device_id: req.body.deviceId || 'huayan_arm'
|
||||
@ -376,6 +376,8 @@ if (isProd) {
|
||||
|
||||
// ========== 启动 =========
|
||||
|
||||
app.listen(PORT, () => {
|
||||
const httpServer = app.listen(PORT, () => {
|
||||
console.log(`gRPC 桥接服务已启动: http://localhost:${PORT}`)
|
||||
})
|
||||
})
|
||||
|
||||
attachAudioWebSocket(httpServer)
|
||||
|
||||
@ -3,9 +3,11 @@ import { defineStore } from 'pinia'
|
||||
// 唯一id:robot
|
||||
export const useRobotStore = defineStore('robot', {
|
||||
state: () => ({
|
||||
ip: '192.168.1.110:50052',
|
||||
ip: '192.168.0.28:50052',
|
||||
agvDeviceId:'src1100',
|
||||
almDeviceId: 'aubo_arm',
|
||||
almDeviceId: 'huayan_arm',
|
||||
spkDeviceId: 'spk1',
|
||||
micDeviceId: 'mic1',
|
||||
position: { x: 0, y: 0, theta: 0 },
|
||||
battery: {
|
||||
percentage: 0.99,
|
||||
@ -35,6 +37,12 @@ export const useRobotStore = defineStore('robot', {
|
||||
setAlmDeviceId(str) {
|
||||
this.almDeviceId = str
|
||||
},
|
||||
setSpkDeviceId(str) {
|
||||
this.spkDeviceId = str
|
||||
},
|
||||
setMicDeviceId(str) {
|
||||
this.micDeviceId = str
|
||||
},
|
||||
// 更新机器人位置
|
||||
setPosition(data = {}) {
|
||||
this.position = data
|
||||
|
||||
@ -15,6 +15,12 @@
|
||||
<el-form-item label="机械臂设备ID" prop="almDeviceId">
|
||||
<el-input v-model="ruleForm.almDeviceId" placeholder="请输入机械臂设备ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="扬声器设备ID" prop="spkDeviceId">
|
||||
<el-input v-model="ruleForm.spkDeviceId" placeholder="请输入扬声器设备ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="麦克风设备ID" prop="micDeviceId">
|
||||
<el-input v-model="ruleForm.micDeviceId" placeholder="请输入麦克风设备ID" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button :loading="loading" @click="testConnect" class="connect">进入上位机控制页面</el-button>
|
||||
</div>
|
||||
@ -37,8 +43,10 @@ const loading = ref(false)
|
||||
const ruleFormRef = ref()
|
||||
const ruleForm = ref({
|
||||
ip: '',
|
||||
agvDeviceId:'',
|
||||
almDeviceId: ''
|
||||
agvDeviceId:'src1100',
|
||||
almDeviceId: 'huayan_arm',
|
||||
spkDeviceId: 'spk1',
|
||||
micDeviceId: 'mic1'
|
||||
})
|
||||
|
||||
const rules = ref({
|
||||
@ -51,6 +59,12 @@ const rules = ref({
|
||||
almDeviceId: [
|
||||
{ required: true, message: '请输入机械臂设备ID', trigger: 'blur' }
|
||||
],
|
||||
spkDeviceId: [
|
||||
{ required: true, message: '请输入扬声器设备ID', trigger: 'blur' }
|
||||
],
|
||||
micDeviceId: [
|
||||
{ required: true, message: '请输入麦克风设备ID', trigger: 'blur' }
|
||||
],
|
||||
})
|
||||
|
||||
const testConnect = async () => {
|
||||
@ -60,6 +74,8 @@ const testConnect = async () => {
|
||||
robotStore.setIp(ruleForm.value.ip)
|
||||
robotStore.setAgvDeviceId(ruleForm.value.agvDeviceId)
|
||||
robotStore.setAlmDeviceId(ruleForm.value.almDeviceId)
|
||||
robotStore.setSpkDeviceId(ruleForm.value.spkDeviceId)
|
||||
robotStore.setMicDeviceId(ruleForm.value.micDeviceId)
|
||||
// const result = await getRuntimeState({
|
||||
// ip: robotStore.ip,
|
||||
// deviceId: ruleForm.value.agvDeviceId
|
||||
|
||||
772
src/views/camera/VoiceConversation.vue
Normal file
772
src/views/camera/VoiceConversation.vue
Normal file
@ -0,0 +1,772 @@
|
||||
<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>
|
||||
|
||||
<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 { useRobotStore } from '@/stores/robot'
|
||||
|
||||
// 机器人实际音频流为 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 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
|
||||
})
|
||||
|
||||
/**
|
||||
* 统一显示通话异常。
|
||||
* @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 控制消息。
|
||||
* 此方法只允许发送 JSON;PCM 音频由 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
|
||||
// 单声道麦克风复制到左右声道,生成交错 S16LE:L0,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。
|
||||
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>
|
||||
@ -1,15 +1,34 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<IPlayer />
|
||||
<div class="video">
|
||||
<IPlayer />
|
||||
</div>
|
||||
<div class="audio">
|
||||
<VoiceConversation />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import IPlayer from '@/components/IPlayer/index.vue'
|
||||
import VoiceConversation from './VoiceConversation.vue';
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: $color-background-base;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.video {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.audio {
|
||||
width: 360px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user