infinicode 2.8.83 → 2.8.85
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/package.json
CHANGED
|
@@ -839,10 +839,70 @@ async def broadcast(event_type: str, data: dict):
|
|
|
839
839
|
ws_clients.remove(client)
|
|
840
840
|
|
|
841
841
|
# =============================================================================
|
|
842
|
-
# ROBOT ENDPOINTS
|
|
843
|
-
# =============================================================================
|
|
844
|
-
|
|
845
|
-
|
|
842
|
+
# ROBOT ENDPOINTS
|
|
843
|
+
# =============================================================================
|
|
844
|
+
|
|
845
|
+
CONNECTING_SESSION_LEASE_SECONDS = max(
|
|
846
|
+
15, int(os.getenv("ROBOPARK_CONNECTING_SESSION_LEASE_SECONDS", "45"))
|
|
847
|
+
)
|
|
848
|
+
ACTIVE_SESSION_LEASE_SECONDS = max(
|
|
849
|
+
CONNECTING_SESSION_LEASE_SECONDS,
|
|
850
|
+
int(os.getenv("ROBOPARK_ACTIVE_SESSION_LEASE_SECONDS", "180")),
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
async def _reap_robot_session_if_stale(db, robot_id: str, current_session_id: Optional[str],
|
|
855
|
+
robot_status: Optional[str] = None) -> Optional[str]:
|
|
856
|
+
"""Release an invalid or expired session pointer without operator action."""
|
|
857
|
+
if not current_session_id:
|
|
858
|
+
return None
|
|
859
|
+
|
|
860
|
+
async with db.execute(
|
|
861
|
+
"SELECT id, started_at, last_activity_at, ended_at FROM sessions WHERE id = ?",
|
|
862
|
+
(current_session_id,),
|
|
863
|
+
) as cursor:
|
|
864
|
+
session = await cursor.fetchone()
|
|
865
|
+
|
|
866
|
+
reason = None
|
|
867
|
+
if not session:
|
|
868
|
+
reason = "missing_session"
|
|
869
|
+
elif session["ended_at"]:
|
|
870
|
+
reason = "ended_session"
|
|
871
|
+
else:
|
|
872
|
+
timestamp = session["last_activity_at"] or session["started_at"]
|
|
873
|
+
try:
|
|
874
|
+
activity_age = (datetime.utcnow() - datetime.fromisoformat(timestamp)).total_seconds()
|
|
875
|
+
except (TypeError, ValueError):
|
|
876
|
+
activity_age = ACTIVE_SESSION_LEASE_SECONDS + 1
|
|
877
|
+
lease = (
|
|
878
|
+
CONNECTING_SESSION_LEASE_SECONDS
|
|
879
|
+
if robot_status == "connecting"
|
|
880
|
+
else ACTIVE_SESSION_LEASE_SECONDS
|
|
881
|
+
)
|
|
882
|
+
if activity_age >= lease:
|
|
883
|
+
reason = "stale_connecting" if robot_status == "connecting" else "stale_activity"
|
|
884
|
+
|
|
885
|
+
if not reason:
|
|
886
|
+
return None
|
|
887
|
+
|
|
888
|
+
now = datetime.utcnow().isoformat()
|
|
889
|
+
if session and not session["ended_at"]:
|
|
890
|
+
await db.execute(
|
|
891
|
+
"UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ? AND ended_at IS NULL",
|
|
892
|
+
(now, reason, current_session_id),
|
|
893
|
+
)
|
|
894
|
+
await db.execute(
|
|
895
|
+
"UPDATE robots SET status = 'idle', current_session_id = NULL, connected_server_id = NULL "
|
|
896
|
+
"WHERE id = ? AND current_session_id = ?",
|
|
897
|
+
(robot_id, current_session_id),
|
|
898
|
+
)
|
|
899
|
+
logger.warning(
|
|
900
|
+
"Automatically released %s session %s for robot %s",
|
|
901
|
+
reason, current_session_id, robot_id,
|
|
902
|
+
)
|
|
903
|
+
return reason
|
|
904
|
+
|
|
905
|
+
@app.get("/api/robots", response_model=List[Robot])
|
|
846
906
|
async def list_robots():
|
|
847
907
|
"""List all robots"""
|
|
848
908
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
@@ -909,11 +969,23 @@ async def request_session(robot_id: str,
|
|
|
909
969
|
(robot_id, device_row["name"]),
|
|
910
970
|
)
|
|
911
971
|
await db.commit()
|
|
912
|
-
robot = {
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
972
|
+
robot = {
|
|
973
|
+
"id": robot_id,
|
|
974
|
+
"name": device_row["name"],
|
|
975
|
+
"status": "idle",
|
|
976
|
+
"current_session_id": None,
|
|
977
|
+
}
|
|
978
|
+
else:
|
|
979
|
+
raise HTTPException(404, "Robot not found")
|
|
980
|
+
stale_reason = await _reap_robot_session_if_stale(
|
|
981
|
+
db, robot_id, robot["current_session_id"], robot["status"]
|
|
982
|
+
)
|
|
983
|
+
if stale_reason:
|
|
984
|
+
await db.commit()
|
|
985
|
+
async with db.execute("SELECT * FROM robots WHERE id = ?", (robot_id,)) as cursor:
|
|
986
|
+
robot = await cursor.fetchone()
|
|
987
|
+
if robot["current_session_id"] or robot["status"] != "idle":
|
|
988
|
+
raise HTTPException(409, f"Robot is {robot['status']}, not idle")
|
|
917
989
|
|
|
918
990
|
# Find least-loaded online server
|
|
919
991
|
async with db.execute("""
|
|
@@ -1046,7 +1118,7 @@ async def simulate_trigger(robot_id: str):
|
|
|
1046
1118
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
1047
1119
|
db.row_factory = aiosqlite.Row
|
|
1048
1120
|
async with db.execute(
|
|
1049
|
-
"SELECT id, current_session_id FROM robots WHERE id = ?", (robot_id,)
|
|
1121
|
+
"SELECT id, current_session_id, status FROM robots WHERE id = ?", (robot_id,)
|
|
1050
1122
|
) as c:
|
|
1051
1123
|
robot = await c.fetchone()
|
|
1052
1124
|
if not robot:
|
|
@@ -1059,11 +1131,14 @@ async def simulate_trigger(robot_id: str):
|
|
|
1059
1131
|
(device["id"], device["name"], device["character_id"]),
|
|
1060
1132
|
)
|
|
1061
1133
|
await db.commit()
|
|
1062
|
-
robot = {"id": device["id"], "current_session_id": None}
|
|
1134
|
+
robot = {"id": device["id"], "current_session_id": None, "status": "idle"}
|
|
1063
1135
|
if not await _effective_device_production_mode(db, robot_id):
|
|
1064
1136
|
return {"queued": False, "reason": "device_production_mode_off"}
|
|
1065
|
-
|
|
1066
|
-
|
|
1137
|
+
stale_reason = await _reap_robot_session_if_stale(
|
|
1138
|
+
db, robot_id, robot["current_session_id"], robot["status"]
|
|
1139
|
+
)
|
|
1140
|
+
if robot["current_session_id"] and not stale_reason:
|
|
1141
|
+
return {"queued": False, "reason": "session_active"}
|
|
1067
1142
|
await db.execute(
|
|
1068
1143
|
"INSERT INTO trigger_commands (robot_id, source, requested_at) VALUES (?, ?, ?) "
|
|
1069
1144
|
"ON CONFLICT(robot_id) DO UPDATE SET source = excluded.source, requested_at = excluded.requested_at",
|
|
@@ -1072,7 +1147,12 @@ async def simulate_trigger(robot_id: str):
|
|
|
1072
1147
|
await db.commit()
|
|
1073
1148
|
await broadcast("robot_triggered", {"robot_id": robot_id, "source": "dashboard"})
|
|
1074
1149
|
await _log_history("robot", "robot", robot_id, "simulate_trigger", "operator")
|
|
1075
|
-
return {
|
|
1150
|
+
return {
|
|
1151
|
+
"queued": True,
|
|
1152
|
+
"source": "dashboard",
|
|
1153
|
+
"recovered_stale_session": bool(stale_reason),
|
|
1154
|
+
"recovery_reason": stale_reason,
|
|
1155
|
+
}
|
|
1076
1156
|
|
|
1077
1157
|
@app.post("/api/robots/{robot_id}/simulate-stop")
|
|
1078
1158
|
async def simulate_stop(robot_id: str):
|
|
@@ -1487,14 +1567,31 @@ async def session_joined(session_id: str,
|
|
|
1487
1567
|
session = await c.fetchone()
|
|
1488
1568
|
if not session:
|
|
1489
1569
|
raise HTTPException(404, "Session not found")
|
|
1490
|
-
if session["joined_at"]:
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1570
|
+
if session["joined_at"]:
|
|
1571
|
+
if not session["ended_at"]:
|
|
1572
|
+
await db.execute(
|
|
1573
|
+
"UPDATE sessions SET last_activity_at = ? WHERE id = ?",
|
|
1574
|
+
(now, session_id),
|
|
1575
|
+
)
|
|
1576
|
+
await db.execute(
|
|
1577
|
+
"UPDATE robots SET status = 'running' WHERE id = ? AND current_session_id = ?",
|
|
1578
|
+
(session["robot_id"], session_id),
|
|
1579
|
+
)
|
|
1580
|
+
await db.commit()
|
|
1581
|
+
return {
|
|
1582
|
+
"status": "already_joined",
|
|
1583
|
+
"joined_at": session["joined_at"],
|
|
1584
|
+
"latency_ms": _session_latency_ms(session["started_at"], session["joined_at"]),
|
|
1585
|
+
}
|
|
1586
|
+
await db.execute(
|
|
1587
|
+
"UPDATE sessions SET joined_at = ?, last_activity_at = ? WHERE id = ?",
|
|
1588
|
+
(now, now, session_id),
|
|
1589
|
+
)
|
|
1590
|
+
await db.execute(
|
|
1591
|
+
"UPDATE robots SET status = 'running' WHERE id = ? AND current_session_id = ?",
|
|
1592
|
+
(session["robot_id"], session_id),
|
|
1593
|
+
)
|
|
1594
|
+
await db.commit()
|
|
1498
1595
|
latency_ms = _session_latency_ms(session["started_at"], now)
|
|
1499
1596
|
await broadcast("session_joined", {"session_id": session_id, "latency_ms": latency_ms})
|
|
1500
1597
|
await _log_history("session", "session", session_id, "joined", "robot", f"latency_ms={latency_ms}")
|
|
@@ -1955,19 +2052,38 @@ async def health_checker():
|
|
|
1955
2052
|
await db.execute("UPDATE devices SET status = 'offline' WHERE id = ?", (did,))
|
|
1956
2053
|
await broadcast("device_status_changed", {"device_id": did, "status": "offline"})
|
|
1957
2054
|
|
|
1958
|
-
#
|
|
1959
|
-
#
|
|
1960
|
-
#
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
2055
|
+
# Release stale robot session pointers with the same lease rules
|
|
2056
|
+
# used by the trigger path. Failed joins cannot require manual
|
|
2057
|
+
# operator recovery or block an unattended robot indefinitely.
|
|
2058
|
+
async with db.execute(
|
|
2059
|
+
"SELECT id, status, current_session_id FROM robots WHERE current_session_id IS NOT NULL"
|
|
2060
|
+
) as c:
|
|
2061
|
+
session_owners = await c.fetchall()
|
|
2062
|
+
for owner in session_owners:
|
|
2063
|
+
reason = await _reap_robot_session_if_stale(
|
|
2064
|
+
db, owner["id"], owner["current_session_id"], owner["status"]
|
|
2065
|
+
)
|
|
2066
|
+
if reason:
|
|
2067
|
+
await broadcast("session_expired", {
|
|
2068
|
+
"session_id": owner["current_session_id"],
|
|
2069
|
+
"robot_id": owner["id"],
|
|
2070
|
+
"reason": reason,
|
|
2071
|
+
})
|
|
2072
|
+
|
|
2073
|
+
# Reap old orphan rows that are no longer owned by a robot.
|
|
2074
|
+
session_cutoff = (
|
|
2075
|
+
datetime.utcnow() - timedelta(seconds=ACTIVE_SESSION_LEASE_SECONDS)
|
|
2076
|
+
).isoformat()
|
|
2077
|
+
async with db.execute(
|
|
2078
|
+
"SELECT id, robot_id FROM sessions "
|
|
2079
|
+
"WHERE ended_at IS NULL AND COALESCE(last_activity_at, started_at) < ? "
|
|
2080
|
+
"AND NOT EXISTS (SELECT 1 FROM robots r WHERE r.current_session_id = sessions.id)",
|
|
2081
|
+
(session_cutoff,),
|
|
2082
|
+
) as c:
|
|
1967
2083
|
stale_sessions = await c.fetchall()
|
|
1968
2084
|
for session in stale_sessions:
|
|
1969
2085
|
await db.execute(
|
|
1970
|
-
"UPDATE sessions SET ended_at = ?, end_reason = '
|
|
2086
|
+
"UPDATE sessions SET ended_at = ?, end_reason = 'stale_orphan' "
|
|
1971
2087
|
"WHERE id = ? AND ended_at IS NULL",
|
|
1972
2088
|
(datetime.utcnow().isoformat(), session["id"]),
|
|
1973
2089
|
)
|
|
@@ -1979,7 +2095,7 @@ async def health_checker():
|
|
|
1979
2095
|
await broadcast("session_expired", {
|
|
1980
2096
|
"session_id": session["id"],
|
|
1981
2097
|
"robot_id": session["robot_id"],
|
|
1982
|
-
|
|
2098
|
+
"reason": "stale_orphan",
|
|
1983
2099
|
})
|
|
1984
2100
|
|
|
1985
2101
|
await db.commit()
|
|
@@ -4748,10 +4864,10 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
|
|
|
4748
4864
|
readiness_code, readiness_message = "server_unavailable", f"LiveKit server {robot['connected_server_id']} is {server_status or 'missing'}"
|
|
4749
4865
|
elif not robot["current_session_id"]:
|
|
4750
4866
|
readiness_code, readiness_message = "ready", "ready for motion trigger"
|
|
4751
|
-
elif robot["status"] == "connecting" and session_age is not None and session_age >
|
|
4752
|
-
readiness_code, readiness_message = "stuck_session", f"joining LiveKit for {session_age}s;
|
|
4753
|
-
elif activity_age is not None and activity_age >
|
|
4754
|
-
readiness_code, readiness_message = "stale_session", f"no session activity for {activity_age}s;
|
|
4867
|
+
elif robot["status"] == "connecting" and session_age is not None and session_age > CONNECTING_SESSION_LEASE_SECONDS:
|
|
4868
|
+
readiness_code, readiness_message = "stuck_session", f"joining LiveKit for {session_age}s; automatic recovery pending"
|
|
4869
|
+
elif activity_age is not None and activity_age > ACTIVE_SESSION_LEASE_SECONDS:
|
|
4870
|
+
readiness_code, readiness_message = "stale_session", f"no session activity for {activity_age}s; automatic recovery pending"
|
|
4755
4871
|
else:
|
|
4756
4872
|
readiness_code, readiness_message = "in_session", "conversation session is active"
|
|
4757
4873
|
|
|
@@ -397,6 +397,10 @@ class PreviewAgent:
|
|
|
397
397
|
# use the resolved hardware name reported in that same inventory.
|
|
398
398
|
self.audio_capture_device = self.audio_device
|
|
399
399
|
self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
|
|
400
|
+
# Keep RoboVision/sounddevice inventory IDs out of PyAudio. Both APIs
|
|
401
|
+
# number the same ALSA cards differently, so resolve the selected ID
|
|
402
|
+
# to its hardware name before opening any playback stream.
|
|
403
|
+
self.audio_playback_device = self.audio_output_device
|
|
400
404
|
self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
|
|
401
405
|
self.use_robovision_camera = bool(cfg.get("use_robovision_camera", False))
|
|
402
406
|
self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
|
|
@@ -689,6 +693,16 @@ class PreviewAgent:
|
|
|
689
693
|
if data.get("audio_output_device") is not None:
|
|
690
694
|
changed = changed or self.audio_output_device != data["audio_output_device"]
|
|
691
695
|
self.audio_output_device = data["audio_output_device"]
|
|
696
|
+
inventory = data.get("device_inventory") or {}
|
|
697
|
+
selected = str(self.audio_output_device)
|
|
698
|
+
self.audio_playback_device = next(
|
|
699
|
+
(
|
|
700
|
+
str(item.get("name"))
|
|
701
|
+
for item in inventory.get("audio_output", [])
|
|
702
|
+
if str(item.get("id")) == selected and item.get("name")
|
|
703
|
+
),
|
|
704
|
+
self.audio_output_device,
|
|
705
|
+
)
|
|
692
706
|
if changed:
|
|
693
707
|
await self._sync_robovision_media_config()
|
|
694
708
|
except Exception as e:
|
|
@@ -812,7 +826,7 @@ class PreviewAgent:
|
|
|
812
826
|
self._vision_last_activity = 0.0
|
|
813
827
|
self._remote_session_ended = False
|
|
814
828
|
await self._stop_publisher()
|
|
815
|
-
await asyncio.to_thread(_play_audio_effect, self.
|
|
829
|
+
await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "disconnect")
|
|
816
830
|
self._current_state = PreviewState()
|
|
817
831
|
|
|
818
832
|
async def _heartbeat_loop(self) -> None:
|
|
@@ -865,7 +879,7 @@ class PreviewAgent:
|
|
|
865
879
|
restore_state = self._current_state if self._publisher else None
|
|
866
880
|
if restore_state:
|
|
867
881
|
await self._stop_publisher()
|
|
868
|
-
await asyncio.sleep(0
|
|
882
|
+
await asyncio.sleep(1.0)
|
|
869
883
|
try:
|
|
870
884
|
result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
|
|
871
885
|
result["media_owner_paused"] = bool(restore_state)
|
|
@@ -1028,7 +1042,7 @@ class PreviewAgent:
|
|
|
1028
1042
|
await asyncio.sleep(0.5)
|
|
1029
1043
|
# I/O. Camera/mic join and the preset greeting are the critical path.
|
|
1030
1044
|
asyncio.create_task(
|
|
1031
|
-
asyncio.to_thread(_play_audio_effect, self.
|
|
1045
|
+
asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
|
|
1032
1046
|
)
|
|
1033
1047
|
try:
|
|
1034
1048
|
r = await self._session.post(
|
|
@@ -1234,7 +1248,7 @@ class LiveKitPublisher:
|
|
|
1234
1248
|
# never actually reaching the real speakers. Prefer WASAPI's default output,
|
|
1235
1249
|
# which is what Windows' own volume mixer/meter is backed by.
|
|
1236
1250
|
output_device_index = None
|
|
1237
|
-
selected_output = str(self.agent.
|
|
1251
|
+
selected_output = str(self.agent.audio_playback_device or "default")
|
|
1238
1252
|
if selected_output.strip().isdigit():
|
|
1239
1253
|
output_device_index = int(selected_output.strip())
|
|
1240
1254
|
try:
|
|
@@ -578,6 +578,49 @@ def _open_pcm_stream(pa, pyaudio_module, kind: str, device_index: Optional[int],
|
|
|
578
578
|
)
|
|
579
579
|
|
|
580
580
|
|
|
581
|
+
def _linux_aplay_pcm16(raw: bytes, selected_output: str, sample_rate: int,
|
|
582
|
+
channels: int = 1) -> tuple[bool, str]:
|
|
583
|
+
"""Play PCM through the same ALSA plughw path proven during Pi setup.
|
|
584
|
+
|
|
585
|
+
PortAudio and RoboVision enumerate devices in different index spaces. The
|
|
586
|
+
inventory name contains the stable ALSA hw tuple, so use it directly and
|
|
587
|
+
let ALSA's plug layer adapt the cached TTS sample rate/channel count.
|
|
588
|
+
"""
|
|
589
|
+
import re
|
|
590
|
+
|
|
591
|
+
match = re.search(r"hw:(\d+),(\d+)", str(selected_output), re.IGNORECASE)
|
|
592
|
+
if not match:
|
|
593
|
+
return False, "selected output has no ALSA hw address"
|
|
594
|
+
alsa_device = f"plughw:{match.group(1)},{match.group(2)}"
|
|
595
|
+
command = [
|
|
596
|
+
"aplay", "-q", "-D", alsa_device, "-t", "raw", "-f", "S16_LE",
|
|
597
|
+
"-r", str(sample_rate), "-c", str(channels),
|
|
598
|
+
]
|
|
599
|
+
last_error = "aplay failed"
|
|
600
|
+
# ALSA can retain an exclusive USB handle briefly after the LiveKit
|
|
601
|
+
# playback stream closes. Retry for a bounded period instead of declaring
|
|
602
|
+
# a valid device unsupported on the first EBUSY response.
|
|
603
|
+
for attempt in range(5):
|
|
604
|
+
if attempt:
|
|
605
|
+
time.sleep(0.5)
|
|
606
|
+
try:
|
|
607
|
+
completed = subprocess.run(
|
|
608
|
+
command,
|
|
609
|
+
input=raw,
|
|
610
|
+
stdout=subprocess.DEVNULL,
|
|
611
|
+
stderr=subprocess.PIPE,
|
|
612
|
+
timeout=max(5.0, len(raw) / max(1, sample_rate * channels * 2) + 3.0),
|
|
613
|
+
check=False,
|
|
614
|
+
)
|
|
615
|
+
except Exception as exc:
|
|
616
|
+
last_error = f"{type(exc).__name__}: {exc}"
|
|
617
|
+
continue
|
|
618
|
+
if completed.returncode == 0:
|
|
619
|
+
return True, alsa_device
|
|
620
|
+
last_error = completed.stderr.decode("utf-8", errors="replace").strip() or f"aplay exited {completed.returncode}"
|
|
621
|
+
return False, f"{alsa_device}: {last_error}"
|
|
622
|
+
|
|
623
|
+
|
|
581
624
|
def _speaker_roundtrip_test(params: dict) -> dict:
|
|
582
625
|
"""Play a test tone + record the mic simultaneously; return metrics.
|
|
583
626
|
|
|
@@ -618,10 +661,46 @@ def _speaker_roundtrip_test(params: dict) -> dict:
|
|
|
618
661
|
if dur > 10.0:
|
|
619
662
|
return {"ok": False, "error": "cached TTS audio exceeds the 10 second test limit"}
|
|
620
663
|
n_samples = int(source_rate * dur)
|
|
621
|
-
if n_samples < 480:
|
|
622
|
-
return {"ok": False, "error": "duration too short"}
|
|
623
|
-
|
|
624
|
-
|
|
664
|
+
if n_samples < 480:
|
|
665
|
+
return {"ok": False, "error": "duration too short"}
|
|
666
|
+
|
|
667
|
+
# Cached character voice is output-only. On Linux, bypass PortAudio's
|
|
668
|
+
# unrelated index space and use the exact ALSA plughw endpoint selected by
|
|
669
|
+
# RoboVision. This is the same path used by the successful onsite test.
|
|
670
|
+
if playback_only and mono_pcm is not None and sys.platform.startswith("linux"):
|
|
671
|
+
t0 = time.monotonic()
|
|
672
|
+
played, detail = _linux_aplay_pcm16(mono_pcm, str(out_name), source_rate)
|
|
673
|
+
duration_ms = int((time.monotonic() - t0) * 1000)
|
|
674
|
+
if not played:
|
|
675
|
+
return {
|
|
676
|
+
"ok": False,
|
|
677
|
+
"error": f"ALSA playback failed: {detail}",
|
|
678
|
+
"duration_ms": duration_ms,
|
|
679
|
+
"output_device": str(out_name),
|
|
680
|
+
"input_device": "not opened (speaker-only test)",
|
|
681
|
+
"duration": dur,
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
"ok": True,
|
|
685
|
+
"pass": True,
|
|
686
|
+
"played": True,
|
|
687
|
+
"playback_only": True,
|
|
688
|
+
"mode": mode,
|
|
689
|
+
"text": params.get("text"),
|
|
690
|
+
"cache_hit": bool(params.get("cache_hit")),
|
|
691
|
+
"tts_provider": params.get("tts_provider"),
|
|
692
|
+
"tts_voice": params.get("tts_voice"),
|
|
693
|
+
"duration": dur,
|
|
694
|
+
"duration_ms": duration_ms,
|
|
695
|
+
"sample_rate": source_rate,
|
|
696
|
+
"output_sample_rate": source_rate,
|
|
697
|
+
"output_channels": 1,
|
|
698
|
+
"output_device": detail,
|
|
699
|
+
"input_device": "not opened (speaker-only test)",
|
|
700
|
+
"diagnostic": "cached voice played through the selected ALSA plughw endpoint",
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
pa = pyaudio.PyAudio()
|
|
625
704
|
out_idx = _resolve_audio_device(pa, out_name, "output")
|
|
626
705
|
in_idx = None if playback_only else _resolve_audio_device(pa, in_name, "input")
|
|
627
706
|
def _dev_name(idx):
|