import asyncio import os import shutil import tempfile from dataclasses import dataclass from pathlib import Path from utils.audio_format import ( DEFAULT_CHANNELS, DEFAULT_SAMPLE_RATE, DEFAULT_SAMPLE_WIDTH, detect_audio_format, wrap_pcm_as_wav, ) from utils.logger import session_logger MP3_MIME_TYPE = "audio/mpeg" MP3_EXTENSION = "mp3" _AUDIO_CONTENT_TYPE_TO_FORMAT = { "audio/mpeg": "mp3", "audio/mp3": "mp3", "audio/aac": "aac", "audio/x-aac": "aac", "audio/wav": "wav", "audio/x-wav": "wav", "audio/m4a": "m4a", "audio/x-m4a": "m4a", "audio/mp4": "m4a", "audio/webm": "webm", "audio/pcm": "pcm_s16le_16k_mono", "audio/raw": "pcm_s16le_16k_mono", "audio/s16le": "pcm_s16le_16k_mono", } _AUDIO_EXTENSION_TO_FORMAT = { ".mp3": "mp3", ".aac": "aac", ".m4a": "m4a", ".wav": "wav", ".webm": "webm", } _AUDIO_FORMAT_TO_SUFFIX = { "mp3": ".mp3", "aac": ".aac", "m4a": ".m4a", "wav": ".wav", "webm": ".webm", "pcm_s16le_16k_mono": ".wav", } class AudioTranscodeError(Exception): pass class AudioTranscodeUnavailable(AudioTranscodeError): pass @dataclass(frozen=True) class PreparedMp3Audio: content: bytes mime_type: str extension: str size_bytes: int source_format: str original_mime_type: str | None original_extension: str | None transcoded: bool def _normalize_content_type(content_type: str | None) -> str | None: normalized = (content_type or "").split(";", 1)[0].strip().lower() return normalized or None def _normalize_extension(filename: str | None) -> str | None: suffix = Path(filename or "").suffix.lower() return suffix.lstrip(".") if suffix else None def _looks_like_adts_aac(audio_data: bytes) -> bool: return len(audio_data) >= 2 and audio_data[0] == 0xFF and (audio_data[1] & 0xF6) == 0xF0 def _looks_like_mp4_container(audio_data: bytes) -> bool: return len(audio_data) >= 12 and audio_data[4:8] == b"ftyp" def _looks_like_mp3_frame(audio_data: bytes) -> bool: if len(audio_data) < 2 or audio_data[0] != 0xFF or (audio_data[1] & 0xE0) != 0xE0: return False layer_bits = (audio_data[1] >> 1) & 0x03 return layer_bits != 0 def infer_audio_source_format( audio_data: bytes, *, filename: str | None = None, content_type: str | None = None, ) -> str: if len(audio_data) >= 12 and audio_data[:4] == b"RIFF" and audio_data[8:12] == b"WAVE": return "wav" if audio_data.startswith(b"ID3") or _looks_like_mp3_frame(audio_data): return "mp3" if _looks_like_adts_aac(audio_data): return "aac" if _looks_like_mp4_container(audio_data): return "m4a" normalized_content_type = _normalize_content_type(content_type) if normalized_content_type in _AUDIO_CONTENT_TYPE_TO_FORMAT: return _AUDIO_CONTENT_TYPE_TO_FORMAT[normalized_content_type] suffix = Path(filename or "").suffix.lower() if suffix in _AUDIO_EXTENSION_TO_FORMAT: return _AUDIO_EXTENSION_TO_FORMAT[suffix] return detect_audio_format(audio_data) def source_suffix_for_audio( *, source_format: str, filename: str | None = None, content_type: str | None = None, ) -> str: if source_format in _AUDIO_FORMAT_TO_SUFFIX: return _AUDIO_FORMAT_TO_SUFFIX[source_format] normalized_content_type = _normalize_content_type(content_type) hinted_format = _AUDIO_CONTENT_TYPE_TO_FORMAT.get(normalized_content_type or "") if hinted_format in _AUDIO_FORMAT_TO_SUFFIX: return _AUDIO_FORMAT_TO_SUFFIX[hinted_format] suffix = Path(filename or "").suffix.lower() if suffix in _AUDIO_EXTENSION_TO_FORMAT: return suffix return ".audio" async def prepare_audio_as_mp3( audio_data: bytes, *, filename: str | None = None, content_type: str | None = None, session_device_id: str = "system", session_id: str = "audio_transcode", ) -> PreparedMp3Audio: source_format = infer_audio_source_format( audio_data, filename=filename, content_type=content_type, ) original_mime_type = _normalize_content_type(content_type) original_extension = _normalize_extension(filename) if source_format == "mp3": return PreparedMp3Audio( content=audio_data, mime_type=MP3_MIME_TYPE, extension=MP3_EXTENSION, size_bytes=len(audio_data), source_format=source_format, original_mime_type=original_mime_type, original_extension=original_extension, transcoded=False, ) source_suffix = source_suffix_for_audio( source_format=source_format, filename=filename, content_type=content_type, ) mp3_data = await transcode_audio_to_mp3( audio_data, source_format=source_format, source_suffix=source_suffix, session_device_id=session_device_id, session_id=session_id, ) return PreparedMp3Audio( content=mp3_data, mime_type=MP3_MIME_TYPE, extension=MP3_EXTENSION, size_bytes=len(mp3_data), source_format=source_format, original_mime_type=original_mime_type, original_extension=original_extension, transcoded=True, ) async def transcode_audio_to_mp3( audio_data: bytes, *, source_format: str, source_suffix: str, session_device_id: str = "system", session_id: str = "audio_transcode", ) -> bytes: ffmpeg_path = shutil.which("ffmpeg") if not ffmpeg_path: raise AudioTranscodeUnavailable("ffmpeg not found in PATH") with tempfile.TemporaryDirectory(prefix="banban-audio-transcode-") as tmp_dir: input_suffix = source_suffix if source_suffix.startswith(".") else f".{source_suffix}" input_content = audio_data if source_format == "pcm_s16le_16k_mono": input_suffix = ".wav" input_content = wrap_pcm_as_wav( audio_data, sample_rate=DEFAULT_SAMPLE_RATE, channels=DEFAULT_CHANNELS, sample_width=DEFAULT_SAMPLE_WIDTH, ) source_path = os.path.join(tmp_dir, f"source{input_suffix}") target_path = os.path.join(tmp_dir, "target.mp3") await asyncio.to_thread(_write_file_bytes, source_path, input_content) await _convert_file_to_mp3( ffmpeg_path=ffmpeg_path, source_path=source_path, target_path=target_path, session_device_id=session_device_id, session_id=session_id, source_format=source_format, ) return await asyncio.to_thread(_read_file_bytes, target_path) async def _convert_file_to_mp3( *, ffmpeg_path: str, source_path: str, target_path: str, session_device_id: str, session_id: str, source_format: str, ) -> None: command = [ ffmpeg_path, "-y", "-loglevel", "error", "-i", source_path, "-vn", "-codec:a", "libmp3lame", "-ar", str(DEFAULT_SAMPLE_RATE), "-ac", str(DEFAULT_CHANNELS), "-b:a", "32k", target_path, ] session_logger.info( session_device_id, session_id, ( "开始转码前端语音为 MP3: " f"source_format={source_format}, command={' '.join(command)}" ), ) process = await asyncio.create_subprocess_exec( *command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await process.communicate() if process.returncode != 0: stderr_text = stderr.decode("utf-8", errors="ignore").strip() raise AudioTranscodeError( f"ffmpeg convert failed, returncode={process.returncode}, stderr={stderr_text}" ) session_logger.info( session_device_id, session_id, ( "前端语音 MP3 转码完成: " f"source_format={source_format}, " f"ffmpeg_stdout={stdout.decode('utf-8', errors='ignore').strip() or 'empty'}" ), ) def _read_file_bytes(filepath: str) -> bytes: with open(filepath, "rb") as file: return file.read() def _write_file_bytes(filepath: str, content: bytes) -> None: with open(filepath, "wb") as file: file.write(content)