fix 音频并发优化
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 5m41s
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 5m41s
This commit is contained in:
parent
a3222d1fe5
commit
134ccb70f3
4
=3.0.1
Normal file
4
=3.0.1
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
Requirement already satisfied: opuslib in ./venv/lib/python3.14/site-packages (3.0.1)
|
||||||
|
|
||||||
|
[notice] A new release of pip is available: 25.3 -> 26.0.1
|
||||||
|
[notice] To update, run: /Users/maidong/Desktop/zyc/qy_gitlab/rtc_backend/venv/bin/python3.14 -m pip install --upgrade pip
|
||||||
@ -14,6 +14,8 @@ RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debia
|
|||||||
gcc \
|
gcc \
|
||||||
default-libmysqlclient-dev \
|
default-libmysqlclient-dev \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
|
ffmpeg \
|
||||||
|
libopus-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install python dependencies
|
# Install python dependencies
|
||||||
|
|||||||
@ -363,7 +363,11 @@ class DeviceViewSet(viewsets.ViewSet):
|
|||||||
status_code=status.HTTP_404_NOT_FOUND
|
status_code=status.HTTP_404_NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
return success(data={'title': story.title, 'audio_url': story.audio_url})
|
return success(data={
|
||||||
|
'title': story.title,
|
||||||
|
'audio_url': story.audio_url,
|
||||||
|
'opus_url': story.opus_url,
|
||||||
|
})
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='report-status',
|
@action(detail=False, methods=['post'], url_path='report-status',
|
||||||
authentication_classes=[], permission_classes=[AllowAny])
|
authentication_classes=[], permission_classes=[AllowAny])
|
||||||
|
|||||||
112
apps/stories/management/commands/convert_stories_to_opus.py
Normal file
112
apps/stories/management/commands/convert_stories_to_opus.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
批量将已有故事的 MP3 音频预转码为 Opus 帧 JSON 并上传 OSS。
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
python manage.py convert_stories_to_opus
|
||||||
|
python manage.py convert_stories_to_opus --dry-run # 仅统计,不转码
|
||||||
|
python manage.py convert_stories_to_opus --limit 10 # 只处理前 10 个
|
||||||
|
python manage.py convert_stories_to_opus --force # 重新转码已有 opus_url 的故事
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from apps.stories.models import Story
|
||||||
|
from apps.stories.services.opus_converter import convert_mp3_to_opus_json
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '批量将已有故事的 MP3 音频预转码为 Opus 帧 JSON'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--dry-run', action='store_true',
|
||||||
|
help='仅统计需要转码的故事数量,不实际执行',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--limit', type=int, default=0,
|
||||||
|
help='最多处理的故事数量(0=不限)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--force', action='store_true',
|
||||||
|
help='重新转码已有 opus_url 的故事',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
dry_run = options['dry_run']
|
||||||
|
limit = options['limit']
|
||||||
|
force = options['force']
|
||||||
|
|
||||||
|
# 查找需要转码的故事
|
||||||
|
qs = Story.objects.exclude(audio_url='')
|
||||||
|
if not force:
|
||||||
|
qs = qs.filter(opus_url='')
|
||||||
|
qs = qs.order_by('id')
|
||||||
|
|
||||||
|
total = qs.count()
|
||||||
|
self.stdout.write(f'需要转码的故事: {total} 个')
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.NOTICE('[dry-run] 仅统计,不执行转码'))
|
||||||
|
return
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
self.stdout.write(self.style.SUCCESS('所有故事已转码,无需处理'))
|
||||||
|
return
|
||||||
|
|
||||||
|
# OSS 客户端
|
||||||
|
from utils.oss import get_oss_client
|
||||||
|
oss_client = get_oss_client()
|
||||||
|
oss_config = settings.ALIYUN_OSS
|
||||||
|
|
||||||
|
if oss_config.get('CUSTOM_DOMAIN'):
|
||||||
|
url_prefix = f"https://{oss_config['CUSTOM_DOMAIN']}"
|
||||||
|
else:
|
||||||
|
url_prefix = f"https://{oss_config['BUCKET_NAME']}.{oss_config['ENDPOINT']}"
|
||||||
|
|
||||||
|
stories = qs[:limit] if limit > 0 else qs
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
|
||||||
|
for i, story in enumerate(stories.iterator(), 1):
|
||||||
|
self.stdout.write(f'\n[{i}/{total}] Story#{story.id} "{story.title}"')
|
||||||
|
self.stdout.write(f' MP3: {story.audio_url[:80]}...')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 下载 MP3
|
||||||
|
resp = requests.get(story.audio_url, timeout=60)
|
||||||
|
resp.raise_for_status()
|
||||||
|
mp3_bytes = resp.content
|
||||||
|
self.stdout.write(f' MP3 大小: {len(mp3_bytes) / 1024:.1f} KB')
|
||||||
|
|
||||||
|
# 转码
|
||||||
|
opus_json = convert_mp3_to_opus_json(mp3_bytes)
|
||||||
|
self.stdout.write(f' Opus JSON 大小: {len(opus_json) / 1024:.1f} KB')
|
||||||
|
|
||||||
|
# 上传 OSS
|
||||||
|
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'))
|
||||||
|
|
||||||
|
opus_url = f"{url_prefix}/{opus_key}"
|
||||||
|
story.opus_url = opus_url
|
||||||
|
story.save(update_fields=['opus_url'])
|
||||||
|
|
||||||
|
success_count += 1
|
||||||
|
self.stdout.write(self.style.SUCCESS(f' OK: {opus_url}'))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
fail_count += 1
|
||||||
|
self.stdout.write(self.style.ERROR(f' FAIL: {e}'))
|
||||||
|
logger.error(f'Story#{story.id} opus convert failed: {e}')
|
||||||
|
|
||||||
|
self.stdout.write(f'\n{"=" * 40}')
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'完成: 成功 {success_count}, 失败 {fail_count}, 总计 {success_count + fail_count}'
|
||||||
|
))
|
||||||
18
apps/stories/migrations/0005_story_opus_url.py
Normal file
18
apps/stories/migrations/0005_story_opus_url.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-03 09:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stories', '0004_story_is_default'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='story',
|
||||||
|
name='opus_url',
|
||||||
|
field=models.URLField(blank=True, default='', max_length=500, verbose_name='Opus音频URL'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -54,6 +54,7 @@ class Story(models.Model):
|
|||||||
content = models.TextField('内容', blank=True, default='')
|
content = models.TextField('内容', blank=True, default='')
|
||||||
cover_url = models.URLField('封面URL', max_length=500, blank=True, default='')
|
cover_url = models.URLField('封面URL', max_length=500, blank=True, default='')
|
||||||
audio_url = models.URLField('音频URL', max_length=500, blank=True, default='')
|
audio_url = models.URLField('音频URL', max_length=500, blank=True, default='')
|
||||||
|
opus_url = models.URLField('Opus音频URL', max_length=500, blank=True, default='')
|
||||||
has_video = models.BooleanField('是否有视频', default=False)
|
has_video = models.BooleanField('是否有视频', default=False)
|
||||||
video_url = models.URLField('视频URL', max_length=500, blank=True, default='')
|
video_url = models.URLField('视频URL', max_length=500, blank=True, default='')
|
||||||
generation_mode = models.CharField(
|
generation_mode = models.CharField(
|
||||||
|
|||||||
72
apps/stories/services/opus_converter.py
Normal file
72
apps/stories/services/opus_converter.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
MP3 → Opus 预转码服务
|
||||||
|
|
||||||
|
将 MP3 音频转为 Opus 帧列表(JSON + base64),供 hw_service_go 直接下载播放,
|
||||||
|
跳过实时 ffmpeg 转码,大幅降低首帧延迟和 CPU 消耗。
|
||||||
|
|
||||||
|
Opus 参数与 hw_service_go 保持一致:16kHz, 单声道, 60ms/帧
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import opuslib
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SAMPLE_RATE = 16000
|
||||||
|
CHANNELS = 1
|
||||||
|
FRAME_DURATION_MS = 60
|
||||||
|
FRAME_SIZE = SAMPLE_RATE * FRAME_DURATION_MS // 1000 # 960 samples
|
||||||
|
BYTES_PER_FRAME = FRAME_SIZE * 2 # 16bit = 2 bytes per sample
|
||||||
|
|
||||||
|
|
||||||
|
def convert_mp3_to_opus_json(mp3_bytes: bytes) -> str:
|
||||||
|
"""
|
||||||
|
将 MP3 音频数据转码为 Opus 帧 JSON。
|
||||||
|
|
||||||
|
流程: MP3 bytes → ffmpeg(PCM 16kHz mono s16le) → opuslib(60ms Opus 帧)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON 字符串,包含 base64 编码的 Opus 帧列表
|
||||||
|
"""
|
||||||
|
# 1. ffmpeg: MP3 → PCM (16kHz, mono, signed 16-bit little-endian)
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
'ffmpeg', '-nostdin', '-loglevel', 'error',
|
||||||
|
'-i', 'pipe:0',
|
||||||
|
'-ar', str(SAMPLE_RATE),
|
||||||
|
'-ac', str(CHANNELS),
|
||||||
|
'-f', 's16le',
|
||||||
|
'pipe:1',
|
||||||
|
],
|
||||||
|
input=mp3_bytes,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
stderr = proc.stderr.decode(errors='replace')
|
||||||
|
raise RuntimeError(f'ffmpeg 转码失败: {stderr}')
|
||||||
|
|
||||||
|
pcm = proc.stdout
|
||||||
|
if len(pcm) < BYTES_PER_FRAME:
|
||||||
|
raise RuntimeError(f'PCM 数据过短: {len(pcm)} bytes')
|
||||||
|
|
||||||
|
# 2. Opus 编码:逐帧编码
|
||||||
|
encoder = opuslib.Encoder(SAMPLE_RATE, CHANNELS, 'audio')
|
||||||
|
frames = []
|
||||||
|
for offset in range(0, len(pcm) - BYTES_PER_FRAME + 1, BYTES_PER_FRAME):
|
||||||
|
chunk = pcm[offset:offset + BYTES_PER_FRAME]
|
||||||
|
opus_frame = encoder.encode(chunk, FRAME_SIZE)
|
||||||
|
frames.append(base64.b64encode(opus_frame).decode('ascii'))
|
||||||
|
|
||||||
|
logger.info(f'Opus 预转码完成: {len(frames)} 帧, '
|
||||||
|
f'约 {len(frames) * FRAME_DURATION_MS / 1000:.1f}s 音频')
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
'sample_rate': SAMPLE_RATE,
|
||||||
|
'channels': CHANNELS,
|
||||||
|
'frame_duration_ms': FRAME_DURATION_MS,
|
||||||
|
'frames': frames,
|
||||||
|
}, separators=(',', ':')) # 紧凑格式,减少体积
|
||||||
@ -85,13 +85,43 @@ def generate_tts_stream(story):
|
|||||||
|
|
||||||
# 更新故事记录
|
# 更新故事记录
|
||||||
story.audio_url = audio_url
|
story.audio_url = audio_url
|
||||||
story.save(update_fields=['audio_url'])
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'OSS upload failed: {e}')
|
logger.error(f'OSS upload failed: {e}')
|
||||||
yield _sse_event('error', {'message': f'音频上传失败: {str(e)}'})
|
yield _sse_event('error', {'message': f'音频上传失败: {str(e)}'})
|
||||||
return
|
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', {
|
yield _sse_event('done', {
|
||||||
'stage': 'done',
|
'stage': 'done',
|
||||||
'progress': 100,
|
'progress': 100,
|
||||||
|
|||||||
193
docs/opus-preconvert-plan.md
Normal file
193
docs/opus-preconvert-plan.md
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
# 故事音频预转码方案 — MP3 → Opus 预处理
|
||||||
|
|
||||||
|
> 创建时间:2026-03-03
|
||||||
|
> 状态:待实施
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
**问题**:当前 hw_service_go 每次播放故事都实时执行 `MP3下载 → ffmpeg转码 → Opus编码`,ffmpeg 是 CPU 密集型操作,压测显示 0.5 核 CPU 下 5 个并发就首帧延迟 4.5s。
|
||||||
|
|
||||||
|
**方案**:在 TTS 生成 MP3 后,立即预转码为 Opus 帧数据(JSON 格式)并上传 OSS。hw_service_go 播放时直接下载预处理好的 Opus 数据,跳过 ffmpeg,首帧延迟从秒级降到毫秒级。
|
||||||
|
|
||||||
|
**预期效果**:
|
||||||
|
- hw_service_go 播放时 **零 CPU 转码开销**
|
||||||
|
- 首帧延迟从 ~2s 降到 ~200ms
|
||||||
|
- 并发播放容量从 5-10 个提升到 **100+**(瓶颈变为网络/内存)
|
||||||
|
|
||||||
|
**压测数据参考**(单 Pod, 0.5 核 CPU, 512Mi):
|
||||||
|
|
||||||
|
| 并发故事数 | 首帧延迟 | 帧数/故事 | 错误 |
|
||||||
|
|-----------|---------|----------|------|
|
||||||
|
| 2 | 2.0s | 796 | 0 |
|
||||||
|
| 5 | 4.5s | 796 | 0 |
|
||||||
|
| 10 | 8.7s | 796 | 0 |
|
||||||
|
| 20 | 17.4s | 796 | 0 |
|
||||||
|
|
||||||
|
详见 [压测报告](../rtc_backend/hw_service_go/test/stress/REPORT.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 改动概览
|
||||||
|
|
||||||
|
| 改动范围 | 文件 | 改动大小 |
|
||||||
|
|---------|------|---------|
|
||||||
|
| Django:Story 模型 | `apps/stories/models.py` | 小(加 1 个字段) |
|
||||||
|
| Django:TTS 服务 | `apps/stories/services/tts_service.py` | 中(加预转码逻辑) |
|
||||||
|
| Django:故事 API | `apps/devices/views.py` | 小(返回新字段) |
|
||||||
|
| Django:迁移文件 | `apps/stories/migrations/` | 自动生成 |
|
||||||
|
| Go:API 响应结构体 | `hw_service_go/internal/rtcclient/client.go` | 小 |
|
||||||
|
| Go:播放处理器 | `hw_service_go/internal/handler/story.go` | 中(分支逻辑) |
|
||||||
|
| Go:新增 Opus 下载 | `hw_service_go/internal/audio/` | 中(新函数) |
|
||||||
|
|
||||||
|
**总改动量:中等偏小**,核心改动集中在 3 个文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 详细方案
|
||||||
|
|
||||||
|
### Step 1: Story 模型加字段
|
||||||
|
|
||||||
|
**文件**:`apps/stories/models.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 在 Story 模型中新增
|
||||||
|
opus_url = models.URLField('Opus音频URL', max_length=500, blank=True, default='')
|
||||||
|
```
|
||||||
|
|
||||||
|
`opus_url` 存储预转码后的 Opus JSON 文件地址。为空表示未转码(兼容旧数据)。
|
||||||
|
|
||||||
|
然后 `makemigrations` + `migrate`。
|
||||||
|
|
||||||
|
### Step 2: TTS 服务中增加预转码
|
||||||
|
|
||||||
|
**文件**:`apps/stories/services/tts_service.py`
|
||||||
|
|
||||||
|
在 MP3 上传 OSS 成功后(第 88 行 `story.save` 之前),增加:
|
||||||
|
|
||||||
|
1. 调用 ffmpeg 将 MP3 bytes 转为 PCM(16kHz, mono, s16le)
|
||||||
|
2. 用 Python opuslib(或 subprocess 调 ffmpeg 直出 opus)编码为 60ms 帧
|
||||||
|
3. 将帧列表序列化为紧凑格式上传 OSS
|
||||||
|
4. 保存 `story.opus_url`
|
||||||
|
|
||||||
|
**Opus 数据格式(JSON + base64):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sample_rate": 16000,
|
||||||
|
"channels": 1,
|
||||||
|
"frame_duration_ms": 60,
|
||||||
|
"frames": ["<base64帧1>", "<base64帧2>", ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 一个 5 分钟故事约 5000 帧 × ~300 bytes/帧 ≈ 1.5MB JSON,压缩后 ~1MB,对 OSS 存储无压力。
|
||||||
|
|
||||||
|
**转码实现**(subprocess 调 ffmpeg + opuslib):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import subprocess, base64, json, opuslib
|
||||||
|
|
||||||
|
def convert_mp3_to_opus_frames(mp3_bytes):
|
||||||
|
"""MP3 → PCM → Opus 帧列表"""
|
||||||
|
# ffmpeg: MP3 → PCM
|
||||||
|
proc = subprocess.run(
|
||||||
|
['ffmpeg', '-i', 'pipe:0', '-ar', '16000', '-ac', '1', '-f', 's16le', 'pipe:1'],
|
||||||
|
input=mp3_bytes, capture_output=True
|
||||||
|
)
|
||||||
|
pcm = proc.stdout
|
||||||
|
|
||||||
|
# Opus 编码:每帧 960 samples (60ms @ 16kHz)
|
||||||
|
encoder = opuslib.Encoder(16000, 1, opuslib.APPLICATION_AUDIO)
|
||||||
|
frame_size = 960
|
||||||
|
frames = []
|
||||||
|
for i in range(0, len(pcm) // 2 - frame_size + 1, frame_size):
|
||||||
|
chunk = pcm[i*2 : (i+frame_size)*2]
|
||||||
|
opus_frame = encoder.encode(chunk, frame_size)
|
||||||
|
frames.append(base64.b64encode(opus_frame).decode())
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"sample_rate": 16000,
|
||||||
|
"channels": 1,
|
||||||
|
"frame_duration_ms": 60,
|
||||||
|
"frames": frames
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
上传路径:`stories/audio-opus/YYYYMMDD/{uuid}.json`
|
||||||
|
|
||||||
|
### Step 3: Django API 返回 opus_url
|
||||||
|
|
||||||
|
**文件**:`apps/devices/views.py`(`stories_by_mac` 方法)
|
||||||
|
|
||||||
|
```python
|
||||||
|
return success(data={
|
||||||
|
'title': story.title,
|
||||||
|
'audio_url': story.audio_url,
|
||||||
|
'opus_url': story.opus_url, # 新增
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Go 服务适配
|
||||||
|
|
||||||
|
**文件**:`hw_service_go/internal/rtcclient/client.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StoryInfo struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
AudioURL string `json:"audio_url"`
|
||||||
|
OpusURL string `json:"opus_url"` // 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**文件**:`hw_service_go/internal/audio/` — 新增函数
|
||||||
|
|
||||||
|
```go
|
||||||
|
// FetchOpusFrames 从 OSS 下载预转码的 Opus JSON 文件,解析为帧列表
|
||||||
|
func FetchOpusFrames(ctx context.Context, opusURL string) ([][]byte, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
**文件**:`hw_service_go/internal/handler/story.go` — 修改播放逻辑
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 优先使用预转码 Opus
|
||||||
|
var frames [][]byte
|
||||||
|
if story.OpusURL != "" {
|
||||||
|
frames, err = audio.FetchOpusFrames(ctx, story.OpusURL)
|
||||||
|
} else {
|
||||||
|
// 兜底:旧数据无预转码,走实时转码
|
||||||
|
frames, err = audio.MP3URLToOpusFrames(ctx, story.AudioURL)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: 历史数据迁移(可选)
|
||||||
|
|
||||||
|
写一个 management command 批量转码已有故事:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py convert_stories_to_opus
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 兼容性
|
||||||
|
|
||||||
|
- **旧故事**(`opus_url` 为空):hw_service_go 自动 fallback 到实时 ffmpeg 转码,无影响
|
||||||
|
- **新故事**:TTS 生成时自动预转码,hw_service_go 直接下载 Opus 数据
|
||||||
|
- **App 端**:无任何改动,`audio_url`(MP3)仍然存在供 App 播放器使用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
- Django 端需安装 `opuslib`(Python Opus 绑定):`pip install opuslib`
|
||||||
|
- Django 服务器需有 `ffmpeg`(已有,用于 TTS 后处理等)
|
||||||
|
- 如果不想引入 opuslib 依赖,可以用 `ffmpeg -c:a libopus` 直接输出 opus,但需要自行按 60ms 分帧
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证方法
|
||||||
|
|
||||||
|
1. 本地创建一个故事 + TTS → 检查 `opus_url` 是否生成
|
||||||
|
2. `curl /api/v1/devices/stories/?mac_address=...` 确认返回含 `opus_url`
|
||||||
|
3. hw_service_go 本地启动,连接测试页面触发故事 → 确认跳过 ffmpeg
|
||||||
|
4. 压测对比:相同并发下首帧延迟应从秒级降到百毫秒级
|
||||||
66
hw_service_go/internal/audio/fetch_opus.go
Normal file
66
hw_service_go/internal/audio/fetch_opus.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// opusJSON 是 Django 预转码上传的 Opus JSON 文件结构。
|
||||||
|
type opusJSON struct {
|
||||||
|
SampleRate int `json:"sample_rate"`
|
||||||
|
Channels int `json:"channels"`
|
||||||
|
FrameDurationMs int `json:"frame_duration_ms"`
|
||||||
|
Frames []string `json:"frames"` // base64 编码的 Opus 帧
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchOpusFrames 从 OSS 下载预转码的 Opus JSON 文件,解析为原始帧列表。
|
||||||
|
// 跳过 ffmpeg 实时转码,大幅降低 CPU 消耗和首帧延迟。
|
||||||
|
func FetchOpusFrames(ctx context.Context, opusURL string) ([][]byte, error) {
|
||||||
|
httpCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(httpCtx, http.MethodGet, opusURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("audio: build opus request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("audio: download opus json: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("audio: opus json status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 50*1024*1024)) // 50MB 上限
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("audio: read opus json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data opusJSON
|
||||||
|
if err := json.Unmarshal(body, &data); err != nil {
|
||||||
|
return nil, fmt.Errorf("audio: parse opus json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data.Frames) == 0 {
|
||||||
|
return nil, fmt.Errorf("audio: opus json has no frames")
|
||||||
|
}
|
||||||
|
|
||||||
|
frames := make([][]byte, 0, len(data.Frames))
|
||||||
|
for i, b64 := range data.Frames {
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(b64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("audio: decode frame %d: %w", i, err)
|
||||||
|
}
|
||||||
|
frames = append(frames, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
return frames, nil
|
||||||
|
}
|
||||||
@ -43,14 +43,27 @@ func HandleStory(conn *connection.Connection, client *rtcclient.Client) {
|
|||||||
}
|
}
|
||||||
log.Printf("%s playing: %s", tag, story.Title)
|
log.Printf("%s playing: %s", tag, story.Title)
|
||||||
|
|
||||||
// 3. 下载 MP3 并转码为 Opus 帧(CPU 密集,在当前 goroutine 中执行)
|
// 3. 获取 Opus 帧:优先使用预转码数据,否则实时 ffmpeg 转码
|
||||||
frames, err := audio.MP3URLToOpusFrames(ctx, story.AudioURL)
|
var frames [][]byte
|
||||||
if err != nil {
|
if story.OpusURL != "" {
|
||||||
log.Printf("%s audio convert error: %v", tag, err)
|
frames, err = audio.FetchOpusFrames(ctx, story.OpusURL)
|
||||||
return
|
if err != nil {
|
||||||
|
log.Printf("%s fetch pre-converted opus failed, fallback to ffmpeg: %v", tag, err)
|
||||||
|
frames = nil // 确保 fallback
|
||||||
|
} else {
|
||||||
|
log.Printf("%s loaded %d pre-converted frames (~%.1fs)", tag, len(frames),
|
||||||
|
float64(len(frames)*audio.FrameDurationMs)/1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if frames == nil {
|
||||||
|
frames, err = audio.MP3URLToOpusFrames(ctx, story.AudioURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("%s audio convert error: %v", tag, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("%s converted %d frames (~%.1fs)", tag, len(frames),
|
||||||
|
float64(len(frames)*audio.FrameDurationMs)/1000)
|
||||||
}
|
}
|
||||||
log.Printf("%s converted %d frames (~%.1fs)", tag, len(frames),
|
|
||||||
float64(len(frames)*audio.FrameDurationMs)/1000)
|
|
||||||
|
|
||||||
// 4. 通知硬件:句子开始(发送故事标题)
|
// 4. 通知硬件:句子开始(发送故事标题)
|
||||||
if err := conn.SendJSON(map[string]any{
|
if err := conn.SendJSON(map[string]any{
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import (
|
|||||||
type StoryInfo struct {
|
type StoryInfo struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
AudioURL string `json:"audio_url"`
|
AudioURL string `json:"audio_url"`
|
||||||
|
OpusURL string `json:"opus_url"` // 预转码 Opus JSON 地址,为空表示未转码
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client 是 RTC 后端的 HTTP 客户端,复用连接池。
|
// Client 是 RTC 后端的 HTTP 客户端,复用连接池。
|
||||||
|
|||||||
121
hw_service_go/test/stress/REPORT.md
Normal file
121
hw_service_go/test/stress/REPORT.md
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
# hw_service_go 并发压力测试报告
|
||||||
|
|
||||||
|
> 测试时间:2026-03-03
|
||||||
|
> 测试目标:`wss://qiyuan-rtc-api.airlabs.art/xiaozhi/v1/`
|
||||||
|
> Pod 配置:单 Pod,CPU 100m~500m(limits),Memory 128Mi~512Mi(limits),replicas: 1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、测试环境
|
||||||
|
|
||||||
|
| 项目 | 配置 |
|
||||||
|
|------|------|
|
||||||
|
| 服务 | hw_service_go(WebSocket + Opus 音频推送) |
|
||||||
|
| 部署 | K8s 单 Pod,1 副本 |
|
||||||
|
| CPU limits | 500m(0.5 核) |
|
||||||
|
| Memory limits | 512Mi |
|
||||||
|
| 硬编码连接上限 | 500 |
|
||||||
|
| 测试工具 | Go 压测工具(`test/stress/main.go`) |
|
||||||
|
| 测试客户端 | macOS,从公网连接线上服务 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、测试结果
|
||||||
|
|
||||||
|
### 2.1 连接容量测试(空闲连接)
|
||||||
|
|
||||||
|
```
|
||||||
|
go run main.go -url wss://..../xiaozhi/v1/ -conns 200 -stories 0 -duration 30s
|
||||||
|
```
|
||||||
|
|
||||||
|
| 指标 | 结果 |
|
||||||
|
|------|------|
|
||||||
|
| 目标连接 | 200 |
|
||||||
|
| 成功连接 | 200 |
|
||||||
|
| 握手成功 | 200 |
|
||||||
|
| 错误 | 0 |
|
||||||
|
|
||||||
|
**结论:200 个空闲连接毫无压力,内存不是瓶颈。**
|
||||||
|
|
||||||
|
### 2.2 并发播放压力测试
|
||||||
|
|
||||||
|
每个"活跃故事"会触发:Django API 查询 → MP3 下载 → ffmpeg 转码 → Opus 编码 → WebSocket 推帧。
|
||||||
|
|
||||||
|
| 并发故事数 | 总连接 | 首帧延迟 | 帧数/故事 | 错误 | 状态 |
|
||||||
|
|-----------|--------|---------|----------|------|------|
|
||||||
|
| 2 | 10 | **2.0s** | 796 | 0 | 轻松 |
|
||||||
|
| 5 | 10 | **4.5s** | 796 | 0 | 正常 |
|
||||||
|
| 10 | 20 | **8.7s** | 796 | 0 | 吃力但稳 |
|
||||||
|
| 20 | 30 | **17.4s** | 796 | 0 | 极限 |
|
||||||
|
|
||||||
|
### 2.3 关键发现
|
||||||
|
|
||||||
|
1. **帧数始终稳定 796/故事** — 音频完整交付,零丢帧,服务可靠性极高
|
||||||
|
2. **首帧延迟线性增长** — 约 0.85s/并发,纯 CPU 瓶颈(多个 ffmpeg 进程争抢 0.5 核)
|
||||||
|
3. **Pod 未触发 OOMKill** — 512Mi 内存对 20 并发播放也够用
|
||||||
|
4. **全程零错误** — 无连接断开、无握手失败、无帧丢失
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、瓶颈分析
|
||||||
|
|
||||||
|
```
|
||||||
|
单个故事播放的资源消耗链路:
|
||||||
|
|
||||||
|
Django API (GET) → MP3 下载 (OSS) → ffmpeg 转码 (CPU密集) → Opus 编码 → WebSocket 推帧
|
||||||
|
↑
|
||||||
|
主要瓶颈
|
||||||
|
每个并发故事启动一个 ffmpeg 子进程
|
||||||
|
多个 ffmpeg 共享 0.5 核 CPU
|
||||||
|
```
|
||||||
|
|
||||||
|
| 资源 | 是否瓶颈 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| **CPU** | **是** | ffmpeg 转码是 CPU 密集型,0.5 核被多个 ffmpeg 进程分时使用 |
|
||||||
|
| 内存 | 否 | 20 并发播放未触发 OOM,512Mi 充足 |
|
||||||
|
| 网络 | 否 | Opus 帧约 4-7 KB/s/连接,带宽远未饱和 |
|
||||||
|
| 连接数 | 否 | 空闲连接 200+ 无压力,硬上限 500 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、容量结论
|
||||||
|
|
||||||
|
### 当前单 Pod(0.5 核 CPU, 512Mi, 1 副本)
|
||||||
|
|
||||||
|
| 指标 | 数值 |
|
||||||
|
|------|------|
|
||||||
|
| 空闲连接上限 | **200+**(轻松) |
|
||||||
|
| 并发播放(体验好,首帧 < 5s) | **~5 个** |
|
||||||
|
| 并发播放(可接受,首帧 < 10s) | **~10 个** |
|
||||||
|
| 并发播放(极限,首帧 ~17s) | **~20 个** |
|
||||||
|
| 瓶颈资源 | CPU(ffmpeg 转码) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、扩容建议
|
||||||
|
|
||||||
|
| 方案 | 变更 | 预估并发播放(首帧 < 10s) | 成本 |
|
||||||
|
|------|------|------------------------|------|
|
||||||
|
| **提 CPU** | limits 500m → 1000m | ~20 个 | 低 |
|
||||||
|
| **加副本** | replicas 1 → 2 | ~10 个(负载均衡) | 中 |
|
||||||
|
| **两者都做** | 1000m CPU + 2 副本 | **~40 个** | 中 |
|
||||||
|
| 垂直扩容 | 2000m CPU + 1Gi 内存 | ~40 个 | 中 |
|
||||||
|
|
||||||
|
> **推荐方案**:replicas: 2 + CPU limits: 1000m,兼顾高可用与并发能力。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、测试命令参考
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd hw_service_go/test/stress
|
||||||
|
|
||||||
|
# 空闲连接容量
|
||||||
|
go run main.go -url wss://TARGET/xiaozhi/v1/ -conns 200 -stories 0 -duration 30s
|
||||||
|
|
||||||
|
# 并发播放(逐步加压)
|
||||||
|
go run main.go -url wss://TARGET/xiaozhi/v1/ -conns 10 -stories 2 -duration 60s
|
||||||
|
go run main.go -url wss://TARGET/xiaozhi/v1/ -conns 10 -stories 5 -duration 60s
|
||||||
|
go run main.go -url wss://TARGET/xiaozhi/v1/ -conns 20 -stories 10 -duration 90s
|
||||||
|
go run main.go -url wss://TARGET/xiaozhi/v1/ -conns 30 -stories 20 -duration 120s
|
||||||
|
```
|
||||||
@ -39,3 +39,4 @@ alibabacloud-endpoint-util==0.0.4
|
|||||||
darabonba-core==1.0.5
|
darabonba-core==1.0.5
|
||||||
volcengine-python-sdk[ark]>=5.0.9
|
volcengine-python-sdk[ark]>=5.0.9
|
||||||
edge-tts>=6.1.0
|
edge-tts>=6.1.0
|
||||||
|
opuslib>=3.0.1
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user