diff --git a/banban-mini/config/dev.js b/banban-mini/config/dev.js index d5d5438..747dcc5 100644 --- a/banban-mini/config/dev.js +++ b/banban-mini/config/dev.js @@ -5,7 +5,7 @@ module.exports = { defineConstants: { __APP_ENV__: '"development"', // Update this to your LAN backend when testing on a real device. - __API_BASE_URL__: '"http://192.168.10.36:8001"', + __API_BASE_URL__: '"https://banban.api.talkingq.com"', }, mini: {}, h5: {} diff --git a/banban-mini/config/prod.js b/banban-mini/config/prod.js index c1a10af..8a8c429 100644 --- a/banban-mini/config/prod.js +++ b/banban-mini/config/prod.js @@ -5,7 +5,7 @@ module.exports = { defineConstants: { __APP_ENV__: '"production"', // Replace with the production backend before release builds. - __API_BASE_URL__: '"http://192.168.10.36:8001"', + __API_BASE_URL__: '"https://banban.api.talkingq.com"', }, mini: {}, h5: {} diff --git a/banban-mini/src/config/env.ts b/banban-mini/src/config/env.ts index 514bce5..78b32df 100644 --- a/banban-mini/src/config/env.ts +++ b/banban-mini/src/config/env.ts @@ -8,7 +8,7 @@ export interface AppConfig { export const APP_CONFIG: AppConfig = { appEnv: typeof __APP_ENV__ === 'undefined' ? 'development' : __APP_ENV__, - apiBaseUrl: typeof __API_BASE_URL__ === 'undefined' ? 'http://127.0.0.1:8001' : __API_BASE_URL__, + apiBaseUrl: typeof __API_BASE_URL__ === 'undefined' ? 'https://banban.api.talkingq.com' : __API_BASE_URL__, } export const API_BASE_URL = APP_CONFIG.apiBaseUrl diff --git a/banban-mini/src/pages/device/index.tsx b/banban-mini/src/pages/device/index.tsx index a14b8a8..287b175 100644 --- a/banban-mini/src/pages/device/index.tsx +++ b/banban-mini/src/pages/device/index.tsx @@ -88,7 +88,7 @@ export default function Device() { const nextValue = Boolean(e.detail?.value) setSleepEnabled(nextValue) Taro.showToast({ - title: nextValue ? '休眠展示已开启' : '休眠展示已关闭', + title: nextValue ? '休眠已开启' : '休眠已关闭', icon: 'none', }) } @@ -241,7 +241,7 @@ export default function Device() { 定时休眠 - 开启后进入休眠展示 + 开启后进入休眠状态 diff --git a/banban-mini/src/pages/location/index.tsx b/banban-mini/src/pages/location/index.tsx index ab83d11..6f46f7f 100644 --- a/banban-mini/src/pages/location/index.tsx +++ b/banban-mini/src/pages/location/index.tsx @@ -375,7 +375,7 @@ export default function Location() { ) : ( <> - + {/* {MODE_OPTIONS.map((item) => ( {item.label} ))} - + */} DeviceLocationCurrentResponse: - row = await location_service.get_device_current_location( - device_id=device_id, - user_id=current_user_id, - ) + # 每次进入小程序地图页面获取最新位置坐标,等待设备上报GPS数据,再查询数据库坐标 + service = await TalkingQMQTTService.get_instance() + if service is None: + raise HTTPException(status_code=503, detail="MQTT 服务未初始化") + await service.send_gps_query(device_id) + try: + # 每隔1s 获取一次GPS数据,最多3次 + # 先判断设备是否在线,不在线直接提示设备没有在线,通过websocket判断 + target_websocket = await connection_manager.get_connection(device_id) + if not target_websocket or target_websocket.client_state.name != "CONNECTED": + raise HTTPException(status_code=408, detail="设备未在线") + i=0 + while i<1: + try: + row = await location_service.get_device_current_location( + device_id=device_id, + user_id=current_user_id, + ) + except Exception as e: + await asyncio.sleep(1) + continue + + if row is None: + await asyncio.sleep(1) + else: + if row["updated_at"] is not None \ + and (datetime.now() - row['updated_at']) <= timedelta(seconds=10): + logger.info( + "device current location fetched", + extra={ + "event": "device_current_location", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "child_id": int(row["child_id"]), + }, + ) + return _row_to_current_location_response(row) + + i+=1 + if row is None: + raise HTTPException(status_code=408, detail="GPS数据上报超时") - logger.info( - "device current location fetched", - extra={ - "event": "device_current_location", - "request_id": getattr(request.state, "request_id", None), - "user_id": current_user_id, - "device_id": device_id, - "child_id": int(row["child_id"]), - }, - ) - return _row_to_current_location_response(row) + logger.info( + "device location reported", + extra={ + "event": "device_location_report", + "request_id": getattr(request.state, "request_id", None), + "device_id": device_id, + "child_id": int(row["child_id"]), + "lat": float(row["lat"]), + "lng": float(row["lng"]), + }, + ) + + except Exception as e: + raise HTTPException(status_code=408, detail=f"GPS数据上报失败: {e}") + + @router.get("/{device_id}/trajectory", response_model=DeviceLocationTrajectoryResponse) diff --git a/talkingq-url/banban/routers/mqtt_router.py b/talkingq-url/banban/routers/mqtt_router.py index 95aa6c2..723b5e5 100644 --- a/talkingq-url/banban/routers/mqtt_router.py +++ b/talkingq-url/banban/routers/mqtt_router.py @@ -10,13 +10,16 @@ from banban.schemas.mqtt_models import ( NFCListenRequest, NFCUnreadRequest, NFCBindRequest, + SleepScheduleRequest, + RemoteSleepWakeRequest, + OpenCommandRequest, CommandResponse, DeviceResponseData, DeviceStatusResponse, ) from fastapi import Depends from handlers.mqtt_handler import TalkingQMQTTService -from banban.security import get_current_user_id +# from banban.security import get_current_user_id @@ -35,7 +38,7 @@ async def _get_service() -> TalkingQMQTTService: @router.post("/gps", response_model=CommandResponse, summary="GPS位置查询") async def query_gps( req: GPSQueryRequest, - current_user_id: int = Depends(get_current_user_id) + # current_user_id: int = Depends(get_current_user_id) ): service = await _get_service() msg_id = await service.send_gps_query(req.device_id) @@ -45,7 +48,7 @@ async def query_gps( @router.post("/volume", response_model=CommandResponse, summary="音量调节") async def set_volume( req: VolumeRequest, - current_user_id: int = Depends(get_current_user_id) + # current_user_id: int = Depends(get_current_user_id) ): service = await _get_service() msg_id = await service.send_volume_command(req.device_id, req.level) @@ -55,7 +58,7 @@ async def set_volume( @router.post("/ota", response_model=CommandResponse, summary="OTA升级") async def start_ota( req: OTARequest, - current_user_id: int = Depends(get_current_user_id) + # current_user_id: int = Depends(get_current_user_id) ): service = await _get_service() msg_id = await service.send_ota_command(req.device_id, req.url, req.version) @@ -65,32 +68,32 @@ async def start_ota( @router.post("/nfc/notice", response_model=CommandResponse, summary="NFC留言下发提示") async def send_nfc_notice( req: NFCNoticeRequest, - current_user_id: int = Depends(get_current_user_id) + # current_user_id: int = Depends(get_current_user_id) ): service = await _get_service() msg_id = await service.send_nfc_notice(req.device_id, req.url) return CommandResponse(msg_id=msg_id, device_id=req.device_id) -@router.post("/nfc/listen", response_model=CommandResponse, summary="NFC收听留言") -async def nfc_listen( - req: NFCListenRequest, - current_user_id: int = Depends(get_current_user_id) - ): - service = await _get_service() - topic = f"device/{req.device_id}/event" - payload = { - "msg_id": "005", - "params": {"uuid": req.uuid} - } - await service._publish(topic, payload) - return CommandResponse(msg_id="005", device_id=req.device_id) +# @router.post("/nfc/listen", response_model=CommandResponse, summary="NFC收听留言") +# async def nfc_listen( +# req: NFCListenRequest, +# # current_user_id: int = Depends(get_current_user_id) +# ): +# service = await _get_service() +# topic = f"device/{req.device_id}/event" +# payload = { +# "msg_id": "005", +# "params": {"uuid": req.uuid} +# } +# await service._publish(topic, payload) +# return CommandResponse(msg_id="005", device_id=req.device_id) @router.post("/nfc/unread", response_model=CommandResponse, summary="设置NFC未读留言") async def set_nfc_unread( req: NFCUnreadRequest, - current_user_id: int = Depends(get_current_user_id) + # current_user_id: int = Depends(get_current_user_id) ): await offline_audio_cache.add_audio_url(req.device_id, req.url) return CommandResponse(msg_id="", device_id=req.device_id, message="已设置未读留言") @@ -99,7 +102,7 @@ async def set_nfc_unread( @router.delete("/nfc/unread/{device_id}", response_model=CommandResponse, summary="清除NFC未读留言定时推送") async def clear_nfc_unread( device_id: str, - current_user_id: int = Depends(get_current_user_id) + # current_user_id: int = Depends(get_current_user_id) ): await offline_audio_cache.clear_audio_urls(device_id) return CommandResponse(msg_id="", device_id=device_id, message="已清除未读留言") @@ -108,14 +111,43 @@ async def clear_nfc_unread( @router.post("/nfc/bind", response_model=CommandResponse, summary="NFC绑定卡片") async def bind_nfc( req: NFCBindRequest, - current_user_id: int = Depends(get_current_user_id) + # current_user_id: int = Depends(get_current_user_id) ): - # 扫码绑定卡片需要发送NFC绑定命令 service = await _get_service() msg_id = await service.send_bind_nfc_command(req.device_id, req.uuid) return CommandResponse(msg_id=msg_id, device_id=req.device_id) +@router.post("/sleep/schedule", response_model=CommandResponse, summary="设置定时休眠") +async def set_sleep_schedule( + req: SleepScheduleRequest, + # current_user_id: int = Depends(get_current_user_id) + ): + service = await _get_service() + msg_id = await service.send_sleep_schedule_command(req.device_id, req.start, req.end) + return CommandResponse(msg_id=msg_id, device_id=req.device_id) + + +@router.post("/sleep/remote", response_model=CommandResponse, summary="远程休眠唤醒") +async def remote_sleep_wake( + req: RemoteSleepWakeRequest, + # current_user_id: int = Depends(get_current_user_id) + ): + service = await _get_service() + msg_id = await service.send_remote_sleep_wake_command(req.device_id, req.switch) + return CommandResponse(msg_id=msg_id, device_id=req.device_id) + + +@router.post("/device/open", response_model=CommandResponse, summary="设备开关控制") +async def control_device_open( + req: OpenCommandRequest, + # current_user_id: int = Depends(get_current_user_id) + ): + service = await _get_service() + msg_id = await service.send_open_command(req.device_id, req.open_type, req.is_open) + return CommandResponse(msg_id=msg_id, device_id=req.device_id) + + # @router.get("/nfc/unread", summary="查询所有未读留言设备列表") # async def get_nfc_unread_devices(): # service = await _get_service() diff --git a/talkingq-url/banban/schemas/mqtt_models.py b/talkingq-url/banban/schemas/mqtt_models.py index 10c5486..24b11d7 100644 --- a/talkingq-url/banban/schemas/mqtt_models.py +++ b/talkingq-url/banban/schemas/mqtt_models.py @@ -37,6 +37,23 @@ class NFCBindRequest(BaseModel): uuid: str = Field(..., description="NFC卡片UUID") +class SleepScheduleRequest(BaseModel): + device_id: str = Field(..., description="设备ID") + start: str = Field(..., description="休眠开始时间,格式 HH:MM,如 22:00") + end: str = Field(..., description="休眠结束时间,格式 HH:MM,如 07:00") + + +class RemoteSleepWakeRequest(BaseModel): + device_id: str = Field(..., description="设备ID") + switch: str = Field(..., description="开关状态,on=唤醒,off=休眠") + + +class OpenCommandRequest(BaseModel): + device_id: str = Field(..., description="设备ID") + open_type: int = Field(..., ge=0, le=2, description="0=查询状态,1=控制打开,2=控制关闭") + is_open: Optional[int] = Field(None, description="open_type=1时,1=打开,0=关闭") + + class CommandResponse(BaseModel): code: int = 0 message: str = "success" diff --git a/talkingq-url/banban/service/message_audio_storage.py b/talkingq-url/banban/service/message_audio_storage.py index 46e3b47..6d66373 100644 --- a/talkingq-url/banban/service/message_audio_storage.py +++ b/talkingq-url/banban/service/message_audio_storage.py @@ -1,6 +1,12 @@ import asyncio from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import datetime +try: + from datetime import UTC # Python 3.11+ +except ImportError: + from datetime import timezone + UTC = timezone.utc # Python 3.10 及更早版本 + from urllib.parse import urlparse from uuid import uuid4 diff --git a/talkingq-url/database/models.py b/talkingq-url/database/models.py index 78e2111..4147097 100644 --- a/talkingq-url/database/models.py +++ b/talkingq-url/database/models.py @@ -267,7 +267,6 @@ class DeviceSetting(Base): onupdate=datetime.utcnow, ) - class IMConversation(Base): __tablename__ = "im_conversations" __table_args__ = ( diff --git a/talkingq-url/docker-compose.yml b/talkingq-url/docker-compose.yml index 05588bf..bfa3de6 100644 --- a/talkingq-url/docker-compose.yml +++ b/talkingq-url/docker-compose.yml @@ -1,80 +1,117 @@ services: - app: - build: . - ports: - - "8080:8080" - env_file: - - .env - environment: - TALKINGQ_MQTT_BROKER: mqtt - TALKINGQ_MQTT_PORT: 1883 - volumes: - - ./assets:/app/assets - - ./logs:/app/logs - depends_on: - mqtt: - condition: service_started - mysql: - condition: service_healthy - restart: unless-stopped - networks: - - talkingq-network - - backend_app: - build: ../backend-v0.1 - ports: - - "8001:8001" - env_file: - - ../backend-v0.1/.env - volumes: - - ../backend-v0.1/app/assets:/app/assets - - ../backend-v0.1/app/logs:/app/logs - depends_on: - mysql: - condition: service_healthy - restart: unless-stopped - networks: - - talkingq-network - - mysql: - image: mysql:8.0 - command: --default-authentication-plugin=caching_sha2_password - restart: unless-stopped - environment: - MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}" - MYSQL_DATABASE: talkingq - MYSQL_USER: talkingq - MYSQL_PASSWORD: "${MYSQL_PASSWORD}" - TZ: Asia/Shanghai - volumes: - - mysql-data:/var/lib/mysql - - ./mysql/init:/docker-entrypoint-initdb.d - - ./mysql/conf.d:/etc/mysql/conf.d - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "talkingq", "-p${MYSQL_PASSWORD}"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - talkingq-network + # mysql: + # build: + # context: . + # dockerfile: Dockerfile-mysql + # ports: + # - "13306:3306" # 修改为 13306 或其他未使用的端口 + # networks: + # - app-network + # volumes: + # - mysql_data:/var/lib/mysql # 持久化数据 + # healthcheck: + # test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-padmin123"] + # interval: 10s + # timeout: 5s + # retries: 5 + # start_period: 30s - mqtt: - image: eclipse-mosquitto:2 - ports: - - "1883:1883" - volumes: - - ./mqtt/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro - - mqtt-data:/mosquitto/data - - mqtt-log:/mosquitto/log - restart: unless-stopped - networks: - - talkingq-network + # redis: + # image: redis:7.2-alpine + # ports: + # - "6379:6379" + # networks: + # - app-network + # volumes: + # - redis_data:/data + # healthcheck: + # test: ["CMD", "redis-cli", "ping"] + # interval: 5s + # timeout: 3s + # retries: 5 + # start_period: 10s + # command: redis-server --appendonly yes -volumes: - mysql-data: - mqtt-data: - mqtt-log: + # node: + # build: + # context: . + # dockerfile: Dockerfile-node + # depends_on: + # mysql: + # condition: service_healthy + # ports: + # - "8084:8084" + # networks: + # - app-network + # environment: + # - API_URL=http://server:8091 + + # server: + # build: + # context: . + # dockerfile: Dockerfile-server + # # 添加构建缓存设置 + # cache_from: + # - eclipse-temurin:21-jre + # depends_on: + # mysql: + # condition: service_healthy + # redis: + # condition: service_healthy + # networks: + # - app-network + # environment: + # SPRING_DATASOURCE_URL: "jdbc:mysql://mysql:3306/talkingq?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=UTC" + # SPRING_DATASOURCE_USERNAME: "root" + # SPRING_DATASOURCE_PASSWORD: "admin123" + # SPRING_DATA_REDIS_HOST: "redis" + # SPRING_DATA_REDIS_PORT: "6379" + # SPRING_DATA_REDIS_PASSWORD: "" + # volumes: + # - maven_repo:/root/.m2/repository # 持久化Maven仓库 + # restart: on-failure # 添加重启策略,如果启动失败会自动重试 + + nginx: + image: nginx:stable-alpine + # depends_on: + # server: + # condition: service_started + ports: + - "80:80" + - "443:443" + networks: + - app-network + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./logs/nginx:/var/log/nginx + - ./certbot/conf:/etc/letsencrypt + - ./certbot/www:/var/www/certbot + - ./web:/usr/share/nginx/html + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + environment: + - TZ=Asia/Shanghai + command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" + + certbot: + image: certbot/certbot + networks: + - app-network + volumes: + - ./certbot/conf:/etc/letsencrypt + - ./certbot/www:/var/www/certbot + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + environment: + - TZ=Asia/Shanghai + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" networks: - talkingq-network: + app-network: driver: bridge + +# volumes: +# mysql_data: # 定义持久化卷 +# maven_repo: # 持久化Maven仓库 +# vosk_models: # 持久化Vosk模型 +# redis_data: #redis持久卷 diff --git a/talkingq-url/docs/mqtt协议_V1.6.txt b/talkingq-url/docs/mqtt协议_V1.6.txt new file mode 100644 index 0000000..560618f --- /dev/null +++ b/talkingq-url/docs/mqtt协议_V1.6.txt @@ -0,0 +1,291 @@ +device/TalkingQ_xxx/command # 服务器下发命令(下行) +device/TalkingQ_xxx/response # 设备响应(上行) +device/TalkingQ_xxx/event # 设备主动事件 +device/TalkingQ_xxx/event_resp # 事件返回 + +消息格式(JSON) +device/TalkingQ_xxx/event +0.设备定时10分钟上报信息 +{ + "msg_id": "000", + "data": { + "id":"TalkingQ_xxx", + "power": 70, 每个间隔10,从10-100 + "signal": 3, 信号强度1是最弱的 5是最强 + "voice":60, 每个间隔20,从40-100 + "version":"v1.0" + } +} +服务器返回 +device/TalkingQ_xxx/event_resp +{ + "msg_id": "000", + "status": "success" +} + +1. GPS位置查询 +device/TalkingQ_xxx/command +服务器下发: +{ + "msg_id": "001" +} + + +device/TalkingQ_xxx/response +4G模块响应: +{ + "msg_id": "001", + "status": "success", + "data": { + "latitude": 39.9042, + "longitude": 116.4074 + } +} + + +2. 音量调节 +device/TalkingQ_xxx/command +服务器下发: +{ + "msg_id": "002", + "params": { + "level": 70 + } +} +device/TalkingQ_xxx/response +4G模块响应: +{ + "msg_id": "002", + "status": "success", + "data": { + "current_level": 70 + } +} + +3. OTA升级 +device/TalkingQ_xxx/command +服务器下发: +{ + "msg_id": "003", + "params": { + "url": "http://47.113.99.138:7050/EG800AKCN_91LC-beta1-20260430170621-fota.bin", + "version": "2.1.0" + } +} + + +device/TalkingQ_xxx/response +4G模块响应(立即确认): +{ + "msg_id": "003", + "status": "accepted", + "data": { + "current_version": "2.0.5", + "target_version": "2.1.0" + } +} + +升级完成 +{ + "msg_id": "003", + "status": "success", + "data": { + "new_version": "2.1.0" + } +} + +4、NFC留言下发提示 +device/TalkingQ_xxx/command +如果设备在线,且有未读留言服务器定时10分钟下发一次: +{ + "msg_id": "004", + "params": { + "url": "http://xxx.mp3" //您有新的留言请注意查收 + } +} +device/TalkingQ_xxx/response +4G模块响应: +{ + "msg_id": "004", + "status": "success", +} + +5、发送或收听留言 +device/TalkingQ_xxx/event +NFC触发,设备端上报 +{ + "msg_id": "005", + "params": { + "uuid": "53C12B6DA20001" + } +} + + + +//如果不是本机本地UUID,则下发“你好,你的朋友已在线请留言” +//如果本机本地UUID但没有留言,"你好,已没可读留言" +服务器下发 +device/TalkingQ_xxx/event_resp + +{ + "msg_id": "005", + "status": "success", + "type": 0, //0代表收听留言 1 代表需要发送留言 + "params": { + "url_1": "http://xxx.mp3" + "url_2": "http://xxx.mp3" + } +} + +{ + "msg_id": "005", + "type": 0, + "params": + { + "url_1": "http://175.24.73.253:8080/assets/audio/no_message.mp3" + } + } + +6、NFC绑定uuid +device/TalkingQ_xxx/command +{ + "msg_id": "006", +} +device/TalkingQ_xxx/response +4G模块响应: +{ + "msg_id": "006", + "status": "success", + "data": { + "uuid": "xxxx" + } +} + +7、开关状态 +device/TalkingQ_xxx/command +{ + "msg_id": "007", + "type": 0 , //0查询状态 1 控制打开 2控制关闭 +} +device/TalkingQ_xxx/response +4G模块响应: +{ + "msg_id": "007", + "status": "success", + "data": { + "status": "on" //on或off 返回当前状态 + } +} + +8、定时休眠 +device/TalkingQ_xxx/command +{ + "msg_id": "008", + "params": { + "start": "22:00" , //设置时和分 + "end": "07:00" //设置时和分 + } +} +device/TalkingQ_xxx/response +4G模块响应: +{ + "msg_id": "008", + "status": "success" +} + +9、远程休眠和唤醒 +device/TalkingQ_xxx/command +{ + "msg_id": "009", + "params": { + "switch": "on" , // on 唤醒 off 休眠 + } +} +device/TalkingQ_xxx/response +4G模块响应: +{ + "msg_id": "009", + "status": "success" +} + +10、额外按键长按发送告警信息 +device/TalkingQ_xxx/event +{ + "msg_id": "010" //发送告警 +} + +device/TalkingQ_xxx/event_resp +4G模块响应: +{ + "msg_id": "010", + "status": "success" +} + +11、额外按键短按发送留言给家长 +device/TalkingQ_xxx/event +{ + "msg_id": "011", + "params": { + "uuid": "TalkingQ_xxx" + } +} +device/TalkingQ_xxx/event_resp +{ + "msg_id": "011", + "type": 0, + "params": + { + "url": "http://175.24.73.253:8080/assets/audio/message_ok.mp3" + } + } + + + +TalkingQ_XQSN00001003 TQ_XQSN000000001003 +53C12B6DA20001 +53D92B6DA20001 +53DA2B6DA20001 + +set id TalkingQ_XQSN00001003 +set sn TQ_XQSN000000001003 + +TalkingQ_XQSN00001004 TQ_XQSN000000001004 +53E12B6DA20001 +53D12B6DA20001 +53D22B6DA20001 + +TalkingQ_XQSN00001005 TQ_XQSN000000001005 +53C22B6DA20001 + +1000000189ABCDEFGHIJKLMNOPQRSTUV +1000000089ABCDEFGHIJKLMNOPQRSTUV + + +1、额外按钮 + 短按 直接与父母留言 + 长按 播报紧急报警系统 + +2、休眠时间段设置功能 + 在设定时间内,设备不可启动 + 设定时间过后,可以正常使用 + + +3、远程一键休眠和一键唤醒 + + +mqtt地址 +http://175.24.73.253:18083/#/login?to=/clients +admin +emqx_DPWQyk + + +{ + "device_id": "TalkingQ_XQSN00001005", + "start": "22:00", + "end": "07:00" +} + +{ + "device_id": "TalkingQ_XQSN00001005", + "url": "http://175.24.73.253:80/assets/audio/test_zh.mp3" +} \ No newline at end of file diff --git a/talkingq-url/docs/mqtt协议_V1.7.txt b/talkingq-url/docs/mqtt协议_V1.7.txt new file mode 100644 index 0000000..0fd5dd4 --- /dev/null +++ b/talkingq-url/docs/mqtt协议_V1.7.txt @@ -0,0 +1,295 @@ +device/imei/command # 服务器下发命令(下行) +device/imei/response # 设备响应(上行) +device/imei/event # 设备主动事件 +device/imei/event_resp # 事件返回 + +消息格式(JSON) +device/imei/event +0.设备定时10分钟上报信息 +{ + "msg_id": "000", + "data": { + "id":"TalkingQ_xxx", + "power": 70, 每个间隔10,从10-100 + "signal": 3, 信号强度1是最弱的 5是最强 + "voice":60, 每个间隔20,从40-100 + "version":"v1.0" + } +} +服务器返回 +device/imei/event_resp +{ + "msg_id": "000", + "status": "success" +} + +1. GPS位置查询 +device/imei/command +服务器下发: +{ + "msg_id": "001" +} + + +device/imei/response +4G模块响应: +{ + "msg_id": "001", + "status": "success", + "data": { + "latitude": 39.9042, + "longitude": 116.4074 + } +} + + +2. 音量调节 +device/imei/command +服务器下发: +{ + "msg_id": "002", + "params": { + "level": 70 + } +} +device/imei/response +4G模块响应: +{ + "msg_id": "002", + "status": "success", + "data": { + "current_level": 70 + } +} + +3. OTA升级 +device/imei/command +服务器下发: +{ + "msg_id": "003", + "params": { + "url": "http://47.113.99.138:7050/EG800AKCN_91LC-beta1-20260501205451-fota.bin", + "version": "2.1.0" + } +} + +device/imei/response +4G模块响应(立即确认): +{ + "msg_id": "003", + "status": "accepted", + "data": { + "current_version": "2.0.5", + "target_version": "2.1.0" + } +} + +升级完成 +{ + "msg_id": "003", + "status": "success", + "data": { + "new_version": "2.1.0" + } +} + +4、NFC留言下发提示 +device/imei/command +如果设备在线,且有未读留言服务器定时10分钟下发一次: +{ + "msg_id": "004", + "params": { + "url": "http://xxx.mp3" //您有新的留言请注意查收 + } +} +device/imei/response +4G模块响应: +{ + "msg_id": "004", + "status": "success", +} + +5、发送或收听留言 +device/imei/event +NFC触发,设备端上报 +{ + "msg_id": "005", + "params": { + "uuid": "53C12B6DA20001" + } +} + + + +//如果不是本机本地UUID,则下发“你好,你的朋友已在线请留言” +//如果本机本地UUID但没有留言,"你好,已没可读留言" +服务器下发 +device/imei/event_resp + +{ + "msg_id": "005", + "status": "success", + "type": 0, //0代表收听留言 1 代表需要发送留言 + "params": { + "url_1": "http://xxx.mp3" + "url_2": "http://xxx.mp3" + } +} + +{ + "msg_id": "005", + "type": 0, + "params": + { + "url_1": "http://175.24.73.253:8080/assets/audio/no_message.mp3" + } + } + +6、NFC绑定uuid +device/imei/command +{ + "msg_id": "006" +} +device/imei/response +4G模块响应: +{ + "msg_id": "006", + "status": "success", + "data": { + "uuid": "xxxx" + } +} + +7、开关状态 +//0查询状态 1 控制打开 2控制关闭 +device/imei/command +{ + "msg_id": "007", + "type": 0 +} +device/imei/response +4G模块响应: +{ + "msg_id": "007", + "status": "success", + "data": { + "status": "on" //on或off 返回当前状态 + } +} + +8、定时休眠 +device/imei/command +{ + "msg_id": "008", + "params": { + "start": "22:00" , //设置时和分 + "end": "07:00" //设置时和分 + } +} +device/imei/response +4G模块响应: +{ + "msg_id": "008", + "status": "success" +} + +9、远程休眠和唤醒 +device/imei/command +{ + "msg_id": "009", + "params": { + "switch": "on" // on 唤醒 off 休眠 + } +} +device/imei/response +4G模块响应: +{ + "msg_id": "009", + "status": "success" +} + +10、额外按键长按发送告警信息 +device/imei/event +{ + "msg_id": "010" //发送告警 +} + +device/imei/event_resp +4G模块响应: +{ + "msg_id": "010", + "status": "success" +} + +11、额外按键短按发送留言给家长 +device/imei/event +{ + "msg_id": "011", + "params": { + "uuid": "TalkingQ_xxx" + } +} +device/imei/event_resp +{ + "msg_id": "011", + "type": 0, + "params": + { + "url": "http://175.24.73.253:8080/assets/audio/message_ok.mp3" + } + } + +12、增加后台自动下发DeviceID和SN流程 +设备端上报 +device/imei/event +{ + "msg_id": "012" +} +服务器下发 +device/imei/event_resp +{ + "msg_id": "012", + "params": + { + "url": "175.24.73.253:80", + "device_id":"TalkingQ_XQSN00001003", + "device_sn":"TQ_XQSN000000001003", + } + } + +TalkingQ_XQSN00001003 TQ_XQSN000000001003 +53C12B6DA20001 +53D92B6DA20001 +53DA2B6DA20001 + +set id TalkingQ_XQSN00001003 +set sn TQ_XQSN000000001003 + +TalkingQ_XQSN00001004 TQ_XQSN000000001004 +53E12B6DA20001 +53D12B6DA20001 +53D22B6DA20001 + +TalkingQ_XQSN00001005 TQ_XQSN000000001005 +53C22B6DA20001 + +1000000189ABCDEFGHIJKLMNOPQRSTUV +1000000089ABCDEFGHIJKLMNOPQRSTUV + + +1、额外按钮 + 短按 直接与父母留言 + 长按 播报紧急报警系统 + +2、休眠时间段设置功能 + 在设定时间内,设备不可启动 + 设定时间过后,可以正常使用 + + +3、远程一键休眠和一键唤醒 + + +mqtt地址 +http://175.24.73.253:18083/#/login?to=/clients +admin +emqx_DPWQyk diff --git a/talkingq-url/handlers/audio_file_handler.py b/talkingq-url/handlers/audio_file_handler.py index e695baf..9d19932 100644 --- a/talkingq-url/handlers/audio_file_handler.py +++ b/talkingq-url/handlers/audio_file_handler.py @@ -5,15 +5,53 @@ from utils.logger import session_logger message_audio_storage_service = MessageAudioStorageService() +# async def save_audio_file(audio_data: bytes, device_id: str) -> str: +# """Upload device audio to COS and return its object key.""" +# try: +# stored = await message_audio_storage_service.upload_audio( +# device_id=device_id, +# content=audio_data, +# ) +# session_logger.info(device_id, "audio", f"audio uploaded to COS: {stored.file_key}") +# return stored.file_key +# except Exception as e: +# session_logger.error(device_id, "audio", f"failed to store audio: {e}", exc_info=True) +# raise + + +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: - """Upload device audio to COS and return its object key.""" + """ + 保存音频数据到 assets/audio 目录 + + Args: + audio_data: 音频二进制数据 + device_id: 设备ID + + Returns: + 音频文件的相对路径 + """ try: - stored = await message_audio_storage_service.upload_audio( - device_id=device_id, - content=audio_data, - ) - session_logger.info(device_id, "audio", f"audio uploaded to COS: {stored.file_key}") - return stored.file_key + 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"failed to store audio: {e}", exc_info=True) - raise + session_logger.error(device_id, "audio", f"保存音频文件时出错: {e}", exc_info=True) + raise \ No newline at end of file diff --git a/talkingq-url/handlers/mqtt_handler.py b/talkingq-url/handlers/mqtt_handler.py index e38bbd3..48c5ec2 100644 --- a/talkingq-url/handlers/mqtt_handler.py +++ b/talkingq-url/handlers/mqtt_handler.py @@ -11,11 +11,16 @@ from banban.service.device_setting import device_setting_service from banban.service.location import location_service from config import settings from services.card_service import card_service +from services.offline_audio_cache import offline_audio_cache +import aiomqtt +from banban.service.location import location_service +from database.models import ChildLocationCurrent +from datetime import datetime from services.device_target_cache import device_target_cache from services.offline_audio_cache import offline_audio_cache from services.task_manager import task_manager from utils.logger import session_logger as logger - +from services.task_manager import task_manager class TalkingQMQTTService: _instance = None @@ -45,6 +50,10 @@ class TalkingQMQTTService: "005": self._handle_nfc_listen_report, "006": self._handle_bind_response, "007": self._handle_open_response, + "008": self._handle_sleep_schedule_response, + "009": self._handle_remote_sleep_wake_response, + "010": self._handle_alarm_report, + "011": self._handle_short_press_message, } @classmethod @@ -189,6 +198,8 @@ class TalkingQMQTTService: ) elif status != "accepted": logger.warning(device_id, "", f"[OTA] command failed: {payload}") + else: + logger.warning(device_id, "", f"[OTA] 设备 {device_id} 升级异常: {payload}") async def _handle_nfc_notice_response(self, device_id: str, payload: dict): status = payload.get("status") @@ -202,19 +213,13 @@ class TalkingQMQTTService: await self._send_nfc_listen_response(device_id, nfc_uuid) async def _handle_bind_response(self, device_id: str, payload: dict): - params = payload.get("params", {}) - data = payload.get("data", {}) - status = payload.get("status") or params.get("status") - logger.info(device_id, "", f"[NFC bind] device={device_id} status={status}") - - if status not in (None, "success", "accepted"): - logger.warning(device_id, "", f"[NFC bind] bind failed: {payload}") - return - - nfc_uuid = data.get("uuid") or params.get("uuid") - if not nfc_uuid: - logger.warning(device_id, "", f"[NFC bind] missing uuid: {payload}") - return + params = payload + status = params.get("status") + logger.info(device_id, "", f"[NFC绑定] 设备 {device_id} 请求绑定卡片返回状态: {status}") + data = params.get("data", {}) + nfc_uuid = data.get("uuid") + # 卡片与设备绑定 + # await card_service.activate_card(device_id=device_id, card_uuid=nfc_uuid) service = BindingService() result = await service.finalize_nfc_bind(device_id=device_id, card_uuid=nfc_uuid) if result is None: @@ -224,8 +229,43 @@ class TalkingQMQTTService: logger.info(device_id, "", f"[NFC bind] bind completed, uuid={nfc_uuid}, result={result}") async def _handle_open_response(self, device_id: str, payload: dict): + params = payload + status = params.get("status") + logger.info(device_id, "", f"[设备状态] 设备 {device_id} 请求返回设备状态: {status}") + data = params.get("data", {}) + device_status = data.get("status") + logger.info(device_id, "", f"[设备状态] 设备 {device_id} 请求设备状态: {device_status}") + + async def _handle_sleep_schedule_response(self, device_id: str, payload: dict): + status = payload.get("status") + if status == "success": + logger.info(device_id, "", f"[定时休眠] 设备 {device_id} 休眠时间设置成功") + else: + logger.warning(device_id, "", f"[定时休眠] 设备 {device_id} 休眠时间设置失败: {payload}") + + async def _handle_remote_sleep_wake_response(self, device_id: str, payload: dict): + status = payload.get("status") + if status == "success": + logger.info(device_id, "", f"[远程休眠唤醒] 设备 {device_id} 操作成功") + else: + logger.warning(device_id, "", f"[远程休眠唤醒] 设备 {device_id} 操作失败: {payload}") + + async def _handle_alarm_report(self, device_id: str, payload: dict): + logger.info(device_id, "", f"[告警] 设备 {device_id} 发送紧急报警") + await self._publish(f"device/{device_id}/event_resp", {"msg_id": "010", "status": "success"}) + + async def _handle_short_press_message(self, device_id: str, payload: dict): params = payload.get("params", {}) - logger.info(device_id, "", f"[device open] response={params}") + nfc_uuid = params.get("uuid") + logger.info(device_id, "", f"[短按留言] 设备 {device_id} 短按发送留言, UUID={nfc_uuid}") + payload = { + "msg_id": "011", + "type": 0, + "params": { + "url": f"http://{settings.server_host}:{settings.server_port}/assets/audio/message_ok.mp3" + } + } + await self._publish(f"device/{device_id}/event_resp", payload) async def _send_nfc_listen_response(self, device_id: str, nfc_uuid: str): topic = f"device/{device_id}/event_resp" @@ -244,7 +284,7 @@ class TalkingQMQTTService: is_owner = await card_service.check_card_ownership(nfc_uuid, device_id) if is_owner: has_pending = await offline_audio_cache.has_pending_audio(device_id) - if nfc_uuid == "53D92B6DA20001": + if nfc_uuid == "53C22B6DA20001": payload = { "msg_id": "005", "type": 0, @@ -257,6 +297,17 @@ class TalkingQMQTTService: if has_pending: audio_urls = await offline_audio_cache.get_audio_urls(device_id) + # 53D92B6DA20001 测试卡片 + if nfc_uuid == "53C22B6DA20001": + payload = { + "msg_id": "005", + "type": 0, + "params": { + "url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/TalkingQ_XQSN00001005_2f654480.mp3" + } + } + await self._publish(topic, payload) + return if len(audio_urls) == 0: payload = { "msg_id": "005", @@ -372,25 +423,69 @@ class TalkingQMQTTService: return "003" async def send_nfc_notice(self, device_id: str, url: str) -> str: - await self._publish( - f"device/{device_id}/command", - {"msg_id": "004", "params": {"url_1": url}}, - ) + topic = f"device/{device_id}/command" + payload = { + "msg_id": "004", + "params": {"url": url} + } + await self._publish(topic, payload) return "004" - async def send_bind_nfc_command(self, device_id: str, uuid: Optional[str] = None) -> str: - del uuid - await self._publish(f"device/{device_id}/command", {"msg_id": "006"}) + + async def send_bind_nfc_command(self, device_id: str, uuid: str) -> str: + topic = f"device/{device_id}/command" + payload = { + "msg_id": "006", + "params": { + "url": f"http://{settings.server_host}:{settings.server_port}/assets/audio/bind_nfc_ready_zh.mp3" + } + } + await self._publish(topic, payload) return "006" async def send_open_command(self, device_id: str, open_type: int, is_open: int) -> str: - if open_type not in [0, 1]: - raise ValueError("open_type must be 0 or 1") - + topic = f"device/{device_id}/command" + if open_type not in [0, 1, 2]: + raise ValueError("open_type must be 0, 1 or 2") if open_type == 0: - payload = {"msg_id": "007", "type": open_type} - else: - payload = {"msg_id": "007", "type": open_type, "status": is_open} - - await self._publish(f"device/{device_id}/command", payload) + payload = { + "msg_id": "007", + "type": open_type + } + elif open_type == 1: + payload = { + "msg_id": "007", + "type": open_type + } + elif open_type == 2: + payload = { + "msg_id": "007", + "type": open_type + } + await self._publish(topic, payload) return "007" + + async def send_sleep_schedule_command(self, device_id: str, start: str, end: str) -> str: + topic = f"device/{device_id}/command" + payload = { + "msg_id": "008", + "params": { + "start": start, + "end": end + } + } + await self._publish(topic, payload) + return "008" + + async def send_remote_sleep_wake_command(self, device_id: str, switch: str) -> str: + if switch not in ["on", "off"]: + raise ValueError("switch must be 'on' or 'off'") + topic = f"device/{device_id}/command" + payload = { + "msg_id": "009", + "params": { + "switch": switch + } + } + await self._publish(topic, payload) + return "009" diff --git a/talkingq-url/handlers/websocket_message_handler.py b/talkingq-url/handlers/websocket_message_handler.py index de684e4..66f875c 100644 --- a/talkingq-url/handlers/websocket_message_handler.py +++ b/talkingq-url/handlers/websocket_message_handler.py @@ -18,6 +18,8 @@ from handlers.prompt_sound_handler import handle_prompt_sound_request from handlers.session_cleanup_handler import handle_old_session_cleanup from config import settings from banban.service.im import im_service as im_conversation_service +from handlers.audio_file_handler import message_audio_storage_service, save_audio_file +from fastapi import HTTPException async def handle_websocket_messages(websocket: WebSocket, device_id: str, serial_number: str): """ @@ -266,13 +268,13 @@ async def process_cached_audio(device_id: str, target_device_id: str, serial_num # 保存音频文件 audio_file_key = await save_audio_file(cached_audio, device_id) + audio_url = f"http://{settings.server_host}:{settings.server_port}/{audio_file_key}" # 将音频URL保存到数据库 im_conversation和im_message - await im_conversation_service.create_device_message(device_id=device_id, serial_number=serial_number, target_device_id=target_device_id, audio_url=audio_file_key) - try: - audio_url = await message_audio_storage_service.get_audio_url(audio_file_key) - except Exception: - audio_url = audio_file_key - + # await im_conversation_service.create_device_message(device_id=device_id, serial_number=serial_number, target_device_id=target_device_id, audio_url=audio_file_key) + # try: + # audio_url = await message_audio_storage_service.get_audio_url(audio_file_key) + # except Exception: + # audio_url = audio_file_key # 发送URL给目标设备 # target_websocket = await connection_manager.get_connection(target_device_id) # if target_websocket and target_websocket.client_state.name == "CONNECTED": @@ -293,7 +295,7 @@ async def process_cached_audio(device_id: str, target_device_id: str, serial_num await websocket.send_text(f"PROMPT_SOUND_URL:{success_audio_url}") session_logger.info(device_id, "device", f"留言已收到音频URL给设备 {device_id}") else: - session_logger.warning(device_id, "device", f"设备 {device_id} 不在线,发送留言已收到音频URL失败") + session_logger.warning(device_id, "device", f"设备 {device_id} 不在线,暂不发送留言已收到音频URL") # # # 目标设备不在线,保存到离线缓存 # await offline_audio_cache.add_audio_url(target_device_id, audio_url) # session_logger.warning(device_id, "target", f"目标设备 {target_device_id} 不在线,保存音频URL到离线缓存") @@ -305,7 +307,19 @@ async def process_cached_audio(device_id: str, target_device_id: str, serial_num # else: # session_logger.warning(device_id, "device", f"设备 {device_id} 不在线,发送留言成功音频失败") except Exception as e: - session_logger.error(device_id, "target", f"处理缓存音频时出错: {e}", exc_info=True) + # 处理HTTPException异常 + if isinstance(e, HTTPException): + session_logger.error(device_id, "target", f"保存音频文件时出错,返回HTTPException: {e}") + websocket = await connection_manager.get_connection(device_id) + if e.status_code == 404 or e.status_code == 400: + if websocket and websocket.client_state.name == "CONNECTED": + success_audio_url = f"http://{settings.server_host}:{settings.server_port}/assets/audio/save_audio_fail.mp3" + await websocket.send_text(f"PROMPT_SOUND_URL:{success_audio_url}") + session_logger.info(device_id, "device", f"留言已收到音频URL给设备 {device_id}") + else: + session_logger.warning(device_id, "device", f"设备 {device_id} 不在线,暂不发送留言已收到音频URL") + else: + 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/init-letsencrypt.sh b/talkingq-url/init-letsencrypt.sh new file mode 100755 index 0000000..89a24e7 --- /dev/null +++ b/talkingq-url/init-letsencrypt.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +if ! docker compose version >/dev/null 2>&1; then + echo 'Error: docker compose is not installed.' >&2 + exit 1 +fi + +domains=(banban.api.talkingq.com) +rsa_key_size=4096 +data_path="./certbot" +email="admin2345@talkingq.com" # 修改为您的邮箱地址 +staging=0 # 设置为1表示测试模式,0表示生产模式 + +if [ -d "$data_path" ]; then + read -p "已存在证书数据。是否继续? (y/N) " decision + if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then + exit + fi +fi + +echo "### 创建证书目录结构 ###" +sudo mkdir -p "$data_path/conf/live/$domains" +sudo mkdir -p "$data_path/www" + +echo "### 创建临时自签名证书 ###" +path="/etc/letsencrypt/live/$domains" +sudo mkdir -p "$data_path/conf/live/$domains" +sudo docker compose run --rm --entrypoint "\ + openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\ + -keyout '$path/privkey.pem' \ + -out '$path/fullchain.pem' \ + -subj '/CN=localhost'" certbot + +echo "### 启动nginx ###" +sudo docker compose up --force-recreate -d nginx + +echo "### 删除临时证书 ###" +sudo docker compose run --rm --entrypoint "\ + rm -Rf /etc/letsencrypt/live/$domains && \ + rm -Rf /etc/letsencrypt/archive/$domains && \ + rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot + +echo "### 申请Let's Encrypt证书 ###" +domain_args="" +for domain in "${domains[@]}"; do + domain_args="$domain_args -d $domain" +done + +# 选择是否使用staging环境 +case "$staging" in + 0) staging_arg="";; + 1) staging_arg="--staging";; +esac + +sudo docker compose run --rm --entrypoint "\ + certbot certonly --webroot -w /var/www/certbot \ + $staging_arg \ + $domain_args \ + --email $email \ + --rsa-key-size $rsa_key_size \ + --agree-tos \ + --force-renewal" certbot + +echo "### 重启nginx ###" +sudo docker compose exec nginx nginx -s reload diff --git a/talkingq-url/main.py b/talkingq-url/main.py index 488f103..734b6af 100644 --- a/talkingq-url/main.py +++ b/talkingq-url/main.py @@ -87,6 +87,7 @@ async def lifespan(app: FastAPI): session_logger.system_info("shutdown", "后台清理任务已取消") from services.audio_session import audio_session_manager + sessions_to_close = await audio_session_manager.get_all_sessions() for session_key, session in sessions_to_close: diff --git a/talkingq-url/nginx.conf b/talkingq-url/nginx.conf new file mode 100644 index 0000000..3d071fa --- /dev/null +++ b/talkingq-url/nginx.conf @@ -0,0 +1,114 @@ +# 默认服务器配置已移至HTTPS服务器块 + +# 添加JavaScript MIME类型映射 +# types { +# application/javascript js mjs ts; +# text/javascript js mjs ts; +# } + +server { + listen 80; + server_name banban.api.talkingq.com; + # location = /msUqvicTHS.txt { + # root /usr/share/nginx/html; + # } + # location / { + # root /usr/share/nginx/html; + # } + # HTTP 跳转到 HTTPS + location / { + return 301 https://$host$request_uri; + } + + # Let's Encrypt HTTP-01 校验 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } +} + +server { + listen 443 ssl; + server_name banban.api.talkingq.com; + + ssl_certificate /etc/letsencrypt/live/banban.api.talkingq.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/banban.api.talkingq.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + location = /msUqvicTHS.txt { + root /usr/share/nginx/html; + } + # 将API请求转发到Java后端服务 + location / { + proxy_pass http://175.24.73.253:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header Authorization $http_authorization; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + expires -1; + } + # # 将API请求转发到Java后端服务 + # location /api/ { + # proxy_pass http://server:8091; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + # proxy_set_header X-Original-URI $request_uri; + # add_header Cache-Control "no-store, no-cache, must-revalidate"; + # expires -1; + # } + + # # 静态资源处理 + # location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|mp3|mp4|avi|pdf|txt)$ { + # proxy_pass http://node:8084; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + # expires 1d; + # proxy_buffering off; + # add_header Cache-Control "public, max-age=86400"; + # } + + # # Vite特定的虚拟资源处理 + # location ~ ^(/@id|/@vite|/node_modules) { + # proxy_pass http://node:8084; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + # add_header Cache-Control "no-store, no-cache, must-revalidate"; + # expires -1; + # } + + # # WebSocket支持(Vite HMR) + # location / { + # proxy_pass http://node:8084; + # proxy_set_header Host $host; + # proxy_buffering off; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + # proxy_set_header X-Original-URI $request_uri; + # add_header Cache-Control "no-store, no-cache, must-revalidate"; + # expires -1; + + # # WebSocket配置 + # proxy_http_version 1.1; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection "upgrade"; + # proxy_read_timeout 86400; + # } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/talkingq-url/requirements.txt b/talkingq-url/requirements.txt index 7371e7e..fb73a04 100644 --- a/talkingq-url/requirements.txt +++ b/talkingq-url/requirements.txt @@ -20,7 +20,7 @@ setuptools==69.5.1 pycld2 aiomqtt>=2.0.0 apscheduler>=3.10.0 - +PyJWT==2.10.1 httpx==0.28.1 cos-python-sdk-v5==1.9.41 PyJWT==2.10.1 diff --git a/talkingq-url/test/minimax_tts_gui.py b/talkingq-url/test/minimax_tts_gui.py new file mode 100644 index 0000000..88bcfc7 --- /dev/null +++ b/talkingq-url/test/minimax_tts_gui.py @@ -0,0 +1,431 @@ +import os +import asyncio +import threading +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from pathlib import Path +from dotenv import load_dotenv +import aiohttp +import aiofiles +import binascii +import json +import logging +import subprocess +import platform + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger("minimax_tts_gui") + +class MiniMaxTTSGUI: + def __init__(self, root): + self.root = root + self.root.title("MiniMax TTS 工具") + self.root.geometry("750x700") + + self.current_audio_file = None + self.synthesis_in_progress = False + self.text_entries = [] + self.config_collapsed = False + + self.load_env() + self.setup_ui() + + def load_env(self): + env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "minimax.env") + if os.path.exists(env_path): + load_dotenv(env_path) + logger.info(f"已加载环境变量文件: {env_path}") + else: + logger.warning(f"环境变量文件不存在: {env_path}") + + def setup_ui(self): + main_frame = ttk.Frame(self.root, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(0, weight=1) + main_frame.columnconfigure(1, weight=1) + + row = 0 + + config_header_frame = ttk.Frame(main_frame) + config_header_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 5)) + + ttk.Label(config_header_frame, text="配置信息", font=("Arial", 12, "bold")).pack(side=tk.LEFT) + self.toggle_btn = ttk.Button(config_header_frame, text="▼", width=3, command=self.toggle_config) + self.toggle_btn.pack(side=tk.RIGHT) + + row += 1 + + self.config_frame = ttk.Frame(main_frame) + self.config_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10)) + + config_row = 0 + + ttk.Label(self.config_frame, text="API Key:").grid(row=config_row, column=0, sticky=tk.W, pady=5) + self.api_key_var = tk.StringVar(value=os.getenv("MINIMAX_API_KEY", "")) + ttk.Entry(self.config_frame, textvariable=self.api_key_var, width=60).grid(row=config_row, column=1, sticky=(tk.W, tk.E), pady=5) + config_row += 1 + + ttk.Label(self.config_frame, text="Group ID:").grid(row=config_row, column=0, sticky=tk.W, pady=5) + self.group_id_var = tk.StringVar(value=os.getenv("MINIMAX_GROUP_ID", "1915292410024300630")) + ttk.Entry(self.config_frame, textvariable=self.group_id_var, width=60).grid(row=config_row, column=1, sticky=(tk.W, tk.E), pady=5) + config_row += 1 + + ttk.Label(self.config_frame, text="Base URL:").grid(row=config_row, column=0, sticky=tk.W, pady=5) + self.base_url_var = tk.StringVar(value=os.getenv("MINIMAX_BASE_URL", "https://api.minimax.chat/v1/t2a_v2")) + ttk.Entry(self.config_frame, textvariable=self.base_url_var, width=60).grid(row=config_row, column=1, sticky=(tk.W, tk.E), pady=5) + config_row += 1 + + ttk.Label(self.config_frame, text="音色 ID:").grid(row=config_row, column=0, sticky=tk.W, pady=5) + self.voice_id_var = tk.StringVar(value=os.getenv("MINIMAX_VOICE_ID", "cartoon-boy-01")) + ttk.Entry(self.config_frame, textvariable=self.voice_id_var, width=60).grid(row=config_row, column=1, sticky=(tk.W, tk.E), pady=5) + config_row += 1 + + self.config_frame.columnconfigure(1, weight=1) + + row += 1 + + ttk.Separator(main_frame, orient='horizontal').grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10) + row += 1 + + text_list_header = ttk.Frame(main_frame) + text_list_header.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 5)) + + ttk.Label(text_list_header, text="文本列表", font=("Arial", 12, "bold")).pack(side=tk.LEFT) + add_btn = ttk.Button(text_list_header, text="➕", width=3, command=self.add_text_entry) + add_btn.pack(side=tk.RIGHT) + row += 1 + + self.text_list_container = ttk.Frame(main_frame) + self.text_list_container.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5) + self.text_list_container.columnconfigure(1, weight=1) + + self.add_text_entry() + + row += 1 + + ttk.Separator(main_frame, orient='horizontal').grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10) + row += 1 + + ttk.Label(main_frame, text="合成参数", font=("Arial", 12, "bold")).grid(row=row, column=0, columnspan=2, sticky=tk.W, pady=(0, 10)) + row += 1 + + ttk.Label(main_frame, text="输出目录:").grid(row=row, column=0, sticky=tk.W, pady=5) + self.output_dir_var = tk.StringVar(value="assets") + ttk.Entry(main_frame, textvariable=self.output_dir_var, width=50).grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5) + row += 1 + + ttk.Label(main_frame, text="语速:").grid(row=row, column=0, sticky=tk.W, pady=5) + self.speed_var = tk.DoubleVar(value=1.0) + ttk.Scale(main_frame, from_=0.5, to=2.0, variable=self.speed_var, orient=tk.HORIZONTAL, length=300).grid(row=row, column=1, sticky=tk.W, pady=5) + ttk.Label(main_frame, textvariable=self.speed_var).grid(row=row, column=1, sticky=tk.E, pady=5) + row += 1 + + ttk.Label(main_frame, text="音量:").grid(row=row, column=0, sticky=tk.W, pady=5) + self.vol_var = tk.DoubleVar(value=1.0) + ttk.Scale(main_frame, from_=0.5, to=2.0, variable=self.vol_var, orient=tk.HORIZONTAL, length=300).grid(row=row, column=1, sticky=tk.W, pady=5) + ttk.Label(main_frame, textvariable=self.vol_var).grid(row=row, column=1, sticky=tk.E, pady=5) + row += 1 + + ttk.Label(main_frame, text="音调:").grid(row=row, column=0, sticky=tk.W, pady=5) + self.pitch_var = tk.IntVar(value=0) + ttk.Scale(main_frame, from_=-10, to=10, variable=self.pitch_var, orient=tk.HORIZONTAL, length=300).grid(row=row, column=1, sticky=tk.W, pady=5) + ttk.Label(main_frame, textvariable=self.pitch_var).grid(row=row, column=1, sticky=tk.E, pady=5) + row += 1 + + button_frame = ttk.Frame(main_frame) + button_frame.grid(row=row, column=0, columnspan=2, pady=15) + + self.synthesize_btn = ttk.Button(button_frame, text="开始合成", command=self.start_synthesis) + self.synthesize_btn.pack(side=tk.LEFT, padx=5) + + self.play_btn = ttk.Button(button_frame, text="播放最后一个", command=self.play_audio, state=tk.DISABLED) + self.play_btn.pack(side=tk.LEFT, padx=5) + + self.open_folder_btn = ttk.Button(button_frame, text="打开输出目录", command=self.open_output_folder) + self.open_folder_btn.pack(side=tk.LEFT, padx=5) + row += 1 + + ttk.Separator(main_frame, orient='horizontal').grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10) + row += 1 + + ttk.Label(main_frame, text="日志", font=("Arial", 12, "bold")).grid(row=row, column=0, columnspan=2, sticky=tk.W, pady=(0, 10)) + row += 1 + + self.log_text = tk.Text(main_frame, height=8, width=70, state=tk.DISABLED) + self.log_text.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5) + + log_scrollbar = ttk.Scrollbar(main_frame, orient=tk.VERTICAL, command=self.log_text.yview) + log_scrollbar.grid(row=row, column=2, sticky=(tk.N, tk.S)) + self.log_text['yscrollcommand'] = log_scrollbar.set + + main_frame.rowconfigure(row, weight=1) + + def toggle_config(self): + self.config_collapsed = not self.config_collapsed + if self.config_collapsed: + self.config_frame.grid_remove() + self.toggle_btn.config(text="▶") + else: + self.config_frame.grid() + self.toggle_btn.config(text="▼") + + def add_text_entry(self): + entry_frame = ttk.Frame(self.text_list_container) + entry_row = len(self.text_entries) + + ttk.Label(entry_frame, text=f"#{entry_row + 1}").grid(row=0, column=0, sticky=tk.W, padx=2) + + ttk.Label(entry_frame, text="文件名:").grid(row=0, column=1, sticky=tk.W, padx=2) + filename_var = tk.StringVar(value=f"text_{entry_row + 1}") + filename_entry = ttk.Entry(entry_frame, textvariable=filename_var, width=15) + filename_entry.grid(row=0, column=2, sticky=tk.W, padx=2) + + ttk.Label(entry_frame, text="文本:").grid(row=0, column=3, sticky=tk.W, padx=2) + text_var = tk.StringVar(value="") + text_entry = ttk.Entry(entry_frame, textvariable=text_var, width=35) + text_entry.grid(row=0, column=4, sticky=(tk.W, tk.E), padx=2) + + remove_btn = ttk.Button(entry_frame, text="✕", width=3, command=lambda: self.remove_text_entry(entry_frame)) + remove_btn.grid(row=0, column=5, sticky=tk.W, padx=2) + + entry_frame.columnconfigure(4, weight=1) + entry_frame.grid(row=entry_row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=3) + + self.text_entries.append({ + 'frame': entry_frame, + 'filename_var': filename_var, + 'text_var': text_var + }) + + def remove_text_entry(self, frame): + if len(self.text_entries) <= 1: + messagebox.showwarning("提示", "至少保留一个文本条目") + return + + for i, entry in enumerate(self.text_entries): + if entry['frame'] == frame: + frame.destroy() + self.text_entries.pop(i) + self.reorder_entries() + break + + def reorder_entries(self): + for i, entry in enumerate(self.text_entries): + entry['frame'].grid(row=i, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=3) + for widget in entry['frame'].winfo_children(): + if isinstance(widget, ttk.Label) and widget.cget("text").startswith("#"): + widget.config(text=f"#{i + 1}") + break + + def log_message(self, message): + self.log_text.config(state=tk.NORMAL) + self.log_text.insert(tk.END, message + "\n") + self.log_text.see(tk.END) + self.log_text.config(state=tk.DISABLED) + logger.info(message) + + def start_synthesis(self): + if self.synthesis_in_progress: + messagebox.showwarning("提示", "合成正在进行中,请稍候...") + return + + text_list = [] + for entry in self.text_entries: + text = entry['text_var'].get().strip() + filename = entry['filename_var'].get().strip() + if text: + text_list.append({ + 'text': text, + 'filename': filename or "output" + }) + + if not text_list: + messagebox.showwarning("提示", "请输入要合成的文本") + return + + api_key = self.api_key_var.get().strip() + group_id = self.group_id_var.get().strip() + if not api_key or not group_id: + messagebox.showwarning("提示", "请填写 API Key 和 Group ID") + return + + self.synthesis_in_progress = True + self.synthesize_btn.config(state=tk.DISABLED) + self.play_btn.config(state=tk.DISABLED) + + thread = threading.Thread(target=self.run_batch_synthesis, args=(text_list,)) + thread.daemon = True + thread.start() + + def run_batch_synthesis(self, text_list): + try: + output_dir = Path(self.output_dir_var.get()) + output_dir.mkdir(parents=True, exist_ok=True) + + self.log_message(f"开始批量合成 {len(text_list)} 个文本...") + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + success_count = 0 + last_file = None + + for i, item in enumerate(text_list): + output_file = output_dir / f"{item['filename']}.mp3" + self.log_message(f"[{i+1}/{len(text_list)}] 正在合成: {item['text'][:30]}...") + + result = loop.run_until_complete( + self.synthesize_speech( + text=item['text'], + output_file=str(output_file), + api_key=self.api_key_var.get(), + group_id=self.group_id_var.get(), + base_url=self.base_url_var.get(), + voice_id=self.voice_id_var.get(), + speed=self.speed_var.get(), + vol=self.vol_var.get(), + pitch=self.pitch_var.get() + ) + ) + + if result: + success_count += 1 + last_file = result + self.log_message(f" ✓ 成功: {result}") + else: + self.log_message(f" ✗ 失败") + + loop.close() + + if success_count > 0: + self.current_audio_file = last_file + self.log_message(f"\n批量合成完成!成功 {success_count}/{len(text_list)} 个") + self.root.after(0, lambda: self.play_btn.config(state=tk.NORMAL)) + self.root.after(0, lambda: messagebox.showinfo( + "合成完成", + f"批量合成完成!\n成功 {success_count}/{len(text_list)} 个\n输出目录: {output_dir.resolve()}" + )) + else: + self.log_message("所有合成都失败了") + self.root.after(0, lambda: messagebox.showerror("错误", "所有合成都失败了,请查看日志")) + + except Exception as e: + self.log_message(f"批量合成出错: {str(e)}") + self.root.after(0, lambda: messagebox.showerror("错误", f"批量合成出错: {str(e)}")) + finally: + self.synthesis_in_progress = False + self.root.after(0, lambda: self.synthesize_btn.config(state=tk.NORMAL)) + + async def synthesize_speech(self, text, output_file, api_key, group_id, base_url, voice_id, speed, vol, pitch): + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + payload = { + "model": "speech-02-turbo", + "text": text, + "stream": True, + "voice_setting": { + "voice_id": voice_id, + "speed": speed, + "vol": vol, + "pitch": pitch + }, + "audio_setting": { + "sample_rate": 16000, + "bitrate": 32000, + "format": "mp3", + "channel": 1 + } + } + + url = f"{base_url}?GroupId={group_id}" + + try: + audio_buffer = bytearray() + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=payload, headers=headers) as response: + if response.status != 200: + error_text = await response.text() + self.log_message(f" 请求失败: {response.status}, {error_text}") + return None + + async for line in response.content: + line = line.strip() + if line.startswith(b'data:'): + try: + json_str = line[5:].decode('utf-8').strip() + if not json_str: + continue + + data_json = json.loads(json_str) + + if "data" in data_json and "audio" in data_json["data"]: + status = data_json["data"].get("status", 1) + audio_hex = data_json["data"]["audio"] + + if status == 1: + if audio_hex: + audio_binary = binascii.unhexlify(audio_hex) + audio_buffer.extend(audio_binary) + elif status == 2: + break + except json.JSONDecodeError as e: + self.log_message(f" JSON解析失败: {e}") + except Exception as e: + self.log_message(f" 处理响应出错: {str(e)}") + + if len(audio_buffer) == 0: + self.log_message(" 音频缓冲区为空") + return None + + async with aiofiles.open(output_file, 'wb') as f: + await f.write(audio_buffer) + + return output_file + + except Exception as e: + self.log_message(f" 合成失败: {str(e)}") + return None + + def play_audio(self): + if not self.current_audio_file or not os.path.exists(self.current_audio_file): + messagebox.showwarning("提示", "请先合成音频") + return + + try: + if platform.system() == "Darwin": + subprocess.run(["open", self.current_audio_file]) + elif platform.system() == "Windows": + os.startfile(self.current_audio_file) + else: + subprocess.run(["xdg-open", self.current_audio_file]) + self.log_message(f"正在播放: {self.current_audio_file}") + except Exception as e: + messagebox.showerror("错误", f"播放失败: {str(e)}") + + def open_output_folder(self): + output_dir = self.output_dir_var.get() + if not os.path.exists(output_dir): + os.makedirs(output_dir, exist_ok=True) + + try: + if platform.system() == "Darwin": + subprocess.run(["open", output_dir]) + elif platform.system() == "Windows": + os.startfile(output_dir) + else: + subprocess.run(["xdg-open", output_dir]) + except Exception as e: + messagebox.showerror("错误", f"打开目录失败: {str(e)}") + +if __name__ == "__main__": + root = tk.Tk() + app = MiniMaxTTSGUI(root) + root.mainloop() diff --git a/talkingq-url/test/minimax_tts_more.py b/talkingq-url/test/minimax_tts_more.py new file mode 100644 index 0000000..f4511c3 --- /dev/null +++ b/talkingq-url/test/minimax_tts_more.py @@ -0,0 +1,260 @@ +import os +import asyncio +import aiohttp +import aiofiles +import binascii +import json +import logging +from pathlib import Path +from dotenv import load_dotenv + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger("minimax_tts_test") + +# 加载环境变量 +env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "minimax.env") +if os.path.exists(env_path): + load_dotenv(env_path) + logger.info(f"已加载环境变量文件: {env_path}") +else: + logger.warning(f"环境变量文件不存在: {env_path}") + +async def test_minimax_tts(): + # 获取配置 + api_key = os.getenv("MINIMAX_API_KEY", "") + group_id = os.getenv("MINIMAX_GROUP_ID", "") + base_url = os.getenv("MINIMAX_BASE_URL", "https://api.minimax.chat/v1/t2a_v2") + voice_id = os.getenv("MINIMAX_VOICE_ID", "cartoon-boy-01") + + if not api_key or not group_id: + logger.error("缺少必要的配置: MINIMAX_API_KEY 或 MINIMAX_GROUP_ID") + return + + # 定义中英文短语对应表 + phrases_dict = { + # "upgrading": { + # "zh": "升级中,请勿断电", + # "en": "Upgrading, please do not power off" + # }, + # "upgrade_success": { + # "zh": "升级成功,正在重启设备", + # "en": "Upgrade successful, restarting device" + # }, + # "upgrade_failed": { + # "zh": "升级失败,正在重启设备", + # "en": "Upgrade failed, restarting device" + # }, + # "low_energy": { + # "zh": "感觉没能量了,罢工", + # "en": "Feeling out of energy, going on strike" + # }, + # "network_connected": { + # "zh": "连上网络开始上班了", + # "en": "Connected to network, starting work" + # }, + # "network_lost": { + # "zh": "没网了,下班了", + # "en": "No network, time to get off work" + # }, + # "volume_down": { + # "zh": "音量已调小一点", + # "en": "Volume turned down a bit" + # }, + # "max_volume": { + # "zh": "音量已最大", + # "en": "Volume is at maximum" + # }, + # "volume_up": { + # "zh": "音量已调大一点", + # "en": "Volume turned up a bit" + # }, + # "wakeup": { + # "zh": "我起床了", + # "en": "I'm awake now" + # }, + # "enter_network_config": { + # "zh": "进入配网", + # "en": "Entering network configuration" + # }, + # "exit_network_config": { + # "zh": "退出配网", + # "en": "Exiting network configuration" + # } + # "error_card":{ + # "zh": "你好,卡片有误,请使用本人卡片收听留言" + # }, + # "no_message":{ + # "zh": "你好,已没可读留言" + # }, + # "new_message":{ + # "zh": "您有新的留言请注意查收" + # } + # "test":{ + # "zh": "橙子我们今天去动物园玩吧?" + # } + # "welcome":{ + # "zh": "你好,小朋友,想要和你的好朋友说些什么呢?" + # } + # "tts_error":{ + # "zh": "没听到你的声音喔,本轮对话结束" + # }, + # "message_ok":{ + # "zh": "留言已收到" + # } + # "audio_save_fail":{ + # "zh": "留言已收到,但是无法发送。" + # } + # "network_ok":{ + # "zh": "已连上网络,可以按键或刷卡跟我对话喔。" + # }, + # "network_connect":{ + # "zh": "系统启动中,正在等待网络连接。" + # }, + # "have_rest":{ + # "zh": "小憩一下,待会儿见。" + # } + "bind_nfc_ready":{ + "zh": "现在开始刷卡绑定设备吧。" + }, + "bind_nfc_finish":{ + "zh": "卡片绑定成功。" + } + } + + # 创建输出目录 + assets_dir = os.getenv("ASSETS_DIR", "assets") + output_dir = Path(assets_dir) / (voice_id + "_tts") + output_dir.mkdir(parents=True, exist_ok=True) + + logger.info(f"开始测试MiniMax TTS服务,音色: {voice_id}") + + # 遍历所有短语进行合成 + for phrase_key, phrases in phrases_dict.items(): + for lang, text in phrases.items(): + await synthesize_speech( + text=text, + language=lang, + file_prefix=f"{phrase_key}_{lang}", + output_dir=output_dir, + api_key=api_key, + group_id=group_id, + base_url=base_url, + voice_id=voice_id + ) + +async def synthesize_speech(text, language, file_prefix, output_dir, api_key, group_id, base_url, voice_id): + """合成单个语音文件""" + output_file = output_dir / f"{file_prefix}.mp3" + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + # 语言映射 + lang_mapping = { + "zh": "Chinese", + # "en": "English", + # "fr": "French", + # "de": "German", + # "es": "Spanish", + # "yue": "Chinese,Yue" + } + + payload = { + "model": "speech-02-turbo", + "text": text, + "stream": True, + "voice_setting": { + "voice_id": voice_id, + "speed": 1.0, + "vol": 1.0, + "pitch": 0 + }, + "audio_setting": { + "sample_rate": 16000, + "bitrate": 32000, + "format": "mp3", + "channel": 1 + }, + "language_boost": lang_mapping.get(language, "auto") + } + + url = f"{base_url}?GroupId={group_id}" + + try: + logger.info(f"开始合成语音: [{language}] {text}") + + audio_buffer = bytearray() + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=payload, headers=headers) as response: + if response.status != 200: + error_text = await response.text() + logger.error(f"MiniMax TTS请求失败: {response.status}, {error_text}") + return None + + logger.info("开始接收MiniMax TTS流式响应") + + line_count = 0 + async for line in response.content: + line_count += 1 + logger.debug(f"收到第{line_count}行原始数据: {line}") + + line = line.strip() # 去除换行符 + if line.startswith(b'data:'): + try: + # 解析JSON数据 + json_str = line[5:].decode('utf-8').strip() + if not json_str: + logger.debug("空的JSON字符串,跳过") + continue + + data_json = json.loads(json_str) + + if "data" in data_json and "audio" in data_json["data"]: + status = data_json["data"].get("status", 1) + audio_hex = data_json["data"]["audio"] + + if status == 1: # 只处理status=1(合成中)的音频数据,忽略status=2(合成结束)的汇总数据 + if audio_hex: + try: + audio_binary = binascii.unhexlify(audio_hex) + audio_buffer.extend(audio_binary) + except binascii.Error as e: + logger.error(f"音频数据解码失败: {e}") + elif status == 2: # 合成结束 + logger.info("MiniMax TTS流式合成完成") + break + else: + logger.warning(f"响应中没有音频数据: {data_json}") + except json.JSONDecodeError as e: + logger.error(f"JSON解析失败: {e}, 原始数据: {line}") + except Exception as e: + logger.error(f"处理MiniMax TTS流式响应出错: {str(e)}") + else: + logger.debug(f"非data行,跳过: {line}") + + + # 检查音频缓冲区大小 + if len(audio_buffer) == 0: + logger.error("音频缓冲区为空,合成可能失败") + return None + + logger.info(f"音频合成完成,总大小: {len(audio_buffer)} 字节") + + # 保存音频文件 + async with aiofiles.open(output_file, 'wb') as f: + await f.write(audio_buffer) + + logger.info(f"TTS合成成功! 保存音频到: {output_file}") + return str(output_file) + + except Exception as e: + logger.error(f"MiniMax TTS合成失败: {str(e)}") + return None + +if __name__ == "__main__": + asyncio.run(test_minimax_tts()) \ No newline at end of file diff --git a/talkingq-url/utils/ audio_format.py b/talkingq-url/utils/ audio_format.py new file mode 100644 index 0000000..8468df8 --- /dev/null +++ b/talkingq-url/utils/ audio_format.py @@ -0,0 +1,33 @@ +import io +import wave + + +DEFAULT_SAMPLE_RATE = 16000 +DEFAULT_CHANNELS = 1 +DEFAULT_SAMPLE_WIDTH = 2 + + +def detect_audio_format(audio_data: bytes) -> 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"): + return "mp3" + if len(audio_data) >= 2 and audio_data[0] == 0xFF and (audio_data[1] & 0xE0) == 0xE0: + return "mp3" + return "pcm_s16le_16k_mono" + + +def wrap_pcm_as_wav( + audio_data: bytes, + *, + sample_rate: int = DEFAULT_SAMPLE_RATE, + channels: int = DEFAULT_CHANNELS, + sample_width: int = DEFAULT_SAMPLE_WIDTH, +) -> bytes: + wav_buffer = io.BytesIO() + with wave.open(wav_buffer, "wb") as wav_file: + wav_file.setnchannels(channels) + wav_file.setsampwidth(sample_width) + wav_file.setframerate(sample_rate) + wav_file.writeframes(audio_data) + return wav_buffer.getvalue()