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.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult
|
||||||
from banban.schemas.im import ChildConversationMessageItem, DeviceMessageCreateRequest, ParentChildMessageCreateRequest
|
from banban.schemas.im import ChildConversationMessageItem, DeviceMessageCreateRequest, ParentChildMessageCreateRequest
|
||||||
from utils.audio_duration import parse_audio_duration_ms
|
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
|
from utils.logger import session_logger
|
||||||
|
|
||||||
|
|
||||||
@@ -401,25 +406,58 @@ class ImService(DatabaseServiceBase):
|
|||||||
raise HTTPException(status_code=404, detail="child has no bound device")
|
raise HTTPException(status_code=404, detail="child has no bound device")
|
||||||
target_device_id = str(device["device_id"])
|
target_device_id = str(device["device_id"])
|
||||||
|
|
||||||
extension, normalized_content_type = normalize_audio_extension(
|
try:
|
||||||
filename=filename,
|
prepared_audio = await prepare_audio_as_mp3(
|
||||||
content_type=content_type,
|
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(
|
stored_audio = await self.audio_storage.upload_audio(
|
||||||
device_id=f"parent-{parent_user_id}",
|
device_id=f"parent-{parent_user_id}",
|
||||||
content=content,
|
content=prepared_audio.content,
|
||||||
content_type=normalized_content_type,
|
content_type=prepared_audio.mime_type,
|
||||||
extension=extension,
|
extension=prepared_audio.extension,
|
||||||
)
|
)
|
||||||
payload = ParentChildMessageCreateRequest(
|
payload = ParentChildMessageCreateRequest(
|
||||||
content_type=2,
|
content_type=2,
|
||||||
media_file_key=stored_audio.file_key,
|
media_file_key=stored_audio.file_key,
|
||||||
media_duration_ms=media_duration_ms,
|
media_duration_ms=media_duration_ms,
|
||||||
media_mime_type=normalized_content_type,
|
media_mime_type=prepared_audio.mime_type,
|
||||||
media_size_bytes=len(content),
|
media_size_bytes=prepared_audio.size_bytes,
|
||||||
media_transcript_text=(media_transcript_text or "").strip() or None,
|
media_transcript_text=(media_transcript_text or "").strip() or None,
|
||||||
client_msg_id=client_msg_id,
|
client_msg_id=client_msg_id,
|
||||||
ext_json=ext_json,
|
ext_json=payload_ext_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = None
|
result = None
|
||||||
@@ -458,8 +496,8 @@ class ImService(DatabaseServiceBase):
|
|||||||
)
|
)
|
||||||
await self.schedule_message_media_duration_parse(
|
await self.schedule_message_media_duration_parse(
|
||||||
message_id=result.message.id,
|
message_id=result.message.id,
|
||||||
audio_data=content,
|
audio_data=prepared_audio.content,
|
||||||
mime_type=normalized_content_type,
|
mime_type=prepared_audio.mime_type,
|
||||||
source="parent_weapp_voice",
|
source="parent_weapp_voice",
|
||||||
)
|
)
|
||||||
except Exception:
|
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