Ai_GirlFriend/xuniYou/pages/chat/voiceCall.vue

477 lines
9.6 KiB
Vue

<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';
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');
const wsUrl = `ws://127.0.0.1:8000/voice/call?token=${token}&ptt=true`;
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>