diff --git a/=3.0.1 b/=3.0.1 new file mode 100644 index 0000000..109b929 --- /dev/null +++ b/=3.0.1 @@ -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 diff --git a/Dockerfile b/Dockerfile index 918ab4b..8c8abe3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,8 @@ RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debia gcc \ default-libmysqlclient-dev \ pkg-config \ + ffmpeg \ + libopus-dev \ && rm -rf /var/lib/apt/lists/* # Install python dependencies diff --git a/apps/devices/views.py b/apps/devices/views.py index 092325c..e55a9dd 100644 --- a/apps/devices/views.py +++ b/apps/devices/views.py @@ -363,7 +363,11 @@ class DeviceViewSet(viewsets.ViewSet): 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', authentication_classes=[], permission_classes=[AllowAny]) diff --git a/apps/stories/management/commands/convert_stories_to_opus.py b/apps/stories/management/commands/convert_stories_to_opus.py new file mode 100644 index 0000000..b57c81b --- /dev/null +++ b/apps/stories/management/commands/convert_stories_to_opus.py @@ -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}' + )) diff --git a/apps/stories/migrations/0005_story_opus_url.py b/apps/stories/migrations/0005_story_opus_url.py new file mode 100644 index 0000000..5b09f5a --- /dev/null +++ b/apps/stories/migrations/0005_story_opus_url.py @@ -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'), + ), + ] diff --git a/apps/stories/models.py b/apps/stories/models.py index b801165..5dc797b 100644 --- a/apps/stories/models.py +++ b/apps/stories/models.py @@ -54,6 +54,7 @@ class Story(models.Model): content = models.TextField('内容', 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='') + opus_url = models.URLField('Opus音频URL', max_length=500, blank=True, default='') has_video = models.BooleanField('是否有视频', default=False) video_url = models.URLField('视频URL', max_length=500, blank=True, default='') generation_mode = models.CharField( diff --git a/apps/stories/services/opus_converter.py b/apps/stories/services/opus_converter.py new file mode 100644 index 0000000..c9ae67c --- /dev/null +++ b/apps/stories/services/opus_converter.py @@ -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=(',', ':')) # 紧凑格式,减少体积 diff --git a/apps/stories/services/tts_service.py b/apps/stories/services/tts_service.py index d0ebd7c..acc2099 100644 --- a/apps/stories/services/tts_service.py +++ b/apps/stories/services/tts_service.py @@ -85,13 +85,43 @@ def generate_tts_stream(story): # 更新故事记录 story.audio_url = audio_url - story.save(update_fields=['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, diff --git a/docs/opus-preconvert-plan.md b/docs/opus-preconvert-plan.md new file mode 100644 index 0000000..4fbfd40 --- /dev/null +++ b/docs/opus-preconvert-plan.md @@ -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": ["", "", ...] +} +``` + +> 一个 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. 压测对比:相同并发下首帧延迟应从秒级降到百毫秒级 diff --git a/hw_service_go/internal/audio/fetch_opus.go b/hw_service_go/internal/audio/fetch_opus.go new file mode 100644 index 0000000..6598c02 --- /dev/null +++ b/hw_service_go/internal/audio/fetch_opus.go @@ -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 +} diff --git a/hw_service_go/internal/handler/story.go b/hw_service_go/internal/handler/story.go index b90d876..1f782e7 100644 --- a/hw_service_go/internal/handler/story.go +++ b/hw_service_go/internal/handler/story.go @@ -43,14 +43,27 @@ func HandleStory(conn *connection.Connection, client *rtcclient.Client) { } log.Printf("%s playing: %s", tag, story.Title) - // 3. 下载 MP3 并转码为 Opus 帧(CPU 密集,在当前 goroutine 中执行) - frames, err := audio.MP3URLToOpusFrames(ctx, story.AudioURL) - if err != nil { - log.Printf("%s audio convert error: %v", tag, err) - return + // 3. 获取 Opus 帧:优先使用预转码数据,否则实时 ffmpeg 转码 + var frames [][]byte + if story.OpusURL != "" { + frames, err = audio.FetchOpusFrames(ctx, story.OpusURL) + 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. 通知硬件:句子开始(发送故事标题) if err := conn.SendJSON(map[string]any{ diff --git a/hw_service_go/internal/rtcclient/client.go b/hw_service_go/internal/rtcclient/client.go index d02cf7e..f59c3c7 100644 --- a/hw_service_go/internal/rtcclient/client.go +++ b/hw_service_go/internal/rtcclient/client.go @@ -15,6 +15,7 @@ import ( type StoryInfo struct { Title string `json:"title"` AudioURL string `json:"audio_url"` + OpusURL string `json:"opus_url"` // 预转码 Opus JSON 地址,为空表示未转码 } // Client 是 RTC 后端的 HTTP 客户端,复用连接池。 diff --git a/hw_service_go/test/stress/REPORT.md b/hw_service_go/test/stress/REPORT.md new file mode 100644 index 0000000..478b9ba --- /dev/null +++ b/hw_service_go/test/stress/REPORT.md @@ -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 +``` diff --git a/requirements.txt b/requirements.txt index e1f2a49..a369903 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,3 +39,4 @@ alibabacloud-endpoint-util==0.0.4 darabonba-core==1.0.5 volcengine-python-sdk[ark]>=5.0.9 edge-tts>=6.1.0 +opuslib>=3.0.1