Ai_GirlFriend/xuniYou/pages/chat/phone.vue
2026-03-05 17:18:04 +08:00

1761 lines
54 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view>
<view class="body">
<uni-nav-bar fixed statusBar left-icon="left" background-color="transparent" :border="false" @clickLeft="back"
color="#ffffff"></uni-nav-bar>
<image class="back"
:src="loverBasicList.image_url ? loverBasicList.image_url : 'https://nvlovers.oss-cn-qingdao.aliyuncs.com/uploads/20251226/39c6f8899c15f60fc59207835f95e07a.png'"
mode="aspectFill"></image>
</view>
<!-- 麦克风权限开关移到右上角 -->
<view class="mic-permission-switch" @click="toggleMicPermission">
<text class="mic-emoji">{{ micEnabled ? '🎤' : '🔇' }}</text>
</view>
<view class="header">
<view class="header_content">
<view class="header_time">{{ isVip ? 'VIP 无限通话' : '通话剩余时间' }}</view>
<view class="header_module fa sb" v-if="!isVip">
<view class="header_title faj">{{ formatTime(remainingTime).hours }}</view>
<view class="header_title faj">{{ formatTime(remainingTime).minutes }}</view>
<view class="header_title faj">{{ formatTime(remainingTime).seconds }}</view>
</view>
<view class="header_recharge faj" @click="goRecharge">充值<image src="/static/images/phone_more.png" mode="widthFix"></image>
</view>
</view>
</view>
<!-- 替换为动态脑电波效果 -->
<view class="dynamic-wave">
<view class="wave-bar" v-for="n in 15" :key="n" :style="{ 'animation-delay': n * 0.1 + 's' }"></view>
</view>
<view class="opt fa">
<view @click="hangUp()" class="opt_item">
<image class="opt_image" src="/static/images/phone_a2.png" mode="widthFix"></image>
<view class="opt_name">挂断</view>
</view>
<!-- 按住说话按钮 -->
<view class="opt_item mic-button"
@touchstart.stop.prevent="startTalking"
@touchend.stop.prevent="stopTalking"
@touchcancel.stop.prevent="stopTalking"
@click="testClick"
:class="{ 'talking': isTalking }">
<image class="opt_image" src="/static/images/phone_a1.png" mode="widthFix"></image>
<view class="opt_name">{{ isTalking ? '松开结束' : '按住说话' }}</view>
</view>
</view>
<view class="black"></view>
</view>
</template>
<script>
import {
} from '@/utils/api.js'
import notHave from '@/components/not-have.vue';
import { baseURLPy } from '@/utils/request.js'
import topSafety from '@/components/top-safety.vue';
// 在组件外部初始化 recorderManager所有平台统一使用
let recorderManager = null;
export default {
components: {
notHave,
topSafety,
},
beforeCreate() {
console.log('beforeCreate', )
// const domModule = uni.requireNativePlugin('io.dcolud.audio.recode')
// console.log('beforeCreate',domModule)
},
data() {
return {
loverBasicList: uni.getStorageSync('loverBasicList'),
socketTask: null,
isRecording: false,
status: 'Ready',
audioContext: null,
audioData: [],
totalDuration: 300000, // 默认 5 分钟
remainingTime: 300000,
timer: null,
isVip: false,
isTalking: false, // 是否正在说话
micEnabled: true, // 麦克风是否开启
isReconnecting: false, // 是否正在重连
recordStartTime: null, // 录音开始时间
baseURLPy: baseURLPy // 添加 baseURLPy 到 data
}
},
onLoad() {
// 检测平台
const systemInfo = uni.getSystemInfoSync()
console.log('systemInfo', systemInfo)
// 检查录音权限
this.checkRecordPermission()
// 所有平台统一使用 uni.getRecorderManager()
recorderManager = uni.getRecorderManager();
console.log('✅ recorderManager 初始化完成')
// 设置录音监听器(只设置一次)
this.setupRecorderListeners()
this.getCallDuration()
this.initAudio()
},
onUnload() {
this.stopCall()
if (this.timer) {
clearInterval(this.timer)
}
},
methods: {
// 检查录音权限
checkRecordPermission() {
// #ifdef APP-PLUS
console.log('📱 检查 Android 录音权限...')
// 检查录音权限
const permissions = ['android.permission.RECORD_AUDIO']
plus.android.requestPermissions(permissions, (result) => {
console.log('📱 录音权限检查结果:', result)
if (result.deniedAlways && result.deniedAlways.length > 0) {
console.error('❌ 录音权限被永久拒绝')
uni.showModal({
title: '权限不足',
content: '录音权限被拒绝,请在设置中手动开启录音权限',
showCancel: false
})
} else if (result.denied && result.denied.length > 0) {
console.warn('⚠️ 录音权限被临时拒绝')
uni.showToast({
title: '需要录音权限才能使用语音功能',
icon: 'none',
duration: 3000
})
} else {
console.log('✅ 录音权限已获取')
}
}, (error) => {
console.error('❌ 权限检查失败:', error)
})
// #endif
// #ifndef APP-PLUS
console.log('📱 非 APP 平台,跳过权限检查')
// #endif
},
getCallDuration() {
uni.request({
url: baseURLPy + '/voice/call/duration',
method: 'GET',
header: {
'Authorization': 'Bearer ' + uni.getStorageSync("token")
},
success: (res) => {
console.log('通话时长配置:', res.data)
if (res.data.code === 1 && res.data.data) {
const duration = res.data.data.duration
this.isVip = res.data.data.is_vip
if (duration > 0) {
this.totalDuration = duration
this.remainingTime = duration
} else {
// VIP 用户无限制,设置为 24 小时
this.totalDuration = 24 * 60 * 60 * 1000
this.remainingTime = 24 * 60 * 60 * 1000
}
}
this.connectWebSocket()
},
fail: (err) => {
console.error('获取通话时长失败:', err)
// 失败时使用默认值
this.connectWebSocket()
}
})
},
formatTime(ms) {
const totalSeconds = Math.floor(ms / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return {
hours: String(hours).padStart(2, '0'),
minutes: String(minutes).padStart(2, '0'),
seconds: String(seconds).padStart(2, '0')
}
},
startTimer() {
if (this.timer) {
clearInterval(this.timer)
}
this.timer = setInterval(() => {
this.remainingTime -= 1000
if (this.remainingTime <= 0) {
clearInterval(this.timer)
uni.showToast({
title: '通话时间已用完',
icon: 'none'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else if (this.remainingTime === 60000) {
// 剩余 1 分钟提示
uni.showToast({
title: '剩余 1 分钟',
icon: 'none',
duration: 2000
})
}
}, 1000)
},
hangUp() {
uni.showModal({
title: '提示',
content: '挂断改通话',
success: function(res) {
if (res.confirm) {
uni.navigateBack();
} else if (res.cancel) {
console.log('用户点击取消');
}
}
});
},
initAudio() {
// 销毁旧的音频上下文(如果存在)
if (this.audioContext) {
this.audioContext.destroy();
}
// 创建新的音频上下文
this.audioContext = uni.createInnerAudioContext()
this.audioContext.volume = 1
// 设置音频源类型
this.audioContext.srcType = 'auto'
// 添加音频事件监听
this.audioContext.onError((err) => {
console.error('音频播放失败:', err)
console.error('错误详情:', JSON.stringify(err))
uni.showToast({
title: '音频播放失败',
icon: 'none'
})
})
this.audioContext.onPlay(() => {
console.log('音频开始播放')
})
this.audioContext.onEnded(() => {
console.log('音频播放结束')
})
this.audioContext.onCanplay(() => {
console.log('音频可以播放了')
})
},
connectWebSocket() {
// 如果已经有连接且状态正常,不重复连接
if (this.socketTask && this.socketTask.readyState === 1) {
console.log('⚠️ WebSocket 已连接,跳过重复连接')
return
}
// 如果正在连接中,不重复连接
if (this.socketTask && this.socketTask.readyState === 0) {
console.log('⚠️ WebSocket 正在连接中,请稍候...')
return
}
// 关闭旧连接
if (this.socketTask) {
console.log('🔌 关闭旧的 WebSocket 连接...')
try {
this.socketTask.close()
} catch (e) {
console.error('关闭旧连接失败:', e)
}
this.socketTask = null
}
// 根据 baseURLPy 构建 WebSocket URL
let wsUrl = baseURLPy.replace('http://', 'ws://').replace('https://', 'wss://') + '/voice/call'
console.log('🔗 WebSocket URL:', wsUrl)
this.socketTask = uni.connectSocket({
url: wsUrl,
header: {
"content-type": "application/json",
'Authorization': 'Bearer ' + uni.getStorageSync("token") || ""
},
success: () => console.log('WS 连接成功')
});
this.socketTask.onOpen((res) => {
console.log('WebSocket onOpen:', res)
// 不要在这里自动开始录音,等用户按住按钮时再开始
// this.startRecording();
this.startTimer();
});
this.socketTask.onMessage((res) => {
console.log('📨 收到服务器消息, 数据类型:', typeof res.data)
console.log('📨 消息内容:', res.data)
this.handleServerMessage(res.data);
});
this.socketTask.onError((err) => {
console.error('❌ WS 错误', err);
console.error('错误详情:', JSON.stringify(err));
// 尝试重连
if (err.errMsg && err.errMsg.includes('timeout')) {
console.log('⚠️ WebSocket 超时,尝试重连...')
setTimeout(() => {
this.connectWebSocket()
}, 2000)
}
});
this.socketTask.onClose((res) => {
console.log('❌ WebSocket 关闭, code:', res.code, 'reason:', res.reason)
if (res.code !== 1000) {
console.error('⚠️ 非正常关闭')
}
if (this.isRecording && recorderManager) {
console.log('关闭录音')
recorderManager.stop()
}
// 自动重连(避免重复重连)
if (!this.isReconnecting) {
this.isReconnecting = true
console.log('⚠️ WebSocket 已关闭3秒后自动重连...')
setTimeout(() => {
console.log('🔄 尝试重新连接 WebSocket...')
this.isReconnecting = false
this.connectWebSocket()
}, 3000)
}
})
},
// 设置录音监听器(只在初始化时调用一次)
setupRecorderListeners() {
if (!recorderManager) {
console.error('❌ recorderManager 未初始化')
return
}
console.log('📝 设置录音监听器...')
// 监听录音开始
recorderManager.onStart(() => {
console.log('✅ 录音已开始')
console.log('当前时间:', new Date().toLocaleTimeString())
})
// 监听音频帧 - 实时发送(关键!)
let frameCount = 0
let hasReceivedFrames = false // 标记是否收到过音频帧
recorderManager.onFrameRecorded((res) => {
frameCount++
hasReceivedFrames = true
const { frameBuffer, isLastFrame } = res
console.log(`🎤 收到音频帧 #${frameCount}, isTalking:`, this.isTalking, 'frameBuffer size:', frameBuffer ? frameBuffer.byteLength : 'null')
if (!frameBuffer) {
console.error('❌ frameBuffer 为空!')
return
}
// 只有在说话状态下才发送音频数据
if (this.isTalking && this.socketTask && this.socketTask.readyState === 1) {
console.log('✅ 发送音频帧到服务器, 帧号:', frameCount)
this.socketTask.send({
data: frameBuffer,
success: () => {
console.log('✅ 音频帧发送成功, 帧号:', frameCount)
},
fail: (err) => {
console.error('❌ 音频帧发送失败:', err)
}
})
} else {
console.log('⏸️ 不发送音频帧 - isTalking:', this.isTalking)
}
})
// 添加测试:在录音开始后检查是否收到音频帧
setTimeout(() => {
if (!hasReceivedFrames) {
console.warn('⚠️ 警告:录音开始 2 秒后仍未收到音频帧onFrameRecorded 可能不工作')
console.warn('⚠️ 将使用备用方案:录音结束后发送完整文件')
}
}, 2000)
// 监听录音错误
recorderManager.onError((err) => {
console.error('❌ 录音错误:', err)
console.error('错误详情:', JSON.stringify(err))
uni.showToast({
title: '录音失败: ' + (err.errMsg || '未知错误'),
icon: 'none'
})
this.isRecording = false
})
// 监听录音停止
recorderManager.onStop((res) => {
const stopTime = Date.now()
const actualDuration = this.recordStartTime ? stopTime - this.recordStartTime : 0
console.log('⏹️ 录音已停止')
console.log('📅 录音停止时间:', new Date(stopTime).toLocaleTimeString())
console.log('⏱️ 实际录音时长:', actualDuration, 'ms')
console.log('📋 系统报告时长:', res.duration, 'ms')
console.log('📦 文件大小:', res.fileSize, 'bytes')
console.log('📁 文件路径:', res.tempFilePath)
console.log('📊 是否收到过音频帧:', hasReceivedFrames)
// 计算预期的录音时长
if (res.fileSize && res.fileSize > 0) {
// PCM 16kHz 单声道 16bit: 每秒 32000 字节
const calculatedDuration = (res.fileSize / 32000) * 1000 // 转换为毫秒
console.log('📊 根据文件大小计算的时长:', calculatedDuration.toFixed(0), 'ms')
if (actualDuration > 1000) { // 只有当实际录音时长超过1秒时才比较
const timeDiff = Math.abs(actualDuration - calculatedDuration)
if (timeDiff > 500) {
console.warn('⚠️ 录音数据丢失严重!')
console.warn('⚠️ 实际录音时长:', actualDuration, 'ms')
console.warn('⚠️ 系统报告时长:', res.duration, 'ms')
console.warn('⚠️ 文件大小计算时长:', calculatedDuration.toFixed(0), 'ms')
console.warn('⚠️ 数据丢失率:', ((actualDuration - calculatedDuration) / actualDuration * 100).toFixed(1), '%')
console.warn('⚠️ 可能的原因uni-app Android 录音 API 问题、设备性能限制、或系统录音限制')
// 提示用户数据丢失问题
uni.showToast({
title: `录音数据丢失${((actualDuration - calculatedDuration) / actualDuration * 100).toFixed(0)}%,识别可能不准确`,
icon: 'none',
duration: 3000
})
}
}
}
// 如果收到过音频帧,说明实时发送工作正常,只需发送结束信号
if (hasReceivedFrames) {
console.log('✅ 已通过实时音频帧发送,发送 ptt_off 信号')
if (this.socketTask && this.socketTask.readyState === 1) {
this.socketTask.send({
data: 'ptt_off',
success: () => {
console.log('✅ ptt_off 信号发送成功')
}
})
}
} else {
// 新的处理方式不通过WebSocket而是在onStop回调中通过HTTP发送
console.log('⚠️ 未收到音频帧,将在 onStop 回调中通过 HTTP 发送到 ASR 端点')
console.log('⚠️ 不发送 WebSocket 信号,避免触发旧的 finalize_asr 流程')
}
})
// 录音停止监听器
recorderManager.onStop((res) => {
console.log('⏹️ 录音已停止')
console.log('📅 录音停止时间:', new Date().toLocaleString())
console.log('⏱️ 实际录音时长:', res.duration, 'ms')
console.log('📋 系统报告时长:', res.duration, 'ms')
console.log('📦 文件大小:', res.fileSize, 'bytes')
console.log('📁 文件路径:', res.tempFilePath)
console.log('📊 是否收到过音频帧:', hasReceivedFrames)
this.isRecording = false
// 处理录音文件
if (!res.tempFilePath) {
console.error('❌ 没有录音文件')
hasReceivedFrames = false // 重置标记
frameCount = 0 // 重置计数
return
}
console.log('📁 开始处理录音文件:', res.tempFilePath)
// 使用之前成功的文件读取方法
let filePath = res.tempFilePath
if (!filePath.startsWith('/') && !filePath.includes('://')) {
if (typeof plus !== 'undefined' && plus.io) {
filePath = plus.io.convertLocalFileSystemURL(filePath)
}
}
console.log('📁 转换后文件路径:', filePath)
const that = this
if (typeof plus !== 'undefined' && plus.io) {
plus.io.resolveLocalFileSystemURL(filePath, (entry) => {
entry.file((file) => {
const reader = new plus.io.FileReader()
reader.onload = async (e) => {
const dataUrl = e.target.result
const base64 = dataUrl.split(',')[1]
const binaryString = atob(base64)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
console.log('✅ 文件读取成功开始发送到ASR')
console.log('📊 音频数据大小:', bytes.length, 'bytes')
// 发送到ASR端点进行处理
try {
const response = await that.sendAudioToASR(bytes)
console.log('✅ ASR处理成功:', response)
} catch (error) {
console.error('❌ ASR处理失败:', error)
}
}
reader.readAsDataURL(file)
}, (error) => {
console.error('❌ 文件读取失败:', error)
})
}, (error) => {
console.error('❌ 文件路径解析失败:', error)
})
} else {
console.error('❌ plus.io 不可用')
}
hasReceivedFrames = false // 重置标记
frameCount = 0 // 重置计数
})
console.log('✅ 所有录音监听器已设置')
},
// 切换麦克风权限开关
toggleMicPermission() {
this.micEnabled = !this.micEnabled
if (this.micEnabled) {
uni.showToast({
title: '麦克风已开启',
icon: 'none'
})
} else {
uni.showToast({
title: '麦克风已关闭',
icon: 'none'
})
// 如果正在说话,停止录音
if (this.isTalking) {
this.stopTalking()
}
}
},
// 测试点击
testClick() {
console.log('🔥🔥🔥 ===== testClick 被调用 ===== 🔥🔥🔥')
console.log('🔥🔥🔥 按钮被点击了!')
console.log('🔥 当前时间:', new Date().toLocaleTimeString())
uni.showToast({
title: '按钮可以点击',
icon: 'none'
})
},
// 开始说话(按住)
// 开始说话(按住)
async startTalking(e) {
console.log('🔥🔥🔥 ===== startTalking 被调用 ===== 🔥🔥🔥')
console.log('🔥 事件对象:', e)
console.log('🔥 当前时间:', new Date().toLocaleTimeString())
console.log('=== startTalking 被调用 ===')
console.log('事件对象:', e)
console.log('micEnabled:', this.micEnabled, 'isRecording:', this.isRecording)
if (!this.micEnabled) {
uni.showToast({
title: '请先开启麦克风权限',
icon: 'none'
})
return
}
// 检查 WebSocket 连接状态
if (!this.socketTask) {
console.error('❌ socketTask 不存在,尝试建立连接...')
uni.showToast({
title: '正在连接,请稍候...',
icon: 'loading',
duration: 2000
})
this.connectWebSocket()
// 等待连接建立(最多 3 秒)
for (let i = 0; i < 30; i++) {
await new Promise(resolve => setTimeout(resolve, 100))
if (this.socketTask && this.socketTask.readyState === 1) {
console.log('✅ WebSocket 连接成功')
uni.hideToast()
break
}
}
// 如果还是没有连接,放弃
if (!this.socketTask || this.socketTask.readyState !== 1) {
console.error('❌ WebSocket 连接超时')
uni.showToast({
title: 'WebSocket 连接失败,请重试',
icon: 'none'
})
return
}
}
if (this.socketTask.readyState !== 1) {
console.error('❌ WebSocket 未连接, 状态:', this.socketTask.readyState)
uni.showToast({
title: 'WebSocket 未连接,正在重连...',
icon: 'none'
})
this.connectWebSocket()
return
}
this.isTalking = true
console.log('✅ 开始说话, isTalking 设置为:', this.isTalking)
// 发送 ptt_on 信号
console.log('📤 发送 ptt_on 信号')
this.socketTask.send({
data: 'ptt_on',
success: () => {
console.log('✅ ptt_on 信号发送成功')
},
fail: (err) => {
console.error('❌ ptt_on 信号发送失败:', err)
}
})
// 如果录音还没开始,先启动录音
if (!this.isRecording) {
console.log('录音未启动,开始启动录音')
this.startRecording()
} else {
console.log('录音已在运行')
}
},
// 停止说话(松开)- 停止录音并发送
stopTalking(e) {
console.log('=== stopTalking 被调用 ===')
console.log('事件对象:', e)
console.log('当前 isTalking:', this.isTalking)
this.isTalking = false
console.log('❌ 停止说话, isTalking 设置为:', this.isTalking)
// 停止录音,触发 onStop 回调发送文件
if (this.isRecording && recorderManager) {
console.log('🛑 停止录音并准备发送...')
recorderManager.stop()
this.isRecording = false
}
},
// 开始录制
async startRecording() {
console.log('=== startRecording 被调用 ===')
console.log('isRecording:', this.isRecording)
console.log('socketTask 状态:', this.socketTask ? this.socketTask.readyState : 'null')
if (this.isRecording) {
console.log('录音已在进行中,跳过')
return
}
this.isRecording = true
this.status = 'Call Started'
// 检查 recorderManager
if (!recorderManager) {
console.error('❌ recorderManager 未初始化')
uni.showToast({
title: '录音功能初始化失败',
icon: 'none'
})
this.isRecording = false
return
}
console.log('🎙️ 启动 recorderManager')
try {
// 使用最稳定的录音配置
const recorderOptions = {
duration: 600000, // 10 分钟
sampleRate: 16000, // 必须 16kHz匹配服务器
numberOfChannels: 1, // 单声道
encodeBitRate: 48000, // WAV 格式的比特率
format: 'wav', // 改用 WAV 格式,兼容性最好
audioSource: 'mic' // 明确指定麦克风作为音频源
// 完全移除 frameSize避免任何实时处理
}
console.log('📋 录音参数:', JSON.stringify(recorderOptions))
console.log('⚠️ 注意:使用最稳定的完整文件模式,无实时处理')
// 记录开始时间
this.recordStartTime = Date.now()
console.log('📅 录音开始时间:', new Date(this.recordStartTime).toLocaleTimeString())
recorderManager.start(recorderOptions)
console.log('✅ recorderManager.start 已调用')
} catch (err) {
console.error('❌ 启动录音失败:', err)
this.isRecording = false
uni.showToast({
title: '启动录音失败: ' + err.message,
icon: 'none'
})
}
},
// 分片发送音频数据(按照官方推荐参数)
async sendAudioInChunks(audioData) {
// 官方推荐每包3200字节约100ms音频延迟100ms
// PCM 16kHz 单声道16000 * 2 * 0.1 = 3200 bytes/100ms
const chunkSize = 3200 // 3.2KB per chunk官方推荐
const chunkDelay = 100 // 100ms官方推荐
// 确保 audioData 是 ArrayBuffer
if (!(audioData instanceof ArrayBuffer)) {
console.error('❌ audioData 不是 ArrayBuffer类型:', typeof audioData)
uni.showToast({
title: '音频数据格式错误',
icon: 'none'
})
return
}
const totalSize = audioData.byteLength
let offset = 0
let chunkCount = 0
console.log('📦 开始分片发送(官方推荐参数)')
console.log('📊 总大小:', totalSize, 'bytes')
console.log('📊 每片大小:', chunkSize, 'bytes')
console.log('📊 发送间隔:', chunkDelay, 'ms')
console.log('📊 预计发送时间:', Math.ceil(totalSize / chunkSize) * chunkDelay, 'ms')
console.log('📊 预计录音时长:', (totalSize / 32000).toFixed(2), '秒')
// 显示加载提示
uni.showLoading({
title: '发送中...',
mask: true
})
// 使用 Promise 确保顺序发送
const sendChunk = (chunk, index) => {
return new Promise((resolve, reject) => {
console.log(`📤 发送第 ${index} 片,大小: ${chunk.byteLength} bytes`)
this.socketTask.send({
data: chunk,
success: () => {
console.log(`✅ 第 ${index} 片发送成功`)
resolve()
},
fail: (err) => {
console.error(`❌ 第 ${index} 片发送失败:`, err)
reject(err)
}
})
})
}
try {
// 顺序发送所有片段
while (offset < totalSize) {
const end = Math.min(offset + chunkSize, totalSize)
const chunk = audioData.slice(offset, end)
chunkCount++
await sendChunk(chunk, chunkCount)
offset = end
// 延迟指定时间再发送下一片(模拟实时流)
if (offset < totalSize) {
await new Promise(resolve => setTimeout(resolve, chunkDelay))
}
}
console.log('✅ 所有音频片段发送完成,共', chunkCount, '片')
console.log('📊 实际发送时间:', chunkCount * chunkDelay, 'ms')
// 延迟 100ms 后发送结束标记
await new Promise(resolve => setTimeout(resolve, 100))
// 发送结束标记
await new Promise((resolve, reject) => {
console.log('📤 发送结束标记 "end"')
this.socketTask.send({
data: 'end',
success: () => {
console.log('✅ 结束标记发送成功,等待服务器处理...')
uni.showLoading({
title: '识别中...',
mask: true
})
resolve()
},
fail: (err) => {
console.error('❌ 结束标记发送失败:', err)
reject(err)
}
})
})
} catch (err) {
console.error('❌ 发送过程出错:', err)
uni.hideLoading()
uni.showToast({
title: '发送失败',
icon: 'none'
})
}
},
stopCall() {
this.isRecording = false;
if (recorderManager) {
recorderManager.stop();
}
if (this.socketTask) {
this.socketTask.close();
}
if (this.audioContext) {
this.audioContext.pause();
this.audioContext.destroy();
}
if (this.timer) {
clearInterval(this.timer);
}
},
// 接收消息
async handleServerMessage(data) {
console.log('=== handleServerMessage 被调用 ===')
console.log('📥 接收到的信息, 类型:', typeof(data))
// 隐藏加载提示
uni.hideLoading()
if (data && typeof(data) == 'object') {
//处理数据流(音频数据)
this.audioData.push(data);
console.log('🎵 收到音频数据流, 当前缓存数量:', this.audioData.length, '本次大小:', data.byteLength)
} else {
//处理非数据流(控制消息)
try {
let dataInfo = JSON.parse(data)
console.log('📋 收到控制消息, type:', dataInfo.type)
console.log('📋 完整消息:', JSON.stringify(dataInfo))
if (dataInfo.type == 'reply_end') {
// 检测是否为 App 平台
// #ifdef APP-PLUS
uni.showLoading({
title: '思考中',
mask: true
});
console.log('reply_end (App平台)')
// 创建一个完整的 ArrayBuffer 来存储所有音频数据
const totalLength = this.audioData.reduce((acc, buffer) => acc + buffer.byteLength, 0)
console.log('totalLength:',totalLength)
const mergedArrayBuffer = new Uint8Array(totalLength)
// 将音频数据填充到mergedArrayBuffer中
let offset = 0;
for (let i = 0; i < this.audioData.length; i++) {
const buffer = new Uint8Array(this.audioData[i]);
mergedArrayBuffer.set(buffer, offset);
offset += buffer.byteLength;
}
const base64Audio = uni.arrayBufferToBase64(mergedArrayBuffer.buffer);
const base64WithPrefix = `data:audio/mp3;base64,${base64Audio}`;
const filePath = await new Promise((resolve) => {
console.log('this:',this)
const fileName = `_doc/${Date.now()}_numberPerson.mp3`;
this.base64ToFile(base64WithPrefix, fileName, (path) => {
console.log('pathpathpath',path)
resolve(path);
});
});
console.log('pathpathpathfilePath',filePath)
this.audioContext.src = filePath;
try {
uni.hideLoading()
console.log('尝试播放...');
this.audioContext.play();
// 清空音频数据
this.audioData = [];
} catch (delayError) {
console.error('播放失败:', delayError);
}
// #endif
// #ifndef APP-PLUS
this.mergeAudioData()
// #endif
}
if (dataInfo.type == 'reply_text') {
// #ifdef APP
uni.showLoading({
title: '思考中',
mask: true
});
// #endif
// #ifdef MP
uni.showLoading({
title: '思考中',
mask: true
});
// #endif
}
if (dataInfo.type == 'interrupt') {
this.audioContext.pause();
// uni.showToast({
// icon: "none",
// duration: 2000,
// title: "被打断"
// })
}
if (dataInfo.type == 'ready') {
// 准备好的
}
} catch (parseError) {
console.error('❌ JSON 解析失败:', parseError)
console.error('原始数据:', data)
}
}
},
base64ToFile(base64Str, fileName, callback) {
// https://blog.csdn.net/qq_45225600/article/details/149859602
var index = base64Str.indexOf(',');
var base64Str = base64Str.slice(index + 1, base64Str.length);
plus.io.requestFileSystem(plus.io.PRIVATE_DOC, function(fs) {
fs.root.getFile(fileName, { create: true }, function(entry) {
var fullPath = entry.fullPath;
let platform = uni.getSystemInfoSync().platform;
if (platform == 'android') {
// Android实现
var Base64 = plus.android.importClass("android.util.Base64");
var FileOutputStream = plus.android.importClass("java.io.FileOutputStream");
try {
var out = new FileOutputStream(fullPath);
var bytes = Base64.decode(base64Str, Base64.DEFAULT);
out.write(bytes);
out.close();
callback && callback(entry.toLocalURL());
} catch (e) {
console.log(e.message);
}
} else if (platform == 'ios') {
// iOS实现
var NSData = plus.ios.importClass('NSData');
var nsData = new NSData();
nsData = nsData.initWithBase64EncodedStringoptions(base64Str, 0);
if (nsData) {
nsData.plusCallMethod({ writeToFile: fullPath, atomically: true });
plus.ios.deleteObject(nsData);
}
callback && callback(entry.toLocalURL());
}
});
});
},
// 合并音频数据并播放
async mergeAudioData() {
console.log('this.audioData:',this.audioData)
if (this.audioData.length === 0) {
console.log('没有音频数据')
return null
}
try {
// 创建一个完整的 ArrayBuffer 来存储所有音频数据
const totalLength = this.audioData.reduce((acc, buffer) => acc + buffer.byteLength, 0)
console.log('totalLength:',totalLength)
const mergedArrayBuffer = new Uint8Array(totalLength)
console.log('mergedArrayBuffer:',mergedArrayBuffer)
let offset = 0
for (let i = 0; i < this.audioData.length; i++) {
const buffer = new Uint8Array(this.audioData[i])
mergedArrayBuffer.set(buffer, offset)
offset += buffer.byteLength
}
// 将合并后的 ArrayBuffer 保存为文件并播放
const fileName = `recording_${Date.now()}.mp3`
let filePath;
// #ifdef APP-PLUS
// App端音频播放处理
try {
// 重新初始化音频上下文,确保状态正确
this.initAudio();
// 先检查存储权限
if (typeof plus !== 'undefined' && plus.android) {
const Context = plus.android.importClass('android.content.Context');
const Environment = plus.android.importClass('android.os.Environment');
const permissions = ['android.permission.READ_EXTERNAL_STORAGE', 'android.permission.WRITE_EXTERNAL_STORAGE'];
plus.android.requestPermissions(permissions, (res) => {
if (res.deniedAlways.length > 0) {
console.error('存储权限被永久拒绝');
}
});
}
// 方案1尝试使用Blob URL直接播放如果支持
if (typeof Blob !== 'undefined' && typeof URL !== 'undefined' && URL.createObjectURL) {
console.log('尝试使用Blob URL播放...');
const blob = new Blob([mergedArrayBuffer.buffer], { type: 'audio/mpeg' });
const blobURL = URL.createObjectURL(blob);
console.log('Blob URL:', blobURL);
this.audioContext.src = blobURL;
} else {
// 方案2写入文件后播放
console.log('尝试使用文件方式播放...');
filePath = `${plus.io.PUBLIC_DOWNLOADS}/${fileName}`;
const fileURL = await this.writeFileApp(mergedArrayBuffer.buffer, filePath);
console.log('文件播放路径:', fileURL);
// 尝试多种路径格式
let finalURL = fileURL;
// 如果fileURL是file://格式,尝试去除协议
if (fileURL.startsWith('file://')) {
const pathWithoutProtocol = fileURL.substring(7);
console.log('尝试无协议路径:', pathWithoutProtocol);
}
// 尝试使用plus.io.convertLocalFileSystemURL转换
const convertedURL = plus.io.convertLocalFileSystemURL(filePath);
console.log('转换后的路径:', convertedURL);
this.audioContext.src = finalURL;
}
} catch (error) {
console.error('App端音频播放准备失败:', error);
// 回退到简单的文件写入方式
filePath = `${plus.io.PUBLIC_DOWNLOADS}/${fileName}`;
const fileURL = await this.writeFileApp(mergedArrayBuffer.buffer, filePath);
this.audioContext.src = fileURL;
}
// #endif
// #ifndef APP-PLUS
// 小程序端使用uni API写入文件
filePath = `${uni.env.USER_DATA_PATH}/${fileName}`;
await this.writeFileMiniProgram(mergedArrayBuffer.buffer, filePath);
this.audioContext.src = filePath;
// #endif
console.log('最终音频源:', this.audioContext.src)
// 播放音频
try {
console.log('开始播放...');
// 确保音频上下文已正确创建
if (!this.audioContext) {
console.error('音频上下文未初始化');
this.initAudio();
this.audioContext.src = filePath;
}
// 先设置src再播放
this.audioContext.play();
// 清空音频数据
this.audioData = [];
} catch (playError) {
console.error('音频播放异常:', playError);
console.error('异常详情:', JSON.stringify(playError));
// 尝试延迟播放
setTimeout(() => {
try {
console.log('尝试延迟播放...');
this.audioContext.play();
} catch (delayError) {
console.error('延迟播放也失败:', delayError);
}
}, 500);
this.audioData = [];
}
} catch (error) {
console.error('合并音频数据失败:', error)
return null
}
},
// App端文件写入方法
writeFileApp(buffer, filePath) {
console.log('App端文件写入方法:',buffer, filePath)
return new Promise((resolve, reject) => {
console.log('plus.io.requestFileSystem',plus.io.requestFileSystem)
console.log('plus.io.PUBLIC_DOWNLOADS',plus.io.PUBLIC_DOWNLOADS)
plus.io.requestFileSystem(plus.io.PUBLIC_DOWNLOADS, (fs) => {
console.log('fs.root:',fs.root)
const fileName = filePath.split('/').pop();
fs.root.getFile(fileName, {create: true, exclusive: false}, (fileEntry) => {
console.log('fileEntry',fileEntry)
fileEntry.createWriter((writer) => {
console.log('writer:',writer)
console.log('writer 的方法:', Object.keys(writer))
console.log('buffer type:', typeof buffer, 'buffer instanceof ArrayBuffer:', buffer instanceof ArrayBuffer)
console.log('Blob 是否存在:', typeof Blob !== 'undefined')
console.log('准备进入 try 块...')
try {
console.log('已进入 try 块')
// 检查 Blob 是否可用,如果不可用则尝试直接写入 ArrayBuffer
let writeData;
if (typeof Blob !== 'undefined') {
// 先将 ArrayBuffer 转换为 Blob再写入
console.log('开始创建 Blob...')
writeData = new Blob([buffer], { type: 'audio/mpeg' });
console.log('Blob 创建成功:', writeData, 'size:', writeData.size)
} else {
// Blob 不可用,直接使用 ArrayBuffer
console.log('Blob 不可用,直接使用 ArrayBuffer')
writeData = buffer;
}
// 先设置所有事件监听器
console.log('开始设置事件监听器...')
writer.onerror = (err) => {
console.error('App端文件写入失败:', err)
reject(err)
}
writer.onwriteend = (e) => {
console.log('App端文件写入完成', e)
// 获取文件信息验证
fileEntry.file((file) => {
console.log('文件信息:', {
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified
})
// 验证文件大小是否大于0
if (file.size === 0) {
console.error('警告: 文件大小为0可能写入失败')
}
}, (err) => {
console.error('获取文件信息失败:', err)
})
// 获取文件路径的多种方式
const toURL = fileEntry.toURL();
const fullPath = fileEntry.fullPath;
console.log('toURL():', toURL)
console.log('fullPath:', fullPath)
let fileURL;
// 优先使用 toURL()如果返回的是有效路径file://开头)
if (toURL && toURL.startsWith('file://')) {
fileURL = toURL;
console.log('使用 toURL() 路径:', fileURL)
} else if (fullPath && fullPath.startsWith('/')) {
// fullPath 是绝对路径(如 /storage/emulated/0/...
// 直接添加 file:// 前缀
fileURL = 'file://' + fullPath;
console.log('使用 fullPath 并添加 file:// 前缀:', fileURL)
} else if (toURL) {
// toURL() 返回相对路径(如 _downloads/xxx.mp3尝试转换
fileURL = plus.io.convertLocalFileSystemURL(toURL);
console.log('使用 toURL() 相对路径转换:', fileURL)
// 如果转换后仍不是 file:// 开头,手动添加
if (!fileURL || !fileURL.startsWith('file://')) {
// 尝试用 fullPath如果可用
if (fullPath && fullPath.startsWith('/')) {
fileURL = 'file://' + fullPath;
console.log('回退到 fullPath 并添加 file:// 前缀:', fileURL)
}
}
}
console.log('App端最终文件URL:', fileURL)
resolve(fileURL)
}
writer.ontruncate = (e) => {
console.log('App端文件清空完成开始写入', e)
console.log('writer.readyState:', writer.readyState)
// truncate 完成后再写入数据
console.log('准备写入数据:', writeData, '类型:', typeof writeData, '是否是ArrayBuffer:', writeData instanceof ArrayBuffer)
// 添加写入开始监听
writer.onwritestart = (e) => {
console.log('文件写入开始', e)
}
// 如果 Blob 不可用ArrayBuffer 需要转换为 Uint8Array 的 buffer
if (writeData instanceof ArrayBuffer) {
console.log('写入 ArrayBuffer大小:', writeData.byteLength, 'bytes')
// FileWriter.write() 应该支持 ArrayBuffer但需要确保格式正确
writer.write(writeData)
} else {
console.log('写入 Blob大小:', writeData.size)
writer.write(writeData)
}
}
console.log('事件监听器设置完成,开始 seek...')
// 清空文件内容(如果文件已存在)
// 先定位到文件开头
writer.seek(0);
console.log('seek 完成,开始 truncate...')
// 然后清空文件,这会触发 ontruncate 事件
writer.truncate(0);
console.log('truncate 调用完成')
} catch (error) {
console.error('写入过程中发生错误:', error)
reject(error)
}
}, (err) => {
console.error('App端创建写入器失败:', err)
reject(err)
})
}, (err) => {
console.error('App端获取文件失败:', err)
reject(err)
})
}, (err) => {
console.error('App端请求文件系统失败:', err)
reject(err)
})
})
},
// 小程序端文件写入方法
writeFileMiniProgram(buffer, filePath) {
return new Promise((resolve, reject) => {
const fs = uni.getFileSystemManager()
fs.writeFile({
filePath: filePath,
data: buffer,
encoding: 'binary',
success: (res) => {
console.log('小程序端文件写入成功:', res)
resolve(filePath)
},
fail: (err) => {
console.error('小程序端文件写入失败:', err)
reject(err)
}
})
})
},
back() {
uni.navigateBack({
delta: 1,
});
},
generateClick() {
uni.navigateBack({
delta: 2,
});
},
// 发送音频到ASR端点进行处理
async sendAudioToASR(audioBytes) {
console.log('📤 开始发送音频进行语音对话')
console.log('📊 音频数据大小:', audioBytes.length, 'bytes')
// 显示加载提示
uni.showLoading({
title: '对话处理中...',
mask: true
})
try {
// 将音频数据转换为base64
let base64Audio = ''
for (let i = 0; i < audioBytes.length; i++) {
base64Audio += String.fromCharCode(audioBytes[i])
}
base64Audio = btoa(base64Audio)
console.log('📤 发送语音对话请求...')
const response = await uni.request({
url: this.baseURLPy + '/voice/call/conversation',
method: 'POST',
header: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + uni.getStorageSync("token")
},
data: {
audio_data: base64Audio,
format: 'wav'
}
})
console.log('✅ 对话响应:', response)
// 隐藏加载提示
uni.hideLoading()
if (response.statusCode === 200 && response.data) {
const result = response.data
// 后端返回格式: {code: 1, msg: "ok", data: {user_text, ai_text, audio_data}}
const data = result.data || result
const userText = data.user_text
const aiText = data.ai_text
const audioData = data.audio_data
console.log('✅ 识别结果:', userText)
console.log('✅ AI回复:', aiText)
console.log('✅ 音频数据:', audioData ? `${audioData.length} 字符` : '无')
// 显示识别结果
if (userText) {
uni.showToast({
title: `你说: ${userText}`,
icon: 'none',
duration: 2000
})
}
// 播放 AI 的语音回复
if (audioData) {
console.log('🔊 开始播放 AI 语音回复...')
console.log('🔊 音频数据前100字符:', audioData.substring(0, 100))
await this.playAIVoice(audioData, aiText)
} else {
console.warn('⚠️ 没有收到音频数据')
// 如果没有语音数据,至少显示文字
if (aiText) {
setTimeout(() => {
uni.showToast({
title: `AI: ${aiText}`,
icon: 'none',
duration: 3000
})
}, 2000)
}
}
return result
} else {
throw new Error(`对话请求失败: ${response.statusCode}`)
}
} catch (error) {
console.error('❌ 对话请求失败:', error)
// 隐藏加载提示
uni.hideLoading()
uni.showToast({
title: '对话处理失败',
icon: 'none',
duration: 2000
})
throw error
}
},
async playAIVoice(base64Audio, aiText) {
console.log('🔊 playAIVoice 被调用')
console.log('🔊 base64Audio 长度:', base64Audio ? base64Audio.length : 0)
console.log('🔊 aiText:', aiText)
if (!base64Audio) {
console.error('❌ 没有音频数据')
return
}
try {
// 显示 AI 回复文字
if (aiText) {
uni.showToast({
title: `AI: ${aiText.substring(0, 20)}...`,
icon: 'none',
duration: 3000
})
}
// 解码 base64 为二进制数据
console.log('📦 开始解码 base64...')
const binaryString = atob(base64Audio)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
console.log('✅ 音频数据解码完成,大小:', bytes.length, 'bytes')
// #ifdef APP-PLUS
// APP 环境 - 简化实现
console.log('📱 APP 环境,保存并播放音频')
const fileName = `ai_voice_${Date.now()}.mp3`
const filePath = `_doc/${fileName}`
// 使用 plus.io 保存文件
plus.io.resolveLocalFileSystemURL('_doc/', (entry) => {
console.log('✅ 获取 _doc 目录成功')
entry.getFile(fileName, {create: true}, (fileEntry) => {
console.log('✅ 创建文件成功:', fileName)
fileEntry.createWriter((writer) => {
writer.onwrite = () => {
console.log('✅ 文件写入成功')
console.log('📁 文件路径:', fileEntry.fullPath)
// 播放音频
console.log('🎵 创建音频上下文...')
const audioContext = uni.createInnerAudioContext()
// 使用完整路径
const fullPath = fileEntry.fullPath
console.log('🎵 设置音频源:', fullPath)
audioContext.src = fullPath
audioContext.autoplay = true
audioContext.onPlay(() => {
console.log('🔊 AI 语音开始播放')
})
audioContext.onEnded(() => {
console.log('✅ AI 语音播放完成')
// 清理
audioContext.destroy()
fileEntry.remove(() => {
console.log('🗑️ 临时文件已清理')
})
})
audioContext.onError((error) => {
console.error('❌ 播放失败:', error)
console.error('错误详情:', JSON.stringify(error))
uni.showToast({
title: '播放失败',
icon: 'none'
})
audioContext.destroy()
})
}
writer.onerror = (error) => {
console.error('❌ 文件写入失败:', error)
uni.showToast({
title: '文件保存失败',
icon: 'none'
})
}
// 写入数据
console.log('📝 开始写入文件...')
const blob = new Blob([bytes.buffer], {type: 'audio/mp3'})
writer.write(blob)
}, (error) => {
console.error('❌ 创建 writer 失败:', error)
})
}, (error) => {
console.error('❌ 创建文件失败:', error)
console.error('错误详情:', JSON.stringify(error))
})
}, (error) => {
console.error('❌ 获取文件系统失败:', error)
console.error('错误详情:', JSON.stringify(error))
})
// #endif
// #ifdef MP-WEIXIN
// 微信小程序
console.log('📱 微信小程序环境')
const fs = uni.getFileSystemManager()
const tempFilePath = `${wx.env.USER_DATA_PATH}/ai_voice_${Date.now()}.mp3`
fs.writeFileSync(tempFilePath, bytes.buffer, 'binary')
console.log('✅ 临时文件已保存:', tempFilePath)
const innerAudioContext = uni.createInnerAudioContext()
innerAudioContext.src = tempFilePath
innerAudioContext.autoplay = true
innerAudioContext.onPlay(() => {
console.log('🔊 AI 语音开始播放')
})
innerAudioContext.onEnded(() => {
console.log('✅ AI 语音播放完成')
try {
fs.unlinkSync(tempFilePath)
console.log('🗑️ 临时文件已清理')
} catch (e) {
console.warn('清理临时文件失败:', e)
}
})
innerAudioContext.onError((error) => {
console.error('❌ AI 语音播放失败:', error)
uni.showToast({
title: '语音播放失败',
icon: 'none'
})
})
// #endif
// #ifdef H5
// H5 环境
console.log('🌐 H5 环境,使用 Blob URL')
const blob = new Blob([bytes.buffer], {type: 'audio/mp3'})
const blobUrl = URL.createObjectURL(blob)
console.log('✅ Blob URL 创建成功:', blobUrl)
const innerAudioContext = uni.createInnerAudioContext()
innerAudioContext.src = blobUrl
innerAudioContext.autoplay = true
innerAudioContext.onPlay(() => {
console.log('🔊 AI 语音开始播放')
})
innerAudioContext.onEnded(() => {
console.log('✅ AI 语音播放完成')
URL.revokeObjectURL(blobUrl)
console.log('🗑️ Blob URL 已释放')
})
innerAudioContext.onError((error) => {
console.error('❌ AI 语音播放失败:', error)
uni.showToast({
title: '语音播放失败',
icon: 'none'
})
})
// #endif
} catch (error) {
console.error('❌ 播放 AI 语音失败:', error)
console.error('错误类型:', error.name)
console.error('错误消息:', error.message)
console.error('错误堆栈:', error.stack)
uni.showToast({
title: '语音播放失败',
icon: 'none'
})
}
},
goRecharge() {
uni.showToast({
title: '充值功能开发中',
icon: 'none'
})
}
}
}
</script>
<style>
/* page 选择器在 nvue 中不支持,已移除 */
</style>
<style>
.body {
position: relative;
}
.back {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: block;
}
.body {
position: relative;
padding: 0 60rpx;
}
.header {
position: absolute;
right: 28rpx;
top: 11%;
z-index: 5;
}
.header_content {
position: relative;
padding: 12rpx 24rpx;
background: linear-gradient(135deg, rgba(159, 71, 255, 0.6) 0%, rgba(0, 83, 250, 0.6) 100%);
border-radius: 12rpx;
text-align: center;
}
.header_time {
font-weight: 400;
font-size: 24rpx;
color: #FFFFFF;
line-height: 50rpx;
}
.header_module {
position: relative;
}
.header_title {
margin: 0 8rpx 0 0;
padding: 0 12rpx;
font-weight: 400;
font-size: 24rpx;
color: #FFFFFF;
line-height: 50rpx;
background: linear-gradient(135deg, rgba(159, 71, 255, 0.6) 0%, rgba(0, 83, 250, 0.6) 100%);
border-radius: 12rpx;
}
.header_title:nth-child(3) {
margin: 0 0 0 0;
}
.header_recharge {
position: relative;
font-weight: 400;
font-size: 24rpx;
color: #FFFFFF;
line-height: 50rpx;
}
.header_recharge image {
margin: 0 0 0 10rpx;
width: 6rpx;
height: 10rpx;
}
/* 删除原来的voice样式添加新的动态波形样式 */
.dynamic-wave {
position: absolute;
left: 0;
right: 0;
bottom: 50%;
width: 300rpx;
/* 增加整体宽度 */
margin: 0 auto;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
height: 150rpx;
/* 增加容器高度 */
}
.wave-bar {
width: 8rpx;
/* 增加单个波形条的宽度 */
height: 40rpx;
/* 增加基础高度 */
margin: 0 4rpx;
/* 增加间距 */
background: linear-gradient(to top, #9f47ff, #0053fa);
border-radius: 4rpx;
/* 调整圆角 */
animation: wave 1.5s infinite ease-in-out;
}
@keyframes wave {
0%,
100% {
height: 40rpx;
/* 调整基础高度 */
opacity: 0.6;
}
50% {
height: 120rpx;
/* 调整最大高度 */
opacity: 1;
}
}
/* 麦克风权限开关样式 */
.mic-permission-switch {
position: absolute;
right: 28rpx;
top: 100rpx;
z-index: 10;
width: 80rpx;
height: 80rpx;
background: rgba(0, 0, 0, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10rpx);
}
.mic-emoji {
font-size: 48rpx;
}
.opt {
position: absolute;
left: 0;
right: 0;
bottom: 15%;
margin: 0 auto;
z-index: 5;
display: flex;
justify-content: center;
gap: 100rpx;
}
.opt_item {
position: relative;
}
.opt_image {
width: 132rpx;
height: 132rpx;
border-radius: 100rpx;
display: block;
}
.opt_name {
margin: 26rpx 0 0 0;
font-weight: 500;
font-size: 32rpx;
color: #FFFFFF;
line-height: 50rpx;
text-align: center;
}
/* 按住说话按钮样式 */
.mic-button {
transition: transform 0.2s;
}
.mic-button.talking {
transform: scale(1.1);
}
.mic-button.talking .opt_image {
box-shadow: 0 0 20rpx rgba(159, 71, 255, 0.8);
}
.black {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 812rpx;
background: linear-gradient(178deg, rgba(0, 0, 0, 0) 0%, #000000 100%);
z-index: 2;
}
</style>