Normalize parent voice uploads to MP3
This commit is contained in:
@@ -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(
|
||||
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:
|
||||
|
||||
301
talkingq-url/utils/audio_transcode.py
Normal file
301
talkingq-url/utils/audio_transcode.py
Normal 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)
|
||||
Reference in New Issue
Block a user