Add story music
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 6m7s
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 6m7s
This commit is contained in:
parent
343a2ae397
commit
51a673e814
@ -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',
|
||||
|
||||
122
apps/music/management/commands/convert_tracks_to_opus.py
Normal file
122
apps/music/management/commands/convert_tracks_to_opus.py
Normal file
@ -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}'
|
||||
))
|
||||
18
apps/music/migrations/0003_track_opus_url.py
Normal file
18
apps/music/migrations/0003_track_opus_url.py
Normal file
@ -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'),
|
||||
),
|
||||
]
|
||||
18
apps/music/migrations/0004_track_intro_opus_data.py
Normal file
18
apps/music/migrations/0004_track_intro_opus_data.py
Normal file
@ -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数据'),
|
||||
),
|
||||
]
|
||||
@ -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'
|
||||
|
||||
132
apps/stories/management/commands/generate_intro_opus.py
Normal file
132
apps/stories/management/commands/generate_intro_opus.py
Normal file
@ -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}'
|
||||
))
|
||||
18
apps/stories/migrations/0006_story_intro_opus_data.py
Normal file
18
apps/stories/migrations/0006_story_intro_opus_data.py
Normal file
@ -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数据'),
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
|
||||
70
apps/stories/services/intro_service.py
Normal file
70
apps/stories/services/intro_service.py
Normal file
@ -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)
|
||||
Loading…
x
Reference in New Issue
Block a user