feat: 组件封装,音频保存
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -114,3 +114,5 @@ node_modules/
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.mp3
|
||||
@@ -1,10 +1,12 @@
|
||||
# tts.py
|
||||
import uuid
|
||||
import websockets
|
||||
import time
|
||||
import fastrand
|
||||
import json
|
||||
import asyncio
|
||||
import os
|
||||
import aiofiles
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional as OptionalType
|
||||
|
||||
from app.constants.tts import APP_ID, TOKEN, SPEAKER
|
||||
@@ -34,8 +36,26 @@ EVENT_TaskRequest = 200
|
||||
EVENT_TTSSentenceEnd = 351
|
||||
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:
|
||||
def __init__(self,
|
||||
protocol_version=PROTOCOL_VERSION,
|
||||
@@ -199,6 +219,8 @@ class TTSState:
|
||||
self.session_id: OptionalType[str] = None
|
||||
self.task: OptionalType[asyncio.Task] = None # 用于追踪异步任务
|
||||
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
|
||||
|
||||
|
||||
# 异步保存音频文件
|
||||
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任务
|
||||
async def process_tts_task(websocket, message_id: str, text: str):
|
||||
"""处理单个TTS任务(独立协程)"""
|
||||
@@ -318,6 +352,8 @@ async def process_tts_task(websocket, message_id: str, text: str):
|
||||
raise Exception(f"找不到TTS状态: {message_id}")
|
||||
|
||||
tts_state.is_processing = True
|
||||
# 生成音频文件名
|
||||
tts_state.audio_filename = generate_audio_filename()
|
||||
|
||||
# 创建独立的TTS连接
|
||||
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:
|
||||
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({
|
||||
"id": audio_count,
|
||||
"type": "tts_audio_data",
|
||||
@@ -387,10 +427,20 @@ async def process_tts_task(websocket, message_id: str, text: str):
|
||||
except asyncio.TimeoutError:
|
||||
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({
|
||||
"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} 个音频包")
|
||||
|
||||
|
||||
2
web/components.d.ts
vendored
2
web/components.d.ts
vendored
@@ -9,6 +9,8 @@ declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Avatar: typeof import('./src/components/avatar.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']
|
||||
NCollapse: typeof import('naive-ui')['NCollapse']
|
||||
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 AIAvatar from "@/assets/ai_avatar.png";
|
||||
import {
|
||||
DocumentDuplicateIcon,
|
||||
ExclamationTriangleIcon,
|
||||
microphone,
|
||||
PaperAirplaneIcon,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
import UserAvatar from "@/assets/user_avatar.jpg";
|
||||
import markdown from "@/components/markdown.vue";
|
||||
import { useAsrStore, useChatStore, useLayoutStore } from "@/stores";
|
||||
import { copy } from "@/utils";
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const { historyMessages, completing, modelList, modelInfo, thinking } =
|
||||
@@ -206,19 +204,7 @@ onMounted(() => {
|
||||
</NCollapse>
|
||||
<!-- 内容↓ 思维链↑ -->
|
||||
<markdown :content="msg.content || ''" />
|
||||
<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>
|
||||
<MessageTools :msg="msg" />
|
||||
<NDivider />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,9 +191,7 @@ onMounted(() => {
|
||||
</NCollapse>
|
||||
<!-- 内容↓ 思维链↑ -->
|
||||
<markdown :content="msg.content || ''" />
|
||||
<div v-if="msg.role !== 'user'" class="mt-2">
|
||||
<tts :text="msg.content || ''" :message-id="msg.id!" />
|
||||
</div>
|
||||
<MessageTools :msg="msg" />
|
||||
<NDivider />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user