Normalize parent voice uploads to MP3

This commit is contained in:
stu2not
2026-06-11 10:46:32 +08:00
parent 32b91936ad
commit 57291c81fc
2 changed files with 350 additions and 11 deletions

View File

@@ -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:

View File

@@ -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)