feat: 组件封装,音频保存
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -114,3 +114,5 @@ node_modules/
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
*.mp3
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
# tts.py
|
|
||||||
import uuid
|
import uuid
|
||||||
import websockets
|
import websockets
|
||||||
import time
|
import time
|
||||||
import fastrand
|
import fastrand
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
|
import aiofiles
|
||||||
|
from datetime import datetime
|
||||||
from typing import Dict, Any, Optional as OptionalType
|
from typing import Dict, Any, Optional as OptionalType
|
||||||
|
|
||||||
from app.constants.tts import APP_ID, TOKEN, SPEAKER
|
from app.constants.tts import APP_ID, TOKEN, SPEAKER
|
||||||
@@ -34,8 +36,26 @@ EVENT_TaskRequest = 200
|
|||||||
EVENT_TTSSentenceEnd = 351
|
EVENT_TTSSentenceEnd = 351
|
||||||
EVENT_TTSResponse = 352
|
EVENT_TTSResponse = 352
|
||||||
|
|
||||||
|
# 音频文件保存目录
|
||||||
|
TEMP_AUDIO_DIR = "./temp_audio"
|
||||||
|
|
||||||
|
|
||||||
|
# 确保音频目录存在
|
||||||
|
async def ensure_audio_dir():
|
||||||
|
"""异步创建音频目录"""
|
||||||
|
if not os.path.exists(TEMP_AUDIO_DIR):
|
||||||
|
os.makedirs(TEMP_AUDIO_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# 生成时间戳文件名
|
||||||
|
def generate_audio_filename() -> str:
|
||||||
|
"""生成基于时间戳的音频文件名"""
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] # 精确到毫秒
|
||||||
|
return f"{timestamp}.mp3"
|
||||||
|
|
||||||
|
|
||||||
|
# ... 保留所有原有的类定义和工具函数 ...
|
||||||
|
|
||||||
# 所有类定义
|
|
||||||
class Header:
|
class Header:
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
protocol_version=PROTOCOL_VERSION,
|
protocol_version=PROTOCOL_VERSION,
|
||||||
@@ -199,6 +219,8 @@ class TTSState:
|
|||||||
self.session_id: OptionalType[str] = None
|
self.session_id: OptionalType[str] = None
|
||||||
self.task: OptionalType[asyncio.Task] = None # 用于追踪异步任务
|
self.task: OptionalType[asyncio.Task] = None # 用于追踪异步任务
|
||||||
self.is_processing = False
|
self.is_processing = False
|
||||||
|
self.audio_data = bytearray() # 用于收集音频数据
|
||||||
|
self.audio_filename = None # 保存的文件名
|
||||||
|
|
||||||
|
|
||||||
# 全局状态管理
|
# 全局状态管理
|
||||||
@@ -305,6 +327,18 @@ async def create_tts_connection() -> websockets.WebSocketServerProtocol:
|
|||||||
return volc_ws
|
return volc_ws
|
||||||
|
|
||||||
|
|
||||||
|
# 异步保存音频文件
|
||||||
|
async def save_audio_file(audio_data: bytes, filename: str) -> str:
|
||||||
|
"""异步保存音频文件"""
|
||||||
|
await ensure_audio_dir()
|
||||||
|
file_path = os.path.join(TEMP_AUDIO_DIR, filename)
|
||||||
|
|
||||||
|
async with aiofiles.open(file_path, 'wb') as f:
|
||||||
|
await f.write(audio_data)
|
||||||
|
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
|
||||||
# 处理单个TTS任务
|
# 处理单个TTS任务
|
||||||
async def process_tts_task(websocket, message_id: str, text: str):
|
async def process_tts_task(websocket, message_id: str, text: str):
|
||||||
"""处理单个TTS任务(独立协程)"""
|
"""处理单个TTS任务(独立协程)"""
|
||||||
@@ -318,6 +352,8 @@ async def process_tts_task(websocket, message_id: str, text: str):
|
|||||||
raise Exception(f"找不到TTS状态: {message_id}")
|
raise Exception(f"找不到TTS状态: {message_id}")
|
||||||
|
|
||||||
tts_state.is_processing = True
|
tts_state.is_processing = True
|
||||||
|
# 生成音频文件名
|
||||||
|
tts_state.audio_filename = generate_audio_filename()
|
||||||
|
|
||||||
# 创建独立的TTS连接
|
# 创建独立的TTS连接
|
||||||
tts_state.volc_ws = await create_tts_connection()
|
tts_state.volc_ws = await create_tts_connection()
|
||||||
@@ -373,8 +409,12 @@ async def process_tts_task(websocket, message_id: str, text: str):
|
|||||||
|
|
||||||
elif res.optional.event == EVENT_TTSResponse:
|
elif res.optional.event == EVENT_TTSResponse:
|
||||||
audio_count += 1
|
audio_count += 1
|
||||||
print(f"发送音频数据 [{message_id}] #{audio_count},大小: {len(res.payload)}")
|
print(f"收到音频数据 [{message_id}] #{audio_count},大小: {len(res.payload)}")
|
||||||
# 发送音频数据
|
|
||||||
|
# 收集音频数据
|
||||||
|
tts_state.audio_data.extend(res.payload)
|
||||||
|
|
||||||
|
# 发送音频数据到前端
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"id": audio_count,
|
"id": audio_count,
|
||||||
"type": "tts_audio_data",
|
"type": "tts_audio_data",
|
||||||
@@ -387,10 +427,20 @@ async def process_tts_task(websocket, message_id: str, text: str):
|
|||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
print(f"TTS响应超时 [{message_id}],强制结束")
|
print(f"TTS响应超时 [{message_id}],强制结束")
|
||||||
|
|
||||||
# 发送完成消息
|
# 异步保存音频文件
|
||||||
|
if tts_state.audio_data:
|
||||||
|
file_path = await save_audio_file(
|
||||||
|
bytes(tts_state.audio_data),
|
||||||
|
tts_state.audio_filename
|
||||||
|
)
|
||||||
|
print(f"音频文件已保存 [{message_id}]: {file_path}")
|
||||||
|
|
||||||
|
# 发送完成消息,包含文件路径
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "tts_audio_complete",
|
"type": "tts_audio_complete",
|
||||||
"messageId": message_id
|
"messageId": message_id,
|
||||||
|
"audioFile": tts_state.audio_filename,
|
||||||
|
"audioPath": os.path.join(TEMP_AUDIO_DIR, tts_state.audio_filename) if tts_state.audio_data else None
|
||||||
})
|
})
|
||||||
print(f"TTS处理完成 [{message_id}],共发送 {audio_count} 个音频包")
|
print(f"TTS处理完成 [{message_id}],共发送 {audio_count} 个音频包")
|
||||||
|
|
||||||
|
|||||||
2
web/components.d.ts
vendored
2
web/components.d.ts
vendored
@@ -9,6 +9,8 @@ declare module 'vue' {
|
|||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
Avatar: typeof import('./src/components/avatar.vue')['default']
|
Avatar: typeof import('./src/components/avatar.vue')['default']
|
||||||
Markdown: typeof import('./src/components/markdown.vue')['default']
|
Markdown: typeof import('./src/components/markdown.vue')['default']
|
||||||
|
Message_tools: typeof import('./src/components/MessageTools.vue')['default']
|
||||||
|
MessageTools: typeof import('./src/components/MessageTools.vue')['default']
|
||||||
NButton: typeof import('naive-ui')['NButton']
|
NButton: typeof import('naive-ui')['NButton']
|
||||||
NCollapse: typeof import('naive-ui')['NCollapse']
|
NCollapse: typeof import('naive-ui')['NCollapse']
|
||||||
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
|
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
|
||||||
|
|||||||
26
web/src/components/MessageTools.vue
Normal file
26
web/src/components/MessageTools.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Message } from "@/interfaces";
|
||||||
|
|
||||||
|
import { DocumentDuplicateIcon } from "@/assets/Icons";
|
||||||
|
import { copy } from "@/utils";
|
||||||
|
|
||||||
|
const { msg } = defineProps<{
|
||||||
|
msg: Message;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-2 justify-end mt-2">
|
||||||
|
<div v-if="msg.role !== 'user'">
|
||||||
|
<tts :text="msg.content || ''" :message-id="msg.id!" />
|
||||||
|
</div>
|
||||||
|
<NPopover trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<NButton quaternary circle @click="copy(msg.content || '')">
|
||||||
|
<DocumentDuplicateIcon class="!w-4 !h-4" />
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
<span>复制内容</span>
|
||||||
|
</NPopover>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -4,7 +4,6 @@ import type { Message } from "@/interfaces";
|
|||||||
import { throttle } from "lodash-es";
|
import { throttle } from "lodash-es";
|
||||||
import AIAvatar from "@/assets/ai_avatar.png";
|
import AIAvatar from "@/assets/ai_avatar.png";
|
||||||
import {
|
import {
|
||||||
DocumentDuplicateIcon,
|
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
microphone,
|
microphone,
|
||||||
PaperAirplaneIcon,
|
PaperAirplaneIcon,
|
||||||
@@ -13,7 +12,6 @@ import {
|
|||||||
import UserAvatar from "@/assets/user_avatar.jpg";
|
import UserAvatar from "@/assets/user_avatar.jpg";
|
||||||
import markdown from "@/components/markdown.vue";
|
import markdown from "@/components/markdown.vue";
|
||||||
import { useAsrStore, useChatStore, useLayoutStore } from "@/stores";
|
import { useAsrStore, useChatStore, useLayoutStore } from "@/stores";
|
||||||
import { copy } from "@/utils";
|
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const { historyMessages, completing, modelList, modelInfo, thinking } =
|
const { historyMessages, completing, modelList, modelInfo, thinking } =
|
||||||
@@ -206,19 +204,7 @@ onMounted(() => {
|
|||||||
</NCollapse>
|
</NCollapse>
|
||||||
<!-- 内容↓ 思维链↑ -->
|
<!-- 内容↓ 思维链↑ -->
|
||||||
<markdown :content="msg.content || ''" />
|
<markdown :content="msg.content || ''" />
|
||||||
<div class="flex items-center gap-2 justify-end mt-2">
|
<MessageTools :msg="msg" />
|
||||||
<div v-if="msg.role !== 'user'">
|
|
||||||
<tts :text="msg.content || ''" :message-id="msg.id!" />
|
|
||||||
</div>
|
|
||||||
<NPopover trigger="hover">
|
|
||||||
<template #trigger>
|
|
||||||
<NButton quaternary circle @click="copy(msg.content || '')">
|
|
||||||
<DocumentDuplicateIcon class="!w-4 !h-4" />
|
|
||||||
</NButton>
|
|
||||||
</template>
|
|
||||||
<span>复制内容</span>
|
|
||||||
</NPopover>
|
|
||||||
</div>
|
|
||||||
<NDivider />
|
<NDivider />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -191,9 +191,7 @@ onMounted(() => {
|
|||||||
</NCollapse>
|
</NCollapse>
|
||||||
<!-- 内容↓ 思维链↑ -->
|
<!-- 内容↓ 思维链↑ -->
|
||||||
<markdown :content="msg.content || ''" />
|
<markdown :content="msg.content || ''" />
|
||||||
<div v-if="msg.role !== 'user'" class="mt-2">
|
<MessageTools :msg="msg" />
|
||||||
<tts :text="msg.content || ''" :message-id="msg.id!" />
|
|
||||||
</div>
|
|
||||||
<NDivider />
|
<NDivider />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user