""" 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=(',', ':')) # 紧凑格式,减少体积