优化:唱歌视频表情优化+语言通话恋人形象会互动

This commit is contained in:
xiao12feng8 2026-02-01 14:19:11 +08:00
parent 82914b355b
commit 3a89cddd43
10 changed files with 4676 additions and 3 deletions

43
.env
View File

@ -9,12 +9,51 @@ DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3306/lover?charset=utf8mb4
# 开发环境暂时使用本地地址,PHP后端配置好后再修改 # 开发环境暂时使用本地地址,PHP后端配置好后再修改
USER_INFO_API=http://127.0.0.1:8080/api/user_basic/get_user_basic USER_INFO_API=http://127.0.0.1:8080/api/user_basic/get_user_basic
# ===== AI 配置 (暂时留空,测试阶段不需要) ===== # ===== AI 配置 =====
DASHSCOPE_API_KEY= # 阿里云 DashScope API 密钥
DASHSCOPE_API_KEY=sk-2473385fd6d54a58a703ce6b92a62074
# === 图像生成模型 ===
IMAGE_GEN_MODEL=wan2.6-t2i IMAGE_GEN_MODEL=wan2.6-t2i
IMAGE_GEN_SIZE=960*1280 IMAGE_GEN_SIZE=960*1280
WAN26_ASYNC=true WAN26_ASYNC=true
# === 视觉理解模型 ===
VISION_MODEL=qwen3-vl-flash
# === 视频生成模型 ===
# 唱歌视频使用 wan2.6-i2v-flash (支持音频同步)
VIDEO_GEN_MODEL=wan2.6-i2v-flash
VIDEO_GEN_RESOLUTION=720P
VIDEO_GEN_DURATION=5
# === EMO 视频生成 (人像驱动) ===
# EMO 模型用于生成唱歌视频的人像动画
EMO_MAX_CONCURRENCY=1
EMO_MIN_SEGMENT_SECONDS=6
# === 语音识别 (ASR) ===
VOICE_CALL_ASR_MODEL=paraformer-realtime-v2
VOICE_CALL_ASR_SAMPLE_RATE=16000
# === 语音合成 (TTS) ===
VOICE_CALL_TTS_MODEL=cosyvoice-v2
VOICE_CALL_TTS_VOICE=longxiaochun_v2
VOICE_CALL_TTS_FORMAT=mp3
# === 对话模型 (LLM) ===
LLM_MODEL=qwen-plus
LLM_TEMPERATURE=0.8
LLM_MAX_TOKENS=2000
# === 语音通话配置 ===
VOICE_CALL_MAX_HISTORY=20
VOICE_CALL_IDLE_TIMEOUT=60
VOICE_CALL_REQUIRE_PTT=true
# === 唱歌视频合成配置 ===
SING_MERGE_MAX_CONCURRENCY=2
# ===== OSS 配置 (暂时留空) ===== # ===== OSS 配置 (暂时留空) =====
ALIYUN_OSS_ACCESS_KEY_ID= ALIYUN_OSS_ACCESS_KEY_ID=
ALIYUN_OSS_ACCESS_KEY_SECRET= ALIYUN_OSS_ACCESS_KEY_SECRET=

View File

@ -2,6 +2,7 @@ from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
import logging import logging
import dashscope
from .routers import config as config_router from .routers import config as config_router
from .routers import lover as lover_router from .routers import lover as lover_router
@ -15,6 +16,10 @@ from .routers import sing as sing_router
from .task_queue import start_sing_workers from .task_queue import start_sing_workers
from .config import settings from .config import settings
# 初始化 DashScope API Key
if settings.DASHSCOPE_API_KEY:
dashscope.api_key = settings.DASHSCOPE_API_KEY
app = FastAPI(title="LOVER API") app = FastAPI(title="LOVER API")
app.add_middleware( app.add_middleware(

View File

@ -62,6 +62,8 @@ SING_WAN26_PROMPT = (
"lip-sync to the provided audio lyrics with natural mouth articulation and varied mouth shapes, " "lip-sync to the provided audio lyrics with natural mouth articulation and varied mouth shapes, "
"clear closures between syllables, smooth transitions; " "clear closures between syllables, smooth transitions; "
"subtle jaw motion only; gentle expressive hand gestures near chest; " "subtle jaw motion only; gentle expressive hand gestures near chest; "
"natural facial expressions matching the song emotion: smile for happy songs, gentle sadness for melancholic songs, "
"soft eye movements and eyebrow raises to convey emotion; "
"realistic lighting, sharp details; pose consistent from start to end" "realistic lighting, sharp details; pose consistent from start to end"
) )
SING_WAN26_NEGATIVE_PROMPT = ( SING_WAN26_NEGATIVE_PROMPT = (

View File

@ -97,6 +97,15 @@
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{
"path": "pages/chat/voiceCall",
"style": {
"navigationBarTitleText": "语音通话",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"navigationStyle": "custom"
}
},
{ {
"path": "pages/chat/loverMessage", "path": "pages/chat/loverMessage",
"style": { "style": {

View File

@ -27,6 +27,10 @@
<view class="list_title">聊天背景</view> <view class="list_title">聊天背景</view>
<image src="/static/images/more.png" mode="widthFix"></image> <image src="/static/images/more.png" mode="widthFix"></image>
</view> </view>
<view class="list_content fa sb" @click="voiceCallClick">
<view class="list_title">语音通话</view>
<image src="/static/images/more.png" mode="widthFix"></image>
</view>
</view> </view>
</view> </view>
</view> </view>
@ -116,6 +120,12 @@ export default {
url: '/pages/chat/background?session_id=' + this.form.session_id url: '/pages/chat/background?session_id=' + this.form.session_id
}) })
}, },
//
voiceCallClick() {
uni.navigateTo({
url: '/pages/chat/voiceCall'
})
},
} }
} }
</script> </script>

View File

@ -0,0 +1,476 @@
<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>

File diff suppressed because it is too large Load Diff

View File

@ -7,4 +7,4 @@
- [ ] 恋人消息回复和消息编辑都需要测试 - [ ] 恋人消息回复和消息编辑都需要测试
6. 将Hbuilder的AppId更换成自己的原本的保留(__UNI__1F3C178)。还是无法正常编译将下面的插件注释掉不用Agora-RTC音视频插件和AudioRecode录音插件。 6. 将Hbuilder的AppId更换成自己的原本的保留(__UNI__1F3C178)。还是无法正常编译将下面的插件注释掉不用Agora-RTC音视频插件和AudioRecode录音插件。
- [ ] 克隆音色API填写然后给你测试 - [ ] 克隆音色API填写然后给你测试
7. 二维码推广功能: 7. 二维码推广功能:创建邀请码邀请新用户但是没有二维码生成API