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

625 lines
14 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 class="voice-call-container">
<!-- 状态栏占位 -->
<view :style="{ height: statusBarHeight + 'px' }"></view>
<!-- 返回按钮 -->
<view class="back-btn" @click="endCall">
<image src="/static/images/back.png" mode="aspectFit"></image>
</view>
<!-- 恋人形象 -->
<view class="lover-avatar-container">
<image
class="lover-avatar"
:class="{ 'speaking': isSpeaking, 'listening': isListening }"
:src="loverAvatar"
mode="aspectFill">
</image>
<!-- 状态指示器 -->
<view class="status-indicator">
<view class="status-dot" :class="callStatus"></view>
<text class="status-text">{{ statusText }}</text>
</view>
<!-- 音频波形动画 -->
<view class="audio-wave" v-if="isSpeaking">
<view class="wave-bar" v-for="i in 5" :key="i"></view>
</view>
</view>
<!-- 对话文本显示 -->
<view class="conversation-text">
<view class="text-bubble user" v-if="userText">
<text>{{ userText }}</text>
</view>
<view class="text-bubble ai" v-if="aiText">
<text>{{ aiText }}</text>
</view>
</view>
<!-- 控制按钮 -->
<view class="control-buttons">
<view class="control-btn" @touchstart="startTalk" @touchend="stopTalk" @touchcancel="stopTalk">
<image src="/static/images/mic.png" mode="aspectFit"></image>
<text>{{ micEnabled ? '松开结束' : '按住说话' }}</text>
</view>
<view class="control-btn end-call" @click="endCall">
<image src="/static/images/phone_end.png" mode="aspectFit"></image>
<text>挂断</text>
</view>
</view>
</view>
</template>
<script>
import { LoverBasic } from '@/utils/api.js';
import { baseURLPy } from '@/utils/request.js';
export default {
data() {
return {
statusBarHeight: uni.getWindowInfo().statusBarHeight,
websocket: null,
loverAvatar: '',
callStatus: 'connecting', // connecting, connected, speaking, listening
isSpeaking: false,
isListening: false,
micEnabled: false,
userText: '',
aiText: '',
recorderManager: null,
audioContext: null,
};
},
computed: {
statusText() {
const statusMap = {
connecting: '连接中...',
connected: '已连接',
speaking: 'AI正在说话',
listening: '正在聆听',
};
return statusMap[this.callStatus] || '未知状态';
}
},
onLoad() {
this.initCall();
},
onUnload() {
this.cleanup();
},
methods: {
async initCall() {
// 获取恋人信息
try {
const res = await LoverBasic();
if (res.code === 1 && res.data) {
this.loverAvatar = res.data.avatar_url || '/static/images/default_avatar.png';
}
} catch (e) {
console.error('获取恋人信息失败', e);
}
// 初始化录音管理器
// #ifndef H5
try {
this.recorderManager = uni.getRecorderManager();
// 录音停止时处理
this.recorderManager.onStop((res) => {
console.log('📝 录音结束', res);
if (!res.tempFilePath) {
console.error('❌ 没有录音文件路径');
return;
}
// 读取录音文件并发送
const fs = uni.getFileSystemManager();
fs.readFile({
filePath: res.tempFilePath,
// 不指定 encoding返回 ArrayBuffer
success: (fileRes) => {
console.log('✅ 文件读取成功');
console.log('📊 数据类型:', typeof fileRes.data);
console.log('📊 是否为 ArrayBuffer:', fileRes.data instanceof ArrayBuffer);
// 验证数据类型
if (!(fileRes.data instanceof ArrayBuffer)) {
console.error('❌ 数据不是 ArrayBuffer');
return;
}
const actualSize = fileRes.data.byteLength;
console.log('📊 文件大小:', actualSize, 'bytes');
console.log('📊 预计录音时长:', (actualSize / 32000).toFixed(2), '秒');
// 验证文件大小至少1秒
if (actualSize < 32000) {
console.error('❌ 文件太小(< 1秒可能录音失败');
uni.showToast({
title: '录音太短,请重试',
icon: 'none'
});
return;
}
// 分片发送音频数据
this.sendAudioInChunks(fileRes.data);
},
fail: (err) => {
console.error('❌ 文件读取失败:', err);
uni.showToast({
title: '文件读取失败',
icon: 'none'
});
}
});
});
// 录音错误处理
this.recorderManager.onError((err) => {
console.error('❌ 录音错误:', err);
uni.showToast({
title: '录音失败',
icon: 'none'
});
});
} catch (e) {
console.warn('录音管理器初始化失败', e);
}
// #endif
// 初始化音频播放器
try {
this.audioContext = uni.createInnerAudioContext();
this.audioContext.onPlay(() => {
this.isSpeaking = true;
this.callStatus = 'speaking';
});
this.audioContext.onEnded(() => {
this.isSpeaking = false;
this.callStatus = 'connected';
});
} catch (e) {
console.warn('音频播放器初始化失败', e);
}
// 连接WebSocket
this.connectWebSocket();
},
connectWebSocket() {
const token = uni.getStorageSync('token');
// 将 http:// 替换为 ws://https:// 替换为 wss://
const wsBaseUrl = baseURLPy.replace('http://', 'ws://').replace('https://', 'wss://');
const wsUrl = `${wsBaseUrl}/voice/call?token=${token}&ptt=true`;
console.log('WebSocket URL:', wsUrl);
this.websocket = uni.connectSocket({
url: wsUrl,
success: () => {
console.log('WebSocket连接成功');
},
fail: (err) => {
console.error('WebSocket连接失败', err);
uni.showToast({
title: '连接失败',
icon: 'none'
});
}
});
this.websocket.onOpen(() => {
console.log('WebSocket已打开');
this.callStatus = 'connected';
});
this.websocket.onMessage((res) => {
console.log('收到消息', res);
if (typeof res.data === 'string') {
try {
const data = JSON.parse(res.data);
this.handleSignal(data);
} catch (e) {
console.error('解析消息失败', e);
}
} else {
// 二进制音频数据
this.playAudio(res.data);
}
});
this.websocket.onError((err) => {
console.error('WebSocket错误', err);
this.callStatus = 'error';
});
this.websocket.onClose(() => {
console.log('WebSocket已关闭');
this.callStatus = 'disconnected';
});
},
handleSignal(data) {
switch (data.type) {
case 'ready':
console.log('通话准备就绪');
break;
case 'partial_asr':
this.userText = data.text;
break;
case 'reply_text':
this.aiText = data.text;
break;
case 'reply_end':
this.isSpeaking = false;
this.callStatus = 'connected';
break;
case 'interrupt':
console.log('AI被打断');
break;
case 'error':
uni.showToast({
title: data.msg || '发生错误',
icon: 'none'
});
break;
}
},
startTalk() {
this.micEnabled = true;
this.isListening = true;
this.callStatus = 'listening';
this.userText = '';
// 发送PTT开始信号
if (this.websocket) {
this.websocket.send({
data: 'ptt_on'
});
}
// #ifndef H5
if (this.recorderManager) {
this.recorderManager.start({
format: 'pcm',
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 48000,
frameSize: 5
});
}
// #endif
},
stopTalk() {
this.micEnabled = false;
this.isListening = false;
this.callStatus = 'connected';
// 停止录音(会触发 onStop 回调,在那里发送音频和 ptt_off
// #ifndef H5
if (this.recorderManager) {
this.recorderManager.stop();
}
// #endif
},
playAudio(audioData) {
// 播放接收到的音频数据
// 注意:实际实现需要处理音频格式转换
console.log('播放音频', audioData);
},
// 分片发送音频数据(按照官方推荐参数)
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('📊 预计录音时长:', (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.websocket.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, '片');
// 发送 ptt_off 信号,告诉服务器录音结束
this.websocket.send({
data: 'ptt_off',
success: () => {
console.log('✅ ptt_off 信号发送成功');
}
});
uni.hideLoading();
} catch (err) {
console.error('❌ 发送音频失败:', err);
uni.hideLoading();
uni.showToast({
title: '发送失败',
icon: 'none'
});
}
},
endCall() {
this.cleanup();
uni.navigateBack();
},
cleanup() {
if (this.websocket) {
this.websocket.close();
this.websocket = null;
}
if (this.recorderManager) {
this.recorderManager.stop();
}
if (this.audioContext) {
this.audioContext.stop();
this.audioContext.destroy();
}
}
}
};
</script>
<style scoped>
.voice-call-container {
width: 100vw;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.back-btn {
position: absolute;
top: 60rpx;
left: 30rpx;
width: 60rpx;
height: 60rpx;
z-index: 10;
}
.back-btn image {
width: 100%;
height: 100%;
filter: brightness(0) invert(1);
}
.lover-avatar-container {
margin-top: 150rpx;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.lover-avatar {
width: 400rpx;
height: 400rpx;
border-radius: 50%;
border: 8rpx solid rgba(255, 255, 255, 0.3);
transition: all 0.3s ease;
}
.lover-avatar.speaking {
transform: scale(1.05);
border-color: rgba(255, 255, 255, 0.8);
box-shadow: 0 0 40rpx rgba(255, 255, 255, 0.6);
}
.lover-avatar.listening {
border-color: rgba(102, 126, 234, 0.8);
box-shadow: 0 0 40rpx rgba(102, 126, 234, 0.6);
}
.status-indicator {
margin-top: 40rpx;
display: flex;
align-items: center;
gap: 15rpx;
}
.status-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #fff;
}
.status-dot.connecting {
animation: pulse 1.5s ease-in-out infinite;
}
.status-dot.connected {
background: #4ade80;
}
.status-dot.speaking {
background: #f59e0b;
animation: pulse 0.8s ease-in-out infinite;
}
.status-dot.listening {
background: #3b82f6;
animation: pulse 1s ease-in-out infinite;
}
.status-text {
color: #fff;
font-size: 28rpx;
}
.audio-wave {
display: flex;
align-items: center;
gap: 10rpx;
margin-top: 30rpx;
height: 80rpx;
}
.wave-bar {
width: 8rpx;
background: rgba(255, 255, 255, 0.8);
border-radius: 4rpx;
animation: wave 1s ease-in-out infinite;
}
.wave-bar:nth-child(1) { animation-delay: 0s; }
.wave-bar:nth-child(2) { animation-delay: 0.1s; }
.wave-bar:nth-child(3) { animation-delay: 0.2s; }
.wave-bar:nth-child(4) { animation-delay: 0.3s; }
.wave-bar:nth-child(5) { animation-delay: 0.4s; }
@keyframes wave {
0%, 100% { height: 20rpx; }
50% { height: 80rpx; }
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
}
.conversation-text {
flex: 1;
width: 90%;
margin-top: 60rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
overflow-y: auto;
}
.text-bubble {
padding: 20rpx 30rpx;
border-radius: 20rpx;
max-width: 80%;
word-wrap: break-word;
}
.text-bubble.user {
align-self: flex-end;
background: rgba(255, 255, 255, 0.9);
color: #333;
}
.text-bubble.ai {
align-self: flex-start;
background: rgba(255, 255, 255, 0.2);
color: #fff;
backdrop-filter: blur(10rpx);
}
.control-buttons {
width: 100%;
padding: 40rpx;
display: flex;
justify-content: space-around;
align-items: center;
gap: 40rpx;
}
.control-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 15rpx;
}
.control-btn image {
width: 120rpx;
height: 120rpx;
padding: 30rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
backdrop-filter: blur(10rpx);
transition: all 0.3s ease;
}
.control-btn:active image {
transform: scale(0.95);
background: rgba(255, 255, 255, 0.3);
}
.control-btn.end-call image {
background: rgba(239, 68, 68, 0.8);
}
.control-btn text {
color: #fff;
font-size: 24rpx;
}
</style>