infinicode 2.8.73 → 2.8.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/robopark.js +2 -2
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +227 -55
- package/dist/robopark/robot-runtime.js +3 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +103 -20
- package/packages/robopark/scheduler/preview_agent.py +80 -24
- package/packages/robopark/scheduler/robot_supervisor.py +41 -16
|
@@ -3,8 +3,9 @@ RoboPark Session Scheduler - Backend API
|
|
|
3
3
|
Manages robot fleet, LiveKit servers, and session orchestration
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
import os
|
|
7
|
-
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import asyncio
|
|
8
|
+
import base64
|
|
8
9
|
import contextvars
|
|
9
10
|
import json
|
|
10
11
|
import logging
|
|
@@ -210,6 +211,7 @@ class DeviceBootstrapRequest(BaseModel):
|
|
|
210
211
|
name: str
|
|
211
212
|
lan_ip: Optional[str] = None
|
|
212
213
|
tailscale_ip: Optional[str] = None
|
|
214
|
+
livekit_url: Optional[str] = None
|
|
213
215
|
|
|
214
216
|
class DeviceEnrollResponse(BaseModel):
|
|
215
217
|
device_id: str
|
|
@@ -220,8 +222,9 @@ class DeviceHeartbeat(BaseModel):
|
|
|
220
222
|
status: Optional[str] = None
|
|
221
223
|
ip: Optional[str] = None
|
|
222
224
|
uptime_seconds: Optional[int] = None
|
|
223
|
-
production_mode: Optional[bool] = None
|
|
225
|
+
production_mode: Optional[bool] = None
|
|
224
226
|
device_inventory: Optional[dict] = None
|
|
227
|
+
livekit_url: Optional[str] = None
|
|
225
228
|
|
|
226
229
|
class PipelineEventPayload(BaseModel):
|
|
227
230
|
stage: str
|
|
@@ -2811,16 +2814,17 @@ async def bootstrap_device(
|
|
|
2811
2814
|
await db.execute(
|
|
2812
2815
|
"""UPDATE devices SET token_hash = ?, status = 'enrolled',
|
|
2813
2816
|
lan_ip = COALESCE(?, lan_ip), tailscale_ip = COALESCE(?, tailscale_ip),
|
|
2817
|
+
livekit_url = COALESCE(?, livekit_url),
|
|
2814
2818
|
enrolled_at = COALESCE(enrolled_at, ?) WHERE id = ?""",
|
|
2815
|
-
(new_hash, payload.lan_ip, payload.tailscale_ip, now, device_id),
|
|
2819
|
+
(new_hash, payload.lan_ip, payload.tailscale_ip, payload.livekit_url, now, device_id),
|
|
2816
2820
|
)
|
|
2817
2821
|
else:
|
|
2818
2822
|
device_id = f"dev_{secrets.token_hex(4)}"
|
|
2819
2823
|
await db.execute(
|
|
2820
2824
|
"""INSERT INTO devices
|
|
2821
|
-
(id, name, lan_ip, tailscale_ip, token_hash, status, enrolled_at, created_at)
|
|
2822
|
-
VALUES (?, ?, ?, ?, ?, 'enrolled', ?, ?)""",
|
|
2823
|
-
(device_id, name, payload.lan_ip, payload.tailscale_ip, new_hash, now, now),
|
|
2825
|
+
(id, name, lan_ip, tailscale_ip, livekit_url, token_hash, status, enrolled_at, created_at)
|
|
2826
|
+
VALUES (?, ?, ?, ?, ?, ?, 'enrolled', ?, ?)""",
|
|
2827
|
+
(device_id, name, payload.lan_ip, payload.tailscale_ip, payload.livekit_url, new_hash, now, now),
|
|
2824
2828
|
)
|
|
2825
2829
|
await db.execute(
|
|
2826
2830
|
"INSERT OR IGNORE INTO robots (id, name, status) VALUES (?, ?, 'idle')",
|
|
@@ -2988,16 +2992,18 @@ async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
|
|
|
2988
2992
|
"""UPDATE devices
|
|
2989
2993
|
SET status = ?, last_heartbeat = ?,
|
|
2990
2994
|
lan_ip = COALESCE(?, lan_ip),
|
|
2991
|
-
last_seen_ip = COALESCE(?, last_seen_ip),
|
|
2992
|
-
device_inventory = COALESCE(?, device_inventory)
|
|
2995
|
+
last_seen_ip = COALESCE(?, last_seen_ip),
|
|
2996
|
+
device_inventory = COALESCE(?, device_inventory),
|
|
2997
|
+
livekit_url = COALESCE(?, livekit_url)
|
|
2993
2998
|
WHERE id = ?""",
|
|
2994
2999
|
(
|
|
2995
3000
|
payload.status or "online",
|
|
2996
3001
|
datetime.utcnow().isoformat(),
|
|
2997
3002
|
payload.ip,
|
|
2998
|
-
payload.ip,
|
|
2999
|
-
json.dumps(payload.device_inventory) if payload.device_inventory is not None else None,
|
|
3000
|
-
|
|
3003
|
+
payload.ip,
|
|
3004
|
+
json.dumps(payload.device_inventory) if payload.device_inventory is not None else None,
|
|
3005
|
+
payload.livekit_url,
|
|
3006
|
+
device_id,
|
|
3001
3007
|
),
|
|
3002
3008
|
) as cur:
|
|
3003
3009
|
await db.commit()
|
|
@@ -3435,24 +3441,101 @@ async def shell_allowlist():
|
|
|
3435
3441
|
# other shell commands (the supervisor treats speaker_test as
|
|
3436
3442
|
# just another request kind).
|
|
3437
3443
|
|
|
3438
|
-
class SpeakerTestRequest(BaseModel):
|
|
3444
|
+
class SpeakerTestRequest(BaseModel):
|
|
3445
|
+
mode: str = "tone"
|
|
3446
|
+
text: Optional[str] = None
|
|
3439
3447
|
frequency: Optional[float] = None
|
|
3440
3448
|
duration: Optional[float] = None
|
|
3441
3449
|
amplitude: Optional[float] = None
|
|
3442
3450
|
threshold_db: Optional[float] = None
|
|
3443
3451
|
output: Optional[str] = None
|
|
3444
3452
|
input: Optional[str] = None
|
|
3445
|
-
sample_rate: Optional[int] = None
|
|
3453
|
+
sample_rate: Optional[int] = None
|
|
3454
|
+
|
|
3455
|
+
|
|
3456
|
+
async def _cached_speaker_tts(robot_id: str, text: str) -> dict:
|
|
3457
|
+
"""Generate configured ElevenLabs PCM once, then reuse it for device tests."""
|
|
3458
|
+
phrase = " ".join((text or "Hello, this is a RoboPark speaker test.").split())[:240]
|
|
3459
|
+
config = await _resolve_robot_voice_config(robot_id)
|
|
3460
|
+
stack = config.voice_stack if config else None
|
|
3461
|
+
provider = (stack.tts_provider if stack else "elevenlabs").lower()
|
|
3462
|
+
voice_id = stack.tts_voice if stack else "21m00Tcm4TlvDq8ikWAM"
|
|
3463
|
+
if provider != "elevenlabs":
|
|
3464
|
+
raise HTTPException(400, f"Cached voice test currently requires ElevenLabs; selected stack uses {provider}")
|
|
3465
|
+
if not ELEVENLABS_API_KEY:
|
|
3466
|
+
raise HTTPException(503, "ELEVENLABS_API_KEY is not configured on the scheduler")
|
|
3467
|
+
if not voice_id:
|
|
3468
|
+
raise HTTPException(400, "Selected voice stack has no ElevenLabs voice id")
|
|
3469
|
+
cache_dir = os.path.join(os.path.dirname(DB_PATH), "tts-test-cache")
|
|
3470
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
3471
|
+
cache_key = hashlib.sha256(f"{provider}|{voice_id}|{phrase}".encode("utf8")).hexdigest()
|
|
3472
|
+
cache_path = os.path.join(cache_dir, cache_key + ".pcm")
|
|
3473
|
+
cache_hit = os.path.isfile(cache_path)
|
|
3474
|
+
if cache_hit:
|
|
3475
|
+
with open(cache_path, "rb") as handle:
|
|
3476
|
+
pcm = handle.read()
|
|
3477
|
+
else:
|
|
3478
|
+
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
3479
|
+
response = await client.post(
|
|
3480
|
+
f"{ELEVENLABS_BASE_URL}/v1/text-to-speech/{voice_id}",
|
|
3481
|
+
params={"output_format": "pcm_24000"},
|
|
3482
|
+
headers={"xi-api-key": ELEVENLABS_API_KEY, "Accept": "audio/pcm"},
|
|
3483
|
+
json={"text": phrase, "model_id": "eleven_flash_v2_5"},
|
|
3484
|
+
)
|
|
3485
|
+
if response.status_code != 200:
|
|
3486
|
+
detail = response.text[:240] or "no response body"
|
|
3487
|
+
raise HTTPException(502, f"ElevenLabs speaker test failed ({response.status_code}): {detail}")
|
|
3488
|
+
pcm = response.content
|
|
3489
|
+
if len(pcm) < 480:
|
|
3490
|
+
raise HTTPException(502, "ElevenLabs returned empty speaker-test audio")
|
|
3491
|
+
temp_path = cache_path + ".tmp"
|
|
3492
|
+
with open(temp_path, "wb") as handle:
|
|
3493
|
+
handle.write(pcm)
|
|
3494
|
+
os.replace(temp_path, cache_path)
|
|
3495
|
+
return {
|
|
3496
|
+
"mode": "tts", "text": phrase, "tts_provider": provider, "tts_voice": voice_id,
|
|
3497
|
+
"sample_rate": 24000, "audio_pcm_base64": base64.b64encode(pcm).decode("ascii"),
|
|
3498
|
+
"cache_hit": cache_hit,
|
|
3499
|
+
}
|
|
3446
3500
|
|
|
3447
3501
|
|
|
3448
3502
|
@app.post("/api/robots/{robot_id}/shell/speaker-test")
|
|
3449
|
-
async def robot_speaker_test(robot_id: str, payload: SpeakerTestRequest):
|
|
3503
|
+
async def robot_speaker_test(robot_id: str, payload: SpeakerTestRequest):
|
|
3450
3504
|
"""Queue a speaker + mic round-trip test on the robot. Returns a
|
|
3451
3505
|
request_id; poll /api/robots/{id}/shell/wait/{rid} for the result."""
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3506
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
3507
|
+
db.row_factory = aiosqlite.Row
|
|
3508
|
+
async with db.execute(
|
|
3509
|
+
"SELECT id FROM devices WHERE id = ? OR lower(name) = lower(?) "
|
|
3510
|
+
"ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END LIMIT 1",
|
|
3511
|
+
(robot_id, robot_id, robot_id),
|
|
3512
|
+
) as cursor:
|
|
3513
|
+
device = await cursor.fetchone()
|
|
3514
|
+
if not device:
|
|
3515
|
+
raise HTTPException(404, f"Robot device not found: {robot_id}")
|
|
3516
|
+
device_id = device["id"]
|
|
3517
|
+
params = payload.model_dump(exclude_none=True)
|
|
3518
|
+
if payload.mode == "tts":
|
|
3519
|
+
params.update(await _cached_speaker_tts(robot_id, payload.text or ""))
|
|
3520
|
+
elif payload.mode != "tone":
|
|
3521
|
+
raise HTTPException(422, "mode must be tone or tts")
|
|
3522
|
+
request_id = await _queue_shell_request_async(device_id, "speaker_test", None, params)
|
|
3523
|
+
return {"status": "queued", "request_id": request_id, "device_id": device_id}
|
|
3524
|
+
|
|
3525
|
+
|
|
3526
|
+
@app.get("/api/devices/{device_id}/shell/next-speaker-test")
|
|
3527
|
+
async def next_device_speaker_test(device_id: str, authorization: Optional[str] = Header(default=None)):
|
|
3528
|
+
"""Allow the always-running preview agent to execute audio tests."""
|
|
3529
|
+
if not await _authorize_device(device_id, authorization):
|
|
3530
|
+
raise HTTPException(401, "Invalid device token")
|
|
3531
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
3532
|
+
db.row_factory = aiosqlite.Row
|
|
3533
|
+
async with db.execute(
|
|
3534
|
+
"SELECT id, params FROM shell_requests WHERE device_id = ? AND kind = 'speaker_test' "
|
|
3535
|
+
"AND completed_at IS NULL ORDER BY requested_at LIMIT 1", (device_id,),
|
|
3536
|
+
) as cursor:
|
|
3537
|
+
row = await cursor.fetchone()
|
|
3538
|
+
return {"request": {"id": row["id"], "params": json.loads(row["params"] or "{}")} if row else None}
|
|
3456
3539
|
|
|
3457
3540
|
|
|
3458
3541
|
# ── Incident acknowledgement (C4) ──
|
|
@@ -3655,7 +3738,7 @@ async def device_request_session(device_id: str,
|
|
|
3655
3738
|
return {
|
|
3656
3739
|
"session_id": session_id,
|
|
3657
3740
|
"server_id": server["id"],
|
|
3658
|
-
"server_url": server["url"],
|
|
3741
|
+
"server_url": device["livekit_url"] or server["url"],
|
|
3659
3742
|
"room_name": room_name,
|
|
3660
3743
|
"token": token,
|
|
3661
3744
|
"voice_config": voice_config.model_dump() if voice_config else None,
|
|
@@ -278,7 +278,7 @@ async def _bootstrap_mesh_device(scheduler_url: str, robot_id: str) -> tuple[str
|
|
|
278
278
|
async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
|
|
279
279
|
response = await client.post(
|
|
280
280
|
f"{scheduler_url.rstrip('/')}/api/devices/bootstrap",
|
|
281
|
-
json={"name": robot_id, "lan_ip": _get_lan_ip()},
|
|
281
|
+
json={"name": robot_id, "lan_ip": _get_lan_ip(), "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
|
|
282
282
|
timeout=30.0,
|
|
283
283
|
)
|
|
284
284
|
response.raise_for_status()
|
|
@@ -302,8 +302,9 @@ async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> s
|
|
|
302
302
|
import socket
|
|
303
303
|
payload = {
|
|
304
304
|
"enrollment_token": enrollment_token,
|
|
305
|
-
"name": robot_id,
|
|
306
|
-
"lan_ip": _get_lan_ip(),
|
|
305
|
+
"name": robot_id,
|
|
306
|
+
"lan_ip": _get_lan_ip(),
|
|
307
|
+
"livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None,
|
|
307
308
|
}
|
|
308
309
|
async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
|
|
309
310
|
r = await client.post(
|
|
@@ -346,7 +347,8 @@ async def _send_heartbeat(
|
|
|
346
347
|
async with httpx.AsyncClient() as client:
|
|
347
348
|
response = await client.post(
|
|
348
349
|
f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
|
|
349
|
-
json={"status": "online", "ip": _get_lan_ip(), "device_inventory": inventory
|
|
350
|
+
json={"status": "online", "ip": _get_lan_ip(), "device_inventory": inventory,
|
|
351
|
+
"livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
|
|
350
352
|
headers=headers,
|
|
351
353
|
timeout=10.0,
|
|
352
354
|
)
|
|
@@ -421,7 +423,8 @@ class PreviewAgent:
|
|
|
421
423
|
# with a hard ceiling — see DEFAULT_VISION_SILENCE_TIMEOUT above and
|
|
422
424
|
# _check_vision_session() below for the full design.
|
|
423
425
|
self._vision_session_id: Optional[str] = None
|
|
424
|
-
self._vision_trigger_in_flight = False
|
|
426
|
+
self._vision_trigger_in_flight = False
|
|
427
|
+
self._speaker_test_in_flight = False
|
|
425
428
|
self._vision_hard_deadline: float = 0.0
|
|
426
429
|
self._vision_last_activity: float = 0.0
|
|
427
430
|
self.production_mode = False
|
|
@@ -820,11 +823,41 @@ class PreviewAgent:
|
|
|
820
823
|
await self._report_pipeline("microphone_ready", "ok" if real_inputs else "blocked", f"{len(real_inputs)} microphone device(s) detected")
|
|
821
824
|
await self._report_pipeline("speaker_ready", "ok" if real_outputs else "blocked", f"{len(real_outputs)} speaker device(s) detected")
|
|
822
825
|
if not production_mode and self._vision_session_id:
|
|
823
|
-
await self._end_vision_session()
|
|
826
|
+
await self._end_vision_session()
|
|
827
|
+
await self._poll_speaker_test()
|
|
824
828
|
try:
|
|
825
829
|
await asyncio.wait_for(self._shutdown.wait(), timeout=self.heartbeat_interval)
|
|
826
|
-
except asyncio.TimeoutError:
|
|
827
|
-
pass
|
|
830
|
+
except asyncio.TimeoutError:
|
|
831
|
+
pass
|
|
832
|
+
|
|
833
|
+
async def _poll_speaker_test(self) -> None:
|
|
834
|
+
"""Execute queued speaker tests even when no optional supervisor runs."""
|
|
835
|
+
if self._speaker_test_in_flight or not self._session or not self.device_id or not self.device_token:
|
|
836
|
+
return
|
|
837
|
+
headers = {"Authorization": f"Bearer {self.device_token}", **_mesh_proxy_headers()}
|
|
838
|
+
try:
|
|
839
|
+
response = await self._session.get(
|
|
840
|
+
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/shell/next-speaker-test",
|
|
841
|
+
headers=headers, timeout=5.0,
|
|
842
|
+
)
|
|
843
|
+
response.raise_for_status()
|
|
844
|
+
request = response.json().get("request")
|
|
845
|
+
if not request:
|
|
846
|
+
return
|
|
847
|
+
self._speaker_test_in_flight = True
|
|
848
|
+
from robot_supervisor import _speaker_roundtrip_test
|
|
849
|
+
result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
|
|
850
|
+
result_response = await self._session.post(
|
|
851
|
+
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/supervisor-output",
|
|
852
|
+
json={"kind": "speaker_test", "service": None, "payload": result,
|
|
853
|
+
"request_id": request.get("id")},
|
|
854
|
+
headers=headers, timeout=8.0,
|
|
855
|
+
)
|
|
856
|
+
result_response.raise_for_status()
|
|
857
|
+
except Exception as e:
|
|
858
|
+
logger.warning(f"speaker test execution failed: {e}")
|
|
859
|
+
finally:
|
|
860
|
+
self._speaker_test_in_flight = False
|
|
828
861
|
|
|
829
862
|
async def _fetch_preview_state(self) -> PreviewState:
|
|
830
863
|
if not self._session or not self.device_token:
|
|
@@ -863,23 +896,25 @@ class PreviewAgent:
|
|
|
863
896
|
return
|
|
864
897
|
await self._start_publisher(state)
|
|
865
898
|
|
|
866
|
-
async def _start_publisher(self, state: PreviewState) ->
|
|
867
|
-
await self._stop_publisher()
|
|
868
|
-
if not state.url or not state.token or not state.room:
|
|
869
|
-
return
|
|
899
|
+
async def _start_publisher(self, state: PreviewState) -> bool:
|
|
900
|
+
await self._stop_publisher()
|
|
901
|
+
if not state.url or not state.token or not state.room:
|
|
902
|
+
return False
|
|
870
903
|
pub = None
|
|
871
904
|
try:
|
|
872
905
|
pub = LiveKitPublisher(state.url, state.token, state.room, self)
|
|
873
906
|
await pub.start()
|
|
874
|
-
self._publisher = pub
|
|
875
|
-
logger.info(f"joined preview room {state.room}")
|
|
907
|
+
self._publisher = pub
|
|
908
|
+
logger.info(f"joined preview room {state.room}")
|
|
909
|
+
return True
|
|
876
910
|
except Exception as e:
|
|
877
911
|
logger.error(f"failed to start publisher: {e}")
|
|
878
912
|
if pub is not None:
|
|
879
913
|
try:
|
|
880
914
|
await pub.stop()
|
|
881
|
-
except Exception as cleanup_error:
|
|
882
|
-
logger.debug(f"publisher cleanup after start failure: {cleanup_error}")
|
|
915
|
+
except Exception as cleanup_error:
|
|
916
|
+
logger.debug(f"publisher cleanup after start failure: {cleanup_error}")
|
|
917
|
+
return False
|
|
883
918
|
|
|
884
919
|
async def _stop_publisher(self) -> None:
|
|
885
920
|
if self._publisher:
|
|
@@ -1005,14 +1040,35 @@ class PreviewAgent:
|
|
|
1005
1040
|
# and lets downstream components (audio server, vision, etc.) adapt.
|
|
1006
1041
|
if voice_config and session_id:
|
|
1007
1042
|
logger.info(f"scheduler voice config for session {session_id}: {voice_config}")
|
|
1008
|
-
await self._start_publisher(state)
|
|
1009
|
-
if
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1043
|
+
joined = await self._start_publisher(state)
|
|
1044
|
+
if not joined:
|
|
1045
|
+
await self._report_pipeline(
|
|
1046
|
+
"livekit_join", "failed", "Robot could not connect to its assigned LiveKit route",
|
|
1047
|
+
details={"server_url": state.url}, once=False,
|
|
1048
|
+
)
|
|
1049
|
+
if session_id and self._session:
|
|
1050
|
+
try:
|
|
1051
|
+
response = await self._session.post(
|
|
1052
|
+
f"{self.scheduler_url.rstrip('/')}/api/robots/{self.device_id}/end-session",
|
|
1053
|
+
params={"reason": "livekit_join_failed"},
|
|
1054
|
+
headers={"Authorization": f"Bearer {self.device_token}"}, timeout=10.0,
|
|
1055
|
+
)
|
|
1056
|
+
response.raise_for_status()
|
|
1057
|
+
except Exception as e:
|
|
1058
|
+
logger.debug(f"could not end failed LiveKit session: {e}")
|
|
1059
|
+
self._vision_session_id = None
|
|
1060
|
+
self._vision_hard_deadline = 0.0
|
|
1061
|
+
self._vision_last_activity = 0.0
|
|
1062
|
+
self._current_state = PreviewState()
|
|
1063
|
+
return
|
|
1064
|
+
if session_id and self._session:
|
|
1065
|
+
try:
|
|
1066
|
+
response = await self._session.post(
|
|
1067
|
+
f"{self.scheduler_url.rstrip('/')}/api/sessions/{session_id}/joined",
|
|
1068
|
+
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
1069
|
+
timeout=10.0,
|
|
1070
|
+
)
|
|
1071
|
+
response.raise_for_status()
|
|
1016
1072
|
except Exception as e:
|
|
1017
1073
|
logger.debug(f"could not mark session joined: {e}")
|
|
1018
1074
|
|
|
@@ -30,7 +30,8 @@ Auto-start on boot:
|
|
|
30
30
|
"""
|
|
31
31
|
from __future__ import annotations
|
|
32
32
|
|
|
33
|
-
import argparse
|
|
33
|
+
import argparse
|
|
34
|
+
import base64
|
|
34
35
|
import json
|
|
35
36
|
import logging
|
|
36
37
|
import math
|
|
@@ -493,7 +494,7 @@ def _resolve_audio_device(pa, selected: str, kind: str) -> Optional[int]:
|
|
|
493
494
|
return None
|
|
494
495
|
|
|
495
496
|
|
|
496
|
-
def _speaker_roundtrip_test(params: dict) -> dict:
|
|
497
|
+
def _speaker_roundtrip_test(params: dict) -> dict:
|
|
497
498
|
"""Play a test tone + record the mic simultaneously; return metrics.
|
|
498
499
|
|
|
499
500
|
params may include {frequency, duration, amplitude, output, input,
|
|
@@ -502,20 +503,32 @@ def _speaker_roundtrip_test(params: dict) -> dict:
|
|
|
502
503
|
import pyaudio
|
|
503
504
|
except ImportError as e:
|
|
504
505
|
return {"ok": False, "error": f"pyaudio not installed on this robot: {e}"}
|
|
505
|
-
|
|
506
|
-
|
|
506
|
+
mode = str(params.get("mode", "tone")).lower()
|
|
507
|
+
freq = float(params.get("frequency", SPEAKER_TEST_FREQUENCY_HZ))
|
|
508
|
+
dur = float(params.get("duration", SPEAKER_TEST_DURATION_S))
|
|
507
509
|
amp = float(params.get("amplitude", SPEAKER_TEST_AMPLITUDE))
|
|
508
510
|
threshold_db = float(params.get("threshold_db", SPEAKER_TEST_PEAK_DB_THRESHOLD))
|
|
509
511
|
out_name = params.get("output", os.environ.get("ROBOPARK_AUDIO_OUTPUT") or SPEAKER_TEST_OUTPUT_DEVICE)
|
|
510
512
|
in_name = params.get("input", os.environ.get("ROBOPARK_AUDIO_INPUT") or SPEAKER_TEST_INPUT_DEVICE)
|
|
511
513
|
sample_rate = int(params.get("sample_rate", SPEAKER_TEST_SAMPLE_RATE))
|
|
512
|
-
if not (
|
|
514
|
+
if mode not in ("tone", "tts"):
|
|
515
|
+
return {"ok": False, "error": f"unsupported speaker test mode: {mode}"}
|
|
516
|
+
if mode == "tone" and not (50.0 <= freq <= 8000.0):
|
|
513
517
|
return {"ok": False, "error": f"frequency {freq}Hz out of allowed range (50..8000)"}
|
|
514
518
|
if not (0.1 <= dur <= 3.0):
|
|
515
519
|
return {"ok": False, "error": f"duration {dur}s out of allowed range (0.1..3.0)"}
|
|
516
520
|
if not (0.05 <= amp <= 1.0):
|
|
517
521
|
return {"ok": False, "error": f"amplitude {amp} out of allowed range (0.05..1.0)"}
|
|
518
|
-
|
|
522
|
+
mono_pcm = None
|
|
523
|
+
if mode == "tts":
|
|
524
|
+
try:
|
|
525
|
+
mono_pcm = base64.b64decode(params.get("audio_pcm_base64") or "", validate=True)
|
|
526
|
+
except Exception as e:
|
|
527
|
+
return {"ok": False, "error": f"invalid cached TTS audio: {e}"}
|
|
528
|
+
if len(mono_pcm) < 480 or len(mono_pcm) % 2:
|
|
529
|
+
return {"ok": False, "error": "cached TTS audio is empty or malformed"}
|
|
530
|
+
dur = len(mono_pcm) / 2 / sample_rate
|
|
531
|
+
n_samples = int(sample_rate * dur)
|
|
519
532
|
if n_samples < 480:
|
|
520
533
|
return {"ok": False, "error": "duration too short"}
|
|
521
534
|
|
|
@@ -529,13 +542,20 @@ def _speaker_roundtrip_test(params: dict) -> dict:
|
|
|
529
542
|
out_label = _dev_name(out_idx)
|
|
530
543
|
in_label = _dev_name(in_idx)
|
|
531
544
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
545
|
+
if mono_pcm is not None:
|
|
546
|
+
frames = bytearray()
|
|
547
|
+
for pos in range(0, len(mono_pcm), 2):
|
|
548
|
+
sample = mono_pcm[pos:pos + 2]
|
|
549
|
+
frames.extend(sample + sample)
|
|
550
|
+
raw = bytes(frames)
|
|
551
|
+
else:
|
|
552
|
+
frames = bytearray()
|
|
553
|
+
peak_value = int(32767 * amp)
|
|
554
|
+
for n in range(n_samples):
|
|
555
|
+
env = min(1.0, n / (sample_rate * 0.004), (n_samples - n) / (sample_rate * 0.008))
|
|
556
|
+
value = int(peak_value * env * math.sin(2 * math.pi * freq * n / sample_rate))
|
|
557
|
+
frames.extend(struct.pack("<hh", value, value))
|
|
558
|
+
raw = bytes(frames)
|
|
539
559
|
|
|
540
560
|
recorded_peak = 0
|
|
541
561
|
recorded_rms = 0.0
|
|
@@ -614,9 +634,14 @@ def _speaker_roundtrip_test(params: dict) -> dict:
|
|
|
614
634
|
else:
|
|
615
635
|
rms_db = -120.0
|
|
616
636
|
pass_ = peak_db >= threshold_db
|
|
617
|
-
return {
|
|
618
|
-
"ok": True,
|
|
619
|
-
"pass": pass_,
|
|
637
|
+
return {
|
|
638
|
+
"ok": True,
|
|
639
|
+
"pass": pass_,
|
|
640
|
+
"mode": mode,
|
|
641
|
+
"text": params.get("text"),
|
|
642
|
+
"cache_hit": bool(params.get("cache_hit")),
|
|
643
|
+
"tts_provider": params.get("tts_provider"),
|
|
644
|
+
"tts_voice": params.get("tts_voice"),
|
|
620
645
|
"frequency": freq,
|
|
621
646
|
"duration": dur,
|
|
622
647
|
"duration_ms": duration_ms,
|