From 602f0f7737656082af4298991115bf5a8c200774 Mon Sep 17 00:00:00 2001 From: HycJack <772403255@qq.com> Date: Thu, 16 Apr 2026 21:13:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=AE=BE=E5=A4=87=E7=AB=AF?= =?UTF-8?q?=E5=AF=B9=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- talkingq-url/assets/audio/noise_sample.wav | Bin 0 -> 12844 bytes talkingq-url/assets/audio/offline.mp3 | Bin 0 -> 13272 bytes talkingq-url/assets/audio/welcome.mp3 | Bin 0 -> 20472 bytes talkingq-url/config.py | 10 + talkingq-url/database/models.py | 16 + talkingq-url/handlers/audio_file_handler.py | 36 ++ talkingq-url/handlers/websocket_handler.py | 17 +- .../handlers/websocket_message_handler.py | 108 +++- talkingq-url/implementations/aliyun_asr.py | 121 +++- talkingq-url/implementations/base_llm.py | 22 +- talkingq-url/implementations/minimax_tts.py | 111 ++-- talkingq-url/requirements.txt | 8 + talkingq-url/services/card_service.py | 190 +++++++ talkingq-url/services/device_target_cache.py | 29 + talkingq-url/services/offline_audio_cache.py | 31 + talkingq-url/services/target_audio_cache.py | 35 ++ talkingq-url/test/aliyun_asr_simple.py | 538 ++++++++++++++++++ talkingq-url/test/minimax_audio.py | 55 ++ talkingq-url/utils/audio_denoiser.py | 118 ++++ 19 files changed, 1385 insertions(+), 60 deletions(-) create mode 100644 talkingq-url/assets/audio/noise_sample.wav create mode 100644 talkingq-url/assets/audio/offline.mp3 create mode 100644 talkingq-url/assets/audio/welcome.mp3 create mode 100644 talkingq-url/handlers/audio_file_handler.py create mode 100644 talkingq-url/services/card_service.py create mode 100644 talkingq-url/services/device_target_cache.py create mode 100644 talkingq-url/services/offline_audio_cache.py create mode 100644 talkingq-url/services/target_audio_cache.py create mode 100644 talkingq-url/test/aliyun_asr_simple.py create mode 100644 talkingq-url/test/minimax_audio.py create mode 100644 talkingq-url/utils/audio_denoiser.py diff --git a/talkingq-url/assets/audio/noise_sample.wav b/talkingq-url/assets/audio/noise_sample.wav new file mode 100644 index 0000000000000000000000000000000000000000..de9918d1b2b89cb78581046542644132ce7eec3f GIT binary patch literal 12844 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 diTXHy)U$rAr6tT@e8lqzQK*Vqsx%^{W5^ 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 literal 0 HcmV?d00001 diff --git a/talkingq-url/assets/audio/welcome.mp3 b/talkingq-url/assets/audio/welcome.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..4bf71ebffc1f1ef092e74d8f8ca8d0947ce27898 GIT binary patch literal 20472 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 literal 0 HcmV?d00001 diff --git a/talkingq-url/config.py b/talkingq-url/config.py index 940c206..0a99485 100644 --- a/talkingq-url/config.py +++ b/talkingq-url/config.py @@ -37,6 +37,16 @@ class Settings(BaseSettings): db_name: str = "talkingq" db_echo: bool = False # 是否打印SQL语句 + # TalkingQ设备MQTT命令服务配置 + talkingq_mqtt_broker: str = "broker.emqx.io" + talkingq_mqtt_port: int = 1883 + talkingq_mqtt_username: str = "" + talkingq_mqtt_password: str = "" + talkingq_mqtt_device_prefix: str = "TalkingQ" + talkingq_mqtt_qos: int = 1 + talkingq_mqtt_keepalive: int = 60 + talkingq_mqtt_nfc_notice_interval: int = 600 + admin_api_key: str # 用于设备注册的管理员API密钥 client_api_key: str # 用于微信小程序客户端验证的API密钥 diff --git a/talkingq-url/database/models.py b/talkingq-url/database/models.py index 78d410c..5cb359c 100644 --- a/talkingq-url/database/models.py +++ b/talkingq-url/database/models.py @@ -126,3 +126,19 @@ class SystemConfig(Base): __table_args__ = ( {'mysql_charset': 'utf8mb4', 'mysql_collate': 'utf8mb4_unicode_ci'} ) + +class Card(Base): + __tablename__ = "cards" + + card_id = Column(Integer, primary_key=True, autoincrement=True) + card_uuid = Column(String(64), unique=True, nullable=False) + device_id = Column(String(64), index=True) + card_name = Column(String(64)) + status = Column(Integer, server_default="0") + total_swaps = Column(Integer, server_default="0") + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + {'mysql_charset': 'utf8mb4', 'mysql_collate': 'utf8mb4_unicode_ci'} + ) diff --git a/talkingq-url/handlers/audio_file_handler.py b/talkingq-url/handlers/audio_file_handler.py new file mode 100644 index 0000000..cce0e60 --- /dev/null +++ b/talkingq-url/handlers/audio_file_handler.py @@ -0,0 +1,36 @@ +import os +import uuid +from config import settings +from utils.logger import session_logger +from utils.audio_denoiser import reduce_background_noise + + +async def save_audio_file(audio_data: bytes, device_id: str) -> str: + """ + 保存音频数据到 assets/audio 目录 + + Args: + audio_data: 音频二进制数据 + device_id: 设备ID + + Returns: + 音频文件的相对路径 + """ + try: + audio_dir = os.path.join(settings.assets_dir, "audio") + os.makedirs(audio_dir, exist_ok=True) + + filename = f"{device_id}_{uuid.uuid4().hex[:8]}.mp3" + filepath = os.path.join(audio_dir, filename) + + with open(filepath, 'wb') as f: + f.write(audio_data) + + # relative_path = f"assets/audio/{filename}" + session_logger.info(device_id, "audio", f"音频文件已保存: {filepath}") + + # reduce_background_noise(filepath, relative_path,noise_path='assets/audio/noise_sample.wav',normalize_volume=True) + return filepath + except Exception as e: + session_logger.error(device_id, "audio", f"保存音频文件时出错: {e}", exc_info=True) + raise \ No newline at end of file diff --git a/talkingq-url/handlers/websocket_handler.py b/talkingq-url/handlers/websocket_handler.py index 050f9a7..aa624b9 100644 --- a/talkingq-url/handlers/websocket_handler.py +++ b/talkingq-url/handlers/websocket_handler.py @@ -2,6 +2,7 @@ from fastapi import WebSocket, WebSocketDisconnect from utils.logger import session_logger from services.connection_manager import connection_manager from services.task_manager import task_manager +from services.offline_audio_cache import offline_audio_cache from handlers.websocket_auth_handler import authenticate_websocket from handlers.websocket_message_handler import handle_websocket_messages from handlers.session_cleanup_handler import cleanup_device_sessions @@ -29,6 +30,20 @@ async def websocket_endpoint(websocket: WebSocket): await websocket.send_text('{"status": "error", "message": "Not authenticated"}') return + # 检查设备是否有离线音频URL需要发送 + has_pending = await offline_audio_cache.has_pending_audio(device_id) + if has_pending: + audio_urls = await offline_audio_cache.get_audio_urls(device_id) + for audio_url in audio_urls: + try: + await websocket.send_text(f"SOUND_URL:{audio_url}") + session_logger.info(device_id, "offline", f"发送离线音频URL: {audio_url}") + except Exception as e: + session_logger.error(device_id, "offline", f"发送离线音频URL失败: {e}") + # 清空已发送的离线音频URL + await offline_audio_cache.clear_audio_urls(device_id) + session_logger.info(device_id, "offline", f"已清空设备的离线音频URL缓存,共 {len(audio_urls)} 个") + await handle_websocket_messages(websocket, device_id) except WebSocketDisconnect: @@ -43,7 +58,7 @@ async def websocket_endpoint(websocket: WebSocket): ) finally: if device_id: - await connection_manager.remove_connection(device_id) + # await connection_manager.remove_connection(device_id) await cleanup_device_sessions(device_id) # 清理设备相关的所有异步任务 await task_manager.cancel_device_tasks(device_id) diff --git a/talkingq-url/handlers/websocket_message_handler.py b/talkingq-url/handlers/websocket_message_handler.py index a15946e..25ce8c0 100644 --- a/talkingq-url/handlers/websocket_message_handler.py +++ b/talkingq-url/handlers/websocket_message_handler.py @@ -4,12 +4,19 @@ import asyncio from fastapi import WebSocket from handlers.audio_packet_parser import parse_packet from handlers.audio_session_handler import handle_websocket_data +from handlers.audio_file_handler import save_audio_file from services.audio_session import audio_session_manager from services.interrupt_handler import interrupt_handler from services.task_manager import task_manager +from services.connection_manager import connection_manager +from services.device_target_cache import device_target_cache +from services.offline_audio_cache import offline_audio_cache +from services.target_audio_cache import target_audio_cache +from services.card_service import card_service from utils.logger import session_logger from handlers.prompt_sound_handler import handle_prompt_sound_request from handlers.session_cleanup_handler import handle_old_session_cleanup +from config import settings async def handle_websocket_messages(websocket: WebSocket, device_id: str): """ @@ -52,7 +59,36 @@ async def handle_websocket_messages(websocket: WebSocket, device_id: str): async def handle_text_message(websocket: WebSocket, device_id: str, text_data: str): """处理文本消息""" - if text_data.startswith("REQUEST_PROMPT_SOUND:"): + if text_data.startswith("REGISTER_TARGET_DEVICE:"): + card_uuid = text_data.split(":", 1)[1].strip() + + # 检查卡片是否存在 + existing_card = await card_service.get_card_by_uuid(card_uuid) + + if existing_card: + # 卡片已存在,使用卡片绑定的设备ID作为目标设备ID + target_device_id = existing_card.device_id + session_logger.info(device_id, "card", f"卡片已存在,绑定的设备ID: {target_device_id}") + else: + # 卡片不存在,创建新卡片并绑定到当前设备 + new_card = await card_service.activate_card(card_uuid, device_id) + session_logger.info(device_id, "card", f"新卡片{card_uuid}已创建并激活,绑定到设备: {device_id}") + return + + # 设置目标设备 + await device_target_cache.set_target(device_id, target_device_id) + + # 检查目标设备是否在线 + target_websocket = await connection_manager.get_connection(target_device_id) + if target_websocket and target_websocket.client_state.name == "CONNECTED": + sound_url = f"http://{settings.server_host}:{settings.server_port}/assets/audio/welcome.mp3" + await websocket.send_text(f"TARGET_DEVICE_REGISTERED_URL:{sound_url}") + session_logger.info(device_id, "target", f"成功注册目标设备: {target_device_id}") + else: + sound_url = f"http://{settings.server_host}:{settings.server_port}/assets/audio/offline.mp3" + await websocket.send_text(f"TARGET_DEVICE_REGISTERED_URL:{sound_url}") + session_logger.warning(device_id, "target", f"目标设备 {target_device_id} 不在线") + elif text_data.startswith("REQUEST_PROMPT_SOUND:"): prompt_type = text_data.split(":", 1)[1] if device_id: await handle_prompt_sound_request(device_id, prompt_type) @@ -107,16 +143,36 @@ async def handle_binary_message(websocket: WebSocket, device_id: str, binary_dat session_id = session_key[1] if packet_type == 1: # 开始包 + target_device_id = await device_target_cache.get_target(device_id) + if target_device_id: + # 清除音频缓存 + await target_audio_cache.clear_audio_data(target_device_id) + return None, None current_active_session = await handle_start_packet( device_id, session_id, session_key, session, current_active_session ) elif packet_type == 3: # 中断包 + target_device_id = await device_target_cache.get_target(device_id) + if target_device_id: + return None, None current_active_session = await handle_interrupt_packet( device_id, session_id, session_key, session, current_active_session ) elif packet_type == 2: # 结束包 + target_device_id = await device_target_cache.get_target(device_id) + if target_device_id: + # 处理缓存的音频数据 + session_logger.info(device_id, session_id, f"收到结束包,开始处理缓存音频数据") + await process_cached_audio(device_id, target_device_id) + + # 移除目标设备关联 + await device_target_cache.remove_target(device_id) + return None, None if current_active_session == session_key: current_active_session = None + elif packet_type == 4: # 发送给目标设备的音频包 + await handle_target_audio_packet(device_id, audio_data) + return None, None elif packet_type == 0 and first_audio_received_time is None: first_audio_received_time = time.perf_counter() session.start_time = first_audio_received_time @@ -178,3 +234,53 @@ async def handle_interrupt_packet(device_id, session_id, session_key, session, c task_type="interrupt" ) return current_active_session + + +async def handle_target_audio_packet(device_id: str, audio_data: bytes): + """处理发送给目标设备的音频包""" + try: + target_device_id = await device_target_cache.get_target(device_id) + + if not target_device_id: + session_logger.warning(device_id, "target", "未设置目标设备,无法发送音频") + return + + # 缓存音频数据 + await target_audio_cache.add_audio_data(target_device_id, audio_data) + session_logger.info(device_id, "target", f"已缓存音频数据到目标设备 {target_device_id}") + + except Exception as e: + session_logger.error(device_id, "target", f"处理目标音频包时出错: {e}", exc_info=True) + + +async def process_cached_audio(device_id: str, target_device_id: str): + """处理缓存的音频数据并发送音频URL""" + try: + # 获取缓存的音频数据 + cached_audio = await target_audio_cache.get_audio_data(target_device_id) + if not cached_audio: + session_logger.info(device_id, "target", f"目标设备 {target_device_id} 没有缓存的音频数据") + return + + # 保存音频文件 + audio_path = await save_audio_file(cached_audio, device_id) + audio_url = f"http://{settings.server_host}:{settings.server_port}/{audio_path}" + + # 发送URL给目标设备 + target_websocket = await connection_manager.get_connection(target_device_id) + if target_websocket and target_websocket.client_state.name == "CONNECTED": + await target_websocket.send_text("TTS_START") + session_logger.info(device_id, "target", "已发送 TTS_START 给客户端") + await target_websocket.send_text(f"NFC_SOUND_URL:{audio_url}") + session_logger.info(device_id, "target", f"已发送音频URL给目标设备 {target_device_id}: {audio_url}") + await target_websocket.send_text("TTS_END") + session_logger.info(device_id, "target", "已发送 TTS_END 给客户端") + else: + # 目标设备不在线,保存到离线缓存 + await offline_audio_cache.add_audio_url(target_device_id, audio_url) + session_logger.warning(device_id, "target", f"目标设备 {target_device_id} 不在线,保存音频URL到离线缓存") + except Exception as e: + session_logger.error(device_id, "target", f"处理缓存音频时出错: {e}", exc_info=True) + finally: + # 清除缓存 + await target_audio_cache.clear_audio_data(target_device_id) diff --git a/talkingq-url/implementations/aliyun_asr.py b/talkingq-url/implementations/aliyun_asr.py index 99059c8..564f783 100644 --- a/talkingq-url/implementations/aliyun_asr.py +++ b/talkingq-url/implementations/aliyun_asr.py @@ -1,4 +1,5 @@ import asyncio +import threading from dashscope.audio.asr import TranslationRecognizerChat from dashscope.audio.asr import TranscriptionResult from dashscope.audio.asr import TranslationRecognizerCallback @@ -10,15 +11,18 @@ from utils.logger import session_logger class GummyCallback(TranslationRecognizerCallback): - def __init__(self, device_id, session_id): + def __init__(self, device_id, session_id, asr_instance=None): self.device_id = device_id self.session_id = session_id + self.asr_instance = asr_instance self.transcript = "" self.transcript_event = asyncio.Event() self.received_result = False self.error_message = None self.is_closed = False self.session_end = False + self.reconnect_event = threading.Event() + self.is_rate_limit_error = False def on_open(self) -> None: session_logger.info(self.device_id, self.session_id, "Gummy ASR连接建立成功") @@ -50,9 +54,22 @@ class GummyCallback(TranslationRecognizerCallback): def on_error(self, result) -> None: self.error_message = str(result) - session_logger.error( - self.device_id, self.session_id, f"Gummy ASR错误: {self.error_message}" - ) + error_str = str(result).lower() + self.is_rate_limit_error = "throttling" in error_str or "rate limit" in error_str + + if self.is_rate_limit_error: + session_logger.warning( + self.device_id, + self.session_id, + f"Gummy ASR限流错误,触发重连信号: {self.error_message}" + ) + self.reconnect_event.set() + if self.asr_instance: + self.asr_instance.need_reconnect = True + else: + session_logger.error( + self.device_id, self.session_id, f"Gummy ASR错误: {self.error_message}" + ) self.transcript_event.set() def on_close(self) -> None: @@ -74,8 +91,11 @@ class AliyunASR(ASR, BaseService): self.audio_buffer = bytearray() self.translator = None self.callback = None - self.buffer_size = 3200 # 约100ms的音频,按16000采样率计算 - self.max_end_silence = 2000 # 设为2000ms,允许更长的停顿 + self.buffer_size = 3200 + self.max_end_silence = 2000 + self.max_retries = 3 + self.retry_delay = 1.0 + self.need_reconnect = False async def connect(self): try: @@ -86,9 +106,8 @@ class AliyunASR(ASR, BaseService): return self.transcript = "" - self.audio_buffer = bytearray() - self.callback = GummyCallback(self.device_id, self.session_id) + self.callback = GummyCallback(self.device_id, self.session_id, self) self.translator = TranslationRecognizerChat( model="gummy-chat-v1", @@ -106,20 +125,79 @@ class AliyunASR(ASR, BaseService): self.translator.start() self.callback.transcript_event.clear() self.connected = True + self.need_reconnect = False except Exception as e: session_logger.error( self.device_id, self.session_id, f"建立Gummy ASR连接失败: {str(e)}" ) - self.connected = False - if self.translator: - try: - self.translator.stop() - except: - pass - self.translator = None + await self._cleanup_connection() + + async def _cleanup_connection(self): + if self.translator: + try: + self.translator.stop() + except: + pass + self.translator = None + self.callback = None + self.connected = False + + async def _handle_reconnect(self): + if not self.need_reconnect: + return True + + session_logger.warning( + self.device_id, + self.session_id, + "检测到限流错误,准备重连..." + ) + + await self._cleanup_connection() + + current_delay = self.retry_delay + for attempt in range(self.max_retries): + session_logger.info( + self.device_id, + self.session_id, + f"重连尝试 {attempt + 1}/{self.max_retries},等待{current_delay}秒" + ) + await asyncio.sleep(current_delay) + + try: + await self.connect() + if self.connected: + session_logger.info( + self.device_id, + self.session_id, + "重连成功" + ) + return True + except Exception as e: + session_logger.error( + self.device_id, + self.session_id, + f"重连失败: {str(e)}" + ) + + current_delay *= 2 + self.retry_delay = current_delay + + session_logger.error( + self.device_id, + self.session_id, + "重连失败,已达到最大重试次数" + ) + self.need_reconnect = False + return False async def send_audio(self, audio_data: bytes): + if self.need_reconnect: + reconnected = await self._handle_reconnect() + if not reconnected: + session_logger.error(self.device_id, self.session_id, "Gummy ASR重连失败,无法发送音频") + return + if not self.connected or not self.translator: session_logger.error(self.device_id, self.session_id, "Gummy ASR连接未建立") return @@ -128,6 +206,19 @@ class AliyunASR(ASR, BaseService): self.audio_buffer.extend(audio_data) while len(self.audio_buffer) >= self.buffer_size: + if self.need_reconnect: + session_logger.info( + self.device_id, + self.session_id, + "检测到限流错误,暂停发送音频,准备重连" + ) + reconnected = await self._handle_reconnect() + if not reconnected: + return + if not self.connected or not self.translator: + return + continue + chunk = bytes(self.audio_buffer[: self.buffer_size]) self.audio_buffer = self.audio_buffer[self.buffer_size :] diff --git a/talkingq-url/implementations/base_llm.py b/talkingq-url/implementations/base_llm.py index a2834d4..91cf076 100644 --- a/talkingq-url/implementations/base_llm.py +++ b/talkingq-url/implementations/base_llm.py @@ -24,9 +24,19 @@ class BaseLLM(LLM, BaseService): async def connect(self): try: - if self.session: - await self.session.close() - self.session = aiohttp.ClientSession() + if self.session and not self.session.closed: + return + connector = aiohttp.TCPConnector( + limit=20, + limit_per_host=5, + force_close=True, + enable_cleanup_closed=True + ) + timeout = aiohttp.ClientTimeout(total=120, connect=30) + self.session = aiohttp.ClientSession( + connector=connector, + timeout=timeout + ) self.connected = True except Exception as e: session_logger.error( @@ -97,8 +107,10 @@ class BaseLLM(LLM, BaseService): } try: if not self.session or self.session.closed: - self.session = aiohttp.ClientSession() - self.connected = True + await self.connect() + if not self.connected: + yield None + return response = await self.session.post( f"{self.base_url}/chat/completions", headers=headers, json=data ) diff --git a/talkingq-url/implementations/minimax_tts.py b/talkingq-url/implementations/minimax_tts.py index 9f14d68..ffd325f 100644 --- a/talkingq-url/implementations/minimax_tts.py +++ b/talkingq-url/implementations/minimax_tts.py @@ -25,9 +25,24 @@ class MiniMaxTTS(TTS, BaseService): self.voice_id = selected_role["minimax_voice_id"] self.model = "speech-02-turbo" self.selected_role = selected_role + self._client_session: Optional[aiohttp.ClientSession] = None async def connect(self): """连接到MiniMax服务""" + if self._client_session is None or self._client_session.closed: + connector = aiohttp.TCPConnector( + limit=20, + limit_per_host=5, + force_close=True, + enable_cleanup_closed=True + ) + timeout = aiohttp.ClientTimeout(total=120, connect=30) + self._client_session = aiohttp.ClientSession( + connector=connector, + timeout=timeout, + max_field_size=1024*1024, + max_line_size=10*1024*1024 + ) self.connected = True session_logger.info( self.device_id, @@ -38,6 +53,8 @@ class MiniMaxTTS(TTS, BaseService): async def close(self): """关闭MiniMax服务连接""" self.connected = False + if self._client_session and not self._client_session.closed: + await self._client_session.close() async def tts( self, @@ -138,48 +155,66 @@ class MiniMaxTTS(TTS, BaseService): audio_buffer = bytearray() - async with aiohttp.ClientSession() as client_session: - async with client_session.post(url, json=payload, headers=headers) as response: - if response.status != 200: - error_text = await response.text() - session_logger.error( - self.device_id, - self.session_id, - f"MiniMax TTS请求失败: {response.status}, {error_text}" - ) - return None - - session_logger.info( + if self._client_session is None or self._client_session.closed: + await self.connect() + + async with self._client_session.post(url, json=payload, headers=headers) as response: + if response.status != 200: + error_text = await response.text() + session_logger.error( self.device_id, self.session_id, - "开始接收MiniMax TTS流式响应" + f"MiniMax TTS请求失败: {response.status}, {error_text}" ) + return None + + session_logger.info( + self.device_id, + self.session_id, + "开始接收MiniMax TTS流式响应" + ) + + buffer = b"" + chunk_size = 8192 + async for chunk in response.content.iter_chunked(chunk_size): + buffer += chunk - async for line in response.content: - if line.startswith(b'data:'): - try: - data_json = json.loads(line[5:]) - if "data" in data_json and "audio" in data_json["data"]: - status = data_json["data"].get("status", 1) - - if status == 1: # 只处理status=1(合成中)的音频数据,忽略status=2(合成结束)的汇总数据 - audio_hex = data_json["data"]["audio"] - audio_binary = binascii.unhexlify(audio_hex) - audio_buffer.extend(audio_binary) - - if status == 2: # 合成结束 - session_logger.info( - self.device_id, - self.session_id, - "MiniMax TTS流式合成完成" - ) - except Exception as e: - session_logger.error( - self.device_id, - self.session_id, - f"处理MiniMax TTS流式响应出错: {str(e)}", - exc_info=True - ) + while b'\n' in buffer: + line, buffer = buffer.split(b'\n', 1) + line = line.strip() + + if not line or not line.startswith(b'data:'): + continue + + try: + data_json = json.loads(line[5:]) + if "data" in data_json and "audio" in data_json["data"]: + status = data_json["data"].get("status", 1) + + if status == 1: + audio_hex = data_json["data"]["audio"] + audio_binary = binascii.unhexlify(audio_hex) + audio_buffer.extend(audio_binary) + + if status == 2: + session_logger.info( + self.device_id, + self.session_id, + "MiniMax TTS流式合成完成" + ) + except json.JSONDecodeError as e: + session_logger.warning( + self.device_id, + self.session_id, + f"JSON解析失败: {str(e)}" + ) + except Exception as e: + session_logger.error( + self.device_id, + self.session_id, + f"处理MiniMax TTS流式响应出错: {str(e)}", + exc_info=True + ) async with aiofiles.open(output_file, 'wb') as f: await f.write(audio_buffer) diff --git a/talkingq-url/requirements.txt b/talkingq-url/requirements.txt index 7241274..42a6f64 100644 --- a/talkingq-url/requirements.txt +++ b/talkingq-url/requirements.txt @@ -11,3 +11,11 @@ aiomysql==0.2.0 sqlalchemy[asyncio]==2.0.40 loguru==0.7.3 +noisereduce==3.0.2 +soundfile==0.12.1 +numpy==1.26.4 +scipy==1.11.4 +librosa==0.10.1 +setuptools==69.5.1 + +paho-mqtt>=2.0.0 \ No newline at end of file diff --git a/talkingq-url/services/card_service.py b/talkingq-url/services/card_service.py new file mode 100644 index 0000000..737f32b --- /dev/null +++ b/talkingq-url/services/card_service.py @@ -0,0 +1,190 @@ +import asyncio +from typing import Optional, Dict +import time +from sqlalchemy import select, update, insert +from sqlalchemy.ext.asyncio import AsyncSession +from utils.logger import session_logger +from pydantic import BaseModel +from database.models import Card as DBCard +from services.database_service_base import DatabaseServiceBase + +class Card(BaseModel): + card_id: Optional[int] = None + card_uuid: str + device_id: Optional[str] = None + card_name: Optional[str] = None + status: int = 0 + total_swaps: int = 0 + created_at: Optional[float] = None + updated_at: Optional[float] = None + + @classmethod + def from_db_model(cls, db_model: DBCard): + """从数据库模型创建卡片对象""" + return cls( + card_id=db_model.card_id, + card_uuid=db_model.card_uuid, + device_id=db_model.device_id, + card_name=db_model.card_name, + status=db_model.status, + total_swaps=db_model.total_swaps, + created_at=db_model.created_at.timestamp() if db_model.created_at else None, + updated_at=db_model.updated_at.timestamp() if db_model.updated_at else None + ) + +class CardService(DatabaseServiceBase): + def __init__(self): + super().__init__(service_name="card") + self.cards: Dict[str, Card] = {} + self.lock = asyncio.Lock() + + async def _load_card_from_db(self, card_uuid: str, async_session: AsyncSession) -> Optional[Card]: + """从数据库加载卡片信息""" + try: + query = select(DBCard).where(DBCard.card_uuid == card_uuid) + result = await async_session.execute(query) + db_card = result.scalar_one_or_none() + + if db_card: + card = Card.from_db_model(db_card) + async with self.lock: + self.cards[card_uuid] = card + return card + return None + except Exception as e: + session_logger.error("card", "service", f"从数据库加载卡片失败: {str(e)}") + return None + + async def _save_card_to_db(self, card: Card, async_session: AsyncSession): + """保存卡片信息到数据库""" + try: + query = select(DBCard).where(DBCard.card_uuid == card.card_uuid) + result = await async_session.execute(query) + existing_card = result.scalar_one_or_none() + + if existing_card: + stmt = update(DBCard).where( + DBCard.card_uuid == card.card_uuid + ).values( + device_id=card.device_id, + card_name=card.card_name, + status=card.status, + total_swaps=card.total_swaps + ) + else: + stmt = insert(DBCard).values( + card_uuid=card.card_uuid, + device_id=card.device_id, + card_name=card.card_name, + status=card.status, + total_swaps=card.total_swaps + ) + + await async_session.execute(stmt) + await async_session.commit() + + session_logger.info("card", "service", f"卡片已保存到数据库: {card.card_uuid}") + except Exception as e: + await async_session.rollback() + session_logger.error("card", "service", f"保存卡片到数据库失败: {str(e)}") + raise + + async def get_card_by_uuid(self, card_uuid: str, force_refresh: bool = False) -> Optional[Card]: + """根据UUID获取卡片信息""" + await self._init_database() + + card = None + + if not force_refresh: + async with self.lock: + card = self.cards.get(card_uuid) + + if not card: + db_session = await self.db_manager.get_session() + try: + card = await self._load_card_from_db(card_uuid, db_session) + finally: + await db_session.close() + + return card + + async def get_card_by_device_id(self, device_id: str) -> list[Card]: + """根据设备ID获取卡片列表""" + await self._init_database() + + db_session = await self.db_manager.get_session() + try: + query = select(DBCard).where(DBCard.device_id == device_id) + result = await db_session.execute(query) + db_cards = result.scalars().all() + + cards = [] + for db_card in db_cards: + card = Card.from_db_model(db_card) + async with self.lock: + self.cards[card.card_uuid] = card + cards.append(card) + + return cards + except Exception as e: + session_logger.error(device_id, "card", f"根据设备ID获取卡片失败: {str(e)}") + return [] + finally: + await db_session.close() + + async def activate_card(self, card_uuid: str, device_id: str, card_name: Optional[str] = None) -> Card: + """激活卡片并绑定到设备""" + await self._init_database() + + db_session = await self.db_manager.get_session() + try: + # 检查卡片是否已存在 + existing_card = await self.get_card_by_uuid(card_uuid) + + if existing_card: + # 更新现有卡片 + existing_card.device_id = device_id + existing_card.card_name = card_name + existing_card.status = 1 # 激活状态 + await self._save_card_to_db(existing_card, db_session) + session_logger.info(device_id, "card", f"卡片已激活并绑定到设备: {card_uuid}") + return existing_card + else: + # 创建新卡片 + new_card = Card( + card_uuid=card_uuid, + device_id=device_id, + card_name=card_name, + status=1, # 激活状态 + total_swaps=0 + ) + await self._save_card_to_db(new_card, db_session) + session_logger.info(device_id, "card", f"新卡片已创建并激活: {card_uuid}") + return new_card + finally: + await db_session.close() + + async def increment_swap_count(self, card_uuid: str) -> Optional[Card]: + """增加卡片交换次数""" + await self._init_database() + + card = await self.get_card_by_uuid(card_uuid) + if card: + card.total_swaps += 1 + db_session = await self.db_manager.get_session() + try: + await self._save_card_to_db(card, db_session) + session_logger.info("card", "service", f"卡片交换次数已增加: {card_uuid}, 总次数: {card.total_swaps}") + return card + finally: + await db_session.close() + return None + + async def check_card_ownership(self, card_uuid: str, device_id: str) -> bool: + """检查卡片是否属于指定设备""" + card = await self.get_card_by_uuid(card_uuid) + if card and card.device_id == device_id: + return True + return False + +card_service = CardService() diff --git a/talkingq-url/services/device_target_cache.py b/talkingq-url/services/device_target_cache.py new file mode 100644 index 0000000..1f3b1c9 --- /dev/null +++ b/talkingq-url/services/device_target_cache.py @@ -0,0 +1,29 @@ +import asyncio +from typing import Dict, Optional + + +class DeviceTargetCache: + def __init__(self): + self.targets: Dict[str, str] = {} + self.lock = asyncio.Lock() + + async def get_target(self, device_id: str) -> Optional[str]: + async with self.lock: + return self.targets.get(device_id) + + async def set_target(self, device_id: str, target_device_id: str): + async with self.lock: + self.targets[device_id] = target_device_id + + async def remove_target(self, device_id: str): + async with self.lock: + if device_id in self.targets: + del self.targets[device_id] + + async def get_all_targets(self) -> list: + async with self.lock: + return list(self.targets.items()) + + +# 单例实例 +device_target_cache = DeviceTargetCache() \ No newline at end of file diff --git a/talkingq-url/services/offline_audio_cache.py b/talkingq-url/services/offline_audio_cache.py new file mode 100644 index 0000000..4654b78 --- /dev/null +++ b/talkingq-url/services/offline_audio_cache.py @@ -0,0 +1,31 @@ +import asyncio +from typing import Dict, List + + +class OfflineAudioCache: + def __init__(self): + self.offline_audio: Dict[str, List[str]] = {} + self.lock = asyncio.Lock() + + async def add_audio_url(self, device_id: str, audio_url: str): + async with self.lock: + if device_id not in self.offline_audio: + self.offline_audio[device_id] = [] + self.offline_audio[device_id].append(audio_url) + + async def get_audio_urls(self, device_id: str) -> List[str]: + async with self.lock: + return self.offline_audio.get(device_id, []) + + async def clear_audio_urls(self, device_id: str): + async with self.lock: + if device_id in self.offline_audio: + del self.offline_audio[device_id] + + async def has_pending_audio(self, device_id: str) -> bool: + async with self.lock: + return device_id in self.offline_audio and len(self.offline_audio[device_id]) > 0 + + +# 单例实例 +offline_audio_cache = OfflineAudioCache() \ No newline at end of file diff --git a/talkingq-url/services/target_audio_cache.py b/talkingq-url/services/target_audio_cache.py new file mode 100644 index 0000000..c139de0 --- /dev/null +++ b/talkingq-url/services/target_audio_cache.py @@ -0,0 +1,35 @@ +import asyncio +from typing import Dict, List, Optional + + +class TargetAudioCache: + def __init__(self): + self.audio_data_cache: Dict[str, List[bytes]] = {} + self.lock = asyncio.Lock() + + async def add_audio_data(self, device_id: str, audio_data: bytes): + async with self.lock: + if device_id not in self.audio_data_cache: + self.audio_data_cache[device_id] = [] + self.audio_data_cache[device_id].append(audio_data) + + async def get_audio_data(self, device_id: str) -> Optional[bytes]: + async with self.lock: + if device_id not in self.audio_data_cache: + return None + # 合并所有音频数据 + combined_audio = b''.join(self.audio_data_cache[device_id]) + return combined_audio + + async def clear_audio_data(self, device_id: str): + async with self.lock: + if device_id in self.audio_data_cache: + del self.audio_data_cache[device_id] + + async def has_cached_audio(self, device_id: str) -> bool: + async with self.lock: + return device_id in self.audio_data_cache and len(self.audio_data_cache[device_id]) > 0 + + +# 单例实例 +target_audio_cache = TargetAudioCache() \ No newline at end of file diff --git a/talkingq-url/test/aliyun_asr_simple.py b/talkingq-url/test/aliyun_asr_simple.py new file mode 100644 index 0000000..8157e6d --- /dev/null +++ b/talkingq-url/test/aliyun_asr_simple.py @@ -0,0 +1,538 @@ +import asyncio +from test.audio_processor import AudioProcessor +import math +import asyncio +import struct +import websockets +from dashscope.audio.asr import TranslationRecognizerChat +from dashscope.audio.asr import TranscriptionResult +from dashscope.audio.asr import TranslationRecognizerCallback +# from config import settings +from providers.asr import ASR +# from providers.impl.base_service import BaseService +from providers.impl.interrupt_handler import interrupt_handler +from utils.logger import session_logger + + +class GummyCallback(TranslationRecognizerCallback): + def __init__(self, device_id, session_id): + self.device_id = device_id + self.session_id = session_id + self.transcript = "" + self.transcript_event = asyncio.Event() + self.received_result = False + self.error_message = None + self.is_closed = False + self.session_end = False + + def on_open(self) -> None: + session_logger.info(self.device_id, self.session_id, "Gummy ASR连接建立成功") + + def on_event( + self, + request_id, + transcription_result: TranscriptionResult, + translation_result, + usage, + ) -> None: + if transcription_result: + if transcription_result.is_sentence_end: + self.transcript = transcription_result.text + self.received_result = True + session_logger.info( + self.device_id, + self.session_id, + f"Gummy ASR完整句子: {self.transcript}", + ) + self.transcript_event.set() + else: + self.transcript = transcription_result.text + + def on_complete(self) -> None: + session_logger.info(self.device_id, self.session_id, "Gummy ASR识别完成") + self.session_end = True + self.transcript_event.set() + + def on_error(self, result) -> None: + self.error_message = str(result) + session_logger.error( + self.device_id, self.session_id, f"Gummy ASR错误: {self.error_message}" + ) + self.transcript_event.set() + + def on_close(self) -> None: + session_logger.info(self.device_id, self.session_id, "Gummy ASR连接已关闭") + self.is_closed = True + self.transcript_event.set() + + +class AliyunASR(ASR): + def __init__(self, selected_role=None): + # BaseService.__init__(self, "asr", selected_role) + self.api_key = 'sk-7a50eca6856d4afb968ac3bf512f6d1b' + self.vocabulary_id = 'vocab-talkingq-8c1ed0b3b76342819a1d39988ff22a17' + self.connected = False + self.transcript = "" + self.device_id = "unknown" + self.session_id = "unknown" + self.sample_rate = 16000 + self.audio_buffer = bytearray() + self.translator = None + self.callback = None + self.buffer_size = 3200 # 约100ms的音频,按16000采样率计算 + self.max_end_silence = 2000 # 设为2000ms,允许更长的停顿 + + async def connect(self): + try: + if self.connected and self.translator: + session_logger.info( + self.device_id, self.session_id, "Gummy ASR连接已存在,复用当前连接" + ) + return + + self.transcript = "" + self.audio_buffer = bytearray() + + self.callback = GummyCallback(self.device_id, self.session_id) + + self.translator = TranslationRecognizerChat( + model="gummy-chat-v1", + format="mp3", + sample_rate=self.sample_rate, + callback=self.callback, + transcription_enabled=True, + source_language="auto", + semantic_punctuation_enabled=False, + max_end_silence=self.max_end_silence, + api_key=self.api_key, + vocabulary_id=self.vocabulary_id, + ) + + self.translator.start() + self.callback.transcript_event.clear() + self.connected = True + + except Exception as e: + session_logger.error( + self.device_id, self.session_id, f"建立Gummy ASR连接失败: {str(e)}" + ) + self.connected = False + if self.translator: + try: + self.translator.stop() + except: + pass + self.translator = None + + async def send_audio(self, audio_data: bytes): + if not self.connected or not self.translator: + session_logger.error(self.device_id, self.session_id, "Gummy ASR连接未建立") + return + + try: + self.audio_buffer.extend(audio_data) + + while len(self.audio_buffer) >= self.buffer_size: + chunk = bytes(self.audio_buffer[: self.buffer_size]) + self.audio_buffer = self.audio_buffer[self.buffer_size :] + + if self.device_id and self.session_id: + session_key = (self.device_id, self.session_id) + if interrupt_handler.is_interrupted(session_key): + session_logger.info( + self.device_id, + self.session_id, + "检测到中断,停止发送音频数据", + ) + return + + try: + self.translator.send_audio_frame(chunk) + except Exception as e: + session_logger.error( + self.device_id, + self.session_id, + f"发送音频帧到Gummy ASR出错: {str(e)}", + ) + + except Exception as e: + session_logger.error( + self.device_id, self.session_id, f"发送音频到Gummy ASR出错: {str(e)}" + ) + + async def send_end(self): + if not self.connected or not self.translator: + session_logger.error( + self.device_id, self.session_id, "Gummy ASR连接未建立,无法发送结束信号" + ) + return + + try: + if len(self.audio_buffer) > 0: + chunk = bytes(self.audio_buffer) + self.audio_buffer = bytearray() + self.translator.send_audio_frame(chunk) + + session_logger.info( + self.device_id, + self.session_id, + "客户端发送结束包,等待Gummy ASR处理完毕", + ) + + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, lambda: self._safe_stop_translator()) + + except Exception as e: + session_logger.error( + self.device_id, + self.session_id, + f"发送结束信号到Gummy ASR出错: {str(e)}", + ) + + async def receive_results(self): + if not self.connected: + session_logger.error( + self.device_id, self.session_id, "Gummy ASR连接未建立,无法接收结果" + ) + return + + try: + try: + await asyncio.wait_for( + self.callback.transcript_event.wait(), timeout=0.5 + ) + except asyncio.TimeoutError: + session_logger.warning( + self.device_id, + self.session_id, + "接收Gummy ASR结果超时,使用最后的中间结果", + ) + + if self.callback.received_result or self.callback.transcript: + self.transcript = self.callback.transcript + + except Exception as e: + session_logger.error( + self.device_id, self.session_id, f"接收Gummy ASR结果出错: {str(e)}" + ) + + return self.transcript + + async def close(self): + if self.connected: + try: + if self.translator is not None: + try: + self.translator.stop() + except Exception as e: + if "has stopped" not in str(e): + session_logger.warning( + self.device_id, + self.session_id, + f"关闭Gummy translator出错: {str(e)}" + ) + self.translator = None + self.connected = False + session_logger.info( + self.device_id, self.session_id, "Gummy ASR连接已关闭" + ) + except Exception as e: + session_logger.error( + self.device_id, self.session_id, f"关闭Gummy ASR连接出错: {str(e)}" + ) + + def _safe_stop_translator(self): + """安全停止translator,忽略已停止的异常""" + try: + if self.translator: + self.translator.stop() + except Exception as e: + if "has stopped" not in str(e): + session_logger.warning( + self.device_id, + self.session_id, + f"停止translator时出现非标准错误: {str(e)}", + ) + return None + +async def transcribe_stream(asr: AliyunASR, stream_generator): + """ + 流式转录音频数据 + stream_generator: 生成器函数,每次产生音频块(bytes) + 返回: 完整转录文本 + """ + try: + # 连接ASR服务 + await asr.connect() + if not asr.connected: + raise ConnectionError("无法连接到ASR服务") + + # 处理音频流 + for audio_chunk in stream_generator: + # 检查中断 + # if interrupt_handler.is_interrupted((asr.device_id, asr.session_id)): + # session_logger.info( + # asr.device_id, + # asr.session_id, + # "检测到中断,停止音频发送" + # ) + # return "" + + await asr.send_audio(audio_chunk) + + # 可选:获取中间结果 + intermediate = await asr.receive_results() + if intermediate: + yield intermediate + + # 发送结束信号并获取最终结果 + await asr.send_end() + # return await asr.receive_results() + + except Exception as e: + # session_logger.error( + # asr.device_id, + # asr.session_id, + # f"流式转录出错: {str(e)}" + # ) + print(e) + yield "" + finally: + await asr.close() + +# def audio_chunk_generator1(file_path, chunk_duration=100): +# """ +# 生成器函数:从音频文件产生指定时长的块 +# chunk_duration - 毫秒 +# """ +# audio = AudioProcessor.load_audio(file_path) +# chunk_size = int(16000 * 2 * (chunk_duration / 1000)) # 16kHz * 2字节 * 时长 + +# # 获取PCM数据 +# pcm_data = AudioProcessor.audio_to_pcm(audio) + +# # 分块产生 +# for i in range(0, len(pcm_data), chunk_size): +# yield pcm_data[i:i+chunk_size] +# # 模拟实时延迟 +# # await asyncio.sleep(chunk_duration / 1000) + +def audio_chunk_generator(file_path, chunk_duration=100): + """ + 生成器函数:从音频文件产生指定时长的块 + chunk_duration - 毫秒 + + 特点:直接从AudioSegment对象生成音频块,无需额外PCM转换 + """ + # 加载音频文件 + # audio = AudioProcessor.load_audio(file_path) + with open(file_path, "rb") as f: + audio = f.read() + # 计算每秒的字节数 (sample_rate * sample_width * channels) + # bytes_per_second = audio.frame_rate * (audio.sample_width) * audio.channels + + # 计算每个块的字节数 (按时间计算而非固定值) + chunk_size_bytes = 320 #int(bytes_per_second * (chunk_duration / 1000.0)) + + # 计算块的数量 + total_bytes = len(audio) + num_chunks = math.ceil(total_bytes / chunk_size_bytes) + + # 分块产生音频数据 + for i in range(num_chunks): + # 计算当前块的起始和结束位置 + start_byte = i * chunk_size_bytes + end_byte = min((i + 1) * chunk_size_bytes, total_bytes) + + # 提取原始字节数据 + chunk_data = audio[start_byte:end_byte] + + yield chunk_data + +def create_websocket_packet(device_id: str, session_id: str, sequence_number: int, packet_type: int, audio_data: bytes) -> bytes: + """ + 创建符合WebSocket接口格式的数据包 + """ + # 设备ID(以null结尾) + device_id_bytes = device_id.encode('ascii') + b'\x00' + + # 会话ID(固定33字节,不足补null) + session_id_bytes = session_id.encode('ascii') + session_id_bytes = session_id_bytes.ljust(33, b'\x00')[:33] + + # 序列号(4字节小端) + seq_bytes = struct.pack('=3: + break + chunk = pcm_data[i:i+chunk_size] + audio_packet = create_websocket_packet(device_id, session_id, sequence_number, 0, chunk) + await websocket.send(audio_packet) + + # 接收中间结果 + try: + response = await asyncio.wait_for(websocket.recv(), timeout=1.0) + print(f"中间结果: {response}") + except asyncio.TimeoutError: + pass # 没有中间结果时继续 + + sequence_number += 1 + + # 添加延迟模拟实时流 + await asyncio.sleep(0.1) + + # 发送结束包 + end_packet = create_websocket_packet(device_id, session_id, sequence_number, 2, b"") + await websocket.send(end_packet) + print("发送结束包") + + # 接收最终结果 + try: + response = await asyncio.wait_for(websocket.recv(), timeout=10.0) + print(f"最终结果: {response}") + except asyncio.TimeoutError: + print("等待最终结果超时") + + print(f"音频文件 {file_path} 发送完成") + +async def stream_large_audio(): + asr = AliyunASR() + asr.device_id = "stream-device" + asr.session_id = "large-file-session" + + # 创建音频流生成器 + generator = audio_chunk_generator("./test/output(1).mp3", chunk_duration=500) + + # generator1 = audio_chunk_generator("./test/16k.amr", chunk_duration=500) + + # 转录MP3文件 + print("开始转录MP3文件...") + async for result in transcribe_stream(asr, generator): + print(f"MP3中间结果: {result}") + +async def send_audio_with_continuous_receive(file_path: str, websocket_url: str = "ws://118.25.13.180:8080/ws"): + """ + 发送音频并持续接收所有返回结果 + """ + device_id = "TalkingQ_10003B58862C" + session_id = "1000000189ABCDEFGHIJKLMNOPQRSTUV" + + # 加载音频文件 + # audio = AudioProcessor.load_audio(file_path) + with open(file_path, "rb") as f: + pcm_data = f.read() + # pcm_audio = audio.set_frame_rate(16000).set_channels(1) + # pcm_data = pcm_audio.raw_data + # pcm_data = AudioProcessor.audio_to_pcm(pcm_audio) + chunk_size = 320 + sequence_number = 0 + print(len(pcm_data)) + async with websockets.connect(websocket_url) as websocket: + print(f"连接到WebSocket: {websocket_url}") + + # 启动接收任务 + receive_task = asyncio.create_task(receive_messages(websocket)) + + try: + # 发送开始包 + start_packet = create_websocket_packet(device_id, session_id, sequence_number, 1, b"") + await websocket.send(start_packet) + sequence_number += 1 + print("发送开始包") + + # 发送音频数据 + for i in range(0, len(pcm_data), chunk_size): + # if i>=1: + # break + chunk = pcm_data[i:i+chunk_size] + audio_packet = create_websocket_packet(device_id, session_id, sequence_number, 0, chunk) + await websocket.send(audio_packet) + sequence_number += 1 + await asyncio.sleep(1) + + # 发送结束包 + end_packet = create_websocket_packet(device_id, session_id, sequence_number, 2, b"") + await websocket.send(end_packet) + print("发送结束包") + + # 等待接收任务完成 + await receive_task + + except Exception as e: + print(f"发送过程中出错: {e}") + receive_task.cancel() + +async def receive_messages(websocket): + """ + 持续接收WebSocket消息 + """ + try: + while True: + try: + message = await asyncio.wait_for(websocket.recv(), timeout=60.0) + print(f"收到消息: {message}") + except asyncio.TimeoutError: + print("接收超时,停止监听") + break + except websockets.exceptions.ConnectionClosed: + print("WebSocket连接已关闭") + break + except Exception as e: + print(f"接收消息时出错: {e}") + +if __name__ == "__main__": + # 运行WebSocket测试 - 简单版本 + # asyncio.run(send_audio_to_websocket("./test/output(1).mp3")) + + # 运行WebSocket测试 - 持续接收版本 + asyncio.run(send_audio_with_continuous_receive("./test/output(1).mp3")) + + # 或者运行原来的测试 + # asyncio.run(stream_large_audio()) \ No newline at end of file diff --git a/talkingq-url/test/minimax_audio.py b/talkingq-url/test/minimax_audio.py new file mode 100644 index 0000000..a9bd3f6 --- /dev/null +++ b/talkingq-url/test/minimax_audio.py @@ -0,0 +1,55 @@ +import requests + +url = "https://api.minimaxi.com/v1/t2a_v2" +offline="你的好朋友现在不在哦" +welcome="你的好朋友已经在线,现在开始对话吧" +payload = { + "model": "speech-02-hd", + "text": welcome, + "voice_setting": { + "voice_id": "Chinese (Mandarin)_Cute_Spirit", + "speed": float(1.0), + "vol": 1.0, + "pitch": 0, + "emotion": 'happy', + }, + "audio_setting": { + "sample_rate": 16000, + "bitrate": 32000, + "format": "mp3", + "channel": 1 + }, + "language_boost": "zh" + } +headers = { + "Content-Type": "application/json", + "Authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJHcm91cE5hbWUiOiLovbvoiJ_mmbrlkK_vvIjmna3lt57vvInnp5HmioDmnInpmZDlhazlj7giLCJVc2VyTmFtZSI6Im1veSIsIkFjY291bnQiOiJtb3lAMTkxNTI5MjQxMDAyNDMwMDYzMCIsIlN1YmplY3RJRCI6IjE5MTY3OTA4MTQyNTY2NjQ2MDAiLCJQaG9uZSI6IiIsIkdyb3VwSUQiOiIxOTE1MjkyNDEwMDI0MzAwNjMwIiwiUGFnZU5hbWUiOiIiLCJNYWlsIjoiIiwiQ3JlYXRlVGltZSI6IjIwMjUtMDUtMDYgMTE6Mzg6MTMiLCJUb2tlblR5cGUiOjEsImlzcyI6Im1pbmltYXgifQ.Gw48hGemgBRA7YzjsWz5N2Vun7XRKyBXAKQLAnRZY6FdQQfDn__ZEUCMNVxMKcRns60-FrH-3Xp-nH-8nmQ-V67XtJ_JnBS4TH0NKDKt-vzj0xHmEWsMOEcwE24fOh1HB2U2o6teeLWJT_0fc2wRsdMD84NcRe0DSZ-Yi-et3_fbO8fmc6MTXGPkGQvYzS9k21Lm7A6rRUonL6IbTpqHSYuvA7bEV-6925gLxhwjSJ7q3r8KltKP4daGm2sIXhjr0mftR5t-NJs7pz-IWqoW5Lsaf3KgsIXTJn1wKwQtfo5L9oQxR7v8JifAOqJ_XPei5fXBYqIe5AoNATz5oKEo_A" +} + +response = requests.post(url, json=payload, headers=headers) + +print(response.json()) +audio_data = response.json().get('data', {}).get('audio', '') +# print(audio_data) +if not audio_data: + raise Exception(f"Failed to get audio data from response") + +# hex->bytes +output_file = "welcome.mp3" +audio_bytes = bytes.fromhex(audio_data) +with open(output_file, "wb") as f: + f.write(audio_bytes) + +# import requests + +# url = "https://api.minimaxi.com/v1/get_voice" + +# payload = { "voice_type": "all" } +# headers = { +# "Content-Type": "application/json", +# "Authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJHcm91cE5hbWUiOiLovbvoiJ_mmbrlkK_vvIjmna3lt57vvInnp5HmioDmnInpmZDlhazlj7giLCJVc2VyTmFtZSI6Im1veSIsIkFjY291bnQiOiJtb3lAMTkxNTI5MjQxMDAyNDMwMDYzMCIsIlN1YmplY3RJRCI6IjE5MTY3OTA4MTQyNTY2NjQ2MDAiLCJQaG9uZSI6IiIsIkdyb3VwSUQiOiIxOTE1MjkyNDEwMDI0MzAwNjMwIiwiUGFnZU5hbWUiOiIiLCJNYWlsIjoiIiwiQ3JlYXRlVGltZSI6IjIwMjUtMDUtMDYgMTE6Mzg6MTMiLCJUb2tlblR5cGUiOjEsImlzcyI6Im1pbmltYXgifQ.Gw48hGemgBRA7YzjsWz5N2Vun7XRKyBXAKQLAnRZY6FdQQfDn__ZEUCMNVxMKcRns60-FrH-3Xp-nH-8nmQ-V67XtJ_JnBS4TH0NKDKt-vzj0xHmEWsMOEcwE24fOh1HB2U2o6teeLWJT_0fc2wRsdMD84NcRe0DSZ-Yi-et3_fbO8fmc6MTXGPkGQvYzS9k21Lm7A6rRUonL6IbTpqHSYuvA7bEV-6925gLxhwjSJ7q3r8KltKP4daGm2sIXhjr0mftR5t-NJs7pz-IWqoW5Lsaf3KgsIXTJn1wKwQtfo5L9oQxR7v8JifAOqJ_XPei5fXBYqIe5AoNATz5oKEo_A" +# } + +# response = requests.post(url, json=payload, headers=headers) + +# print(response.json()) \ No newline at end of file diff --git a/talkingq-url/utils/audio_denoiser.py b/talkingq-url/utils/audio_denoiser.py new file mode 100644 index 0000000..19112c4 --- /dev/null +++ b/talkingq-url/utils/audio_denoiser.py @@ -0,0 +1,118 @@ +import noisereduce as nr +import soundfile as sf +import librosa +import numpy as np +import os + +def normalize_audio_volume(audio, original_rms=None, target_dbfs=-20): + """ + 对音频进行音量归一化 + + 参数: + audio: 音频数据 + original_rms: 原始音频的RMS值(可选) + target_dbfs: 目标音量级别(dBFS) + + 返回: + 归一化后的音频 + """ + # 计算当前音频的RMS + current_rms = np.sqrt(np.mean(audio**2)) + + # 方法1:恢复到原始音量水平 + if original_rms is not None and original_rms > 0: + scale_factor = original_rms / current_rms + normalized_audio = audio * scale_factor + print(f"音量恢复: 缩放因子 {scale_factor:.2f}") + + # 方法2:基于目标dBFS的音量归一化 + else: + # 计算当前音频的dBFS + current_dbfs = 20 * np.log10(current_rms / (np.max(np.abs(audio)) + 1e-8)) + + # 计算需要的增益 + gain_db = target_dbfs - current_dbfs + gain_linear = 10 ** (gain_db / 20) + + normalized_audio = audio * gain_linear + print(f"音量归一化: 增益 {gain_db:.1f} dB, 缩放因子 {gain_linear:.2f}") + + # 防止削波(clipping) + max_val = np.max(np.abs(normalized_audio)) + if max_val > 0.95: # 如果接近最大值1.0 + normalized_audio = normalized_audio * 0.95 / max_val + print("已应用防削波处理") + + return normalized_audio +def reduce_background_noise(audio_path, output_path=None, use_default_noise=True, + noise_path='assets/audio/noise_sample.wav', noise_start=0.1, noise_end=0.5, + normalize_volume=True, target_dbfs=-20): + """ + 使用noisereduce库去除背景噪声 + + 参数: + audio_path: 音频文件路径 + output_path: 输出文件路径(可选) + use_default_noise: 是否使用默认噪声样本 + noise_path: 默认噪声样本路径 + noise_start: 噪声样本开始时间(秒)(仅当use_default_noise=False时使用) + noise_end: 噪声样本结束时间(秒)(仅当use_default_noise=False时使用) + """ + + # 加载音频 + y, sr = librosa.load(audio_path, sr=None) + + print(f"音频信息: 时长 {len(y)/sr:.2f}秒, 采样率 {sr}Hz") + + # 提取或加载噪声样本 + if use_default_noise: + try: + noise_path = os.path.join("assets", "audio", "noise_sample.wav") + noise_clip, noise_sr = librosa.load(noise_path, sr=None) + # 检查采样率是否匹配 + if noise_sr != sr: + print(f"警告: 噪声样本采样率({noise_sr}Hz)与音频采样率({sr}Hz)不匹配") + # 重采样噪声样本以匹配音频采样率 + noise_clip = librosa.resample(noise_clip, orig_sr=noise_sr, target_sr=sr) + print("已对噪声样本进行重采样以匹配音频采样率") + except FileNotFoundError as e: + print(f"错误: {e}") + print("将使用当前音频提取噪声样本") + use_default_noise = False + + if not use_default_noise: + # 从当前音频提取噪声样本 + noise_start_sample = int(noise_start * sr) + noise_end_sample = int(noise_end * sr) + noise_clip = y[noise_start_sample:noise_end_sample] + print(f"使用当前音频的 {noise_end-noise_start:.2f}秒 噪声样本进行降噪") + else: + print(f"使用默认噪声样本进行降噪,噪声长度: {len(noise_clip)/sr:.2f}秒") + + # 应用降噪 + reduced_noise = nr.reduce_noise( + y=y, + sr=sr, + y_noise=noise_clip, + prop_decrease=0.95, # 降噪比例 + n_fft=1024, + win_length=1024, + hop_length=256, + n_std_thresh_stationary=1.5, + stationary=True + ) + + # 记录原始音频的音量(RMS) + original_rms = np.sqrt(np.mean(y**2)) + print(f"原始音频RMS: {original_rms:.4f}") + # 音量归一化处理 + if normalize_volume: + normalize_audio_volume(reduced_noise, original_rms, target_dbfs) + + # 保存结果 + if output_path: + sf.write(output_path, reduced_noise, sr) + print(f"降噪后的音频已保存至: {output_path}") + +if __name__ == "__main__": + reduce_background_noise('test/TalkingQ_XQSN00001004_eba844b5.mp3', 'test/denoised2.mp3') \ No newline at end of file