Device streams continuous 20ms Opus frames with no turn markers. The loop now decodes Opus, feeds the VAD, fires one turn on end-of-speech, and replies with the firmware JSON contract (tts.state / stt.text) + Opus audio. - app/audio/opus.py: RealOpus/Opus16kEncoder/PassthroughOpus; fix add_dll_directory to use absolute paths (WinError 87) - app/xiaozhi/websocket.py: DeviceLoop.run() = decode -> VAD -> run_turn -> reply - app/config.py: AudioConfig (opus kind + VAD thresholds) - config.yaml: audio.opus=real, VAD 5/8/3000 frames - tests/test_app.py: utterance = loud burst + silence; asserts stt + audio + tts stop - requirements.txt: opuslib>=3 21/21 tests; live handshake verified via zhi.moreminimore.com on venv python.
113 lines
3.7 KiB
Python
113 lines
3.7 KiB
Python
"""AC-002: app imports and the /ws/xiaozhi endpoint is live (handshake + turn)."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.config import Config
|
|
from app.main import make_app
|
|
from app.security import DeviceAuth
|
|
|
|
TOKEN = "test-device-token"
|
|
|
|
|
|
def _cfg() -> Config:
|
|
return Config.from_dict(
|
|
{
|
|
"security": {"enabled": True, "devices": [
|
|
{"device_id": "d1", "token_hash": DeviceAuth.hash_token(TOKEN)}
|
|
]},
|
|
}
|
|
)
|
|
|
|
|
|
def test_health():
|
|
client = TestClient(make_app(_cfg()))
|
|
r = client.get("/health")
|
|
assert r.status_code == 200
|
|
assert r.json()["status"] == "ok"
|
|
|
|
|
|
def test_ota_endpoint_delivers_server():
|
|
"""FIRMWARE: device reads websocket.url/token from /ota POST."""
|
|
cfg = _cfg()
|
|
from app.config import OtaConfig
|
|
|
|
cfg.ota = OtaConfig(
|
|
server_ws_url="wss://zhi.moreminimore.com/ws/xiaozhi",
|
|
server_token=TOKEN,
|
|
)
|
|
client = TestClient(make_app(cfg))
|
|
r = client.post("/ota", json={"device_id": "d1"})
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["websocket"]["url"] == "wss://zhi.moreminimore.com/ws/xiaozhi"
|
|
assert body["websocket"]["token"] == TOKEN
|
|
assert "server_time" in body
|
|
|
|
|
|
def test_ws_auth_via_headers():
|
|
"""FIRMWARE: token in Authorization header, Device-Id = MAC."""
|
|
client = TestClient(make_app(_cfg()))
|
|
with client.websocket_connect(
|
|
"/ws/xiaozhi",
|
|
headers={
|
|
"Device-Id": "d1",
|
|
"Authorization": f"Bearer {TOKEN}",
|
|
},
|
|
) as ws:
|
|
# hello frame WITHOUT auth fields — auth came in headers
|
|
ws.send_text(json.dumps({"device_id": "d1"}))
|
|
reply = json.loads(ws.receive_text())
|
|
assert reply["type"] == "hello"
|
|
assert reply["transport"] == "websocket"
|
|
assert reply["session_id"]
|
|
|
|
|
|
def test_ws_header_auth_rejects_bad_token():
|
|
client = TestClient(make_app(_cfg()))
|
|
with client.websocket_connect(
|
|
"/ws/xiaozhi",
|
|
headers={"Device-Id": "d1", "Authorization": "Bearer wrong"},
|
|
) as ws:
|
|
ws.send_text(json.dumps({"device_id": "d1"}))
|
|
reply = json.loads(ws.receive_text())
|
|
assert reply["type"] == "error"
|
|
|
|
|
|
def test_ws_handshake_and_turn():
|
|
"""One spoken utterance (burst of speech frames + trailing silence)
|
|
-> firmware-compatible reply: stt text, tts start/sentence/stop, audio."""
|
|
client = TestClient(make_app(_cfg()))
|
|
with client.websocket_connect("/ws/xiaozhi") as ws:
|
|
# hello frame
|
|
ws.send_text(json.dumps({"device_id": "d1", "authorization": TOKEN}))
|
|
reply = json.loads(ws.receive_text())
|
|
assert reply["type"] == "hello"
|
|
assert reply["session_id"]
|
|
# one utterance: 6 loud frames (16-bit, RMS >= 300), then silence
|
|
# frames that push the VAD past min_silence_frames.
|
|
loud = b"\x10\x0f" * 320 # 20 ms @ 16 kHz mono, amplitude 3856
|
|
silent = b"\x00\x00" * 320
|
|
for _ in range(6):
|
|
ws.send_bytes(loud)
|
|
for _ in range(12):
|
|
ws.send_bytes(silent)
|
|
got_stt = got_audio = False
|
|
tts_stop = False
|
|
deadline = 0
|
|
while not tts_stop and deadline < 256:
|
|
frame = ws.receive()
|
|
deadline += 1
|
|
if frame.get("text") is not None:
|
|
payload = json.loads(frame["text"])
|
|
if payload.get("type") == "stt":
|
|
got_stt = True
|
|
if payload.get("type") == "tts" and payload.get("state") == "stop":
|
|
tts_stop = True
|
|
else:
|
|
got_audio = True
|
|
assert got_stt and got_audio and tts_stop
|