Ai_GirlFriend/xuniYou/pages/chat/voiceCall.vue
2026-02-01 17:46:31 +08:00

482 lines
9.8 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);
// 这里可以将录音文件发送到服务器
});
} 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';
// 发送PTT结束信号
if (this.websocket) {
this.websocket.send({
data: 'ptt_off'
});
}
// #ifndef H5
if (this.recorderManager) {
this.recorderManager.stop();
}
// #endif
},
playAudio(audioData) {
// 播放接收到的音频数据
// 注意:实际实现需要处理音频格式转换
console.log('播放音频', audioData);
},
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>