Ai_GirlFriend/xuniYou/pages/chat/phone.vue
2026-03-02 18:57:11 +08:00

1436 lines
43 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 // 是否正在重连
}
},
onLoad() {
// 检测平台
const systemInfo = uni.getSystemInfoSync()
console.log('systemInfo', systemInfo)
// 所有平台统一使用 uni.getRecorderManager()
recorderManager = uni.getRecorderManager();
console.log('✅ recorderManager 初始化完成')
// 设置录音监听器(只设置一次)
this.setupRecorderListeners()
this.getCallDuration()
this.initAudio()
},
onUnload() {
this.stopCall()
if (this.timer) {
clearInterval(this.timer)
}
},
methods: {
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())
})
// 监听录音错误
recorderManager.onError((err) => {
console.error('❌ 录音错误:', err)
console.error('错误详情:', JSON.stringify(err))
uni.showToast({
title: '录音失败: ' + (err.errMsg || '未知错误'),
icon: 'none'
})
this.isRecording = false
})
// 监听录音停止 - 作为备用方案
recorderManager.onStop((res) => {
console.log('⏹️ 录音已停止')
console.log('📋 完整的 res 对象:', JSON.stringify(res))
console.log('📁 文件路径:', res.tempFilePath)
console.log('⏱️ 录音时长:', res.duration, 'ms')
console.log('📦 文件大小:', res.fileSize, 'bytes')
// 检查录音是否有效
if (!res.tempFilePath) {
console.error('❌ 没有录音文件路径!')
uni.showToast({
title: '录音失败:没有生成文件',
icon: 'none'
})
return
}
// 检查录音时长
if (res.duration !== undefined && res.duration < 500) {
console.error('❌ 录音时长太短:', res.duration, 'ms')
uni.showToast({
title: '录音太短,请至少说 2 秒',
icon: 'none'
})
return
}
console.log('✅ 录音文件路径有效,准备读取文件...')
// 检查 WebSocket 状态
console.log('🔍 检查 WebSocket 状态...')
console.log('🔍 this.socketTask 是否存在:', !!this.socketTask)
if (!this.socketTask) {
console.error('❌ socketTask 不存在')
uni.showToast({
title: 'WebSocket 未连接',
icon: 'none'
})
return
}
console.log('🔌 WebSocket 状态:', this.socketTask.readyState)
console.log('🔌 状态说明: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED')
if (this.socketTask.readyState !== 1) {
console.error('❌ WebSocket 未连接,无法发送,状态:', this.socketTask.readyState)
uni.showToast({
title: 'WebSocket 未连接,请重新进入',
icon: 'none'
})
return
}
console.log('✅ WebSocket 状态正常,开始读取文件...')
// 处理文件路径(确保是绝对路径)
let filePath = res.tempFilePath
// 如果是相对路径,转换为绝对路径
if (!filePath.startsWith('/') && !filePath.includes('://')) {
// #ifdef APP-PLUS
filePath = plus.io.convertLocalFileSystemURL(filePath)
console.log('📁 转换后的绝对路径:', filePath)
// #endif
}
// 读取文件内容
const fs = uni.getFileSystemManager()
console.log('📂 获取文件系统管理器:', fs ? '成功' : '失败')
console.log('📁 准备读取文件:', filePath)
// 添加超时保护
let readTimeout = setTimeout(() => {
console.error('❌ 文件读取超时5秒')
uni.showToast({
title: '文件读取超时',
icon: 'none'
})
}, 5000)
fs.readFile({
filePath: filePath,
// 不指定 encoding让它返回 ArrayBuffer
success: (fileRes) => {
clearTimeout(readTimeout)
console.log('✅ 文件读取成功')
console.log('📊 数据类型:', typeof fileRes.data)
console.log('📊 是否为 ArrayBuffer:', fileRes.data instanceof ArrayBuffer)
const actualSize = fileRes.data.byteLength || fileRes.data.length
console.log('📊 实际文件大小:', actualSize, 'bytes')
console.log('📊 预计录音时长:', (actualSize / 32000).toFixed(2), '秒')
// 验证文件大小
if (actualSize < 32000) {
console.error('❌ 文件太小(< 1秒可能录音失败')
uni.showToast({
title: '录音文件太小,请重试',
icon: 'none'
})
return
}
// 再次检查 WebSocket 状态
if (this.socketTask.readyState !== 1) {
console.error('❌ 读取文件后 WebSocket 已断开')
return
}
// 确保数据是 ArrayBuffer
let audioData = fileRes.data
if (!(audioData instanceof ArrayBuffer)) {
console.error('❌ 数据不是 ArrayBuffer类型:', typeof audioData)
uni.showToast({
title: '音频数据格式错误',
icon: 'none'
})
return
}
// 分片发送音频数据
this.sendAudioInChunks(audioData)
},
fail: (err) => {
clearTimeout(readTimeout)
console.error('❌ 文件读取失败:', err)
console.error('错误代码:', err.errCode)
console.error('错误信息:', err.errMsg)
console.error('完整错误:', JSON.stringify(err))
console.error('尝试读取的文件路径:', filePath)
uni.showToast({
title: '文件读取失败: ' + (err.errMsg || '未知错误'),
icon: 'none'
})
}
})
})
// 监听音频帧 - 实时发送
let frameCount = 0
recorderManager.onFrameRecorded((res) => {
frameCount++
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)
}
})
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 {
// 使用 PCM 格式匹配服务器期望
const recorderOptions = {
duration: 600000, // 10 分钟
sampleRate: 16000, // 必须 16kHz匹配服务器
numberOfChannels: 1, // 单声道
encodeBitRate: 48000,
format: 'pcm', // 使用 PCM 格式,匹配服务器
frameSize: 5, // 启用 onFrameRecorded每 5 帧回调一次
audioSource: 'auto'
}
console.log('📋 录音参数:', JSON.stringify(recorderOptions))
console.log('⚠️ 注意启用了实时音频帧传输frameSize: 5')
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,
});
},
goRecharge() {
uni.showToast({
title: '充值功能开发中',
icon: 'none'
})
}
}
}
</script>
<style>
page {
/* opacity: 0.7; */
}
</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>