优化:唱歌视频表情优化+语言通话恋人形象会互动
This commit is contained in:
parent
82914b355b
commit
3a89cddd43
43
.env
43
.env
|
|
@ -9,12 +9,51 @@ DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3306/lover?charset=utf8mb4
|
|||
# 开发环境暂时使用本地地址,PHP后端配置好后再修改
|
||||
USER_INFO_API=http://127.0.0.1:8080/api/user_basic/get_user_basic
|
||||
|
||||
# ===== AI 配置 (暂时留空,测试阶段不需要) =====
|
||||
DASHSCOPE_API_KEY=
|
||||
# ===== AI 配置 =====
|
||||
# 阿里云 DashScope API 密钥
|
||||
DASHSCOPE_API_KEY=sk-2473385fd6d54a58a703ce6b92a62074
|
||||
|
||||
# === 图像生成模型 ===
|
||||
IMAGE_GEN_MODEL=wan2.6-t2i
|
||||
IMAGE_GEN_SIZE=960*1280
|
||||
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 配置 (暂时留空) =====
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -2,6 +2,7 @@ from fastapi import FastAPI, HTTPException, Request
|
|||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
import logging
|
||||
import dashscope
|
||||
|
||||
from .routers import config as config_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 .config import settings
|
||||
|
||||
# 初始化 DashScope API Key
|
||||
if settings.DASHSCOPE_API_KEY:
|
||||
dashscope.api_key = settings.DASHSCOPE_API_KEY
|
||||
|
||||
app = FastAPI(title="LOVER API")
|
||||
|
||||
app.add_middleware(
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -62,6 +62,8 @@ SING_WAN26_PROMPT = (
|
|||
"lip-sync to the provided audio lyrics with natural mouth articulation and varied mouth shapes, "
|
||||
"clear closures between syllables, smooth transitions; "
|
||||
"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"
|
||||
)
|
||||
SING_WAN26_NEGATIVE_PROMPT = (
|
||||
|
|
|
|||
|
|
@ -97,6 +97,15 @@
|
|||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/voiceCall",
|
||||
"style": {
|
||||
"navigationBarTitleText": "语音通话",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/loverMessage",
|
||||
"style": {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@
|
|||
<view class="list_title">聊天背景</view>
|
||||
<image src="/static/images/more.png" mode="widthFix"></image>
|
||||
</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>
|
||||
|
|
@ -116,6 +120,12 @@ export default {
|
|||
url: '/pages/chat/background?session_id=' + this.form.session_id
|
||||
})
|
||||
},
|
||||
// 语音通话
|
||||
voiceCallClick() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/chat/voiceCall'
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
476
xuniYou/pages/chat/voiceCall.vue
Normal file
476
xuniYou/pages/chat/voiceCall.vue
Normal 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
|
|
@ -7,4 +7,4 @@
|
|||
- [ ] 恋人消息回复和消息编辑都需要测试
|
||||
6. 将Hbuilder的AppId更换成自己的,原本的保留(__UNI__1F3C178)。还是无法正常编译,将下面的插件注释掉不用,Agora-RTC:音视频插件和AudioRecode:录音插件。
|
||||
- [ ] 克隆音色API填写然后给你测试
|
||||
7. 二维码推广功能:
|
||||
7. 二维码推广功能:创建邀请码邀请新用户,但是没有二维码生成API
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user