feat: 组件封装,音频保存

This commit is contained in:
2025-07-01 00:23:04 +08:00
parent ac549bd939
commit faa4ca20b1
6 changed files with 88 additions and 24 deletions

2
.gitignore vendored
View File

@@ -114,3 +114,5 @@ node_modules/
*.njsproj
*.sln
*.sw?
*.mp3

View File

@@ -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
View File

@@ -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']

View 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>

View File

@@ -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>

View File

@@ -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>