From 51a673e81406baef12e9a41473cb4fe4468acd60 Mon Sep 17 00:00:00 2001 From: repair-agent Date: Wed, 4 Mar 2026 13:11:10 +0800 Subject: [PATCH] Add story music --- apps/devices/views.py | 67 +++++++++ .../commands/convert_tracks_to_opus.py | 122 ++++++++++++++++ apps/music/migrations/0003_track_opus_url.py | 18 +++ .../migrations/0004_track_intro_opus_data.py | 18 +++ apps/music/models.py | 2 + .../commands/generate_intro_opus.py | 132 ++++++++++++++++++ .../migrations/0006_story_intro_opus_data.py | 18 +++ apps/stories/models.py | 1 + apps/stories/services/intro_service.py | 70 ++++++++++ 9 files changed, 448 insertions(+) create mode 100644 apps/music/management/commands/convert_tracks_to_opus.py create mode 100644 apps/music/migrations/0003_track_opus_url.py create mode 100644 apps/music/migrations/0004_track_intro_opus_data.py create mode 100644 apps/stories/management/commands/generate_intro_opus.py create mode 100644 apps/stories/migrations/0006_story_intro_opus_data.py create mode 100644 apps/stories/services/intro_service.py diff --git a/apps/devices/views.py b/apps/devices/views.py index e55a9dd..905bb03 100644 --- a/apps/devices/views.py +++ b/apps/devices/views.py @@ -367,6 +367,73 @@ class DeviceViewSet(viewsets.ViewSet): 'title': story.title, 'audio_url': story.audio_url, 'opus_url': story.opus_url, + 'intro_opus_data': story.intro_opus_data, + }) + + @action( + detail=False, methods=['get'], + url_path='music', + authentication_classes=[], permission_classes=[AllowAny] + ) + def music_by_mac(self, request): + """ + 获取设备关联用户的随机音乐(公开接口,无需认证) + GET /api/v1/devices/music/?mac_address=AA:BB:CC:DD:EE:FF + 供 hw-ws-service 调用。 + 优先返回用户自己的音乐,无则兜底返回系统默认曲目(is_default=True)。 + """ + mac = request.query_params.get('mac_address', '').strip() + if not mac: + return error(message='mac_address 参数不能为空') + + mac = mac.upper().replace('-', ':') + + from apps.music.models import Track + track = None + + # 1. 尝试查找设备 → 绑定用户 → 用户音乐 + try: + device = Device.objects.get(mac_address=mac) + user_device = ( + UserDevice.objects + .filter(device=device, is_active=True, bind_type='owner') + .select_related('user') + .first() + ) + if user_device: + track = ( + Track.objects + .filter(user=user_device.user, generation_status='completed') + .exclude(audio_url='') + .order_by('?') + .first() + ) + except Device.DoesNotExist: + pass + + # 2. 兜底:设备不存在/未绑定/用户无音乐 → 使用系统默认曲目 + if not track: + track = ( + Track.objects + .filter(is_default=True, generation_status='completed') + .exclude(audio_url='') + .order_by('?') + .first() + ) + if not track: + return error( + code=ErrorCode.TRACK_NOT_FOUND, + message='暂无可播放的音乐', + status_code=status.HTTP_404_NOT_FOUND + ) + + return success(data={ + 'title': track.title, + 'audio_url': track.audio_url, + 'opus_url': track.opus_url, + 'intro_opus_data': track.intro_opus_data, + 'cover_url': track.cover_url, + 'duration': track.duration, }) @action(detail=False, methods=['post'], url_path='report-status', diff --git a/apps/music/management/commands/convert_tracks_to_opus.py b/apps/music/management/commands/convert_tracks_to_opus.py new file mode 100644 index 0000000..96277cd --- /dev/null +++ b/apps/music/management/commands/convert_tracks_to_opus.py @@ -0,0 +1,122 @@ +""" +批量将已有音乐的 MP3 音频预转码为 Opus 帧 JSON 并上传 OSS。 + +使用方法: + python manage.py convert_tracks_to_opus + python manage.py convert_tracks_to_opus --dry-run # 仅统计,不转码 + python manage.py convert_tracks_to_opus --limit 10 # 只处理前 10 个 + python manage.py convert_tracks_to_opus --force # 重新转码已有 opus_url 的曲目 + python manage.py convert_tracks_to_opus --default # 仅处理系统默认曲目 +""" +import uuid +import logging +from datetime import datetime + +import requests +from django.conf import settings +from django.core.management.base import BaseCommand + +from apps.music.models import Track +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 的曲目', + ) + parser.add_argument( + '--default', action='store_true', + help='仅处理系统默认曲目(is_default=True)', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + limit = options['limit'] + force = options['force'] + default_only = options['default'] + + # 查找需要转码的曲目 + qs = Track.objects.filter( + generation_status='completed', + ).exclude(audio_url='') + if not force: + qs = qs.filter(opus_url='') + if default_only: + qs = qs.filter(is_default=True) + 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']}" + + tracks = qs[:limit] if limit > 0 else qs + success_count = 0 + fail_count = 0 + + for i, track in enumerate(tracks.iterator(), 1): + self.stdout.write(f'\n[{i}/{total}] Track#{track.id} "{track.title}"') + self.stdout.write(f' MP3: {track.audio_url[:80]}...') + + try: + # 下载 MP3 + resp = requests.get(track.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"music/audio-opus/{opus_filename}" + oss_client.bucket.put_object(opus_key, opus_json.encode('utf-8')) + + opus_url = f"{url_prefix}/{opus_key}" + track.opus_url = opus_url + track.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'Track#{track.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/music/migrations/0003_track_opus_url.py b/apps/music/migrations/0003_track_opus_url.py new file mode 100644 index 0000000..6e83147 --- /dev/null +++ b/apps/music/migrations/0003_track_opus_url.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-03-04 03:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('music', '0002_track_generation_status_track_is_default_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='track', + name='opus_url', + field=models.URLField(blank=True, default='', max_length=500, verbose_name='Opus音频URL'), + ), + ] diff --git a/apps/music/migrations/0004_track_intro_opus_data.py b/apps/music/migrations/0004_track_intro_opus_data.py new file mode 100644 index 0000000..d97cf1b --- /dev/null +++ b/apps/music/migrations/0004_track_intro_opus_data.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-03-04 03:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('music', '0003_track_opus_url'), + ] + + operations = [ + migrations.AddField( + model_name='track', + name='intro_opus_data', + field=models.TextField(blank=True, default='', verbose_name='引导语Opus数据'), + ), + ] diff --git a/apps/music/models.py b/apps/music/models.py index 2a6e9e8..ab96f34 100644 --- a/apps/music/models.py +++ b/apps/music/models.py @@ -30,6 +30,7 @@ class Track(models.Model): title = models.CharField('标题', max_length=200) lyrics = models.TextField('歌词', 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='') cover_url = models.URLField('封面URL', max_length=500, blank=True, default='') mood = models.CharField( '情绪标签', max_length=20, @@ -39,6 +40,7 @@ class Track(models.Model): prompt = models.TextField('生成提示词', blank=True, default='') is_favorite = models.BooleanField('是否收藏', default=False) is_default = models.BooleanField('是否默认曲目', default=False) + intro_opus_data = models.TextField('引导语Opus数据', blank=True, default='') generation_status = models.CharField( '生成状态', max_length=20, choices=GENERATION_STATUS_CHOICES, default='completed' diff --git a/apps/stories/management/commands/generate_intro_opus.py b/apps/stories/management/commands/generate_intro_opus.py new file mode 100644 index 0000000..1517f84 --- /dev/null +++ b/apps/stories/management/commands/generate_intro_opus.py @@ -0,0 +1,132 @@ +""" +批量为故事和音乐生成引导语 Opus 数据并写入数据库。 + +使用方法: + python manage.py generate_intro_opus # 处理所有 + python manage.py generate_intro_opus --type story # 仅故事 + python manage.py generate_intro_opus --type music # 仅音乐 + python manage.py generate_intro_opus --dry-run # 仅统计 + python manage.py generate_intro_opus --limit 10 # 只处理前 10 个 + python manage.py generate_intro_opus --force # 重新生成已有引导语的记录 +""" +import logging + +from django.core.management.base import BaseCommand + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = '批量为故事和音乐生成引导语 Opus 数据' + + def add_arguments(self, parser): + parser.add_argument( + '--type', choices=['story', 'music', 'all'], default='all', + help='处理类型:story / music / all(默认 all)', + ) + 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='重新生成已有引导语的记录', + ) + + def handle(self, *args, **options): + content_type = options['type'] + dry_run = options['dry_run'] + limit = options['limit'] + force = options['force'] + + if content_type in ('story', 'all'): + self._process_stories(dry_run, limit, force) + + if content_type in ('music', 'all'): + self._process_tracks(dry_run, limit, force) + + def _process_stories(self, dry_run, limit, force): + from apps.stories.models import Story + from apps.stories.services.intro_service import generate_intro_opus + + self.stdout.write(self.style.MIGRATE_HEADING('\n=== 故事引导语 ===')) + + qs = Story.objects.exclude(audio_url='') + if not force: + qs = qs.filter(intro_opus_data='') + qs = qs.order_by('id') + + total = qs.count() + self.stdout.write(f'需要处理的故事: {total} 个') + + if dry_run or total == 0: + return + + items = qs[:limit] if limit > 0 else qs + success_count = 0 + fail_count = 0 + + for i, story in enumerate(items.iterator(), 1): + self.stdout.write(f'[{i}/{total}] Story#{story.id} "{story.title}"') + try: + opus_json = generate_intro_opus(story.title, content_type='story') + story.intro_opus_data = opus_json + story.save(update_fields=['intro_opus_data']) + success_count += 1 + self.stdout.write(self.style.SUCCESS( + f' OK ({len(opus_json) / 1024:.1f} KB)' + )) + except Exception as e: + fail_count += 1 + self.stdout.write(self.style.ERROR(f' FAIL: {e}')) + logger.error(f'Story#{story.id} intro generate failed: {e}') + + self.stdout.write(self.style.SUCCESS( + f'故事完成: 成功 {success_count}, 失败 {fail_count}' + )) + + def _process_tracks(self, dry_run, limit, force): + from apps.music.models import Track + from apps.stories.services.intro_service import generate_intro_opus + + self.stdout.write(self.style.MIGRATE_HEADING('\n=== 音乐引导语 ===')) + + qs = Track.objects.filter( + generation_status='completed', + ).exclude(audio_url='') + if not force: + qs = qs.filter(intro_opus_data='') + qs = qs.order_by('id') + + total = qs.count() + self.stdout.write(f'需要处理的曲目: {total} 个') + + if dry_run or total == 0: + return + + items = qs[:limit] if limit > 0 else qs + success_count = 0 + fail_count = 0 + + for i, track in enumerate(items.iterator(), 1): + self.stdout.write(f'[{i}/{total}] Track#{track.id} "{track.title}"') + try: + opus_json = generate_intro_opus(track.title, content_type='music') + track.intro_opus_data = opus_json + track.save(update_fields=['intro_opus_data']) + success_count += 1 + self.stdout.write(self.style.SUCCESS( + f' OK ({len(opus_json) / 1024:.1f} KB)' + )) + except Exception as e: + fail_count += 1 + self.stdout.write(self.style.ERROR(f' FAIL: {e}')) + logger.error(f'Track#{track.id} intro generate failed: {e}') + + self.stdout.write(self.style.SUCCESS( + f'音乐完成: 成功 {success_count}, 失败 {fail_count}' + )) diff --git a/apps/stories/migrations/0006_story_intro_opus_data.py b/apps/stories/migrations/0006_story_intro_opus_data.py new file mode 100644 index 0000000..1c95161 --- /dev/null +++ b/apps/stories/migrations/0006_story_intro_opus_data.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-03-04 03:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stories', '0005_story_opus_url'), + ] + + operations = [ + migrations.AddField( + model_name='story', + name='intro_opus_data', + field=models.TextField(blank=True, default='', verbose_name='引导语Opus数据'), + ), + ] diff --git a/apps/stories/models.py b/apps/stories/models.py index 5dc797b..25fc70c 100644 --- a/apps/stories/models.py +++ b/apps/stories/models.py @@ -63,6 +63,7 @@ class Story(models.Model): ) prompt = models.TextField('生成提示词', blank=True, default='') is_default = models.BooleanField('是否默认故事', default=False) + intro_opus_data = models.TextField('引导语Opus数据', blank=True, default='') created_at = models.DateTimeField('创建时间', auto_now_add=True) updated_at = models.DateTimeField('更新时间', auto_now=True) diff --git a/apps/stories/services/intro_service.py b/apps/stories/services/intro_service.py new file mode 100644 index 0000000..2f2028a --- /dev/null +++ b/apps/stories/services/intro_service.py @@ -0,0 +1,70 @@ +""" +引导语 Opus 生成服务 + +为故事/音乐生成一句引导语(如"正在为您播放,卡皮巴拉蹦蹦蹦"), +转为 Opus 帧 JSON 字符串,直接存入数据库字段。 +""" +import asyncio +import logging +import random + +logger = logging.getLogger(__name__) + +TTS_VOICE = 'zh-CN-XiaoxiaoNeural' + +STORY_PROMPTS = [ + "正在为您播放,{}", + "请欣赏故事,{}", + "即将为您播放,{}", + "为您带来,{}", + "让我们聆听,{}", + "接下来请欣赏,{}", + "为您献上,{}", +] + +MUSIC_PROMPTS = [ + "正在为您播放,{}", + "请享受音乐,{}", + "即将为您播放,{}", + "为您带来,{}", + "让我们聆听,{}", + "接下来请欣赏,{}", + "为您献上,{}", +] + + +def generate_intro_opus(title: str, content_type: str = 'story') -> str: + """ + 为指定标题生成引导语 Opus JSON。 + + Args: + title: 故事或音乐标题 + content_type: 'story' 或 'music' + + Returns: + Opus 帧 JSON 字符串(与 opus_url 指向的格式一致) + """ + prompts = STORY_PROMPTS if content_type == 'story' else MUSIC_PROMPTS + text = random.choice(prompts).format(title) + logger.info(f'生成引导语: "{text}"') + + # edge-tts 合成 MP3 + mp3_bytes = asyncio.run(_synthesize(text)) + + # MP3 → Opus 帧 JSON + from apps.stories.services.opus_converter import convert_mp3_to_opus_json + opus_json = convert_mp3_to_opus_json(mp3_bytes) + + return opus_json + + +async def _synthesize(text: str) -> bytes: + """使用 edge-tts 合成语音,返回 MP3 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)