Files
banban/talkingq-url/banban/service/device_voice_archive.py
2026-05-05 16:33:04 +08:00

428 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import os
import shutil
from dataclasses import dataclass
from uuid import uuid4
from sqlalchemy import text
from banban.dao.im import ImDAO
from banban.schemas.im import DeviceMessageCreateRequest
from banban.service.message_audio_storage import (
MessageAudioStorageError,
message_audio_storage_service,
)
from config import settings
from services.database_service_base import DatabaseServiceBase
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
CHILD_PARTICIPANT_TYPE = 2
CHILD_PEER_CONVERSATION_TYPE = 1
@dataclass(frozen=True)
class PreparedArchiveAudio:
filepath: str
mime_type: str
size_bytes: int
source_format: str
archive_format: str
class DeviceVoiceArchiveService(DatabaseServiceBase):
def __init__(self) -> None:
super().__init__(service_name="device_voice_archive")
async def archive_peer_voice_message(
self,
*,
sender_device_id: str,
receiver_device_id: str,
local_audio_path: str,
) -> bool:
archive_id = f"voice_archive:{uuid4().hex[:12]}"
session_logger.info(
sender_device_id,
archive_id,
(
"开始归档设备语音消息: "
f"sender_device_id={sender_device_id}, receiver_device_id={receiver_device_id}, "
f"local_audio_path={local_audio_path}"
),
)
if not local_audio_path or not os.path.exists(local_audio_path):
session_logger.warning(
sender_device_id,
archive_id,
f"跳过语音归档: 本地音频文件不存在, local_audio_path={local_audio_path}",
)
return False
prepared_audio = None
db_session = await self.get_session()
try:
prepared_audio = await self._prepare_archive_audio(
sender_device_id=sender_device_id,
archive_id=archive_id,
local_audio_path=local_audio_path,
)
if prepared_audio is None:
return False
dao = ImDAO(db_session)
sender_identity = await self._get_child_identity_by_device_id(
db_session=db_session,
device_id=sender_device_id,
)
receiver_identity = await self._get_child_identity_by_device_id(
db_session=db_session,
device_id=receiver_device_id,
)
if sender_identity is None:
session_logger.warning(
sender_device_id,
archive_id,
f"跳过语音归档: 发送设备未绑定有效 child, device_id={sender_device_id}",
)
return False
if receiver_identity is None:
session_logger.warning(
sender_device_id,
archive_id,
f"跳过语音归档: 接收设备未绑定有效 child, device_id={receiver_device_id}",
)
return False
if sender_identity["child_id"] == receiver_identity["child_id"]:
session_logger.warning(
sender_device_id,
archive_id,
(
"跳过语音归档: 发送和接收设备映射到了同一个 child, "
f"child_id={sender_identity['child_id']}"
),
)
return False
session_logger.info(
sender_device_id,
archive_id,
(
"设备绑定解析完成: "
f"sender_child_id={sender_identity['child_id']}, "
f"sender_child_name={sender_identity['child_name'] or 'unknown'}, "
f"receiver_child_id={receiver_identity['child_id']}, "
f"receiver_child_name={receiver_identity['child_name'] or 'unknown'}"
),
)
with open(prepared_audio.filepath, "rb") as archive_file:
archive_audio_data = archive_file.read()
session_logger.info(
sender_device_id,
archive_id,
(
"开始上传语音到 COS: "
f"bucket={settings.cos_bucket_message or 'unset'}, "
f"media_mime_type={prepared_audio.mime_type}, "
f"audio_size_bytes={prepared_audio.size_bytes}, "
f"archive_format={prepared_audio.archive_format}"
),
)
stored_audio = await message_audio_storage_service.upload_audio(
sender_device_id=sender_device_id,
receiver_device_id=receiver_device_id,
content=archive_audio_data,
content_type=prepared_audio.mime_type,
)
session_logger.info(
sender_device_id,
archive_id,
f"COS 上传成功: file_key={stored_audio.file_key}",
)
participant_a_id, participant_b_id, pair_key = dao._build_child_peer_pair(
int(sender_identity["child_id"]),
int(receiver_identity["child_id"]),
)
client_msg_id = f"device_voice_{uuid4().hex[:24]}"
payload = DeviceMessageCreateRequest(
conversation_type=CHILD_PEER_CONVERSATION_TYPE,
peer_child_id=int(receiver_identity["child_id"]),
content_type=2,
media_file_key=stored_audio.file_key,
media_mime_type=prepared_audio.mime_type,
media_size_bytes=prepared_audio.size_bytes,
client_msg_id=client_msg_id,
ext_json={
"archive_source": "device_voice",
"sender_device_id": sender_device_id,
"receiver_device_id": receiver_device_id,
"local_audio_path": local_audio_path,
"archive_format": prepared_audio.archive_format,
"source_format": prepared_audio.source_format,
},
)
session_logger.info(
sender_device_id,
archive_id,
(
"开始写入 IM 消息: "
f"conversation_type={CHILD_PEER_CONVERSATION_TYPE}, "
f"pair_key={pair_key}, client_msg_id={client_msg_id}"
),
)
conversation_id, idempotent = await dao.create_message(
conversation_type=CHILD_PEER_CONVERSATION_TYPE,
participant_a_type=CHILD_PARTICIPANT_TYPE,
participant_a_id=participant_a_id,
participant_b_type=CHILD_PARTICIPANT_TYPE,
participant_b_id=participant_b_id,
pair_key=pair_key,
sender_type=CHILD_PARTICIPANT_TYPE,
sender_id=str(sender_identity["child_id"]),
receiver_type=CHILD_PARTICIPANT_TYPE,
receiver_id=str(receiver_identity["child_id"]),
sender_name_snapshot=sender_identity["child_name"],
sender_avatar_snapshot=None,
receiver_name_snapshot=receiver_identity["child_name"],
receiver_avatar_snapshot=None,
payload=payload,
)
message_row = await dao._get_message_by_conversation_client_id(
conversation_id=conversation_id,
client_msg_id=client_msg_id,
)
session_logger.info(
sender_device_id,
archive_id,
(
"IM 消息写入完成: "
f"conversation_id={conversation_id}, "
f"message_id={message_row['id'] if message_row else 'unknown'}, "
f"seq={message_row['seq'] if message_row else 'unknown'}, "
f"idempotent={idempotent}"
),
)
session_logger.info(
sender_device_id,
archive_id,
(
"设备语音归档成功: "
f"sender_device_id={sender_device_id}, "
f"receiver_device_id={receiver_device_id}, "
f"conversation_id={conversation_id}, "
f"file_key={stored_audio.file_key}, "
f"archive_format={prepared_audio.archive_format}"
),
)
return True
except MessageAudioStorageError as exc:
session_logger.error(
sender_device_id,
archive_id,
f"设备语音归档失败: COS 上传异常: {exc}",
)
return False
except Exception as exc:
session_logger.error(
sender_device_id,
archive_id,
f"设备语音归档失败: {exc}",
exc_info=True,
)
return False
finally:
if prepared_audio and prepared_audio.archive_format == "mp3" and prepared_audio.filepath != local_audio_path:
if os.path.exists(prepared_audio.filepath):
os.remove(prepared_audio.filepath)
await db_session.close()
async def _prepare_archive_audio(
self,
*,
sender_device_id: str,
archive_id: str,
local_audio_path: str,
) -> PreparedArchiveAudio | None:
with open(local_audio_path, "rb") as local_file:
local_audio_data = local_file.read()
source_format = detect_audio_format(local_audio_data)
local_size = len(local_audio_data)
session_logger.info(
sender_device_id,
archive_id,
(
"异步归档开始判断音频格式: "
f"local_audio_path={local_audio_path}, source_format={source_format}, "
f"local_size_bytes={local_size}"
),
)
if source_format == "mp3":
session_logger.info(
sender_device_id,
archive_id,
"异步归档判断结果: 已是 MP3无需转码",
)
return PreparedArchiveAudio(
filepath=local_audio_path,
mime_type="audio/mpeg",
size_bytes=local_size,
source_format=source_format,
archive_format="mp3",
)
wav_path = f"{local_audio_path}.archive.wav"
mp3_path = f"{local_audio_path}.archive.mp3"
try:
if source_format == "wav":
with open(wav_path, "wb") as wav_file:
wav_file.write(local_audio_data)
session_logger.info(
sender_device_id,
archive_id,
f"异步归档检测到 WAV准备转 MP3: wav_path={wav_path}",
)
else:
wrapped_wav = wrap_pcm_as_wav(
local_audio_data,
sample_rate=DEFAULT_SAMPLE_RATE,
channels=DEFAULT_CHANNELS,
sample_width=DEFAULT_SAMPLE_WIDTH,
)
with open(wav_path, "wb") as wav_file:
wav_file.write(wrapped_wav)
session_logger.info(
sender_device_id,
archive_id,
(
"异步归档将原始字节封装为 WAV: "
f"wav_path={wav_path}, sample_rate={DEFAULT_SAMPLE_RATE}, "
f"channels={DEFAULT_CHANNELS}, sample_width={DEFAULT_SAMPLE_WIDTH}"
),
)
await self._convert_to_mp3(
sender_device_id=sender_device_id,
archive_id=archive_id,
source_path=wav_path,
target_path=mp3_path,
)
mp3_size = os.path.getsize(mp3_path)
session_logger.info(
sender_device_id,
archive_id,
(
"异步归档转码完成: "
f"mp3_path={mp3_path}, mp3_size_bytes={mp3_size}, "
f"source_format={source_format}"
),
)
return PreparedArchiveAudio(
filepath=mp3_path,
mime_type="audio/mpeg",
size_bytes=mp3_size,
source_format=source_format,
archive_format="mp3",
)
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
async def _convert_to_mp3(
self,
*,
sender_device_id: str,
archive_id: str,
source_path: str,
target_path: str,
) -> None:
ffmpeg_path = shutil.which("ffmpeg")
if not ffmpeg_path:
raise RuntimeError("ffmpeg not found in PATH")
command = [
ffmpeg_path,
"-y",
"-loglevel",
"error",
"-i",
source_path,
"-codec:a",
"libmp3lame",
"-b:a",
"32k",
target_path,
]
session_logger.info(
sender_device_id,
archive_id,
f"异步归档开始转码为 MP3: 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 RuntimeError(
f"ffmpeg convert failed, returncode={process.returncode}, stderr={stderr_text}"
)
session_logger.info(
sender_device_id,
archive_id,
(
"异步归档 FFmpeg 转码完成: "
f"source_path={source_path}, target_path={target_path}, "
f"ffmpeg_stdout={stdout.decode('utf-8', errors='ignore').strip() or 'empty'}"
),
)
async def _get_child_identity_by_device_id(self, *, db_session, device_id: str):
result = await db_session.execute(
text(
"""
SELECT
da.device_id,
db.child_id,
c.child_name
FROM device_auth AS da
LEFT JOIN device_bindings AS db
ON db.device_id = da.device_id
AND db.status = 1
LEFT JOIN children AS c
ON c.child_id = db.child_id
AND c.status = 1
WHERE da.device_id = :device_id
AND da.is_active = 1
LIMIT 1
"""
),
{"device_id": device_id},
)
row = result.mappings().first()
if not row or row["child_id"] is None:
return None
return {
"device_id": str(row["device_id"]),
"child_id": int(row["child_id"]),
"child_name": row["child_name"],
}
device_voice_archive_service = DeviceVoiceArchiveService()