All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 5m41s
148 lines
4.2 KiB
Python
148 lines
4.2 KiB
Python
"""
|
||
TTS 语音合成服务 + OSS 上传
|
||
"""
|
||
import io
|
||
import json
|
||
import uuid
|
||
import logging
|
||
from datetime import datetime
|
||
from django.conf import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# TTS 提供商可在此切换,当前预留 edge-tts(免费)
|
||
TTS_VOICE = 'zh-CN-XiaoxiaoNeural'
|
||
|
||
|
||
def generate_tts_stream(story):
|
||
"""
|
||
为故事生成 TTS 音频并上传 OSS,通过 SSE 推送进度。
|
||
|
||
Args:
|
||
story: Story model instance
|
||
|
||
Yields:
|
||
str: SSE 格式的事件数据行
|
||
"""
|
||
yield _sse_event('stage', {
|
||
'stage': 'connecting',
|
||
'progress': 0,
|
||
'message': '正在准备语音合成...',
|
||
})
|
||
|
||
try:
|
||
import edge_tts
|
||
except ImportError:
|
||
yield _sse_event('error', {'message': 'edge-tts 库未安装,请运行 pip install edge-tts'})
|
||
return
|
||
|
||
# 如果已有音频,直接返回
|
||
if story.audio_url:
|
||
yield _sse_event('done', {
|
||
'stage': 'done',
|
||
'progress': 100,
|
||
'message': '音频已存在',
|
||
'audio_url': story.audio_url,
|
||
})
|
||
return
|
||
|
||
yield _sse_event('stage', {
|
||
'stage': 'generating',
|
||
'progress': 10,
|
||
'message': '正在合成语音...',
|
||
})
|
||
|
||
try:
|
||
# edge-tts 是异步的,需要在同步上下文中运行
|
||
import asyncio
|
||
audio_data = asyncio.run(_synthesize(story.content))
|
||
except Exception as e:
|
||
logger.error(f'TTS synthesis failed: {e}')
|
||
yield _sse_event('error', {'message': f'语音合成失败: {str(e)}'})
|
||
return
|
||
|
||
yield _sse_event('stage', {
|
||
'stage': 'saving',
|
||
'progress': 70,
|
||
'message': '正在保存音频文件...',
|
||
})
|
||
|
||
# 上传到 OSS
|
||
try:
|
||
from utils.oss import get_oss_client
|
||
oss_client = get_oss_client()
|
||
|
||
filename = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.mp3"
|
||
key = f"stories/audio/{filename}"
|
||
|
||
oss_client.bucket.put_object(key, audio_data)
|
||
|
||
oss_config = settings.ALIYUN_OSS
|
||
if oss_config.get('CUSTOM_DOMAIN'):
|
||
audio_url = f"https://{oss_config['CUSTOM_DOMAIN']}/{key}"
|
||
else:
|
||
audio_url = f"https://{oss_config['BUCKET_NAME']}.{oss_config['ENDPOINT']}/{key}"
|
||
|
||
# 更新故事记录
|
||
story.audio_url = audio_url
|
||
|
||
except Exception as e:
|
||
logger.error(f'OSS upload failed: {e}')
|
||
yield _sse_event('error', {'message': f'音频上传失败: {str(e)}'})
|
||
return
|
||
|
||
# Opus 预转码:MP3 → Opus 帧 JSON,上传 OSS
|
||
yield _sse_event('stage', {
|
||
'stage': 'opus_converting',
|
||
'progress': 80,
|
||
'message': '正在预转码 Opus 音频...',
|
||
})
|
||
|
||
try:
|
||
from apps.stories.services.opus_converter import convert_mp3_to_opus_json
|
||
|
||
opus_json = convert_mp3_to_opus_json(audio_data)
|
||
|
||
opus_filename = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.json"
|
||
opus_key = f"stories/audio-opus/{opus_filename}"
|
||
|
||
oss_client.bucket.put_object(opus_key, opus_json.encode('utf-8'))
|
||
|
||
if oss_config.get('CUSTOM_DOMAIN'):
|
||
opus_url = f"https://{oss_config['CUSTOM_DOMAIN']}/{opus_key}"
|
||
else:
|
||
opus_url = f"https://{oss_config['BUCKET_NAME']}.{oss_config['ENDPOINT']}/{opus_key}"
|
||
|
||
story.opus_url = opus_url
|
||
logger.info(f'Opus 预转码上传成功: {opus_url}')
|
||
|
||
except Exception as e:
|
||
logger.warning(f'Opus 预转码失败(不影响 MP3 播放): {e}')
|
||
# 预转码失败不阻断流程,MP3 仍可正常使用
|
||
|
||
story.save(update_fields=['audio_url', 'opus_url'])
|
||
|
||
yield _sse_event('done', {
|
||
'stage': 'done',
|
||
'progress': 100,
|
||
'message': '语音合成完成!',
|
||
'audio_url': audio_url,
|
||
})
|
||
|
||
|
||
async def _synthesize(text):
|
||
"""使用 edge-tts 合成语音,返回音频 bytes"""
|
||
import edge_tts
|
||
|
||
communicate = edge_tts.Communicate(text, TTS_VOICE)
|
||
audio_chunks = []
|
||
async for chunk in communicate.stream():
|
||
if chunk['type'] == 'audio':
|
||
audio_chunks.append(chunk['data'])
|
||
return b''.join(audio_chunks)
|
||
|
||
|
||
def _sse_event(event, data):
|
||
"""格式化 SSE 事件"""
|
||
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|