302 lines
8.3 KiB
Python
302 lines
8.3 KiB
Python
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)
|