From 57291c81fc1f140c09ce81fe17cc78b926a3f98d Mon Sep 17 00:00:00 2001 From: stu2not Date: Thu, 11 Jun 2026 10:46:32 +0800 Subject: [PATCH] Normalize parent voice uploads to MP3 --- talkingq-url/banban/service/im.py | 60 ++++- talkingq-url/utils/audio_transcode.py | 301 ++++++++++++++++++++++++++ 2 files changed, 350 insertions(+), 11 deletions(-) create mode 100644 talkingq-url/utils/audio_transcode.py diff --git a/talkingq-url/banban/service/im.py b/talkingq-url/banban/service/im.py index b1a593a..c1330d8 100644 --- a/talkingq-url/banban/service/im.py +++ b/talkingq-url/banban/service/im.py @@ -22,6 +22,11 @@ except ModuleNotFoundError: from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult from banban.schemas.im import ChildConversationMessageItem, DeviceMessageCreateRequest, ParentChildMessageCreateRequest from utils.audio_duration import parse_audio_duration_ms +from utils.audio_transcode import ( + AudioTranscodeError, + AudioTranscodeUnavailable, + prepare_audio_as_mp3, +) from utils.logger import session_logger @@ -401,25 +406,58 @@ class ImService(DatabaseServiceBase): raise HTTPException(status_code=404, detail="child has no bound device") target_device_id = str(device["device_id"]) - extension, normalized_content_type = normalize_audio_extension( - filename=filename, - content_type=content_type, + try: + prepared_audio = await prepare_audio_as_mp3( + content, + filename=filename, + content_type=content_type, + session_device_id=target_device_id, + session_id="parent_weapp_voice", + ) + except AudioTranscodeUnavailable as exc: + session_logger.error( + target_device_id, + "parent_weapp_voice", + f"audio transcode service unavailable: {exc}", + exc_info=True, + ) + raise HTTPException(status_code=500, detail="audio transcode service unavailable") from exc + except AudioTranscodeError as exc: + session_logger.warning( + target_device_id, + "parent_weapp_voice", + f"failed to transcode uploaded parent voice: {exc}", + ) + raise HTTPException(status_code=415, detail="unsupported audio file type") from exc + + payload_ext_json = dict(ext_json or {}) + payload_ext_json.update( + { + "normalized_audio_format": "mp3", + "source_audio_format": prepared_audio.source_format, + "audio_transcoded": prepared_audio.transcoded, + } ) + if prepared_audio.original_mime_type: + payload_ext_json["original_media_mime_type"] = prepared_audio.original_mime_type + if prepared_audio.original_extension: + payload_ext_json["original_media_extension"] = prepared_audio.original_extension + stored_audio = await self.audio_storage.upload_audio( device_id=f"parent-{parent_user_id}", - content=content, - content_type=normalized_content_type, - extension=extension, + content=prepared_audio.content, + content_type=prepared_audio.mime_type, + extension=prepared_audio.extension, ) payload = ParentChildMessageCreateRequest( content_type=2, media_file_key=stored_audio.file_key, media_duration_ms=media_duration_ms, - media_mime_type=normalized_content_type, - media_size_bytes=len(content), + media_mime_type=prepared_audio.mime_type, + media_size_bytes=prepared_audio.size_bytes, media_transcript_text=(media_transcript_text or "").strip() or None, client_msg_id=client_msg_id, - ext_json=ext_json, + ext_json=payload_ext_json, ) result = None @@ -458,8 +496,8 @@ class ImService(DatabaseServiceBase): ) await self.schedule_message_media_duration_parse( message_id=result.message.id, - audio_data=content, - mime_type=normalized_content_type, + audio_data=prepared_audio.content, + mime_type=prepared_audio.mime_type, source="parent_weapp_voice", ) except Exception: diff --git a/talkingq-url/utils/audio_transcode.py b/talkingq-url/utils/audio_transcode.py new file mode 100644 index 0000000..b372ddb --- /dev/null +++ b/talkingq-url/utils/audio_transcode.py @@ -0,0 +1,301 @@ +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)