robopark 3.2.0 → 3.3.0
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/README.md +66 -105
- package/bin/robopark.js +9 -2
- package/conversation/elevenlabs_agent.py +1617 -1617
- package/conversation/supervisor_store.py +189 -189
- package/dist/{kernel/config-schema.js → config-schema.js} +1 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/robopark/add-robot.js +1 -0
- package/dist/robopark/add-robot.js.map +1 -0
- package/dist/robopark/agent-ctl.js +1 -0
- package/dist/robopark/agent-ctl.js.map +1 -0
- package/dist/robopark/auto-start.js +51 -73
- package/dist/robopark/auto-start.js.map +1 -0
- package/dist/robopark/conversation.js +1 -0
- package/dist/robopark/conversation.js.map +1 -0
- package/dist/robopark/discovery.js +1 -0
- package/dist/robopark/discovery.js.map +1 -0
- package/dist/robopark/doctor.js +1 -0
- package/dist/robopark/doctor.js.map +1 -0
- package/dist/robopark/enroll.js +1 -0
- package/dist/robopark/enroll.js.map +1 -0
- package/dist/robopark/llm-set.js +1 -0
- package/dist/robopark/llm-set.js.map +1 -0
- package/dist/robopark/motor-control.js +1 -0
- package/dist/robopark/motor-control.js.map +1 -0
- package/dist/robopark/preview-agent-launcher.js +1 -0
- package/dist/robopark/preview-agent-launcher.js.map +1 -0
- package/dist/robopark/probe.js +16 -15
- package/dist/robopark/probe.js.map +1 -0
- package/dist/robopark/profile.js +1 -0
- package/dist/robopark/profile.js.map +1 -0
- package/dist/robopark/python-env.js +1 -7
- package/dist/robopark/python-env.js.map +1 -0
- package/dist/robopark/robot-runtime.js +1 -0
- package/dist/robopark/robot-runtime.js.map +1 -0
- package/dist/robopark/scan.js +1 -0
- package/dist/robopark/scan.js.map +1 -0
- package/dist/robopark/screen-control.js +1 -0
- package/dist/robopark/screen-control.js.map +1 -0
- package/dist/robopark/secrets.js +1 -0
- package/dist/robopark/secrets.js.map +1 -0
- package/dist/robopark/serve.js +6 -8
- package/dist/robopark/serve.js.map +1 -0
- package/dist/robopark/server-add.js +1 -0
- package/dist/robopark/server-add.js.map +1 -0
- package/dist/robopark/setup-livekit.js +53 -52
- package/dist/robopark/setup-livekit.js.map +1 -0
- package/dist/robopark/setup.js +1 -0
- package/dist/robopark/setup.js.map +1 -0
- package/dist/robopark/stop-all.js +1 -0
- package/dist/robopark/stop-all.js.map +1 -0
- package/dist/robopark/verify.js +1 -0
- package/dist/robopark/verify.js.map +1 -0
- package/dist/robopark/vision-agent-launcher.js +1 -0
- package/dist/robopark/vision-agent-launcher.js.map +1 -0
- package/dist/robopark/vision-control.js +1 -0
- package/dist/robopark/vision-control.js.map +1 -0
- package/dist/robopark-cli.js +7 -125
- package/dist/robopark-cli.js.map +1 -0
- package/package.json +16 -56
- package/pi-client/_install_steps.sh +29 -29
- package/pi-client/client.py +76 -76
- package/pi-client/install.sh +40 -40
- package/pi-client/join_convo.sh +54 -54
- package/pi-client/livekit_bridge.py +24 -24
- package/pi-client/motor_bridge.py +10 -10
- package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
- package/scheduler/main.py +196 -17
- package/scheduler/media_lock.py +57 -57
- package/scheduler/preview_agent.py +1122 -1122
- package/scheduler/production_config.json +73 -113
- package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -33
- package/{ui/standalone/public → scheduler/static}/voice-join.html +101 -13
- package/screen/screen_runtime.py +75 -75
- package/vision/app_pi_clean.py +297 -297
- package/vision/audio_server_pi.py +24 -24
- package/vision/install.sh +34 -34
- package/vision/motor_server.py +275 -275
- package/vision/requirements_camera.txt +6 -6
- package/vision/requirements_motor.txt +4 -4
- package/vision/requirements_pi_unified.txt +2 -2
- package/vision/requirements_vision_agent.txt +19 -19
- package/vision/run.sh +244 -244
- package/vision/services/services.sh +12 -12
- package/dist/kernel/types.js +0 -7
- package/dist/robopark/access.js +0 -99
- package/dist/robopark/control-center-html.js +0 -9191
- package/dist/robopark/control-server.js +0 -148
- package/dist/robopark/deployment-commands.js +0 -47
- package/dist/robopark/frontend-service.js +0 -80
- package/dist/robopark/standalone.js +0 -584
- package/dist/robopark/voice-join-html.js +0 -57
- package/scripts/windows-shim-fix.cjs +0 -58
- package/static/robopark/barracuda.png +0 -0
- package/static/robopark/bmw.png +0 -0
- package/static/robopark/jaguar.png +0 -0
- package/static/robopark/magnus.jpg +0 -0
- package/static/robopark/panda.png +0 -0
- package/static/robopark/tesla.png +0 -0
- package/static/robopark/titan.jpg +0 -0
- package/static/robopark/volt.png +0 -0
- package/static/vendor/elevenlabs-client.LICENSE +0 -21
- package/static/vendor/elevenlabs-client.iife.js +0 -24200
- package/ui/ROBOVOICE_BUILD.json +0 -9
- package/ui/standalone/.next/BUILD_ID +0 -1
- package/ui/standalone/.next/app-build-manifest.json +0 -391
- package/ui/standalone/.next/app-path-routes-manifest.json +0 -55
- package/ui/standalone/.next/build-manifest.json +0 -33
- package/ui/standalone/.next/package.json +0 -1
- package/ui/standalone/.next/prerender-manifest.json +0 -41
- package/ui/standalone/.next/react-loadable-manifest.json +0 -14
- package/ui/standalone/.next/required-server-files.json +0 -362
- package/ui/standalone/.next/routes-manifest.json +0 -246
- package/ui/standalone/.next/server/app/(app)/opengraph-image-xg4ifa/route.js +0 -1
- package/ui/standalone/.next/server/app/(app)/opengraph-image-xg4ifa/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/(app)/opengraph-image-xg4ifa/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/(app)/page.js +0 -2
- package/ui/standalone/.next/server/app/(app)/page.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/(app)/page_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/(app)/voice/page.js +0 -12
- package/ui/standalone/.next/server/app/(app)/voice/page.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/(app)/voice/page_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/_not-found/page.js +0 -12
- package/ui/standalone/.next/server/app/_not-found/page.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/connection-details/route.js +0 -1
- package/ui/standalone/.next/server/app/api/connection-details/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/connection-details/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/device-character/route.js +0 -1
- package/ui/standalone/.next/server/app/api/device-character/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/device-character/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/download-piper-model/route.js +0 -1
- package/ui/standalone/.next/server/app/api/download-piper-model/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/download-piper-model/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/model-status/route.js +0 -1
- package/ui/standalone/.next/server/app/api/model-status/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/model-status/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/models/route.js +0 -1
- package/ui/standalone/.next/server/app/api/models/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/models/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/prewarm/route.js +0 -1
- package/ui/standalone/.next/server/app/api/prewarm/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/prewarm/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/prompt/route.js +0 -1
- package/ui/standalone/.next/server/app/api/prompt/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/prompt/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/reload-tools/route.js +0 -1
- package/ui/standalone/.next/server/app/api/reload-tools/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/reload-tools/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/camera-token/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/camera-token/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/camera-token/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/character-ai/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/character-ai/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/character-ai/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/device-access/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/device-access/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/device-access/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/devices/[deviceId]/control/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/devices/[deviceId]/control/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/devices/[deviceId]/control/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/live-rooms/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/live-rooms/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/live-rooms/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/monitor-token/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/monitor-token/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/monitor-token/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/operations/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/operations/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/operations/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/voice-engine/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/voice-engine/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/voice-engine/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/metrics/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/metrics/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/metrics/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/sessions/[sessionId]/end/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/sessions/[sessionId]/end/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/sessions/[sessionId]/end/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/sessions/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/sessions/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/sessions/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/transcripts/[sessionId]/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/transcripts/[sessionId]/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/transcripts/[sessionId]/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/transcripts/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/transcripts/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/transcripts/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robovoice/robots/[robotId]/media-health/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robovoice/robots/[robotId]/media-health/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robovoice/robots/[robotId]/media-health/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/settings/route.js +0 -1
- package/ui/standalone/.next/server/app/api/settings/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/settings/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/complete/route.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/complete/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/setup/complete/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/status/route.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/status/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/setup/status/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-groq/route.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-groq/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-groq/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-hass/route.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-hass/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-hass/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-n8n/route.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-n8n/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-n8n/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-ollama/route.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-ollama/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-ollama/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/test-tts/route.js +0 -1
- package/ui/standalone/.next/server/app/api/test-tts/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/test-tts/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/turn-credentials/route.js +0 -1
- package/ui/standalone/.next/server/app/api/turn-credentials/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/turn-credentials/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/voices/route.js +0 -1
- package/ui/standalone/.next/server/app/api/voices/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/voices/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/wake/route.js +0 -1
- package/ui/standalone/.next/server/app/api/wake/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/wake/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/models/route.js +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/models/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/models/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/status/route.js +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/status/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/status/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/upload/route.js +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/upload/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/upload/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/favicon.ico/route.js +0 -1
- package/ui/standalone/.next/server/app/favicon.ico/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/favicon.ico.body +0 -0
- package/ui/standalone/.next/server/app/favicon.ico.meta +0 -1
- package/ui/standalone/.next/server/app/fed/[...path]/route.js +0 -4
- package/ui/standalone/.next/server/app/fed/[...path]/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/fed/[...path]/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/fed/media/robots/[robotId]/video_feed/route.js +0 -1
- package/ui/standalone/.next/server/app/fed/media/robots/[robotId]/video_feed/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/fed/media/robots/[robotId]/video_feed/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/robopark/[...path]/route.js +0 -1
- package/ui/standalone/.next/server/app/robopark/[...path]/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/robopark/[...path]/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/robopark/api/[...path]/route.js +0 -1
- package/ui/standalone/.next/server/app/robopark/api/[...path]/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/robopark/api/[...path]/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/robopark/api/robots/[robotId]/stream/route.js +0 -1
- package/ui/standalone/.next/server/app/robopark/api/robots/[robotId]/stream/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/robopark/api/robots/[robotId]/stream/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/robopark/page.js +0 -2
- package/ui/standalone/.next/server/app/robopark/page.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/robopark/page_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/tailnet/route.js +0 -1
- package/ui/standalone/.next/server/app/tailnet/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/tailnet/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/vendor/livekit-client.umd.js/route.js +0 -1
- package/ui/standalone/.next/server/app/vendor/livekit-client.umd.js/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/vendor/livekit-client.umd.js/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/voice-tester/page.js +0 -2
- package/ui/standalone/.next/server/app/voice-tester/page.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/voice-tester/page_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app-paths-manifest.json +0 -55
- package/ui/standalone/.next/server/chunks/1428.js +0 -1
- package/ui/standalone/.next/server/chunks/1692.js +0 -1
- package/ui/standalone/.next/server/chunks/37.js +0 -1
- package/ui/standalone/.next/server/chunks/4876.js +0 -1
- package/ui/standalone/.next/server/chunks/5232.js +0 -9
- package/ui/standalone/.next/server/chunks/5455.js +0 -11
- package/ui/standalone/.next/server/chunks/5611.js +0 -6
- package/ui/standalone/.next/server/chunks/5873.js +0 -22
- package/ui/standalone/.next/server/chunks/6018.js +0 -1
- package/ui/standalone/.next/server/chunks/8755.js +0 -1
- package/ui/standalone/.next/server/chunks/9077.js +0 -11
- package/ui/standalone/.next/server/functions-config-manifest.json +0 -9
- package/ui/standalone/.next/server/middleware-build-manifest.js +0 -1
- package/ui/standalone/.next/server/middleware-manifest.json +0 -6
- package/ui/standalone/.next/server/middleware-react-loadable-manifest.js +0 -1
- package/ui/standalone/.next/server/next-font-manifest.js +0 -1
- package/ui/standalone/.next/server/next-font-manifest.json +0 -1
- package/ui/standalone/.next/server/pages/500.html +0 -1
- package/ui/standalone/.next/server/pages/_app.js +0 -1
- package/ui/standalone/.next/server/pages/_app.js.nft.json +0 -1
- package/ui/standalone/.next/server/pages/_document.js +0 -1
- package/ui/standalone/.next/server/pages/_document.js.nft.json +0 -1
- package/ui/standalone/.next/server/pages/_error.js +0 -19
- package/ui/standalone/.next/server/pages/_error.js.nft.json +0 -1
- package/ui/standalone/.next/server/pages-manifest.json +0 -5
- package/ui/standalone/.next/server/server-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/server-reference-manifest.json +0 -1
- package/ui/standalone/.next/server/webpack-runtime.js +0 -1
- package/ui/standalone/.next/static/T3eGaQUuZNb6rhEStcouc/_buildManifest.js +0 -1
- package/ui/standalone/.next/static/T3eGaQUuZNb6rhEStcouc/_ssgManifest.js +0 -1
- package/ui/standalone/.next/static/chunks/1255-404bb8de49c90395.js +0 -1
- package/ui/standalone/.next/static/chunks/144d3bae-bd5d150c0cab972e.js +0 -1
- package/ui/standalone/.next/static/chunks/1646.9123ee47220ed70b.js +0 -1
- package/ui/standalone/.next/static/chunks/2333-43c39acb531f7d4e.js +0 -3
- package/ui/standalone/.next/static/chunks/4bd1b696-f785427dddbba9fb.js +0 -1
- package/ui/standalone/.next/static/chunks/5139.c5e46d26064a85db.js +0 -1
- package/ui/standalone/.next/static/chunks/76e8f37e-2ae0e3fe7b44d874.js +0 -1
- package/ui/standalone/.next/static/chunks/9880-53262785284464cc.js +0 -1
- package/ui/standalone/.next/static/chunks/adfb71a7-625c1b5ebd039b6a.js +0 -1
- package/ui/standalone/.next/static/chunks/app/(app)/layout-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/(app)/opengraph-image-xg4ifa/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/(app)/page-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/(app)/voice/page-72d72d9a70d9c1d4.js +0 -1
- package/ui/standalone/.next/static/chunks/app/_not-found/page-cfd7daa82d71540c.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/connection-details/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/device-character/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/download-piper-model/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/model-status/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/models/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/prewarm/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/prompt/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/reload-tools/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/camera-token/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/character-ai/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/device-access/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/devices/[deviceId]/control/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/live-rooms/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/monitor-token/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/robots/[robotId]/operations/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/robots/[robotId]/voice-engine/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/robots/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/servers/[serverId]/metrics/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/servers/[serverId]/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/servers/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/sessions/[sessionId]/end/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/sessions/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/transcripts/[sessionId]/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/transcripts/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robovoice/robots/[robotId]/media-health/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/settings/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/setup/complete/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/setup/status/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/setup/test-groq/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/setup/test-hass/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/setup/test-n8n/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/setup/test-ollama/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/test-tts/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/turn-credentials/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/voices/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/wake/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/wake-word/models/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/wake-word/status/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/wake-word/upload/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/fed/[...path]/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/fed/media/robots/[robotId]/video_feed/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/layout-fcce963da1d8220f.js +0 -1
- package/ui/standalone/.next/static/chunks/app/robopark/[...path]/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/robopark/api/[...path]/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/robopark/api/robots/[robotId]/stream/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/robopark/page-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/tailnet/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/vendor/livekit-client.umd.js/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/voice-tester/page-ae699e048e0eb834.js +0 -1
- package/ui/standalone/.next/static/chunks/framework-e60c938074ff7136.js +0 -1
- package/ui/standalone/.next/static/chunks/main-9e24e8708ff075fc.js +0 -1
- package/ui/standalone/.next/static/chunks/main-app-a1f59168303c7f84.js +0 -1
- package/ui/standalone/.next/static/chunks/pages/_app-131c90850aef965b.js +0 -1
- package/ui/standalone/.next/static/chunks/pages/_error-e4ba546eb376bdf4.js +0 -1
- package/ui/standalone/.next/static/chunks/polyfills-42372ed130431b0a.js +0 -1
- package/ui/standalone/.next/static/chunks/webpack-5252e5254da079b5.js +0 -1
- package/ui/standalone/.next/static/css/3f478a573f9be82e.css +0 -1
- package/ui/standalone/.next/static/css/51560c4af1d5a21f.css +0 -1
- package/ui/standalone/.next/static/media/27fe93087559d480-s.p.otf +0 -0
- package/ui/standalone/.next/static/media/4d3869dd8a56345a-s.p.otf +0 -0
- package/ui/standalone/.next/static/media/63f3bb9ce29e22d5-s.p.otf +0 -0
- package/ui/standalone/.next/static/media/79e1c5d0f2fe9f68-s.p.otf +0 -0
- package/ui/standalone/.next/static/media/8bf7b2ceda89477b-s.p.woff2 +0 -0
- package/ui/standalone/.next/static/media/98baa4a7809fbf72-s.woff2 +0 -0
- package/ui/standalone/.next/static/media/f7650dfaa438c37c-s.woff2 +0 -0
- package/ui/standalone/package.json +0 -57
- package/ui/standalone/public/caal-logo.svg +0 -3
- package/ui/standalone/public/commit-mono-400-regular.woff +0 -0
- package/ui/standalone/public/cwl-logo-round.png +0 -0
- package/ui/standalone/public/everett-light.woff +0 -0
- package/ui/standalone/public/hey_cal.ppn +0 -0
- package/ui/standalone/public/install-robot-kiosk.sh +0 -242
- package/ui/standalone/public/lk-logo-dark.svg +0 -18
- package/ui/standalone/public/lk-logo.svg +0 -18
- package/ui/standalone/public/lk-wordmark.svg +0 -12
- package/ui/standalone/public/opengraph-image-bg.png +0 -0
- package/ui/standalone/public/porcupine_params.pv +0 -0
- package/ui/standalone/public/robopark-control.html +0 -9200
- package/ui/standalone/public/static/robopark/barracuda.png +0 -0
- package/ui/standalone/public/static/robopark/bmw.png +0 -0
- package/ui/standalone/public/static/robopark/jaguar.png +0 -0
- package/ui/standalone/public/static/robopark/magnus.jpg +0 -0
- package/ui/standalone/public/static/robopark/panda.png +0 -0
- package/ui/standalone/public/static/robopark/tesla.png +0 -0
- package/ui/standalone/public/static/robopark/titan.jpg +0 -0
- package/ui/standalone/public/static/robopark/volt.png +0 -0
- package/ui/standalone/public/tailnet/index.html +0 -8995
- package/ui/standalone/public/transcript-analytics.html +0 -72
- package/ui/standalone/public/vendor/livekit-client.umd.js +0 -2
- package/ui/standalone/server.js +0 -38
- /package/{ui/standalone/public → scheduler/static}/vendor/elevenlabs-client.iife.js +0 -0
|
@@ -66,38 +66,38 @@ import httpx
|
|
|
66
66
|
|
|
67
67
|
logger = logging.getLogger("robopark.preview_agent")
|
|
68
68
|
|
|
69
|
-
_DEVICE_INVENTORY_CACHE: Optional[dict] = None
|
|
70
|
-
_DEVICE_INVENTORY_CACHE_AT = 0.0
|
|
71
|
-
DEVICE_INVENTORY_CACHE_SECONDS = 5.0
|
|
72
|
-
ROBOVISION_MEDIA_URL = os.getenv("ROBOVISION_MEDIA_URL", "http://127.0.0.1:5000/api/media/inventory")
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _stable_audio_label(value: object) -> str:
|
|
76
|
-
"""Compare USB product names without volatile ALSA card coordinates."""
|
|
77
|
-
import re
|
|
78
|
-
return " ".join(re.sub(r"\s*\(hw:\d+,\d+\)\s*$", "", str(value), flags=re.I).lower().split())
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _resolve_inventory_audio(items: list, selected: object, preferred: str) -> str:
|
|
82
|
-
if preferred:
|
|
83
|
-
wanted = _stable_audio_label(preferred)
|
|
84
|
-
match = next((item for item in items if _stable_audio_label(item.get("name")) == wanted), None)
|
|
85
|
-
if match and match.get("name"):
|
|
86
|
-
return str(match["name"])
|
|
87
|
-
selected_text = str(selected)
|
|
88
|
-
selected_label = _stable_audio_label(selected_text)
|
|
89
|
-
match = next(
|
|
90
|
-
(
|
|
91
|
-
item for item in items
|
|
92
|
-
if str(item.get("id")) == selected_text
|
|
93
|
-
or _stable_audio_label(item.get("name")) == selected_label
|
|
94
|
-
),
|
|
95
|
-
None,
|
|
96
|
-
)
|
|
97
|
-
return str(match.get("name")) if match and match.get("name") else selected_text
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
69
|
+
_DEVICE_INVENTORY_CACHE: Optional[dict] = None
|
|
70
|
+
_DEVICE_INVENTORY_CACHE_AT = 0.0
|
|
71
|
+
DEVICE_INVENTORY_CACHE_SECONDS = 5.0
|
|
72
|
+
ROBOVISION_MEDIA_URL = os.getenv("ROBOVISION_MEDIA_URL", "http://127.0.0.1:5000/api/media/inventory")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _stable_audio_label(value: object) -> str:
|
|
76
|
+
"""Compare USB product names without volatile ALSA card coordinates."""
|
|
77
|
+
import re
|
|
78
|
+
return " ".join(re.sub(r"\s*\(hw:\d+,\d+\)\s*$", "", str(value), flags=re.I).lower().split())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_inventory_audio(items: list, selected: object, preferred: str) -> str:
|
|
82
|
+
if preferred:
|
|
83
|
+
wanted = _stable_audio_label(preferred)
|
|
84
|
+
match = next((item for item in items if _stable_audio_label(item.get("name")) == wanted), None)
|
|
85
|
+
if match and match.get("name"):
|
|
86
|
+
return str(match["name"])
|
|
87
|
+
selected_text = str(selected)
|
|
88
|
+
selected_label = _stable_audio_label(selected_text)
|
|
89
|
+
match = next(
|
|
90
|
+
(
|
|
91
|
+
item for item in items
|
|
92
|
+
if str(item.get("id")) == selected_text
|
|
93
|
+
or _stable_audio_label(item.get("name")) == selected_label
|
|
94
|
+
),
|
|
95
|
+
None,
|
|
96
|
+
)
|
|
97
|
+
return str(match.get("name")) if match and match.get("name") else selected_text
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
101
101
|
"""Avoid Windows localhost IPv6/IPv4 ambiguity for local LiveKit."""
|
|
102
102
|
if not url:
|
|
103
103
|
return url
|
|
@@ -105,105 +105,105 @@ def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
|
105
105
|
prefix = f"{scheme}://localhost"
|
|
106
106
|
if url.startswith(prefix):
|
|
107
107
|
return f"{scheme}://127.0.0.1" + url[len(prefix):]
|
|
108
|
-
return url
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _pcm16_scale_and_peak(data: bytes, gain: float) -> tuple[bytes, int]:
|
|
112
|
-
"""Apply gain and measure peak without audioop (removed in Python 3.13)."""
|
|
113
|
-
from array import array
|
|
114
|
-
|
|
115
|
-
samples = array("h")
|
|
116
|
-
samples.frombytes(data[:len(data) - (len(data) % 2)])
|
|
117
|
-
if sys.byteorder != "little":
|
|
118
|
-
samples.byteswap()
|
|
119
|
-
peak = 0
|
|
120
|
-
for index, value in enumerate(samples):
|
|
121
|
-
scaled = max(-32768, min(32767, int(value * gain))) if gain != 1.0 else value
|
|
122
|
-
samples[index] = scaled
|
|
123
|
-
peak = max(peak, abs(scaled))
|
|
124
|
-
if sys.byteorder != "little":
|
|
125
|
-
samples.byteswap()
|
|
126
|
-
return samples.tobytes(), peak
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def _pcm16_resample_mono(data: bytes, source_rate: int, target_rate: int) -> bytes:
|
|
130
|
-
"""Linearly resample a PCM16 mono chunk using only the standard library."""
|
|
131
|
-
from array import array
|
|
132
|
-
|
|
133
|
-
if source_rate == target_rate or len(data) < 4:
|
|
134
|
-
return data
|
|
135
|
-
source = array("h")
|
|
136
|
-
source.frombytes(data[:len(data) - (len(data) % 2)])
|
|
137
|
-
if sys.byteorder != "little":
|
|
138
|
-
source.byteswap()
|
|
139
|
-
target_count = max(1, round(len(source) * target_rate / source_rate))
|
|
140
|
-
target = array("h", [0]) * target_count
|
|
141
|
-
scale = source_rate / target_rate
|
|
142
|
-
last = len(source) - 1
|
|
143
|
-
for index in range(target_count):
|
|
144
|
-
position = min(last, index * scale)
|
|
145
|
-
left = int(position)
|
|
146
|
-
right = min(last, left + 1)
|
|
147
|
-
fraction = position - left
|
|
148
|
-
target[index] = max(-32768, min(32767, round(
|
|
149
|
-
source[left] + (source[right] - source[left]) * fraction
|
|
150
|
-
)))
|
|
151
|
-
if sys.byteorder != "little":
|
|
152
|
-
target.byteswap()
|
|
153
|
-
return target.tobytes()
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
157
|
-
"""Play a short local cue without involving the voice pipeline."""
|
|
158
|
-
sample_rate = 48000
|
|
159
|
-
channels = 2
|
|
108
|
+
return url
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _pcm16_scale_and_peak(data: bytes, gain: float) -> tuple[bytes, int]:
|
|
112
|
+
"""Apply gain and measure peak without audioop (removed in Python 3.13)."""
|
|
113
|
+
from array import array
|
|
114
|
+
|
|
115
|
+
samples = array("h")
|
|
116
|
+
samples.frombytes(data[:len(data) - (len(data) % 2)])
|
|
117
|
+
if sys.byteorder != "little":
|
|
118
|
+
samples.byteswap()
|
|
119
|
+
peak = 0
|
|
120
|
+
for index, value in enumerate(samples):
|
|
121
|
+
scaled = max(-32768, min(32767, int(value * gain))) if gain != 1.0 else value
|
|
122
|
+
samples[index] = scaled
|
|
123
|
+
peak = max(peak, abs(scaled))
|
|
124
|
+
if sys.byteorder != "little":
|
|
125
|
+
samples.byteswap()
|
|
126
|
+
return samples.tobytes(), peak
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _pcm16_resample_mono(data: bytes, source_rate: int, target_rate: int) -> bytes:
|
|
130
|
+
"""Linearly resample a PCM16 mono chunk using only the standard library."""
|
|
131
|
+
from array import array
|
|
132
|
+
|
|
133
|
+
if source_rate == target_rate or len(data) < 4:
|
|
134
|
+
return data
|
|
135
|
+
source = array("h")
|
|
136
|
+
source.frombytes(data[:len(data) - (len(data) % 2)])
|
|
137
|
+
if sys.byteorder != "little":
|
|
138
|
+
source.byteswap()
|
|
139
|
+
target_count = max(1, round(len(source) * target_rate / source_rate))
|
|
140
|
+
target = array("h", [0]) * target_count
|
|
141
|
+
scale = source_rate / target_rate
|
|
142
|
+
last = len(source) - 1
|
|
143
|
+
for index in range(target_count):
|
|
144
|
+
position = min(last, index * scale)
|
|
145
|
+
left = int(position)
|
|
146
|
+
right = min(last, left + 1)
|
|
147
|
+
fraction = position - left
|
|
148
|
+
target[index] = max(-32768, min(32767, round(
|
|
149
|
+
source[left] + (source[right] - source[left]) * fraction
|
|
150
|
+
)))
|
|
151
|
+
if sys.byteorder != "little":
|
|
152
|
+
target.byteswap()
|
|
153
|
+
return target.tobytes()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
157
|
+
"""Play a short local cue without involving the voice pipeline."""
|
|
158
|
+
sample_rate = 48000
|
|
159
|
+
channels = 2
|
|
160
160
|
if effect == "motion":
|
|
161
161
|
notes = ((880, 0.09), (1320, 0.13))
|
|
162
162
|
else:
|
|
163
163
|
notes = ((660, 0.10), (440, 0.16))
|
|
164
|
-
selected = str(selected_output or "default")
|
|
165
|
-
frames = bytearray()
|
|
166
|
-
for frequency, duration in notes:
|
|
167
|
-
count = int(sample_rate * duration)
|
|
168
|
-
for n in range(count):
|
|
169
|
-
envelope = min(1.0, n / 240.0, (count - n) / 1200.0)
|
|
170
|
-
value = int(5000 * envelope * math.sin(2 * math.pi * frequency * n / sample_rate))
|
|
171
|
-
frames.extend(struct.pack("<hh", value, value))
|
|
172
|
-
|
|
173
|
-
if sys.platform.startswith("linux") and "hw:" in selected:
|
|
174
|
-
import re
|
|
175
|
-
import subprocess
|
|
176
|
-
from media_lock import media_lock
|
|
177
|
-
|
|
178
|
-
match = re.search(r"\b(hw:\d+,\d+)\b", selected)
|
|
179
|
-
if not match:
|
|
180
|
-
return
|
|
181
|
-
try:
|
|
182
|
-
with media_lock("speaker", timeout=3.0):
|
|
183
|
-
result = subprocess.run(
|
|
184
|
-
[
|
|
185
|
-
"aplay", "-q", "-D", f"plug{match.group(1)}", "-t", "raw",
|
|
186
|
-
"-f", "S16_LE", "-r", str(sample_rate), "-c", str(channels),
|
|
187
|
-
],
|
|
188
|
-
input=bytes(frames), capture_output=True, timeout=3.0,
|
|
189
|
-
)
|
|
190
|
-
except TimeoutError as exc:
|
|
191
|
-
logger.warning("audio effect skipped: %s", exc)
|
|
192
|
-
return
|
|
193
|
-
if result.returncode:
|
|
194
|
-
logger.warning(
|
|
195
|
-
"audio effect failed on %s: %s",
|
|
196
|
-
match.group(1), result.stderr.decode("utf-8", errors="replace").strip(),
|
|
197
|
-
)
|
|
198
|
-
return
|
|
199
|
-
|
|
200
|
-
try:
|
|
201
|
-
import pyaudio
|
|
202
|
-
except Exception:
|
|
203
|
-
return
|
|
204
|
-
pa = pyaudio.PyAudio()
|
|
205
|
-
device_index = None
|
|
206
|
-
try:
|
|
164
|
+
selected = str(selected_output or "default")
|
|
165
|
+
frames = bytearray()
|
|
166
|
+
for frequency, duration in notes:
|
|
167
|
+
count = int(sample_rate * duration)
|
|
168
|
+
for n in range(count):
|
|
169
|
+
envelope = min(1.0, n / 240.0, (count - n) / 1200.0)
|
|
170
|
+
value = int(5000 * envelope * math.sin(2 * math.pi * frequency * n / sample_rate))
|
|
171
|
+
frames.extend(struct.pack("<hh", value, value))
|
|
172
|
+
|
|
173
|
+
if sys.platform.startswith("linux") and "hw:" in selected:
|
|
174
|
+
import re
|
|
175
|
+
import subprocess
|
|
176
|
+
from media_lock import media_lock
|
|
177
|
+
|
|
178
|
+
match = re.search(r"\b(hw:\d+,\d+)\b", selected)
|
|
179
|
+
if not match:
|
|
180
|
+
return
|
|
181
|
+
try:
|
|
182
|
+
with media_lock("speaker", timeout=3.0):
|
|
183
|
+
result = subprocess.run(
|
|
184
|
+
[
|
|
185
|
+
"aplay", "-q", "-D", f"plug{match.group(1)}", "-t", "raw",
|
|
186
|
+
"-f", "S16_LE", "-r", str(sample_rate), "-c", str(channels),
|
|
187
|
+
],
|
|
188
|
+
input=bytes(frames), capture_output=True, timeout=3.0,
|
|
189
|
+
)
|
|
190
|
+
except TimeoutError as exc:
|
|
191
|
+
logger.warning("audio effect skipped: %s", exc)
|
|
192
|
+
return
|
|
193
|
+
if result.returncode:
|
|
194
|
+
logger.warning(
|
|
195
|
+
"audio effect failed on %s: %s",
|
|
196
|
+
match.group(1), result.stderr.decode("utf-8", errors="replace").strip(),
|
|
197
|
+
)
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
import pyaudio
|
|
202
|
+
except Exception:
|
|
203
|
+
return
|
|
204
|
+
pa = pyaudio.PyAudio()
|
|
205
|
+
device_index = None
|
|
206
|
+
try:
|
|
207
207
|
if selected.strip().isdigit():
|
|
208
208
|
device_index = int(selected.strip())
|
|
209
209
|
elif selected.lower() == "default":
|
|
@@ -223,7 +223,7 @@ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
|
223
223
|
output=True,
|
|
224
224
|
output_device_index=device_index,
|
|
225
225
|
)
|
|
226
|
-
stream.write(bytes(frames))
|
|
226
|
+
stream.write(bytes(frames))
|
|
227
227
|
stream.stop_stream()
|
|
228
228
|
stream.close()
|
|
229
229
|
except Exception as e:
|
|
@@ -232,109 +232,109 @@ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
|
232
232
|
pa.terminate()
|
|
233
233
|
|
|
234
234
|
|
|
235
|
-
def _get_device_inventory() -> dict:
|
|
236
|
-
"""Return discoverable camera and audio devices for dashboard selection."""
|
|
237
|
-
global _DEVICE_INVENTORY_CACHE, _DEVICE_INVENTORY_CACHE_AT
|
|
238
|
-
now = time.monotonic()
|
|
239
|
-
if (_DEVICE_INVENTORY_CACHE is not None
|
|
240
|
-
and now - _DEVICE_INVENTORY_CACHE_AT < DEVICE_INVENTORY_CACHE_SECONDS):
|
|
241
|
-
return _DEVICE_INVENTORY_CACHE
|
|
242
|
-
|
|
243
|
-
inventory = {"video": [], "audio_input": [], "audio_output": [], "platform": sys.platform}
|
|
244
|
-
inventory["video"].append({"id": "auto", "name": "Auto detect"})
|
|
245
|
-
inventory["video"].append({"id": "none", "name": "Disable camera"})
|
|
246
|
-
|
|
247
|
-
# RoboVisionAI_PI owns the production camera. Prefer its native inventory
|
|
248
|
-
# so this process never probes an already-open V4L2 device just to fill a
|
|
249
|
-
# dashboard dropdown.
|
|
250
|
-
robovision_inventory = None
|
|
251
|
-
try:
|
|
252
|
-
response = httpx.get(ROBOVISION_MEDIA_URL, timeout=0.8)
|
|
253
|
-
if response.is_success:
|
|
254
|
-
robovision_inventory = response.json()
|
|
255
|
-
for key in ("video", "audio_input", "audio_output"):
|
|
256
|
-
if isinstance(robovision_inventory.get(key), list):
|
|
257
|
-
inventory[key] = robovision_inventory[key]
|
|
258
|
-
inventory["source"] = robovision_inventory.get("source", "robovision_pi")
|
|
259
|
-
except Exception:
|
|
260
|
-
pass
|
|
261
|
-
|
|
262
|
-
if not robovision_inventory:
|
|
263
|
-
try:
|
|
264
|
-
import cv2
|
|
265
|
-
candidates = sorted(glob.glob("/dev/video*")) if os.name != "nt" else [str(i) for i in range(10)]
|
|
266
|
-
for candidate in candidates:
|
|
267
|
-
value = int(candidate) if os.name == "nt" else candidate
|
|
268
|
-
backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY
|
|
269
|
-
cap = cv2.VideoCapture(value, backend)
|
|
270
|
-
if cap.isOpened():
|
|
271
|
-
device_id = str(value)
|
|
272
|
-
inventory["video"].append({"id": device_id, "name": f"Camera {candidate}", "backend": "dshow" if os.name == "nt" else "v4l2"})
|
|
273
|
-
cap.release()
|
|
274
|
-
except Exception as e:
|
|
275
|
-
logger.debug(f"camera inventory unavailable: {e}")
|
|
276
|
-
|
|
277
|
-
if not robovision_inventory:
|
|
278
|
-
try:
|
|
279
|
-
import pyaudio
|
|
280
|
-
pa = pyaudio.PyAudio()
|
|
281
|
-
default_in = None
|
|
282
|
-
default_out = None
|
|
283
|
-
try:
|
|
284
|
-
wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
|
|
285
|
-
default_in = wasapi.get("defaultInputDevice")
|
|
286
|
-
default_out = wasapi.get("defaultOutputDevice")
|
|
287
|
-
except Exception:
|
|
288
|
-
pass
|
|
289
|
-
inventory["audio_input"].append({"id": "default", "name": "System default input"})
|
|
290
|
-
inventory["audio_output"].append({"id": "default", "name": "System default output"})
|
|
291
|
-
for i in range(pa.get_device_count()):
|
|
292
|
-
info = pa.get_device_info_by_index(i)
|
|
293
|
-
name = str(info.get("name", f"Audio device {i}"))
|
|
294
|
-
item = {"id": str(i), "name": name, "host_api": str(info.get("hostApi", ""))}
|
|
295
|
-
if info.get("maxInputChannels", 0) > 0:
|
|
296
|
-
item["default"] = i == default_in
|
|
297
|
-
inventory["audio_input"].append(item.copy())
|
|
298
|
-
if info.get("maxOutputChannels", 0) > 0:
|
|
299
|
-
item["default"] = i == default_out
|
|
300
|
-
inventory["audio_output"].append(item.copy())
|
|
301
|
-
pa.terminate()
|
|
302
|
-
except Exception as e:
|
|
303
|
-
logger.debug(f"audio inventory unavailable: {e}")
|
|
304
|
-
|
|
305
|
-
media_health = {
|
|
306
|
-
"service_uid": os.geteuid() if hasattr(os, "geteuid") else None,
|
|
307
|
-
"camera_access": None,
|
|
308
|
-
"audio_access": None,
|
|
309
|
-
"camera_worker": None,
|
|
310
|
-
"camera_stalled": None,
|
|
311
|
-
"camera_frame_age_seconds": None,
|
|
312
|
-
}
|
|
313
|
-
if sys.platform.startswith("linux"):
|
|
314
|
-
camera_nodes = [
|
|
315
|
-
str(item.get("id")) for item in inventory.get("video", [])
|
|
316
|
-
if str(item.get("id", "")).startswith("/dev/video")
|
|
317
|
-
]
|
|
318
|
-
sound_nodes = glob.glob("/dev/snd/pcm*")
|
|
319
|
-
media_health["camera_access"] = bool(camera_nodes) and all(
|
|
320
|
-
os.access(path, os.R_OK | os.W_OK) for path in camera_nodes[:1]
|
|
321
|
-
)
|
|
322
|
-
media_health["audio_access"] = bool(sound_nodes) and all(
|
|
323
|
-
os.access(path, os.R_OK | os.W_OK) for path in sound_nodes
|
|
324
|
-
)
|
|
325
|
-
try:
|
|
326
|
-
camera_response = httpx.get("http://127.0.0.1:5000/api/camera/status", timeout=0.6)
|
|
327
|
-
if camera_response.is_success:
|
|
328
|
-
camera_status = camera_response.json()
|
|
329
|
-
media_health["camera_worker"] = bool(camera_status.get("worker_started"))
|
|
330
|
-
media_health["camera_stalled"] = bool(camera_status.get("read_stalled"))
|
|
331
|
-
media_health["camera_frame_age_seconds"] = camera_status.get("last_frame_age_seconds")
|
|
332
|
-
except Exception:
|
|
333
|
-
pass
|
|
334
|
-
inventory["media_health"] = media_health
|
|
335
|
-
|
|
336
|
-
_DEVICE_INVENTORY_CACHE = inventory
|
|
337
|
-
_DEVICE_INVENTORY_CACHE_AT = now
|
|
235
|
+
def _get_device_inventory() -> dict:
|
|
236
|
+
"""Return discoverable camera and audio devices for dashboard selection."""
|
|
237
|
+
global _DEVICE_INVENTORY_CACHE, _DEVICE_INVENTORY_CACHE_AT
|
|
238
|
+
now = time.monotonic()
|
|
239
|
+
if (_DEVICE_INVENTORY_CACHE is not None
|
|
240
|
+
and now - _DEVICE_INVENTORY_CACHE_AT < DEVICE_INVENTORY_CACHE_SECONDS):
|
|
241
|
+
return _DEVICE_INVENTORY_CACHE
|
|
242
|
+
|
|
243
|
+
inventory = {"video": [], "audio_input": [], "audio_output": [], "platform": sys.platform}
|
|
244
|
+
inventory["video"].append({"id": "auto", "name": "Auto detect"})
|
|
245
|
+
inventory["video"].append({"id": "none", "name": "Disable camera"})
|
|
246
|
+
|
|
247
|
+
# RoboVisionAI_PI owns the production camera. Prefer its native inventory
|
|
248
|
+
# so this process never probes an already-open V4L2 device just to fill a
|
|
249
|
+
# dashboard dropdown.
|
|
250
|
+
robovision_inventory = None
|
|
251
|
+
try:
|
|
252
|
+
response = httpx.get(ROBOVISION_MEDIA_URL, timeout=0.8)
|
|
253
|
+
if response.is_success:
|
|
254
|
+
robovision_inventory = response.json()
|
|
255
|
+
for key in ("video", "audio_input", "audio_output"):
|
|
256
|
+
if isinstance(robovision_inventory.get(key), list):
|
|
257
|
+
inventory[key] = robovision_inventory[key]
|
|
258
|
+
inventory["source"] = robovision_inventory.get("source", "robovision_pi")
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
if not robovision_inventory:
|
|
263
|
+
try:
|
|
264
|
+
import cv2
|
|
265
|
+
candidates = sorted(glob.glob("/dev/video*")) if os.name != "nt" else [str(i) for i in range(10)]
|
|
266
|
+
for candidate in candidates:
|
|
267
|
+
value = int(candidate) if os.name == "nt" else candidate
|
|
268
|
+
backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY
|
|
269
|
+
cap = cv2.VideoCapture(value, backend)
|
|
270
|
+
if cap.isOpened():
|
|
271
|
+
device_id = str(value)
|
|
272
|
+
inventory["video"].append({"id": device_id, "name": f"Camera {candidate}", "backend": "dshow" if os.name == "nt" else "v4l2"})
|
|
273
|
+
cap.release()
|
|
274
|
+
except Exception as e:
|
|
275
|
+
logger.debug(f"camera inventory unavailable: {e}")
|
|
276
|
+
|
|
277
|
+
if not robovision_inventory:
|
|
278
|
+
try:
|
|
279
|
+
import pyaudio
|
|
280
|
+
pa = pyaudio.PyAudio()
|
|
281
|
+
default_in = None
|
|
282
|
+
default_out = None
|
|
283
|
+
try:
|
|
284
|
+
wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
|
|
285
|
+
default_in = wasapi.get("defaultInputDevice")
|
|
286
|
+
default_out = wasapi.get("defaultOutputDevice")
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
inventory["audio_input"].append({"id": "default", "name": "System default input"})
|
|
290
|
+
inventory["audio_output"].append({"id": "default", "name": "System default output"})
|
|
291
|
+
for i in range(pa.get_device_count()):
|
|
292
|
+
info = pa.get_device_info_by_index(i)
|
|
293
|
+
name = str(info.get("name", f"Audio device {i}"))
|
|
294
|
+
item = {"id": str(i), "name": name, "host_api": str(info.get("hostApi", ""))}
|
|
295
|
+
if info.get("maxInputChannels", 0) > 0:
|
|
296
|
+
item["default"] = i == default_in
|
|
297
|
+
inventory["audio_input"].append(item.copy())
|
|
298
|
+
if info.get("maxOutputChannels", 0) > 0:
|
|
299
|
+
item["default"] = i == default_out
|
|
300
|
+
inventory["audio_output"].append(item.copy())
|
|
301
|
+
pa.terminate()
|
|
302
|
+
except Exception as e:
|
|
303
|
+
logger.debug(f"audio inventory unavailable: {e}")
|
|
304
|
+
|
|
305
|
+
media_health = {
|
|
306
|
+
"service_uid": os.geteuid() if hasattr(os, "geteuid") else None,
|
|
307
|
+
"camera_access": None,
|
|
308
|
+
"audio_access": None,
|
|
309
|
+
"camera_worker": None,
|
|
310
|
+
"camera_stalled": None,
|
|
311
|
+
"camera_frame_age_seconds": None,
|
|
312
|
+
}
|
|
313
|
+
if sys.platform.startswith("linux"):
|
|
314
|
+
camera_nodes = [
|
|
315
|
+
str(item.get("id")) for item in inventory.get("video", [])
|
|
316
|
+
if str(item.get("id", "")).startswith("/dev/video")
|
|
317
|
+
]
|
|
318
|
+
sound_nodes = glob.glob("/dev/snd/pcm*")
|
|
319
|
+
media_health["camera_access"] = bool(camera_nodes) and all(
|
|
320
|
+
os.access(path, os.R_OK | os.W_OK) for path in camera_nodes[:1]
|
|
321
|
+
)
|
|
322
|
+
media_health["audio_access"] = bool(sound_nodes) and all(
|
|
323
|
+
os.access(path, os.R_OK | os.W_OK) for path in sound_nodes
|
|
324
|
+
)
|
|
325
|
+
try:
|
|
326
|
+
camera_response = httpx.get("http://127.0.0.1:5000/api/camera/status", timeout=0.6)
|
|
327
|
+
if camera_response.is_success:
|
|
328
|
+
camera_status = camera_response.json()
|
|
329
|
+
media_health["camera_worker"] = bool(camera_status.get("worker_started"))
|
|
330
|
+
media_health["camera_stalled"] = bool(camera_status.get("read_stalled"))
|
|
331
|
+
media_health["camera_frame_age_seconds"] = camera_status.get("last_frame_age_seconds")
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
inventory["media_health"] = media_health
|
|
335
|
+
|
|
336
|
+
_DEVICE_INVENTORY_CACHE = inventory
|
|
337
|
+
_DEVICE_INVENTORY_CACHE_AT = now
|
|
338
338
|
logger.info(
|
|
339
339
|
f"device inventory: {len(inventory['video']) - 2} cameras, "
|
|
340
340
|
f"{len(inventory['audio_input']) - 1} inputs, "
|
|
@@ -385,57 +385,57 @@ def _load_token() -> Optional[str]:
|
|
|
385
385
|
return None
|
|
386
386
|
|
|
387
387
|
|
|
388
|
-
def _save_token(token: str) -> None:
|
|
389
|
-
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
390
|
-
TOKEN_FILE.write_text(token, encoding="utf8")
|
|
391
|
-
os.chmod(TOKEN_FILE, 0o600)
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
def _mesh_proxy_headers() -> dict:
|
|
395
|
-
"""Add hub authentication only when the runtime supplied it.
|
|
396
|
-
|
|
397
|
-
Direct LAN scheduler calls work unchanged. Tailscale calls use the hub's
|
|
398
|
-
/robopark proxy, which needs this separate mesh credential while the normal
|
|
399
|
-
Authorization header remains the scheduler device token.
|
|
400
|
-
"""
|
|
401
|
-
token = os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
|
|
402
|
-
return {"X-RoboPark-Mesh-Token": token} if token else {}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
async def _bootstrap_mesh_device(scheduler_url: str, robot_id: str) -> tuple[str, str]:
|
|
406
|
-
"""Create or recover this robot's scheduler identity using mesh auth."""
|
|
407
|
-
async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
|
|
408
|
-
response = await client.post(
|
|
409
|
-
f"{scheduler_url.rstrip('/')}/api/devices/bootstrap",
|
|
410
|
-
json={"name": robot_id, "lan_ip": _get_lan_ip(), "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
|
|
411
|
-
timeout=30.0,
|
|
412
|
-
)
|
|
413
|
-
response.raise_for_status()
|
|
414
|
-
data = response.json()
|
|
415
|
-
device_id = str(data.get("device_id") or "").strip()
|
|
416
|
-
device_token = str(data.get("device_token") or "").strip()
|
|
417
|
-
if not device_id or not device_token:
|
|
418
|
-
raise RuntimeError("mesh bootstrap did not return device credentials")
|
|
419
|
-
_save_token(device_token)
|
|
420
|
-
cfg = _load_config()
|
|
421
|
-
cfg["device_id"] = device_id
|
|
422
|
-
cfg["device_token"] = device_token
|
|
423
|
-
cfg["scheduler_url"] = scheduler_url
|
|
424
|
-
_save_config(cfg)
|
|
425
|
-
logger.info("mesh bootstrap resolved scheduler device %s", device_id)
|
|
426
|
-
return device_id, device_token
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> tuple[str, str]:
|
|
430
|
-
"""Enroll this Pi and return the exact scheduler identity and token."""
|
|
388
|
+
def _save_token(token: str) -> None:
|
|
389
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
390
|
+
TOKEN_FILE.write_text(token, encoding="utf8")
|
|
391
|
+
os.chmod(TOKEN_FILE, 0o600)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _mesh_proxy_headers() -> dict:
|
|
395
|
+
"""Add hub authentication only when the runtime supplied it.
|
|
396
|
+
|
|
397
|
+
Direct LAN scheduler calls work unchanged. Tailscale calls use the hub's
|
|
398
|
+
/robopark proxy, which needs this separate mesh credential while the normal
|
|
399
|
+
Authorization header remains the scheduler device token.
|
|
400
|
+
"""
|
|
401
|
+
token = os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
|
|
402
|
+
return {"X-RoboPark-Mesh-Token": token} if token else {}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
async def _bootstrap_mesh_device(scheduler_url: str, robot_id: str) -> tuple[str, str]:
|
|
406
|
+
"""Create or recover this robot's scheduler identity using mesh auth."""
|
|
407
|
+
async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
|
|
408
|
+
response = await client.post(
|
|
409
|
+
f"{scheduler_url.rstrip('/')}/api/devices/bootstrap",
|
|
410
|
+
json={"name": robot_id, "lan_ip": _get_lan_ip(), "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
|
|
411
|
+
timeout=30.0,
|
|
412
|
+
)
|
|
413
|
+
response.raise_for_status()
|
|
414
|
+
data = response.json()
|
|
415
|
+
device_id = str(data.get("device_id") or "").strip()
|
|
416
|
+
device_token = str(data.get("device_token") or "").strip()
|
|
417
|
+
if not device_id or not device_token:
|
|
418
|
+
raise RuntimeError("mesh bootstrap did not return device credentials")
|
|
419
|
+
_save_token(device_token)
|
|
420
|
+
cfg = _load_config()
|
|
421
|
+
cfg["device_id"] = device_id
|
|
422
|
+
cfg["device_token"] = device_token
|
|
423
|
+
cfg["scheduler_url"] = scheduler_url
|
|
424
|
+
_save_config(cfg)
|
|
425
|
+
logger.info("mesh bootstrap resolved scheduler device %s", device_id)
|
|
426
|
+
return device_id, device_token
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> tuple[str, str]:
|
|
430
|
+
"""Enroll this Pi and return the exact scheduler identity and token."""
|
|
431
431
|
import socket
|
|
432
432
|
payload = {
|
|
433
433
|
"enrollment_token": enrollment_token,
|
|
434
|
-
"name": robot_id,
|
|
435
|
-
"lan_ip": _get_lan_ip(),
|
|
436
|
-
"livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None,
|
|
434
|
+
"name": robot_id,
|
|
435
|
+
"lan_ip": _get_lan_ip(),
|
|
436
|
+
"livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None,
|
|
437
437
|
}
|
|
438
|
-
async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
|
|
438
|
+
async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
|
|
439
439
|
r = await client.post(
|
|
440
440
|
f"{scheduler_url.rstrip('/')}/api/devices/enroll",
|
|
441
441
|
json=payload,
|
|
@@ -443,17 +443,17 @@ async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> t
|
|
|
443
443
|
)
|
|
444
444
|
r.raise_for_status()
|
|
445
445
|
data = r.json()
|
|
446
|
-
device_id = str(data.get("device_id") or "").strip()
|
|
447
|
-
device_token = str(data.get("device_token") or "").strip()
|
|
448
|
-
if not device_id or not device_token:
|
|
449
|
-
raise RuntimeError("enrollment did not return device credentials")
|
|
446
|
+
device_id = str(data.get("device_id") or "").strip()
|
|
447
|
+
device_token = str(data.get("device_token") or "").strip()
|
|
448
|
+
if not device_id or not device_token:
|
|
449
|
+
raise RuntimeError("enrollment did not return device credentials")
|
|
450
450
|
_save_token(device_token)
|
|
451
451
|
cfg = _load_config()
|
|
452
|
-
cfg["device_id"] = device_id
|
|
452
|
+
cfg["device_id"] = device_id
|
|
453
453
|
cfg["scheduler_url"] = data.get("scheduler_url", scheduler_url)
|
|
454
454
|
_save_config(cfg)
|
|
455
|
-
logger.info("enrolled as device %s", device_id)
|
|
456
|
-
return device_id, device_token
|
|
455
|
+
logger.info("enrolled as device %s", device_id)
|
|
456
|
+
return device_id, device_token
|
|
457
457
|
|
|
458
458
|
|
|
459
459
|
def _get_lan_ip() -> Optional[str]:
|
|
@@ -468,39 +468,39 @@ def _get_lan_ip() -> Optional[str]:
|
|
|
468
468
|
return None
|
|
469
469
|
|
|
470
470
|
|
|
471
|
-
async def _send_heartbeat(
|
|
472
|
-
scheduler_url: str, device_id: str, token: str
|
|
473
|
-
) -> tuple[Optional[bool], bool]:
|
|
474
|
-
try:
|
|
475
|
-
inventory = _get_device_inventory()
|
|
476
|
-
headers = {"Authorization": f"Bearer {token}", **_mesh_proxy_headers()}
|
|
477
|
-
async with httpx.AsyncClient() as client:
|
|
478
|
-
response = await client.post(
|
|
479
|
-
f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
|
|
480
|
-
json={"status": "online", "ip": _get_lan_ip(), "device_inventory": inventory,
|
|
481
|
-
"livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
|
|
482
|
-
headers=headers,
|
|
483
|
-
timeout=10.0,
|
|
484
|
-
)
|
|
485
|
-
if response.status_code in (401, 404):
|
|
486
|
-
logger.warning(
|
|
487
|
-
"heartbeat credentials rejected for %s (%s)",
|
|
488
|
-
device_id,
|
|
489
|
-
response.status_code,
|
|
490
|
-
)
|
|
491
|
-
return None, False
|
|
492
|
-
response.raise_for_status()
|
|
493
|
-
logger.info(
|
|
494
|
-
"heartbeat accepted: inventory source=%s camera=%d mic=%d speaker=%d",
|
|
495
|
-
inventory.get("source", "local"),
|
|
496
|
-
len(inventory.get("video", [])),
|
|
497
|
-
len(inventory.get("audio_input", [])),
|
|
498
|
-
len(inventory.get("audio_output", [])),
|
|
499
|
-
)
|
|
500
|
-
return bool(response.json().get("production_mode", False)), True
|
|
501
|
-
except Exception as e:
|
|
502
|
-
logger.warning(f"heartbeat failed: {e}")
|
|
503
|
-
return None, True
|
|
471
|
+
async def _send_heartbeat(
|
|
472
|
+
scheduler_url: str, device_id: str, token: str
|
|
473
|
+
) -> tuple[Optional[bool], bool]:
|
|
474
|
+
try:
|
|
475
|
+
inventory = _get_device_inventory()
|
|
476
|
+
headers = {"Authorization": f"Bearer {token}", **_mesh_proxy_headers()}
|
|
477
|
+
async with httpx.AsyncClient() as client:
|
|
478
|
+
response = await client.post(
|
|
479
|
+
f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
|
|
480
|
+
json={"status": "online", "ip": _get_lan_ip(), "device_inventory": inventory,
|
|
481
|
+
"livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
|
|
482
|
+
headers=headers,
|
|
483
|
+
timeout=10.0,
|
|
484
|
+
)
|
|
485
|
+
if response.status_code in (401, 404):
|
|
486
|
+
logger.warning(
|
|
487
|
+
"heartbeat credentials rejected for %s (%s)",
|
|
488
|
+
device_id,
|
|
489
|
+
response.status_code,
|
|
490
|
+
)
|
|
491
|
+
return None, False
|
|
492
|
+
response.raise_for_status()
|
|
493
|
+
logger.info(
|
|
494
|
+
"heartbeat accepted: inventory source=%s camera=%d mic=%d speaker=%d",
|
|
495
|
+
inventory.get("source", "local"),
|
|
496
|
+
len(inventory.get("video", [])),
|
|
497
|
+
len(inventory.get("audio_input", [])),
|
|
498
|
+
len(inventory.get("audio_output", [])),
|
|
499
|
+
)
|
|
500
|
+
return bool(response.json().get("production_mode", False)), True
|
|
501
|
+
except Exception as e:
|
|
502
|
+
logger.warning(f"heartbeat failed: {e}")
|
|
503
|
+
return None, True
|
|
504
504
|
|
|
505
505
|
|
|
506
506
|
@dataclass
|
|
@@ -521,18 +521,18 @@ class PreviewAgent:
|
|
|
521
521
|
self.device_id: Optional[str] = cfg.get("device_id")
|
|
522
522
|
self.enrollment_token: Optional[str] = cfg.get("enrollment_token")
|
|
523
523
|
self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
|
|
524
|
-
self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
|
|
525
|
-
# Scheduler inventory IDs come from RoboVision/sounddevice. They are
|
|
526
|
-
# not guaranteed to equal PyAudio's device indexes, so capture must
|
|
527
|
-
# use the resolved hardware name reported in that same inventory.
|
|
528
|
-
self.audio_capture_device = self.audio_device
|
|
529
|
-
self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
|
|
530
|
-
# Keep RoboVision/sounddevice inventory IDs out of PyAudio. Both APIs
|
|
531
|
-
# number the same ALSA cards differently, so resolve the selected ID
|
|
532
|
-
# to its hardware name before opening any playback stream.
|
|
533
|
-
self.audio_playback_device = self.audio_output_device
|
|
534
|
-
self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
|
|
535
|
-
self.use_robovision_camera = bool(cfg.get("use_robovision_camera", True))
|
|
524
|
+
self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
|
|
525
|
+
# Scheduler inventory IDs come from RoboVision/sounddevice. They are
|
|
526
|
+
# not guaranteed to equal PyAudio's device indexes, so capture must
|
|
527
|
+
# use the resolved hardware name reported in that same inventory.
|
|
528
|
+
self.audio_capture_device = self.audio_device
|
|
529
|
+
self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
|
|
530
|
+
# Keep RoboVision/sounddevice inventory IDs out of PyAudio. Both APIs
|
|
531
|
+
# number the same ALSA cards differently, so resolve the selected ID
|
|
532
|
+
# to its hardware name before opening any playback stream.
|
|
533
|
+
self.audio_playback_device = self.audio_output_device
|
|
534
|
+
self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
|
|
535
|
+
self.use_robovision_camera = bool(cfg.get("use_robovision_camera", True))
|
|
536
536
|
self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
|
|
537
537
|
self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
|
|
538
538
|
self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
|
|
@@ -542,17 +542,17 @@ class PreviewAgent:
|
|
|
542
542
|
self.vision_trigger_cooldown = float(cfg.get("vision_trigger_cooldown", os.getenv("VISION_TRIGGER_COOLDOWN", DEFAULT_VISION_TRIGGER_COOLDOWN)))
|
|
543
543
|
self.vision_session_seconds = float(cfg.get("vision_session_seconds", os.getenv("VISION_SESSION_SECONDS", DEFAULT_VISION_SESSION_SECONDS)))
|
|
544
544
|
self.vision_silence_timeout = float(cfg.get("vision_silence_timeout", os.getenv("VISION_SILENCE_TIMEOUT", DEFAULT_VISION_SILENCE_TIMEOUT)))
|
|
545
|
-
self.local_camera_motion = str(
|
|
545
|
+
self.local_camera_motion = str(
|
|
546
546
|
# Production needs an always-on detector. The preview publisher
|
|
547
547
|
# remains the single camera owner when a session starts, so this
|
|
548
548
|
# does not require the separate OpenCV vision server.
|
|
549
549
|
cfg.get("local_camera_motion", os.getenv("LOCAL_CAMERA_MOTION", "true"))
|
|
550
|
-
).lower() in ("1", "true", "yes", "on")
|
|
551
|
-
# The character greeting is the production acknowledgement. A local
|
|
552
|
-
# motion beep serializes on the same ALSA output and delays allocation.
|
|
553
|
-
self.motion_cue_enabled = str(
|
|
554
|
-
cfg.get("motion_cue_enabled", os.getenv("ROBOPARK_MOTION_CUE", "false"))
|
|
555
|
-
).lower() in ("1", "true", "yes", "on")
|
|
550
|
+
).lower() in ("1", "true", "yes", "on")
|
|
551
|
+
# The character greeting is the production acknowledgement. A local
|
|
552
|
+
# motion beep serializes on the same ALSA output and delays allocation.
|
|
553
|
+
self.motion_cue_enabled = str(
|
|
554
|
+
cfg.get("motion_cue_enabled", os.getenv("ROBOPARK_MOTION_CUE", "false"))
|
|
555
|
+
).lower() in ("1", "true", "yes", "on")
|
|
556
556
|
|
|
557
557
|
self._shutdown = asyncio.Event()
|
|
558
558
|
self._task: Optional[asyncio.Task] = None
|
|
@@ -566,96 +566,96 @@ class PreviewAgent:
|
|
|
566
566
|
# with a hard ceiling — see DEFAULT_VISION_SILENCE_TIMEOUT above and
|
|
567
567
|
# _check_vision_session() below for the full design.
|
|
568
568
|
self._vision_session_id: Optional[str] = None
|
|
569
|
-
self._vision_trigger_in_flight = False
|
|
570
|
-
self._speaker_test_in_flight = False
|
|
571
|
-
# The USB adapter is an exclusive ALSA endpoint. Keep operator tests
|
|
572
|
-
# and motion cues from racing each other while LiveKit is paused.
|
|
573
|
-
self._speaker_operation_lock = asyncio.Lock()
|
|
569
|
+
self._vision_trigger_in_flight = False
|
|
570
|
+
self._speaker_test_in_flight = False
|
|
571
|
+
# The USB adapter is an exclusive ALSA endpoint. Keep operator tests
|
|
572
|
+
# and motion cues from racing each other while LiveKit is paused.
|
|
573
|
+
self._speaker_operation_lock = asyncio.Lock()
|
|
574
574
|
self._vision_hard_deadline: float = 0.0
|
|
575
575
|
self._vision_last_activity: float = 0.0
|
|
576
|
-
self.production_mode = False
|
|
577
|
-
self._robovision_motion_state: Optional[bool] = None
|
|
576
|
+
self.production_mode = False
|
|
577
|
+
self._robovision_motion_state: Optional[bool] = None
|
|
578
578
|
self._remote_session_ended = False
|
|
579
579
|
self._last_device_config_poll = 0.0
|
|
580
580
|
self._motion_reference = None
|
|
581
581
|
self._last_motion_sample = 0.0
|
|
582
|
-
self._motion_capture: Optional[VideoCapture] = None
|
|
583
|
-
self._motion_capture_lock = asyncio.Lock()
|
|
584
|
-
self._mesh_bootstrap_ready = False
|
|
585
|
-
self._next_mesh_bootstrap = 0.0
|
|
586
|
-
self._reported_pipeline: set[tuple[str, str]] = set()
|
|
587
|
-
|
|
588
|
-
async def _report_pipeline(self, stage: str, status: str = "ok", message: str = "",
|
|
589
|
-
details: Optional[dict] = None, once: bool = True) -> None:
|
|
590
|
-
"""Report a real robot-side transition for the Park test timeline."""
|
|
591
|
-
if not self._session or not self.device_id or not self.device_token:
|
|
592
|
-
return
|
|
593
|
-
session_id = self._vision_session_id or self._current_state.session_id
|
|
594
|
-
key = (session_id or "idle", stage)
|
|
595
|
-
if once and key in self._reported_pipeline:
|
|
596
|
-
return
|
|
597
|
-
try:
|
|
598
|
-
response = await self._session.post(
|
|
599
|
-
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/pipeline-events",
|
|
600
|
-
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
601
|
-
json={"stage": stage, "status": status, "message": message,
|
|
602
|
-
"session_id": session_id, "source": "preview_agent",
|
|
603
|
-
"details": details or {}},
|
|
604
|
-
timeout=5.0,
|
|
605
|
-
)
|
|
606
|
-
response.raise_for_status()
|
|
607
|
-
if once:
|
|
608
|
-
self._reported_pipeline.add(key)
|
|
609
|
-
except Exception as exc:
|
|
610
|
-
logger.debug("pipeline event %s was not accepted: %s", stage, exc)
|
|
611
|
-
|
|
612
|
-
async def _ensure_mesh_identity(self) -> bool:
|
|
613
|
-
if not _mesh_proxy_headers():
|
|
614
|
-
return bool(self.device_id and self.device_token)
|
|
615
|
-
now = time.monotonic()
|
|
616
|
-
if self._mesh_bootstrap_ready:
|
|
617
|
-
return True
|
|
618
|
-
if now < self._next_mesh_bootstrap:
|
|
619
|
-
return False
|
|
620
|
-
self._next_mesh_bootstrap = now + 5.0
|
|
621
|
-
try:
|
|
622
|
-
self.device_id, self.device_token = await _bootstrap_mesh_device(
|
|
623
|
-
self.scheduler_url, self.robot_id
|
|
624
|
-
)
|
|
625
|
-
self._mesh_bootstrap_ready = True
|
|
626
|
-
return True
|
|
627
|
-
except Exception as exc:
|
|
628
|
-
logger.warning("mesh device bootstrap failed: %s", exc)
|
|
629
|
-
return False
|
|
630
|
-
|
|
631
|
-
async def run(self) -> None:
|
|
632
|
-
enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
|
|
633
|
-
# A UI-minted token identifies a specific pre-created device row.
|
|
634
|
-
# Consume it before generic mesh recovery so heartbeats cannot bind to
|
|
635
|
-
# a stale same-name identity.
|
|
636
|
-
if not self.device_token and enrollment_token:
|
|
637
|
-
try:
|
|
638
|
-
self.device_id, self.device_token = await _enroll(
|
|
639
|
-
self.scheduler_url, enrollment_token, self.robot_id
|
|
640
|
-
)
|
|
641
|
-
self._mesh_bootstrap_ready = True
|
|
642
|
-
except Exception as exc:
|
|
643
|
-
if not _mesh_proxy_headers():
|
|
644
|
-
raise
|
|
645
|
-
# Enrollment tokens are intentionally one-time. A service
|
|
646
|
-
# reinstall can retain the original systemd argument after
|
|
647
|
-
# its credential file was removed; recover through the hub's
|
|
648
|
-
# authenticated mesh path rather than crash-loop forever.
|
|
649
|
-
logger.warning("device enrollment failed; recovering through mesh bootstrap: %s", exc)
|
|
650
|
-
await self._ensure_mesh_identity()
|
|
651
|
-
elif _mesh_proxy_headers():
|
|
652
|
-
await self._ensure_mesh_identity()
|
|
582
|
+
self._motion_capture: Optional[VideoCapture] = None
|
|
583
|
+
self._motion_capture_lock = asyncio.Lock()
|
|
584
|
+
self._mesh_bootstrap_ready = False
|
|
585
|
+
self._next_mesh_bootstrap = 0.0
|
|
586
|
+
self._reported_pipeline: set[tuple[str, str]] = set()
|
|
587
|
+
|
|
588
|
+
async def _report_pipeline(self, stage: str, status: str = "ok", message: str = "",
|
|
589
|
+
details: Optional[dict] = None, once: bool = True) -> None:
|
|
590
|
+
"""Report a real robot-side transition for the Park test timeline."""
|
|
591
|
+
if not self._session or not self.device_id or not self.device_token:
|
|
592
|
+
return
|
|
593
|
+
session_id = self._vision_session_id or self._current_state.session_id
|
|
594
|
+
key = (session_id or "idle", stage)
|
|
595
|
+
if once and key in self._reported_pipeline:
|
|
596
|
+
return
|
|
597
|
+
try:
|
|
598
|
+
response = await self._session.post(
|
|
599
|
+
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/pipeline-events",
|
|
600
|
+
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
601
|
+
json={"stage": stage, "status": status, "message": message,
|
|
602
|
+
"session_id": session_id, "source": "preview_agent",
|
|
603
|
+
"details": details or {}},
|
|
604
|
+
timeout=5.0,
|
|
605
|
+
)
|
|
606
|
+
response.raise_for_status()
|
|
607
|
+
if once:
|
|
608
|
+
self._reported_pipeline.add(key)
|
|
609
|
+
except Exception as exc:
|
|
610
|
+
logger.debug("pipeline event %s was not accepted: %s", stage, exc)
|
|
611
|
+
|
|
612
|
+
async def _ensure_mesh_identity(self) -> bool:
|
|
613
|
+
if not _mesh_proxy_headers():
|
|
614
|
+
return bool(self.device_id and self.device_token)
|
|
615
|
+
now = time.monotonic()
|
|
616
|
+
if self._mesh_bootstrap_ready:
|
|
617
|
+
return True
|
|
618
|
+
if now < self._next_mesh_bootstrap:
|
|
619
|
+
return False
|
|
620
|
+
self._next_mesh_bootstrap = now + 5.0
|
|
621
|
+
try:
|
|
622
|
+
self.device_id, self.device_token = await _bootstrap_mesh_device(
|
|
623
|
+
self.scheduler_url, self.robot_id
|
|
624
|
+
)
|
|
625
|
+
self._mesh_bootstrap_ready = True
|
|
626
|
+
return True
|
|
627
|
+
except Exception as exc:
|
|
628
|
+
logger.warning("mesh device bootstrap failed: %s", exc)
|
|
629
|
+
return False
|
|
630
|
+
|
|
631
|
+
async def run(self) -> None:
|
|
632
|
+
enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
|
|
633
|
+
# A UI-minted token identifies a specific pre-created device row.
|
|
634
|
+
# Consume it before generic mesh recovery so heartbeats cannot bind to
|
|
635
|
+
# a stale same-name identity.
|
|
636
|
+
if not self.device_token and enrollment_token:
|
|
637
|
+
try:
|
|
638
|
+
self.device_id, self.device_token = await _enroll(
|
|
639
|
+
self.scheduler_url, enrollment_token, self.robot_id
|
|
640
|
+
)
|
|
641
|
+
self._mesh_bootstrap_ready = True
|
|
642
|
+
except Exception as exc:
|
|
643
|
+
if not _mesh_proxy_headers():
|
|
644
|
+
raise
|
|
645
|
+
# Enrollment tokens are intentionally one-time. A service
|
|
646
|
+
# reinstall can retain the original systemd argument after
|
|
647
|
+
# its credential file was removed; recover through the hub's
|
|
648
|
+
# authenticated mesh path rather than crash-loop forever.
|
|
649
|
+
logger.warning("device enrollment failed; recovering through mesh bootstrap: %s", exc)
|
|
650
|
+
await self._ensure_mesh_identity()
|
|
651
|
+
elif _mesh_proxy_headers():
|
|
652
|
+
await self._ensure_mesh_identity()
|
|
653
653
|
|
|
654
654
|
if not self.device_token:
|
|
655
655
|
logger.error("no DEVICE_TOKEN and no ENROLLMENT_TOKEN; cannot poll scheduler")
|
|
656
656
|
sys.exit(1)
|
|
657
657
|
|
|
658
|
-
self._session = httpx.AsyncClient(headers=_mesh_proxy_headers())
|
|
658
|
+
self._session = httpx.AsyncClient(headers=_mesh_proxy_headers())
|
|
659
659
|
|
|
660
660
|
# Must come after self._session exists — _resolve_device_id() guards
|
|
661
661
|
# on it and silently no-ops otherwise. On a fresh enroll (no cached
|
|
@@ -699,13 +699,13 @@ class PreviewAgent:
|
|
|
699
699
|
if capture is not None:
|
|
700
700
|
await asyncio.to_thread(capture.stop)
|
|
701
701
|
|
|
702
|
-
async def _motion_loop(self) -> None:
|
|
703
|
-
"""Detect motion from RoboVision's shared stream without owning V4L2."""
|
|
704
|
-
while not self._shutdown.is_set():
|
|
705
|
-
if not self.production_mode:
|
|
706
|
-
await self._stop_motion_capture()
|
|
707
|
-
await asyncio.sleep(0.5)
|
|
708
|
-
continue
|
|
702
|
+
async def _motion_loop(self) -> None:
|
|
703
|
+
"""Detect motion from RoboVision's shared stream without owning V4L2."""
|
|
704
|
+
while not self._shutdown.is_set():
|
|
705
|
+
if not self.production_mode:
|
|
706
|
+
await self._stop_motion_capture()
|
|
707
|
+
await asyncio.sleep(0.5)
|
|
708
|
+
continue
|
|
709
709
|
if self._vision_session_id or self._current_state.active:
|
|
710
710
|
await self._stop_motion_capture()
|
|
711
711
|
await asyncio.sleep(0.25)
|
|
@@ -714,17 +714,17 @@ class PreviewAgent:
|
|
|
714
714
|
async with self._motion_capture_lock:
|
|
715
715
|
if self._motion_capture is None:
|
|
716
716
|
self._motion_capture = await asyncio.to_thread(
|
|
717
|
-
create_video_capture,
|
|
718
|
-
self.video_device,
|
|
719
|
-
self.width,
|
|
720
|
-
self.height,
|
|
721
|
-
self.fps,
|
|
722
|
-
self.robovision_url,
|
|
723
|
-
)
|
|
717
|
+
create_video_capture,
|
|
718
|
+
self.video_device,
|
|
719
|
+
self.width,
|
|
720
|
+
self.height,
|
|
721
|
+
self.fps,
|
|
722
|
+
self.robovision_url,
|
|
723
|
+
)
|
|
724
724
|
self._motion_reference = None
|
|
725
725
|
if self._motion_capture is None:
|
|
726
726
|
continue
|
|
727
|
-
logger.info("motion sampler connected to RoboVision shared stream")
|
|
727
|
+
logger.info("motion sampler connected to RoboVision shared stream")
|
|
728
728
|
frame = await asyncio.to_thread(self._motion_capture.read)
|
|
729
729
|
if frame is not None:
|
|
730
730
|
self._detect_motion(frame)
|
|
@@ -816,7 +816,7 @@ class PreviewAgent:
|
|
|
816
816
|
if data.get("trigger"):
|
|
817
817
|
await self._on_vision_motion({"source": data.get("source", "dashboard")})
|
|
818
818
|
|
|
819
|
-
async def _poll_device_config(self) -> None:
|
|
819
|
+
async def _poll_device_config(self) -> None:
|
|
820
820
|
"""Apply dashboard-selected camera and microphone IDs before preview."""
|
|
821
821
|
if not self._session or not self.device_id or not self.device_token:
|
|
822
822
|
return
|
|
@@ -830,53 +830,53 @@ class PreviewAgent:
|
|
|
830
830
|
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
831
831
|
timeout=10.0,
|
|
832
832
|
)
|
|
833
|
-
r.raise_for_status()
|
|
834
|
-
data = r.json()
|
|
835
|
-
changed = False
|
|
836
|
-
if data.get("video_device") is not None:
|
|
837
|
-
changed = changed or self.video_device != data["video_device"]
|
|
838
|
-
self.video_device = data["video_device"]
|
|
839
|
-
if data.get("audio_device") is not None:
|
|
840
|
-
changed = changed or self.audio_device != data["audio_device"]
|
|
841
|
-
self.audio_device = data["audio_device"]
|
|
842
|
-
inventory = data.get("device_inventory") or {}
|
|
843
|
-
selected = str(self.audio_device)
|
|
844
|
-
self.audio_capture_device = _resolve_inventory_audio(
|
|
845
|
-
inventory.get("audio_input", []), selected,
|
|
846
|
-
os.getenv("ROBOPARK_AUDIO_INPUT_MATCH", ""),
|
|
847
|
-
)
|
|
848
|
-
if data.get("audio_output_device") is not None:
|
|
849
|
-
changed = changed or self.audio_output_device != data["audio_output_device"]
|
|
850
|
-
self.audio_output_device = data["audio_output_device"]
|
|
851
|
-
inventory = data.get("device_inventory") or {}
|
|
852
|
-
selected = str(self.audio_output_device)
|
|
853
|
-
self.audio_playback_device = _resolve_inventory_audio(
|
|
854
|
-
inventory.get("audio_output", []), selected,
|
|
855
|
-
os.getenv("ROBOPARK_AUDIO_OUTPUT_MATCH", ""),
|
|
856
|
-
)
|
|
857
|
-
if changed:
|
|
858
|
-
await self._sync_robovision_media_config()
|
|
859
|
-
except Exception as e:
|
|
860
|
-
logger.debug(f"device config poll failed: {e}")
|
|
861
|
-
|
|
862
|
-
async def _sync_robovision_media_config(self) -> None:
|
|
863
|
-
"""Apply scheduler media choices to the local RoboVisionAI_PI owner."""
|
|
864
|
-
try:
|
|
865
|
-
response = await self._session.post(
|
|
866
|
-
f"{self.robovision_url.rstrip('/')}/api/media/config",
|
|
867
|
-
json={
|
|
868
|
-
"video_device": self.video_device,
|
|
869
|
-
"audio_device": self.audio_device,
|
|
870
|
-
"audio_output_device": self.audio_output_device,
|
|
871
|
-
},
|
|
872
|
-
timeout=1.0,
|
|
873
|
-
)
|
|
874
|
-
if response.is_success:
|
|
875
|
-
global _DEVICE_INVENTORY_CACHE
|
|
876
|
-
_DEVICE_INVENTORY_CACHE = None
|
|
877
|
-
except Exception:
|
|
878
|
-
# RoboVision is optional on laptops and simulation nodes.
|
|
879
|
-
pass
|
|
833
|
+
r.raise_for_status()
|
|
834
|
+
data = r.json()
|
|
835
|
+
changed = False
|
|
836
|
+
if data.get("video_device") is not None:
|
|
837
|
+
changed = changed or self.video_device != data["video_device"]
|
|
838
|
+
self.video_device = data["video_device"]
|
|
839
|
+
if data.get("audio_device") is not None:
|
|
840
|
+
changed = changed or self.audio_device != data["audio_device"]
|
|
841
|
+
self.audio_device = data["audio_device"]
|
|
842
|
+
inventory = data.get("device_inventory") or {}
|
|
843
|
+
selected = str(self.audio_device)
|
|
844
|
+
self.audio_capture_device = _resolve_inventory_audio(
|
|
845
|
+
inventory.get("audio_input", []), selected,
|
|
846
|
+
os.getenv("ROBOPARK_AUDIO_INPUT_MATCH", ""),
|
|
847
|
+
)
|
|
848
|
+
if data.get("audio_output_device") is not None:
|
|
849
|
+
changed = changed or self.audio_output_device != data["audio_output_device"]
|
|
850
|
+
self.audio_output_device = data["audio_output_device"]
|
|
851
|
+
inventory = data.get("device_inventory") or {}
|
|
852
|
+
selected = str(self.audio_output_device)
|
|
853
|
+
self.audio_playback_device = _resolve_inventory_audio(
|
|
854
|
+
inventory.get("audio_output", []), selected,
|
|
855
|
+
os.getenv("ROBOPARK_AUDIO_OUTPUT_MATCH", ""),
|
|
856
|
+
)
|
|
857
|
+
if changed:
|
|
858
|
+
await self._sync_robovision_media_config()
|
|
859
|
+
except Exception as e:
|
|
860
|
+
logger.debug(f"device config poll failed: {e}")
|
|
861
|
+
|
|
862
|
+
async def _sync_robovision_media_config(self) -> None:
|
|
863
|
+
"""Apply scheduler media choices to the local RoboVisionAI_PI owner."""
|
|
864
|
+
try:
|
|
865
|
+
response = await self._session.post(
|
|
866
|
+
f"{self.robovision_url.rstrip('/')}/api/media/config",
|
|
867
|
+
json={
|
|
868
|
+
"video_device": self.video_device,
|
|
869
|
+
"audio_device": self.audio_device,
|
|
870
|
+
"audio_output_device": self.audio_output_device,
|
|
871
|
+
},
|
|
872
|
+
timeout=1.0,
|
|
873
|
+
)
|
|
874
|
+
if response.is_success:
|
|
875
|
+
global _DEVICE_INVENTORY_CACHE
|
|
876
|
+
_DEVICE_INVENTORY_CACHE = None
|
|
877
|
+
except Exception:
|
|
878
|
+
# RoboVision is optional on laptops and simulation nodes.
|
|
879
|
+
pass
|
|
880
880
|
|
|
881
881
|
async def _check_vision_session(self) -> None:
|
|
882
882
|
"""Decide whether the active motion-triggered session should keep
|
|
@@ -951,7 +951,7 @@ class PreviewAgent:
|
|
|
951
951
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
952
952
|
return dt.timestamp()
|
|
953
953
|
|
|
954
|
-
async def _end_vision_session(self) -> None:
|
|
954
|
+
async def _end_vision_session(self) -> None:
|
|
955
955
|
# Tell the scheduler too, not just the local publisher/LiveKit
|
|
956
956
|
# connection — otherwise the sessions row never gets ended_at set,
|
|
957
957
|
# and the server's active_sessions count climbs forever until it
|
|
@@ -970,100 +970,100 @@ class PreviewAgent:
|
|
|
970
970
|
)
|
|
971
971
|
except Exception as e:
|
|
972
972
|
logger.warning(f"failed to notify scheduler of session end: {e}")
|
|
973
|
-
await self._report_pipeline("session_ended", "ok", "Robot conversation loop stopped")
|
|
974
|
-
self._vision_session_id = None
|
|
973
|
+
await self._report_pipeline("session_ended", "ok", "Robot conversation loop stopped")
|
|
974
|
+
self._vision_session_id = None
|
|
975
975
|
self._vision_trigger_in_flight = False
|
|
976
976
|
self._vision_hard_deadline = 0.0
|
|
977
977
|
self._vision_last_activity = 0.0
|
|
978
978
|
self._remote_session_ended = False
|
|
979
979
|
await self._stop_publisher()
|
|
980
|
-
await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "disconnect")
|
|
980
|
+
await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "disconnect")
|
|
981
981
|
self._current_state = PreviewState()
|
|
982
982
|
|
|
983
|
-
async def _heartbeat_loop(self) -> None:
|
|
984
|
-
while not self._shutdown.is_set():
|
|
985
|
-
if _mesh_proxy_headers() and not self._mesh_bootstrap_ready:
|
|
986
|
-
await self._ensure_mesh_identity()
|
|
987
|
-
if self.device_id and self.device_token:
|
|
988
|
-
production_mode, credentials_ok = await _send_heartbeat(
|
|
989
|
-
self.scheduler_url, self.device_id, self.device_token
|
|
990
|
-
)
|
|
991
|
-
if not credentials_ok:
|
|
992
|
-
self._mesh_bootstrap_ready = False
|
|
993
|
-
self._next_mesh_bootstrap = 0.0
|
|
994
|
-
if production_mode is not None:
|
|
995
|
-
self.production_mode = production_mode
|
|
996
|
-
# Exactly one motion detector is active. With the default
|
|
997
|
-
# local sampler enabled, preview reads RoboVision's shared
|
|
998
|
-
# MJPEG stream and RoboVision only owns capture/encoding.
|
|
999
|
-
robovision_motion = bool(production_mode and not self.local_camera_motion)
|
|
1000
|
-
if self._robovision_motion_state != robovision_motion:
|
|
1001
|
-
try:
|
|
1002
|
-
response = await self._session.post(
|
|
1003
|
-
f"{self.robovision_url.rstrip('/')}/api/motion/toggle",
|
|
1004
|
-
json={"enabled": robovision_motion}, timeout=1.5,
|
|
1005
|
-
)
|
|
1006
|
-
response.raise_for_status()
|
|
1007
|
-
self._robovision_motion_state = robovision_motion
|
|
1008
|
-
except Exception as exc:
|
|
1009
|
-
logger.warning(f"failed to synchronize RoboVision motion mode: {exc}")
|
|
1010
|
-
await self._report_pipeline("robot_online", message="Robot heartbeat accepted")
|
|
1011
|
-
inventory = _get_device_inventory()
|
|
1012
|
-
real_video = [d for d in inventory.get("video", []) if str(d.get("id", "")).lower() not in ("auto", "none")]
|
|
1013
|
-
real_inputs = [d for d in inventory.get("audio_input", []) if str(d.get("id", "")).lower() not in ("default", "none")]
|
|
1014
|
-
real_outputs = [d for d in inventory.get("audio_output", []) if str(d.get("id", "")).lower() not in ("default", "none")]
|
|
1015
|
-
await self._report_pipeline("camera_ready", "ok" if real_video else "blocked", f"{len(real_video)} camera device(s) detected")
|
|
1016
|
-
await self._report_pipeline("microphone_ready", "ok" if real_inputs else "blocked", f"{len(real_inputs)} microphone device(s) detected")
|
|
1017
|
-
await self._report_pipeline("speaker_ready", "ok" if real_outputs else "blocked", f"{len(real_outputs)} speaker device(s) detected")
|
|
1018
|
-
if not production_mode and self._vision_session_id:
|
|
1019
|
-
await self._end_vision_session()
|
|
1020
|
-
await self._poll_speaker_test()
|
|
983
|
+
async def _heartbeat_loop(self) -> None:
|
|
984
|
+
while not self._shutdown.is_set():
|
|
985
|
+
if _mesh_proxy_headers() and not self._mesh_bootstrap_ready:
|
|
986
|
+
await self._ensure_mesh_identity()
|
|
987
|
+
if self.device_id and self.device_token:
|
|
988
|
+
production_mode, credentials_ok = await _send_heartbeat(
|
|
989
|
+
self.scheduler_url, self.device_id, self.device_token
|
|
990
|
+
)
|
|
991
|
+
if not credentials_ok:
|
|
992
|
+
self._mesh_bootstrap_ready = False
|
|
993
|
+
self._next_mesh_bootstrap = 0.0
|
|
994
|
+
if production_mode is not None:
|
|
995
|
+
self.production_mode = production_mode
|
|
996
|
+
# Exactly one motion detector is active. With the default
|
|
997
|
+
# local sampler enabled, preview reads RoboVision's shared
|
|
998
|
+
# MJPEG stream and RoboVision only owns capture/encoding.
|
|
999
|
+
robovision_motion = bool(production_mode and not self.local_camera_motion)
|
|
1000
|
+
if self._robovision_motion_state != robovision_motion:
|
|
1001
|
+
try:
|
|
1002
|
+
response = await self._session.post(
|
|
1003
|
+
f"{self.robovision_url.rstrip('/')}/api/motion/toggle",
|
|
1004
|
+
json={"enabled": robovision_motion}, timeout=1.5,
|
|
1005
|
+
)
|
|
1006
|
+
response.raise_for_status()
|
|
1007
|
+
self._robovision_motion_state = robovision_motion
|
|
1008
|
+
except Exception as exc:
|
|
1009
|
+
logger.warning(f"failed to synchronize RoboVision motion mode: {exc}")
|
|
1010
|
+
await self._report_pipeline("robot_online", message="Robot heartbeat accepted")
|
|
1011
|
+
inventory = _get_device_inventory()
|
|
1012
|
+
real_video = [d for d in inventory.get("video", []) if str(d.get("id", "")).lower() not in ("auto", "none")]
|
|
1013
|
+
real_inputs = [d for d in inventory.get("audio_input", []) if str(d.get("id", "")).lower() not in ("default", "none")]
|
|
1014
|
+
real_outputs = [d for d in inventory.get("audio_output", []) if str(d.get("id", "")).lower() not in ("default", "none")]
|
|
1015
|
+
await self._report_pipeline("camera_ready", "ok" if real_video else "blocked", f"{len(real_video)} camera device(s) detected")
|
|
1016
|
+
await self._report_pipeline("microphone_ready", "ok" if real_inputs else "blocked", f"{len(real_inputs)} microphone device(s) detected")
|
|
1017
|
+
await self._report_pipeline("speaker_ready", "ok" if real_outputs else "blocked", f"{len(real_outputs)} speaker device(s) detected")
|
|
1018
|
+
if not production_mode and self._vision_session_id:
|
|
1019
|
+
await self._end_vision_session()
|
|
1020
|
+
await self._poll_speaker_test()
|
|
1021
1021
|
try:
|
|
1022
1022
|
await asyncio.wait_for(self._shutdown.wait(), timeout=self.heartbeat_interval)
|
|
1023
|
-
except asyncio.TimeoutError:
|
|
1024
|
-
pass
|
|
1025
|
-
|
|
1026
|
-
async def _poll_speaker_test(self) -> None:
|
|
1027
|
-
"""Execute queued speaker tests even when no optional supervisor runs."""
|
|
1028
|
-
if self._speaker_test_in_flight or not self._session or not self.device_id or not self.device_token:
|
|
1029
|
-
return
|
|
1030
|
-
headers = {"Authorization": f"Bearer {self.device_token}", **_mesh_proxy_headers()}
|
|
1031
|
-
try:
|
|
1032
|
-
response = await self._session.get(
|
|
1033
|
-
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/shell/next-speaker-test",
|
|
1034
|
-
headers=headers, timeout=5.0,
|
|
1035
|
-
)
|
|
1036
|
-
response.raise_for_status()
|
|
1037
|
-
request = response.json().get("request")
|
|
1038
|
-
if not request:
|
|
1039
|
-
return
|
|
1040
|
-
self._speaker_test_in_flight = True
|
|
1041
|
-
from robot_supervisor import _speaker_roundtrip_test
|
|
1042
|
-
async with self._speaker_operation_lock:
|
|
1043
|
-
# LiveKit owns the USB microphone and speaker continuously.
|
|
1044
|
-
# Pause it while holding the same lock as the motion cue so
|
|
1045
|
-
# nothing can reopen ALSA before the explicit test starts.
|
|
1046
|
-
restore_state = self._current_state if self._publisher else None
|
|
1047
|
-
if restore_state:
|
|
1048
|
-
await self._stop_publisher()
|
|
1049
|
-
await asyncio.sleep(1.0)
|
|
1050
|
-
try:
|
|
1051
|
-
result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
|
|
1052
|
-
result["media_owner_paused"] = bool(restore_state)
|
|
1053
|
-
finally:
|
|
1054
|
-
if restore_state and restore_state.active:
|
|
1055
|
-
await self._start_publisher(restore_state)
|
|
1056
|
-
result_response = await self._session.post(
|
|
1057
|
-
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/supervisor-output",
|
|
1058
|
-
json={"kind": "speaker_test", "service": None, "payload": result,
|
|
1059
|
-
"request_id": request.get("id")},
|
|
1060
|
-
headers=headers, timeout=8.0,
|
|
1061
|
-
)
|
|
1062
|
-
result_response.raise_for_status()
|
|
1063
|
-
except Exception as e:
|
|
1064
|
-
logger.warning(f"speaker test execution failed: {e}")
|
|
1065
|
-
finally:
|
|
1066
|
-
self._speaker_test_in_flight = False
|
|
1023
|
+
except asyncio.TimeoutError:
|
|
1024
|
+
pass
|
|
1025
|
+
|
|
1026
|
+
async def _poll_speaker_test(self) -> None:
|
|
1027
|
+
"""Execute queued speaker tests even when no optional supervisor runs."""
|
|
1028
|
+
if self._speaker_test_in_flight or not self._session or not self.device_id or not self.device_token:
|
|
1029
|
+
return
|
|
1030
|
+
headers = {"Authorization": f"Bearer {self.device_token}", **_mesh_proxy_headers()}
|
|
1031
|
+
try:
|
|
1032
|
+
response = await self._session.get(
|
|
1033
|
+
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/shell/next-speaker-test",
|
|
1034
|
+
headers=headers, timeout=5.0,
|
|
1035
|
+
)
|
|
1036
|
+
response.raise_for_status()
|
|
1037
|
+
request = response.json().get("request")
|
|
1038
|
+
if not request:
|
|
1039
|
+
return
|
|
1040
|
+
self._speaker_test_in_flight = True
|
|
1041
|
+
from robot_supervisor import _speaker_roundtrip_test
|
|
1042
|
+
async with self._speaker_operation_lock:
|
|
1043
|
+
# LiveKit owns the USB microphone and speaker continuously.
|
|
1044
|
+
# Pause it while holding the same lock as the motion cue so
|
|
1045
|
+
# nothing can reopen ALSA before the explicit test starts.
|
|
1046
|
+
restore_state = self._current_state if self._publisher else None
|
|
1047
|
+
if restore_state:
|
|
1048
|
+
await self._stop_publisher()
|
|
1049
|
+
await asyncio.sleep(1.0)
|
|
1050
|
+
try:
|
|
1051
|
+
result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
|
|
1052
|
+
result["media_owner_paused"] = bool(restore_state)
|
|
1053
|
+
finally:
|
|
1054
|
+
if restore_state and restore_state.active:
|
|
1055
|
+
await self._start_publisher(restore_state)
|
|
1056
|
+
result_response = await self._session.post(
|
|
1057
|
+
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/supervisor-output",
|
|
1058
|
+
json={"kind": "speaker_test", "service": None, "payload": result,
|
|
1059
|
+
"request_id": request.get("id")},
|
|
1060
|
+
headers=headers, timeout=8.0,
|
|
1061
|
+
)
|
|
1062
|
+
result_response.raise_for_status()
|
|
1063
|
+
except Exception as e:
|
|
1064
|
+
logger.warning(f"speaker test execution failed: {e}")
|
|
1065
|
+
finally:
|
|
1066
|
+
self._speaker_test_in_flight = False
|
|
1067
1067
|
|
|
1068
1068
|
async def _fetch_preview_state(self) -> PreviewState:
|
|
1069
1069
|
if not self._session or not self.device_token:
|
|
@@ -1102,25 +1102,25 @@ class PreviewAgent:
|
|
|
1102
1102
|
return
|
|
1103
1103
|
await self._start_publisher(state)
|
|
1104
1104
|
|
|
1105
|
-
async def _start_publisher(self, state: PreviewState) -> bool:
|
|
1106
|
-
await self._stop_publisher()
|
|
1107
|
-
if not state.url or not state.token or not state.room:
|
|
1108
|
-
return False
|
|
1105
|
+
async def _start_publisher(self, state: PreviewState) -> bool:
|
|
1106
|
+
await self._stop_publisher()
|
|
1107
|
+
if not state.url or not state.token or not state.room:
|
|
1108
|
+
return False
|
|
1109
1109
|
pub = None
|
|
1110
1110
|
try:
|
|
1111
1111
|
pub = LiveKitPublisher(state.url, state.token, state.room, self)
|
|
1112
1112
|
await pub.start()
|
|
1113
|
-
self._publisher = pub
|
|
1114
|
-
logger.info(f"joined preview room {state.room}")
|
|
1115
|
-
return True
|
|
1113
|
+
self._publisher = pub
|
|
1114
|
+
logger.info(f"joined preview room {state.room}")
|
|
1115
|
+
return True
|
|
1116
1116
|
except Exception as e:
|
|
1117
1117
|
logger.error(f"failed to start publisher: {e}")
|
|
1118
1118
|
if pub is not None:
|
|
1119
1119
|
try:
|
|
1120
1120
|
await pub.stop()
|
|
1121
|
-
except Exception as cleanup_error:
|
|
1122
|
-
logger.debug(f"publisher cleanup after start failure: {cleanup_error}")
|
|
1123
|
-
return False
|
|
1121
|
+
except Exception as cleanup_error:
|
|
1122
|
+
logger.debug(f"publisher cleanup after start failure: {cleanup_error}")
|
|
1123
|
+
return False
|
|
1124
1124
|
|
|
1125
1125
|
async def _stop_publisher(self) -> None:
|
|
1126
1126
|
if self._publisher:
|
|
@@ -1198,20 +1198,20 @@ class PreviewAgent:
|
|
|
1198
1198
|
if not self.device_id or not self.device_token or not self._session:
|
|
1199
1199
|
logger.warning("vision motion event received but not enrolled yet — ignoring")
|
|
1200
1200
|
return
|
|
1201
|
-
self._vision_trigger_in_flight = True
|
|
1202
|
-
# Observability must not add a scheduler round trip before the actual
|
|
1203
|
-
# session request. The reporter handles and logs its own errors.
|
|
1204
|
-
asyncio.create_task(self._report_pipeline(
|
|
1205
|
-
"motion_detected", "ok",
|
|
1206
|
-
f"Motion received from {payload.get('source', 'camera')}", once=False,
|
|
1207
|
-
))
|
|
1208
|
-
logger.info("motion detected by RoboVisionAI_PI — requesting a session")
|
|
1209
|
-
# RoboVision owns the physical camera and exposes a shared stream, so
|
|
1210
|
-
# there is no capture handle to tear down and no reason to sleep here.
|
|
1211
|
-
if self.motion_cue_enabled:
|
|
1212
|
-
logger.warning("ROBOPARK_MOTION_CUE is enabled; the diagnostic cue adds greeting latency")
|
|
1213
|
-
async with self._speaker_operation_lock:
|
|
1214
|
-
await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
|
|
1201
|
+
self._vision_trigger_in_flight = True
|
|
1202
|
+
# Observability must not add a scheduler round trip before the actual
|
|
1203
|
+
# session request. The reporter handles and logs its own errors.
|
|
1204
|
+
asyncio.create_task(self._report_pipeline(
|
|
1205
|
+
"motion_detected", "ok",
|
|
1206
|
+
f"Motion received from {payload.get('source', 'camera')}", once=False,
|
|
1207
|
+
))
|
|
1208
|
+
logger.info("motion detected by RoboVisionAI_PI — requesting a session")
|
|
1209
|
+
# RoboVision owns the physical camera and exposes a shared stream, so
|
|
1210
|
+
# there is no capture handle to tear down and no reason to sleep here.
|
|
1211
|
+
if self.motion_cue_enabled:
|
|
1212
|
+
logger.warning("ROBOPARK_MOTION_CUE is enabled; the diagnostic cue adds greeting latency")
|
|
1213
|
+
async with self._speaker_operation_lock:
|
|
1214
|
+
await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
|
|
1215
1215
|
try:
|
|
1216
1216
|
r = await self._session.post(
|
|
1217
1217
|
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/request-session",
|
|
@@ -1220,10 +1220,10 @@ class PreviewAgent:
|
|
|
1220
1220
|
)
|
|
1221
1221
|
r.raise_for_status()
|
|
1222
1222
|
data = r.json()
|
|
1223
|
-
except Exception as e:
|
|
1224
|
-
logger.error(f"request-session failed: {e}")
|
|
1225
|
-
await self._report_pipeline("scheduler_session", "failed", f"Session request failed: {type(e).__name__}", once=False)
|
|
1226
|
-
self._vision_trigger_in_flight = False
|
|
1223
|
+
except Exception as e:
|
|
1224
|
+
logger.error(f"request-session failed: {e}")
|
|
1225
|
+
await self._report_pipeline("scheduler_session", "failed", f"Session request failed: {type(e).__name__}", once=False)
|
|
1226
|
+
self._vision_trigger_in_flight = False
|
|
1227
1227
|
return
|
|
1228
1228
|
state = PreviewState(
|
|
1229
1229
|
active=True,
|
|
@@ -1238,44 +1238,44 @@ class PreviewAgent:
|
|
|
1238
1238
|
self._vision_trigger_in_flight = False
|
|
1239
1239
|
self._vision_hard_deadline = now + self.vision_session_seconds
|
|
1240
1240
|
self._vision_last_activity = now
|
|
1241
|
-
self._remote_session_ended = False
|
|
1242
|
-
self._current_state = state
|
|
1243
|
-
await self._report_pipeline("scheduler_session", "ok", "Scheduler allocated a conversation session")
|
|
1241
|
+
self._remote_session_ended = False
|
|
1242
|
+
self._current_state = state
|
|
1243
|
+
await self._report_pipeline("scheduler_session", "ok", "Scheduler allocated a conversation session")
|
|
1244
1244
|
# If the scheduler returned a voice config, let the agent know by
|
|
1245
1245
|
# posting it to our local webhook endpoint. The preview agent itself
|
|
1246
1246
|
# does not consume it, but this makes the config observable locally
|
|
1247
1247
|
# and lets downstream components (audio server, vision, etc.) adapt.
|
|
1248
1248
|
if voice_config and session_id:
|
|
1249
1249
|
logger.info(f"scheduler voice config for session {session_id}: {voice_config}")
|
|
1250
|
-
joined = await self._start_publisher(state)
|
|
1251
|
-
if not joined:
|
|
1252
|
-
await self._report_pipeline(
|
|
1253
|
-
"livekit_join", "failed", "Robot could not connect to its assigned LiveKit route",
|
|
1254
|
-
details={"server_url": state.url}, once=False,
|
|
1255
|
-
)
|
|
1256
|
-
if session_id and self._session:
|
|
1257
|
-
try:
|
|
1258
|
-
response = await self._session.post(
|
|
1259
|
-
f"{self.scheduler_url.rstrip('/')}/api/robots/{self.device_id}/end-session",
|
|
1260
|
-
params={"reason": "livekit_join_failed"},
|
|
1261
|
-
headers={"Authorization": f"Bearer {self.device_token}"}, timeout=10.0,
|
|
1262
|
-
)
|
|
1263
|
-
response.raise_for_status()
|
|
1264
|
-
except Exception as e:
|
|
1265
|
-
logger.debug(f"could not end failed LiveKit session: {e}")
|
|
1266
|
-
self._vision_session_id = None
|
|
1267
|
-
self._vision_hard_deadline = 0.0
|
|
1268
|
-
self._vision_last_activity = 0.0
|
|
1269
|
-
self._current_state = PreviewState()
|
|
1270
|
-
return
|
|
1271
|
-
if session_id and self._session:
|
|
1272
|
-
try:
|
|
1273
|
-
response = await self._session.post(
|
|
1274
|
-
f"{self.scheduler_url.rstrip('/')}/api/sessions/{session_id}/joined",
|
|
1275
|
-
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
1276
|
-
timeout=10.0,
|
|
1277
|
-
)
|
|
1278
|
-
response.raise_for_status()
|
|
1250
|
+
joined = await self._start_publisher(state)
|
|
1251
|
+
if not joined:
|
|
1252
|
+
await self._report_pipeline(
|
|
1253
|
+
"livekit_join", "failed", "Robot could not connect to its assigned LiveKit route",
|
|
1254
|
+
details={"server_url": state.url}, once=False,
|
|
1255
|
+
)
|
|
1256
|
+
if session_id and self._session:
|
|
1257
|
+
try:
|
|
1258
|
+
response = await self._session.post(
|
|
1259
|
+
f"{self.scheduler_url.rstrip('/')}/api/robots/{self.device_id}/end-session",
|
|
1260
|
+
params={"reason": "livekit_join_failed"},
|
|
1261
|
+
headers={"Authorization": f"Bearer {self.device_token}"}, timeout=10.0,
|
|
1262
|
+
)
|
|
1263
|
+
response.raise_for_status()
|
|
1264
|
+
except Exception as e:
|
|
1265
|
+
logger.debug(f"could not end failed LiveKit session: {e}")
|
|
1266
|
+
self._vision_session_id = None
|
|
1267
|
+
self._vision_hard_deadline = 0.0
|
|
1268
|
+
self._vision_last_activity = 0.0
|
|
1269
|
+
self._current_state = PreviewState()
|
|
1270
|
+
return
|
|
1271
|
+
if session_id and self._session:
|
|
1272
|
+
try:
|
|
1273
|
+
response = await self._session.post(
|
|
1274
|
+
f"{self.scheduler_url.rstrip('/')}/api/sessions/{session_id}/joined",
|
|
1275
|
+
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
1276
|
+
timeout=10.0,
|
|
1277
|
+
)
|
|
1278
|
+
response.raise_for_status()
|
|
1279
1279
|
except Exception as e:
|
|
1280
1280
|
logger.debug(f"could not mark session joined: {e}")
|
|
1281
1281
|
|
|
@@ -1299,49 +1299,49 @@ class LiveKitPublisher:
|
|
|
1299
1299
|
self.audio_source: Optional[rtc.AudioSource] = None
|
|
1300
1300
|
self.audio_track: Optional[rtc.LocalAudioTrack] = None
|
|
1301
1301
|
self._stop_event = asyncio.Event()
|
|
1302
|
-
self._tasks: list[asyncio.Task] = []
|
|
1303
|
-
self._capture: Optional["VideoCapture"] = None
|
|
1304
|
-
self._mic_capture: Optional["AudioCapture"] = None
|
|
1305
|
-
self._mic_streaming = asyncio.Event()
|
|
1306
|
-
self._mic_error: Optional[str] = None
|
|
1307
|
-
# The launch hardware has no acoustic echo cancellation. Publishing
|
|
1308
|
-
# the amplified USB microphone while the robot speaker plays TTS makes
|
|
1309
|
-
# the voice worker hear itself and trigger barge-in, truncating or
|
|
1310
|
-
# chopping its own response. Keep the track alive with silence while
|
|
1311
|
-
# playback is active, plus a short room-echo decay tail.
|
|
1312
|
-
self._half_duplex = str(os.getenv("ROBOPARK_HALF_DUPLEX", "true")).lower() in (
|
|
1313
|
-
"1", "true", "yes", "on",
|
|
1314
|
-
)
|
|
1315
|
-
self._speaker_playback_active = threading.Event()
|
|
1316
|
-
self._speaker_gate_until = 0.0
|
|
1317
|
-
self._speaker_started_at = 0.0
|
|
1318
|
-
self._barge_in_until = 0.0
|
|
1319
|
-
self._echo_mic_floor = 0.0
|
|
1320
|
-
self._barge_in_candidate_frames = 0
|
|
1321
|
-
self._speaker_echo_tail = max(
|
|
1322
|
-
0.1, min(float(os.getenv("ROBOPARK_ECHO_TAIL_MS", "350")) / 1000.0, 2.0)
|
|
1323
|
-
)
|
|
1324
|
-
self._adaptive_barge_in = str(
|
|
1325
|
-
os.getenv("ROBOPARK_ADAPTIVE_BARGE_IN", "true")
|
|
1326
|
-
).lower() in ("1", "true", "yes", "on")
|
|
1327
|
-
self._barge_in_min_peak = max(
|
|
1328
|
-
256, min(int(os.getenv("ROBOPARK_BARGE_IN_MIN_PEAK", "2200")), 20000)
|
|
1329
|
-
)
|
|
1330
|
-
self._barge_in_ratio = max(
|
|
1331
|
-
1.25, min(float(os.getenv("ROBOPARK_BARGE_IN_ECHO_RATIO", "2.4")), 8.0)
|
|
1332
|
-
)
|
|
1333
|
-
self._barge_in_hold = max(
|
|
1334
|
-
0.4, min(float(os.getenv("ROBOPARK_BARGE_IN_HOLD_MS", "1400")) / 1000.0, 3.0)
|
|
1335
|
-
)
|
|
1302
|
+
self._tasks: list[asyncio.Task] = []
|
|
1303
|
+
self._capture: Optional["VideoCapture"] = None
|
|
1304
|
+
self._mic_capture: Optional["AudioCapture"] = None
|
|
1305
|
+
self._mic_streaming = asyncio.Event()
|
|
1306
|
+
self._mic_error: Optional[str] = None
|
|
1307
|
+
# The launch hardware has no acoustic echo cancellation. Publishing
|
|
1308
|
+
# the amplified USB microphone while the robot speaker plays TTS makes
|
|
1309
|
+
# the voice worker hear itself and trigger barge-in, truncating or
|
|
1310
|
+
# chopping its own response. Keep the track alive with silence while
|
|
1311
|
+
# playback is active, plus a short room-echo decay tail.
|
|
1312
|
+
self._half_duplex = str(os.getenv("ROBOPARK_HALF_DUPLEX", "true")).lower() in (
|
|
1313
|
+
"1", "true", "yes", "on",
|
|
1314
|
+
)
|
|
1315
|
+
self._speaker_playback_active = threading.Event()
|
|
1316
|
+
self._speaker_gate_until = 0.0
|
|
1317
|
+
self._speaker_started_at = 0.0
|
|
1318
|
+
self._barge_in_until = 0.0
|
|
1319
|
+
self._echo_mic_floor = 0.0
|
|
1320
|
+
self._barge_in_candidate_frames = 0
|
|
1321
|
+
self._speaker_echo_tail = max(
|
|
1322
|
+
0.1, min(float(os.getenv("ROBOPARK_ECHO_TAIL_MS", "350")) / 1000.0, 2.0)
|
|
1323
|
+
)
|
|
1324
|
+
self._adaptive_barge_in = str(
|
|
1325
|
+
os.getenv("ROBOPARK_ADAPTIVE_BARGE_IN", "true")
|
|
1326
|
+
).lower() in ("1", "true", "yes", "on")
|
|
1327
|
+
self._barge_in_min_peak = max(
|
|
1328
|
+
256, min(int(os.getenv("ROBOPARK_BARGE_IN_MIN_PEAK", "2200")), 20000)
|
|
1329
|
+
)
|
|
1330
|
+
self._barge_in_ratio = max(
|
|
1331
|
+
1.25, min(float(os.getenv("ROBOPARK_BARGE_IN_ECHO_RATIO", "2.4")), 8.0)
|
|
1332
|
+
)
|
|
1333
|
+
self._barge_in_hold = max(
|
|
1334
|
+
0.4, min(float(os.getenv("ROBOPARK_BARGE_IN_HOLD_MS", "1400")) / 1000.0, 3.0)
|
|
1335
|
+
)
|
|
1336
1336
|
# Motion sampling state belongs to the publisher instance. Keeping it
|
|
1337
1337
|
# initialized here prevents shutdown/reopen paths from raising while
|
|
1338
1338
|
# the camera is being handed between preview and voice sessions.
|
|
1339
1339
|
self._last_motion_sample = 0.0
|
|
1340
1340
|
|
|
1341
|
-
async def start(self) -> None:
|
|
1342
|
-
self.room = self.rtc.Room()
|
|
1343
|
-
await self.room.connect(self.url, self.token)
|
|
1344
|
-
await self.agent._report_pipeline("livekit_join", "ok", "Robot joined the LiveKit room")
|
|
1341
|
+
async def start(self) -> None:
|
|
1342
|
+
self.room = self.rtc.Room()
|
|
1343
|
+
await self.room.connect(self.url, self.token)
|
|
1344
|
+
await self.agent._report_pipeline("livekit_join", "ok", "Robot joined the LiveKit room")
|
|
1345
1345
|
|
|
1346
1346
|
# Register after signaling. Registering during Room.connect can invoke
|
|
1347
1347
|
# callbacks while the native LiveKit participant state is incomplete;
|
|
@@ -1352,10 +1352,10 @@ class LiveKitPublisher:
|
|
|
1352
1352
|
logger.info(f"track_subscribed: kind={track.kind} from={participant.identity} sid={publication.sid}")
|
|
1353
1353
|
if track.kind != self.rtc.TrackKind.KIND_AUDIO:
|
|
1354
1354
|
return
|
|
1355
|
-
if participant.identity == self.room.local_participant.identity:
|
|
1356
|
-
return
|
|
1357
|
-
self._tasks.append(asyncio.create_task(self.agent._report_pipeline("tts_subscribed", "ok", "Subscribed to remote voice audio")))
|
|
1358
|
-
self._tasks.append(asyncio.create_task(self._play_remote_audio(track, publication.sid)))
|
|
1355
|
+
if participant.identity == self.room.local_participant.identity:
|
|
1356
|
+
return
|
|
1357
|
+
self._tasks.append(asyncio.create_task(self.agent._report_pipeline("tts_subscribed", "ok", "Subscribed to remote voice audio")))
|
|
1358
|
+
self._tasks.append(asyncio.create_task(self._play_remote_audio(track, publication.sid)))
|
|
1359
1359
|
|
|
1360
1360
|
self.room.on("track_subscribed", _on_track_subscribed)
|
|
1361
1361
|
logger.info("audio out: track_subscribed listener registered")
|
|
@@ -1369,8 +1369,8 @@ class LiveKitPublisher:
|
|
|
1369
1369
|
if video_enabled:
|
|
1370
1370
|
try:
|
|
1371
1371
|
self._capture = await asyncio.to_thread(
|
|
1372
|
-
create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps,
|
|
1373
|
-
self.agent.robovision_url if self.agent.use_robovision_camera else None,
|
|
1372
|
+
create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps,
|
|
1373
|
+
self.agent.robovision_url if self.agent.use_robovision_camera else None,
|
|
1374
1374
|
)
|
|
1375
1375
|
except Exception as e:
|
|
1376
1376
|
# Camera availability must not block the microphone/session.
|
|
@@ -1407,16 +1407,16 @@ class LiveKitPublisher:
|
|
|
1407
1407
|
self.video_track = self.rtc.LocalVideoTrack.create_video_track("camera", self.video_source)
|
|
1408
1408
|
vopts = self.rtc.TrackPublishOptions()
|
|
1409
1409
|
vopts.source = self.rtc.TrackSource.SOURCE_CAMERA
|
|
1410
|
-
await self.room.local_participant.publish_track(self.video_track, vopts)
|
|
1411
|
-
self.video_source.capture_frame(first_frame)
|
|
1412
|
-
await self.agent._report_pipeline("camera_published", "ok", "Camera track published")
|
|
1410
|
+
await self.room.local_participant.publish_track(self.video_track, vopts)
|
|
1411
|
+
self.video_source.capture_frame(first_frame)
|
|
1412
|
+
await self.agent._report_pipeline("camera_published", "ok", "Camera track published")
|
|
1413
1413
|
|
|
1414
|
-
if audio_enabled:
|
|
1415
|
-
self.audio_source = self.rtc.AudioSource(48000, 1)
|
|
1416
|
-
self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
|
|
1417
|
-
aopts = self.rtc.TrackPublishOptions()
|
|
1418
|
-
aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
|
|
1419
|
-
await self.room.local_participant.publish_track(self.audio_track, aopts)
|
|
1414
|
+
if audio_enabled:
|
|
1415
|
+
self.audio_source = self.rtc.AudioSource(48000, 1)
|
|
1416
|
+
self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
|
|
1417
|
+
aopts = self.rtc.TrackPublishOptions()
|
|
1418
|
+
aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
|
|
1419
|
+
await self.room.local_participant.publish_track(self.audio_track, aopts)
|
|
1420
1420
|
|
|
1421
1421
|
# Register speaker playback (subscribe to the voice agent's TTS audio
|
|
1422
1422
|
# track) BEFORE opening the camera. Camera open is a slow/occasionally
|
|
@@ -1425,130 +1425,130 @@ class LiveKitPublisher:
|
|
|
1425
1425
|
# entire event loop — including receiving the agent's greeting audio
|
|
1426
1426
|
# — until it finished, so a short "Hello friend!" greeting could be
|
|
1427
1427
|
# over and gone before we ever got a chance to subscribe to it.
|
|
1428
|
-
if audio_enabled:
|
|
1429
|
-
self._tasks.append(asyncio.create_task(self._audio_retry_loop()))
|
|
1430
|
-
try:
|
|
1431
|
-
await asyncio.wait_for(self._mic_streaming.wait(), timeout=5.0)
|
|
1432
|
-
except asyncio.TimeoutError:
|
|
1433
|
-
detail = self._mic_error or f"no PCM received from {self.agent.audio_capture_device}"
|
|
1434
|
-
await self.agent._report_pipeline(
|
|
1435
|
-
"microphone_published", "blocked",
|
|
1436
|
-
f"Microphone track published but PCM capture did not start: {detail}",
|
|
1437
|
-
{"device": self.agent.audio_capture_device, "error": detail},
|
|
1438
|
-
)
|
|
1439
|
-
logger.warning(
|
|
1440
|
-
"microphone PCM is not ready (%s); keeping the session alive while capture retries",
|
|
1441
|
-
detail,
|
|
1442
|
-
)
|
|
1428
|
+
if audio_enabled:
|
|
1429
|
+
self._tasks.append(asyncio.create_task(self._audio_retry_loop()))
|
|
1430
|
+
try:
|
|
1431
|
+
await asyncio.wait_for(self._mic_streaming.wait(), timeout=5.0)
|
|
1432
|
+
except asyncio.TimeoutError:
|
|
1433
|
+
detail = self._mic_error or f"no PCM received from {self.agent.audio_capture_device}"
|
|
1434
|
+
await self.agent._report_pipeline(
|
|
1435
|
+
"microphone_published", "blocked",
|
|
1436
|
+
f"Microphone track published but PCM capture did not start: {detail}",
|
|
1437
|
+
{"device": self.agent.audio_capture_device, "error": detail},
|
|
1438
|
+
)
|
|
1439
|
+
logger.warning(
|
|
1440
|
+
"microphone PCM is not ready (%s); keeping the session alive while capture retries",
|
|
1441
|
+
detail,
|
|
1442
|
+
)
|
|
1443
1443
|
|
|
1444
1444
|
if self._capture and self.video_source:
|
|
1445
1445
|
self._tasks.append(asyncio.create_task(self._video_loop()))
|
|
1446
1446
|
|
|
1447
|
-
async def _play_remote_audio(self, track, sid: str) -> None:
|
|
1448
|
-
OUT_RATE = 48000
|
|
1449
|
-
OUT_CHANNELS = 2
|
|
1450
|
-
selected_output = str(self.agent.audio_playback_device or "default")
|
|
1451
|
-
pa = None
|
|
1452
|
-
output_device_index = None
|
|
1453
|
-
speaker_guard = None
|
|
1454
|
-
|
|
1455
|
-
# RoboVision inventories Linux devices through sounddevice/PortAudio,
|
|
1456
|
-
# but the numeric indices are not stable across PyAudio builds. More
|
|
1457
|
-
# importantly, BMW's production USB adapter is already proven through
|
|
1458
|
-
# ALSA's plughw conversion path. Use that exact endpoint for live TTS
|
|
1459
|
-
# instead of reopening the unrelated PyAudio index.
|
|
1460
|
-
if sys.platform.startswith("linux") and "hw:" in selected_output:
|
|
1461
|
-
import re
|
|
1462
|
-
import subprocess
|
|
1463
|
-
from media_lock import media_lock
|
|
1464
|
-
|
|
1465
|
-
match = re.search(r"\b(hw:\d+,\d+)\b", selected_output)
|
|
1466
|
-
if not match:
|
|
1467
|
-
logger.warning(f"audio out: no ALSA hardware address in {selected_output!r}")
|
|
1468
|
-
return
|
|
1469
|
-
alsa_device = f"plug{match.group(1)}"
|
|
1470
|
-
try:
|
|
1471
|
-
speaker_guard = media_lock("speaker", timeout=8.0).acquire()
|
|
1472
|
-
process = subprocess.Popen(
|
|
1473
|
-
[
|
|
1474
|
-
"aplay", "-q", "-D", alsa_device, "-t", "raw",
|
|
1475
|
-
"-f", "S16_LE", "-r", str(OUT_RATE), "-c", str(OUT_CHANNELS),
|
|
1476
|
-
],
|
|
1477
|
-
stdin=subprocess.PIPE,
|
|
1478
|
-
stderr=subprocess.PIPE,
|
|
1479
|
-
)
|
|
1480
|
-
except Exception as exc:
|
|
1481
|
-
if speaker_guard is not None:
|
|
1482
|
-
speaker_guard.release()
|
|
1483
|
-
logger.warning(f"audio out: could not acquire {alsa_device}: {exc}")
|
|
1484
|
-
return
|
|
1485
|
-
if process.stdin is None:
|
|
1486
|
-
logger.warning(f"audio out: aplay did not expose stdin for {alsa_device}")
|
|
1487
|
-
process.kill()
|
|
1488
|
-
process.wait(timeout=1)
|
|
1489
|
-
speaker_guard.release()
|
|
1490
|
-
return
|
|
1491
|
-
|
|
1492
|
-
class _AplayOutput:
|
|
1493
|
-
def write(self, chunk: bytes) -> None:
|
|
1494
|
-
if process.poll() is not None:
|
|
1495
|
-
detail = ""
|
|
1496
|
-
if process.stderr is not None:
|
|
1497
|
-
detail = process.stderr.read().decode("utf-8", errors="replace").strip()
|
|
1498
|
-
raise OSError(detail or f"aplay exited {process.returncode}")
|
|
1499
|
-
process.stdin.write(chunk)
|
|
1500
|
-
process.stdin.flush()
|
|
1501
|
-
|
|
1502
|
-
def stop_stream(self) -> None:
|
|
1503
|
-
if process.stdin and not process.stdin.closed:
|
|
1504
|
-
process.stdin.close()
|
|
1505
|
-
try:
|
|
1506
|
-
process.wait(timeout=2)
|
|
1507
|
-
except subprocess.TimeoutExpired:
|
|
1508
|
-
process.terminate()
|
|
1509
|
-
try:
|
|
1510
|
-
process.wait(timeout=1)
|
|
1511
|
-
except subprocess.TimeoutExpired:
|
|
1512
|
-
process.kill()
|
|
1513
|
-
process.wait(timeout=1)
|
|
1514
|
-
|
|
1515
|
-
def close(self) -> None:
|
|
1516
|
-
return
|
|
1517
|
-
|
|
1518
|
-
out = _AplayOutput()
|
|
1519
|
-
output_device_index = alsa_device
|
|
1520
|
-
else:
|
|
1521
|
-
try:
|
|
1522
|
-
import pyaudio
|
|
1523
|
-
except Exception as e:
|
|
1524
|
-
logger.warning(f"pyaudio unavailable for playback: {e}")
|
|
1525
|
-
return
|
|
1526
|
-
pa = pyaudio.PyAudio()
|
|
1527
|
-
# Same MME-vs-WASAPI gotcha as mic capture (see
|
|
1528
|
-
# PyAudioCapture._resolve_device): prefer the endpoint backing the
|
|
1529
|
-
# Windows volume mixer instead of the silent MME default.
|
|
1530
|
-
if selected_output.strip().isdigit():
|
|
1531
|
-
output_device_index = int(selected_output.strip())
|
|
1532
|
-
try:
|
|
1533
|
-
if selected_output.lower() == "default":
|
|
1534
|
-
wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
|
|
1535
|
-
idx = wasapi.get("defaultOutputDevice")
|
|
1536
|
-
if idx is not None and idx >= 0:
|
|
1537
|
-
output_device_index = idx
|
|
1538
|
-
elif output_device_index is None:
|
|
1539
|
-
needle = selected_output.lower()
|
|
1540
|
-
for i in range(pa.get_device_count()):
|
|
1541
|
-
info = pa.get_device_info_by_index(i)
|
|
1542
|
-
if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
|
|
1543
|
-
output_device_index = i
|
|
1544
|
-
break
|
|
1545
|
-
except Exception as e:
|
|
1546
|
-
logger.debug(f"audio out: WASAPI default output lookup failed, using PyAudio default: {e}")
|
|
1547
|
-
out = pa.open(
|
|
1548
|
-
format=pyaudio.paInt16, channels=OUT_CHANNELS, rate=OUT_RATE, output=True,
|
|
1549
|
-
output_device_index=output_device_index,
|
|
1550
|
-
frames_per_buffer=960,
|
|
1551
|
-
)
|
|
1447
|
+
async def _play_remote_audio(self, track, sid: str) -> None:
|
|
1448
|
+
OUT_RATE = 48000
|
|
1449
|
+
OUT_CHANNELS = 2
|
|
1450
|
+
selected_output = str(self.agent.audio_playback_device or "default")
|
|
1451
|
+
pa = None
|
|
1452
|
+
output_device_index = None
|
|
1453
|
+
speaker_guard = None
|
|
1454
|
+
|
|
1455
|
+
# RoboVision inventories Linux devices through sounddevice/PortAudio,
|
|
1456
|
+
# but the numeric indices are not stable across PyAudio builds. More
|
|
1457
|
+
# importantly, BMW's production USB adapter is already proven through
|
|
1458
|
+
# ALSA's plughw conversion path. Use that exact endpoint for live TTS
|
|
1459
|
+
# instead of reopening the unrelated PyAudio index.
|
|
1460
|
+
if sys.platform.startswith("linux") and "hw:" in selected_output:
|
|
1461
|
+
import re
|
|
1462
|
+
import subprocess
|
|
1463
|
+
from media_lock import media_lock
|
|
1464
|
+
|
|
1465
|
+
match = re.search(r"\b(hw:\d+,\d+)\b", selected_output)
|
|
1466
|
+
if not match:
|
|
1467
|
+
logger.warning(f"audio out: no ALSA hardware address in {selected_output!r}")
|
|
1468
|
+
return
|
|
1469
|
+
alsa_device = f"plug{match.group(1)}"
|
|
1470
|
+
try:
|
|
1471
|
+
speaker_guard = media_lock("speaker", timeout=8.0).acquire()
|
|
1472
|
+
process = subprocess.Popen(
|
|
1473
|
+
[
|
|
1474
|
+
"aplay", "-q", "-D", alsa_device, "-t", "raw",
|
|
1475
|
+
"-f", "S16_LE", "-r", str(OUT_RATE), "-c", str(OUT_CHANNELS),
|
|
1476
|
+
],
|
|
1477
|
+
stdin=subprocess.PIPE,
|
|
1478
|
+
stderr=subprocess.PIPE,
|
|
1479
|
+
)
|
|
1480
|
+
except Exception as exc:
|
|
1481
|
+
if speaker_guard is not None:
|
|
1482
|
+
speaker_guard.release()
|
|
1483
|
+
logger.warning(f"audio out: could not acquire {alsa_device}: {exc}")
|
|
1484
|
+
return
|
|
1485
|
+
if process.stdin is None:
|
|
1486
|
+
logger.warning(f"audio out: aplay did not expose stdin for {alsa_device}")
|
|
1487
|
+
process.kill()
|
|
1488
|
+
process.wait(timeout=1)
|
|
1489
|
+
speaker_guard.release()
|
|
1490
|
+
return
|
|
1491
|
+
|
|
1492
|
+
class _AplayOutput:
|
|
1493
|
+
def write(self, chunk: bytes) -> None:
|
|
1494
|
+
if process.poll() is not None:
|
|
1495
|
+
detail = ""
|
|
1496
|
+
if process.stderr is not None:
|
|
1497
|
+
detail = process.stderr.read().decode("utf-8", errors="replace").strip()
|
|
1498
|
+
raise OSError(detail or f"aplay exited {process.returncode}")
|
|
1499
|
+
process.stdin.write(chunk)
|
|
1500
|
+
process.stdin.flush()
|
|
1501
|
+
|
|
1502
|
+
def stop_stream(self) -> None:
|
|
1503
|
+
if process.stdin and not process.stdin.closed:
|
|
1504
|
+
process.stdin.close()
|
|
1505
|
+
try:
|
|
1506
|
+
process.wait(timeout=2)
|
|
1507
|
+
except subprocess.TimeoutExpired:
|
|
1508
|
+
process.terminate()
|
|
1509
|
+
try:
|
|
1510
|
+
process.wait(timeout=1)
|
|
1511
|
+
except subprocess.TimeoutExpired:
|
|
1512
|
+
process.kill()
|
|
1513
|
+
process.wait(timeout=1)
|
|
1514
|
+
|
|
1515
|
+
def close(self) -> None:
|
|
1516
|
+
return
|
|
1517
|
+
|
|
1518
|
+
out = _AplayOutput()
|
|
1519
|
+
output_device_index = alsa_device
|
|
1520
|
+
else:
|
|
1521
|
+
try:
|
|
1522
|
+
import pyaudio
|
|
1523
|
+
except Exception as e:
|
|
1524
|
+
logger.warning(f"pyaudio unavailable for playback: {e}")
|
|
1525
|
+
return
|
|
1526
|
+
pa = pyaudio.PyAudio()
|
|
1527
|
+
# Same MME-vs-WASAPI gotcha as mic capture (see
|
|
1528
|
+
# PyAudioCapture._resolve_device): prefer the endpoint backing the
|
|
1529
|
+
# Windows volume mixer instead of the silent MME default.
|
|
1530
|
+
if selected_output.strip().isdigit():
|
|
1531
|
+
output_device_index = int(selected_output.strip())
|
|
1532
|
+
try:
|
|
1533
|
+
if selected_output.lower() == "default":
|
|
1534
|
+
wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
|
|
1535
|
+
idx = wasapi.get("defaultOutputDevice")
|
|
1536
|
+
if idx is not None and idx >= 0:
|
|
1537
|
+
output_device_index = idx
|
|
1538
|
+
elif output_device_index is None:
|
|
1539
|
+
needle = selected_output.lower()
|
|
1540
|
+
for i in range(pa.get_device_count()):
|
|
1541
|
+
info = pa.get_device_info_by_index(i)
|
|
1542
|
+
if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
|
|
1543
|
+
output_device_index = i
|
|
1544
|
+
break
|
|
1545
|
+
except Exception as e:
|
|
1546
|
+
logger.debug(f"audio out: WASAPI default output lookup failed, using PyAudio default: {e}")
|
|
1547
|
+
out = pa.open(
|
|
1548
|
+
format=pyaudio.paInt16, channels=OUT_CHANNELS, rate=OUT_RATE, output=True,
|
|
1549
|
+
output_device_index=output_device_index,
|
|
1550
|
+
frames_per_buffer=960,
|
|
1551
|
+
)
|
|
1552
1552
|
logger.info(f"audio out: opened playback stream for {sid} (device_index={output_device_index})")
|
|
1553
1553
|
frame_count = 0
|
|
1554
1554
|
mismatch_logged = False
|
|
@@ -1567,60 +1567,60 @@ class LiveKitPublisher:
|
|
|
1567
1567
|
# steady pace, independent of how unevenly frames actually arrive.
|
|
1568
1568
|
import queue
|
|
1569
1569
|
import threading
|
|
1570
|
-
write_queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
|
|
1571
|
-
written_frames = [0]
|
|
1572
|
-
PREBUFFER_CHUNKS = 3
|
|
1573
|
-
stream_failed = threading.Event()
|
|
1574
|
-
playback_reported = threading.Event()
|
|
1575
|
-
event_loop = asyncio.get_running_loop()
|
|
1576
|
-
echo_gate_peak = max(16, min(int(os.getenv("ROBOPARK_ECHO_GATE_PEAK", "96")), 4096))
|
|
1577
|
-
|
|
1578
|
-
def _outbound_peak(chunk: bytes) -> int:
|
|
1579
|
-
if len(chunk) < 2:
|
|
1580
|
-
return 0
|
|
1581
|
-
try:
|
|
1582
|
-
samples = memoryview(chunk).cast("h")
|
|
1583
|
-
# Stereo duplication means sampling every eighth value is
|
|
1584
|
-
# sufficient and keeps the writer thread lightweight.
|
|
1585
|
-
return max((abs(int(value)) for value in samples[::8]), default=0)
|
|
1586
|
-
except (TypeError, ValueError):
|
|
1587
|
-
return 0
|
|
1588
|
-
|
|
1589
|
-
def _open_echo_gate() -> None:
|
|
1590
|
-
if not self._half_duplex:
|
|
1591
|
-
return
|
|
1592
|
-
if not self._speaker_playback_active.is_set():
|
|
1593
|
-
self._speaker_started_at = time.monotonic()
|
|
1594
|
-
self._echo_mic_floor = 0.0
|
|
1595
|
-
self._barge_in_candidate_frames = 0
|
|
1596
|
-
self._speaker_playback_active.set()
|
|
1597
|
-
self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
|
|
1598
|
-
|
|
1599
|
-
def _extend_echo_gate() -> None:
|
|
1600
|
-
if self._half_duplex:
|
|
1601
|
-
self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
|
|
1602
|
-
|
|
1603
|
-
def _safe_write(chunk: bytes) -> bool:
|
|
1604
|
-
if stream_failed.is_set():
|
|
1605
|
-
return False
|
|
1606
|
-
try:
|
|
1607
|
-
audible = _outbound_peak(chunk) >= echo_gate_peak
|
|
1608
|
-
if audible:
|
|
1609
|
-
_open_echo_gate()
|
|
1610
|
-
elif time.monotonic() >= self._speaker_gate_until:
|
|
1611
|
-
self._speaker_playback_active.clear()
|
|
1612
|
-
out.write(chunk)
|
|
1613
|
-
if audible:
|
|
1614
|
-
_extend_echo_gate()
|
|
1615
|
-
if not playback_reported.is_set():
|
|
1616
|
-
playback_reported.set()
|
|
1617
|
-
asyncio.run_coroutine_threadsafe(
|
|
1618
|
-
self.agent._report_pipeline(
|
|
1619
|
-
"playback_started", "ok", "First TTS audio chunk written to robot speaker"
|
|
1620
|
-
),
|
|
1621
|
-
event_loop,
|
|
1622
|
-
)
|
|
1623
|
-
return True
|
|
1570
|
+
write_queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
|
|
1571
|
+
written_frames = [0]
|
|
1572
|
+
PREBUFFER_CHUNKS = 3
|
|
1573
|
+
stream_failed = threading.Event()
|
|
1574
|
+
playback_reported = threading.Event()
|
|
1575
|
+
event_loop = asyncio.get_running_loop()
|
|
1576
|
+
echo_gate_peak = max(16, min(int(os.getenv("ROBOPARK_ECHO_GATE_PEAK", "96")), 4096))
|
|
1577
|
+
|
|
1578
|
+
def _outbound_peak(chunk: bytes) -> int:
|
|
1579
|
+
if len(chunk) < 2:
|
|
1580
|
+
return 0
|
|
1581
|
+
try:
|
|
1582
|
+
samples = memoryview(chunk).cast("h")
|
|
1583
|
+
# Stereo duplication means sampling every eighth value is
|
|
1584
|
+
# sufficient and keeps the writer thread lightweight.
|
|
1585
|
+
return max((abs(int(value)) for value in samples[::8]), default=0)
|
|
1586
|
+
except (TypeError, ValueError):
|
|
1587
|
+
return 0
|
|
1588
|
+
|
|
1589
|
+
def _open_echo_gate() -> None:
|
|
1590
|
+
if not self._half_duplex:
|
|
1591
|
+
return
|
|
1592
|
+
if not self._speaker_playback_active.is_set():
|
|
1593
|
+
self._speaker_started_at = time.monotonic()
|
|
1594
|
+
self._echo_mic_floor = 0.0
|
|
1595
|
+
self._barge_in_candidate_frames = 0
|
|
1596
|
+
self._speaker_playback_active.set()
|
|
1597
|
+
self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
|
|
1598
|
+
|
|
1599
|
+
def _extend_echo_gate() -> None:
|
|
1600
|
+
if self._half_duplex:
|
|
1601
|
+
self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
|
|
1602
|
+
|
|
1603
|
+
def _safe_write(chunk: bytes) -> bool:
|
|
1604
|
+
if stream_failed.is_set():
|
|
1605
|
+
return False
|
|
1606
|
+
try:
|
|
1607
|
+
audible = _outbound_peak(chunk) >= echo_gate_peak
|
|
1608
|
+
if audible:
|
|
1609
|
+
_open_echo_gate()
|
|
1610
|
+
elif time.monotonic() >= self._speaker_gate_until:
|
|
1611
|
+
self._speaker_playback_active.clear()
|
|
1612
|
+
out.write(chunk)
|
|
1613
|
+
if audible:
|
|
1614
|
+
_extend_echo_gate()
|
|
1615
|
+
if not playback_reported.is_set():
|
|
1616
|
+
playback_reported.set()
|
|
1617
|
+
asyncio.run_coroutine_threadsafe(
|
|
1618
|
+
self.agent._report_pipeline(
|
|
1619
|
+
"playback_started", "ok", "First TTS audio chunk written to robot speaker"
|
|
1620
|
+
),
|
|
1621
|
+
event_loop,
|
|
1622
|
+
)
|
|
1623
|
+
return True
|
|
1624
1624
|
except Exception as e:
|
|
1625
1625
|
stream_failed.set()
|
|
1626
1626
|
logger.warning(f"audio out stream closed; disabling playback for this track: {e}")
|
|
@@ -1675,11 +1675,11 @@ class LiveKitPublisher:
|
|
|
1675
1675
|
async for frame in stream:
|
|
1676
1676
|
af = frame.frame if hasattr(frame, "frame") else frame
|
|
1677
1677
|
frame_count += 1
|
|
1678
|
-
if frame_count == 1:
|
|
1679
|
-
logger.info(
|
|
1680
|
-
f"audio out: first frame received for {sid} "
|
|
1681
|
-
f"(rate={af.sample_rate}, channels={getattr(af, 'num_channels', 1)})"
|
|
1682
|
-
)
|
|
1678
|
+
if frame_count == 1:
|
|
1679
|
+
logger.info(
|
|
1680
|
+
f"audio out: first frame received for {sid} "
|
|
1681
|
+
f"(rate={af.sample_rate}, channels={getattr(af, 'num_channels', 1)})"
|
|
1682
|
+
)
|
|
1683
1683
|
in_rate = af.sample_rate
|
|
1684
1684
|
in_channels = int(getattr(af, "num_channels", 1) or 1)
|
|
1685
1685
|
raw = bytes(af.data)
|
|
@@ -1720,34 +1720,34 @@ class LiveKitPublisher:
|
|
|
1720
1720
|
else:
|
|
1721
1721
|
data = b"".join(raw[i:i + 2] * OUT_CHANNELS for i in range(0, len(raw), 2))
|
|
1722
1722
|
write_queue.put(data)
|
|
1723
|
-
except Exception as e:
|
|
1724
|
-
logger.warning(f"audio out stream error: {e}")
|
|
1725
|
-
finally:
|
|
1726
|
-
cancelling = bool(asyncio.current_task() and asyncio.current_task().cancelling())
|
|
1727
|
-
if cancelling:
|
|
1728
|
-
# On publisher teardown, close ALSA first. Waiting for the
|
|
1729
|
-
# writer while aplay still owns the device leaves hw:X,Y busy
|
|
1730
|
-
# long enough for the queued dashboard test to fail.
|
|
1731
|
-
stream_failed.set()
|
|
1732
|
-
out.stop_stream()
|
|
1733
|
-
write_queue.put(None)
|
|
1734
|
-
writer_thread.join(timeout=2.0)
|
|
1723
|
+
except Exception as e:
|
|
1724
|
+
logger.warning(f"audio out stream error: {e}")
|
|
1725
|
+
finally:
|
|
1726
|
+
cancelling = bool(asyncio.current_task() and asyncio.current_task().cancelling())
|
|
1727
|
+
if cancelling:
|
|
1728
|
+
# On publisher teardown, close ALSA first. Waiting for the
|
|
1729
|
+
# writer while aplay still owns the device leaves hw:X,Y busy
|
|
1730
|
+
# long enough for the queued dashboard test to fail.
|
|
1731
|
+
stream_failed.set()
|
|
1732
|
+
out.stop_stream()
|
|
1733
|
+
write_queue.put(None)
|
|
1734
|
+
writer_thread.join(timeout=2.0)
|
|
1735
1735
|
logger.info(
|
|
1736
1736
|
f"audio out: {sid} received {frame_count} frames, "
|
|
1737
1737
|
f"writer wrote {written_frames[0]} chunks, queue backlog at close={write_queue.qsize()}"
|
|
1738
1738
|
)
|
|
1739
|
-
if not cancelling:
|
|
1740
|
-
out.stop_stream()
|
|
1741
|
-
out.close()
|
|
1742
|
-
if pa is not None:
|
|
1743
|
-
pa.terminate()
|
|
1744
|
-
if speaker_guard is not None:
|
|
1745
|
-
speaker_guard.release()
|
|
1746
|
-
if self._half_duplex:
|
|
1747
|
-
self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
|
|
1748
|
-
self._speaker_playback_active.clear()
|
|
1749
|
-
|
|
1750
|
-
async def stop(self) -> None:
|
|
1739
|
+
if not cancelling:
|
|
1740
|
+
out.stop_stream()
|
|
1741
|
+
out.close()
|
|
1742
|
+
if pa is not None:
|
|
1743
|
+
pa.terminate()
|
|
1744
|
+
if speaker_guard is not None:
|
|
1745
|
+
speaker_guard.release()
|
|
1746
|
+
if self._half_duplex:
|
|
1747
|
+
self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
|
|
1748
|
+
self._speaker_playback_active.clear()
|
|
1749
|
+
|
|
1750
|
+
async def stop(self) -> None:
|
|
1751
1751
|
self._stop_event.set()
|
|
1752
1752
|
for t in self._tasks:
|
|
1753
1753
|
t.cancel()
|
|
@@ -1755,10 +1755,10 @@ class LiveKitPublisher:
|
|
|
1755
1755
|
await t
|
|
1756
1756
|
except asyncio.CancelledError:
|
|
1757
1757
|
pass
|
|
1758
|
-
self._tasks.clear()
|
|
1759
|
-
if self._mic_capture:
|
|
1760
|
-
await asyncio.to_thread(self._mic_capture.stop)
|
|
1761
|
-
self._mic_capture = None
|
|
1758
|
+
self._tasks.clear()
|
|
1759
|
+
if self._mic_capture:
|
|
1760
|
+
await asyncio.to_thread(self._mic_capture.stop)
|
|
1761
|
+
self._mic_capture = None
|
|
1762
1762
|
if self._capture:
|
|
1763
1763
|
self._capture.stop()
|
|
1764
1764
|
self._capture = None
|
|
@@ -1817,38 +1817,38 @@ class LiveKitPublisher:
|
|
|
1817
1817
|
except Exception as e:
|
|
1818
1818
|
logger.debug(f"preview motion sampling failed: {e}")
|
|
1819
1819
|
|
|
1820
|
-
async def _audio_retry_loop(self) -> None:
|
|
1821
|
-
"""Keep microphone capture alive across transient ALSA ownership errors."""
|
|
1822
|
-
while not self._stop_event.is_set():
|
|
1823
|
-
try:
|
|
1824
|
-
await self._audio_loop()
|
|
1825
|
-
except asyncio.CancelledError:
|
|
1826
|
-
raise
|
|
1827
|
-
except Exception as e:
|
|
1828
|
-
self._mic_error = str(e)
|
|
1829
|
-
logger.warning(
|
|
1830
|
-
f"audio_loop: capture failed on {self.agent.audio_capture_device}: {e}; retrying"
|
|
1831
|
-
)
|
|
1832
|
-
finally:
|
|
1833
|
-
mic = self._mic_capture
|
|
1834
|
-
self._mic_capture = None
|
|
1835
|
-
if mic is not None:
|
|
1836
|
-
await asyncio.to_thread(mic.stop)
|
|
1837
|
-
if not self._stop_event.is_set():
|
|
1838
|
-
try:
|
|
1839
|
-
await asyncio.wait_for(self._stop_event.wait(), timeout=1.0)
|
|
1840
|
-
except asyncio.TimeoutError:
|
|
1841
|
-
pass
|
|
1842
|
-
|
|
1843
|
-
async def _audio_loop(self) -> None:
|
|
1820
|
+
async def _audio_retry_loop(self) -> None:
|
|
1821
|
+
"""Keep microphone capture alive across transient ALSA ownership errors."""
|
|
1822
|
+
while not self._stop_event.is_set():
|
|
1823
|
+
try:
|
|
1824
|
+
await self._audio_loop()
|
|
1825
|
+
except asyncio.CancelledError:
|
|
1826
|
+
raise
|
|
1827
|
+
except Exception as e:
|
|
1828
|
+
self._mic_error = str(e)
|
|
1829
|
+
logger.warning(
|
|
1830
|
+
f"audio_loop: capture failed on {self.agent.audio_capture_device}: {e}; retrying"
|
|
1831
|
+
)
|
|
1832
|
+
finally:
|
|
1833
|
+
mic = self._mic_capture
|
|
1834
|
+
self._mic_capture = None
|
|
1835
|
+
if mic is not None:
|
|
1836
|
+
await asyncio.to_thread(mic.stop)
|
|
1837
|
+
if not self._stop_event.is_set():
|
|
1838
|
+
try:
|
|
1839
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=1.0)
|
|
1840
|
+
except asyncio.TimeoutError:
|
|
1841
|
+
pass
|
|
1842
|
+
|
|
1843
|
+
async def _audio_loop(self) -> None:
|
|
1844
1844
|
assert self.audio_source is not None
|
|
1845
|
-
mic = create_audio_capture(self.agent.audio_capture_device)
|
|
1846
|
-
if mic is None:
|
|
1845
|
+
mic = create_audio_capture(self.agent.audio_capture_device)
|
|
1846
|
+
if mic is None:
|
|
1847
1847
|
logger.warning("audio_loop: create_audio_capture returned None, mic will not publish")
|
|
1848
|
-
return
|
|
1849
|
-
self._mic_capture = mic
|
|
1850
|
-
self._mic_error = None
|
|
1851
|
-
logger.info(f"audio_loop: mic capture started ({type(mic).__name__})")
|
|
1848
|
+
return
|
|
1849
|
+
self._mic_capture = mic
|
|
1850
|
+
self._mic_error = None
|
|
1851
|
+
logger.info(f"audio_loop: mic capture started ({type(mic).__name__})")
|
|
1852
1852
|
# Read in bigger batches (100ms) instead of one 20ms frame per
|
|
1853
1853
|
# asyncio.to_thread() dispatch. Each dispatch/poll cycle has a fixed
|
|
1854
1854
|
# overhead (~30ms observed on this machine) that dominates when the
|
|
@@ -1863,14 +1863,14 @@ class LiveKitPublisher:
|
|
|
1863
1863
|
samples_per_frame = int(48000 * frame_ms / 1000)
|
|
1864
1864
|
samples_per_batch = int(48000 * BATCH_MS / 1000)
|
|
1865
1865
|
bytes_per_frame = samples_per_frame * 2 # int16 mono
|
|
1866
|
-
mic_gain = max(1.0, min(float(os.getenv("ROBOPARK_MIC_GAIN", "4.0")), 12.0))
|
|
1866
|
+
mic_gain = max(1.0, min(float(os.getenv("ROBOPARK_MIC_GAIN", "4.0")), 12.0))
|
|
1867
1867
|
_diag_peak = 0
|
|
1868
1868
|
_diag_count = 0
|
|
1869
1869
|
_diag_last_log = time.monotonic()
|
|
1870
1870
|
_diag_read_ms = 0.0
|
|
1871
|
-
_diag_publish_ms = 0.0
|
|
1872
|
-
_echo_gate_was_active = False
|
|
1873
|
-
batch_interval = BATCH_MS / 1000
|
|
1871
|
+
_diag_publish_ms = 0.0
|
|
1872
|
+
_echo_gate_was_active = False
|
|
1873
|
+
batch_interval = BATCH_MS / 1000
|
|
1874
1874
|
while not self._stop_event.is_set():
|
|
1875
1875
|
_iter_start = time.monotonic()
|
|
1876
1876
|
_t0 = time.monotonic()
|
|
@@ -1878,67 +1878,67 @@ class LiveKitPublisher:
|
|
|
1878
1878
|
_t1 = time.monotonic()
|
|
1879
1879
|
if batch is not None:
|
|
1880
1880
|
data = bytes(batch.data)
|
|
1881
|
-
_pub_start = time.monotonic()
|
|
1882
|
-
for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
|
|
1883
|
-
chunk = data[off:off + bytes_per_frame]
|
|
1884
|
-
now = time.monotonic()
|
|
1885
|
-
echo_gate_active = self._half_duplex and (
|
|
1886
|
-
self._speaker_playback_active.is_set()
|
|
1887
|
-
or now < self._speaker_gate_until
|
|
1888
|
-
)
|
|
1889
|
-
# Learn the microphone's speaker-echo floor while output is
|
|
1890
|
-
# active. A nearby visitor speaking produces a fast peak well
|
|
1891
|
-
# above that floor; reopen the mic briefly so LiveKit VAD can
|
|
1892
|
-
# cancel normal TTS. The initial 300 ms remains protected.
|
|
1893
|
-
_, raw_peak = _pcm16_scale_and_peak(chunk, 1.0)
|
|
1894
|
-
if echo_gate_active and self._adaptive_barge_in:
|
|
1895
|
-
if self._echo_mic_floor <= 0:
|
|
1896
|
-
self._echo_mic_floor = float(raw_peak)
|
|
1897
|
-
else:
|
|
1898
|
-
self._echo_mic_floor = self._echo_mic_floor * 0.92 + raw_peak * 0.08
|
|
1899
|
-
threshold = max(
|
|
1900
|
-
self._barge_in_min_peak,
|
|
1901
|
-
int(self._echo_mic_floor * self._barge_in_ratio),
|
|
1902
|
-
)
|
|
1903
|
-
warmed = now - self._speaker_started_at >= 0.3
|
|
1904
|
-
if warmed and raw_peak >= threshold:
|
|
1905
|
-
self._barge_in_candidate_frames += 1
|
|
1906
|
-
else:
|
|
1907
|
-
self._barge_in_candidate_frames = 0
|
|
1908
|
-
if self._barge_in_candidate_frames >= 3:
|
|
1909
|
-
if now >= self._barge_in_until:
|
|
1910
|
-
logger.info(
|
|
1911
|
-
"audio_loop: adaptive barge-in opened mic "
|
|
1912
|
-
"(peak=%d threshold=%d echo_floor=%d)",
|
|
1913
|
-
raw_peak, threshold, int(self._echo_mic_floor),
|
|
1914
|
-
)
|
|
1915
|
-
self._barge_in_until = now + self._barge_in_hold
|
|
1916
|
-
barge_in_active = self._adaptive_barge_in and now < self._barge_in_until
|
|
1917
|
-
if echo_gate_active and not barge_in_active:
|
|
1918
|
-
# Preserve 20 ms frame cadence; only suppress content.
|
|
1919
|
-
# Stopping publication would create gaps and destabilize
|
|
1920
|
-
# VAD/endpointing when listening resumes.
|
|
1921
|
-
chunk = b"\x00" * len(chunk)
|
|
1922
|
-
p = 0
|
|
1923
|
-
else:
|
|
1924
|
-
chunk, p = _pcm16_scale_and_peak(chunk, mic_gain)
|
|
1925
|
-
if echo_gate_active != _echo_gate_was_active:
|
|
1926
|
-
logger.info(
|
|
1927
|
-
"audio_loop: speaker echo gate %s",
|
|
1928
|
-
"active" if echo_gate_active else "released",
|
|
1929
|
-
)
|
|
1930
|
-
_echo_gate_was_active = echo_gate_active
|
|
1931
|
-
frame = self.rtc.AudioFrame(
|
|
1881
|
+
_pub_start = time.monotonic()
|
|
1882
|
+
for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
|
|
1883
|
+
chunk = data[off:off + bytes_per_frame]
|
|
1884
|
+
now = time.monotonic()
|
|
1885
|
+
echo_gate_active = self._half_duplex and (
|
|
1886
|
+
self._speaker_playback_active.is_set()
|
|
1887
|
+
or now < self._speaker_gate_until
|
|
1888
|
+
)
|
|
1889
|
+
# Learn the microphone's speaker-echo floor while output is
|
|
1890
|
+
# active. A nearby visitor speaking produces a fast peak well
|
|
1891
|
+
# above that floor; reopen the mic briefly so LiveKit VAD can
|
|
1892
|
+
# cancel normal TTS. The initial 300 ms remains protected.
|
|
1893
|
+
_, raw_peak = _pcm16_scale_and_peak(chunk, 1.0)
|
|
1894
|
+
if echo_gate_active and self._adaptive_barge_in:
|
|
1895
|
+
if self._echo_mic_floor <= 0:
|
|
1896
|
+
self._echo_mic_floor = float(raw_peak)
|
|
1897
|
+
else:
|
|
1898
|
+
self._echo_mic_floor = self._echo_mic_floor * 0.92 + raw_peak * 0.08
|
|
1899
|
+
threshold = max(
|
|
1900
|
+
self._barge_in_min_peak,
|
|
1901
|
+
int(self._echo_mic_floor * self._barge_in_ratio),
|
|
1902
|
+
)
|
|
1903
|
+
warmed = now - self._speaker_started_at >= 0.3
|
|
1904
|
+
if warmed and raw_peak >= threshold:
|
|
1905
|
+
self._barge_in_candidate_frames += 1
|
|
1906
|
+
else:
|
|
1907
|
+
self._barge_in_candidate_frames = 0
|
|
1908
|
+
if self._barge_in_candidate_frames >= 3:
|
|
1909
|
+
if now >= self._barge_in_until:
|
|
1910
|
+
logger.info(
|
|
1911
|
+
"audio_loop: adaptive barge-in opened mic "
|
|
1912
|
+
"(peak=%d threshold=%d echo_floor=%d)",
|
|
1913
|
+
raw_peak, threshold, int(self._echo_mic_floor),
|
|
1914
|
+
)
|
|
1915
|
+
self._barge_in_until = now + self._barge_in_hold
|
|
1916
|
+
barge_in_active = self._adaptive_barge_in and now < self._barge_in_until
|
|
1917
|
+
if echo_gate_active and not barge_in_active:
|
|
1918
|
+
# Preserve 20 ms frame cadence; only suppress content.
|
|
1919
|
+
# Stopping publication would create gaps and destabilize
|
|
1920
|
+
# VAD/endpointing when listening resumes.
|
|
1921
|
+
chunk = b"\x00" * len(chunk)
|
|
1922
|
+
p = 0
|
|
1923
|
+
else:
|
|
1924
|
+
chunk, p = _pcm16_scale_and_peak(chunk, mic_gain)
|
|
1925
|
+
if echo_gate_active != _echo_gate_was_active:
|
|
1926
|
+
logger.info(
|
|
1927
|
+
"audio_loop: speaker echo gate %s",
|
|
1928
|
+
"active" if echo_gate_active else "released",
|
|
1929
|
+
)
|
|
1930
|
+
_echo_gate_was_active = echo_gate_active
|
|
1931
|
+
frame = self.rtc.AudioFrame(
|
|
1932
1932
|
data=chunk, sample_rate=48000, num_channels=1, samples_per_channel=samples_per_frame,
|
|
1933
1933
|
)
|
|
1934
|
-
await self.audio_source.capture_frame(frame)
|
|
1935
|
-
if not self._mic_streaming.is_set():
|
|
1936
|
-
self._mic_streaming.set()
|
|
1937
|
-
await self.agent._report_pipeline(
|
|
1938
|
-
"microphone_published", "ok",
|
|
1939
|
-
"Microphone track published with live PCM frames",
|
|
1940
|
-
)
|
|
1941
|
-
_diag_peak = max(_diag_peak, p)
|
|
1934
|
+
await self.audio_source.capture_frame(frame)
|
|
1935
|
+
if not self._mic_streaming.is_set():
|
|
1936
|
+
self._mic_streaming.set()
|
|
1937
|
+
await self.agent._report_pipeline(
|
|
1938
|
+
"microphone_published", "ok",
|
|
1939
|
+
"Microphone track published with live PCM frames",
|
|
1940
|
+
)
|
|
1941
|
+
_diag_peak = max(_diag_peak, p)
|
|
1942
1942
|
_diag_count += 1
|
|
1943
1943
|
_diag_read_ms += (_t1 - _t0) * 1000
|
|
1944
1944
|
_diag_publish_ms += (time.monotonic() - _pub_start) * 1000
|
|
@@ -2070,26 +2070,26 @@ class Picamera2Capture(VideoCapture):
|
|
|
2070
2070
|
pass
|
|
2071
2071
|
|
|
2072
2072
|
|
|
2073
|
-
def create_video_capture(device: str, width: int, height: int, fps: int,
|
|
2074
|
-
robovision_url: Optional[str] = None) -> Optional[VideoCapture]:
|
|
2073
|
+
def create_video_capture(device: str, width: int, height: int, fps: int,
|
|
2074
|
+
robovision_url: Optional[str] = None) -> Optional[VideoCapture]:
|
|
2075
2075
|
if device.lower() in ("none", "", "false", "null"):
|
|
2076
2076
|
return None
|
|
2077
|
-
try:
|
|
2077
|
+
try:
|
|
2078
2078
|
from livekit import rtc
|
|
2079
2079
|
_ = rtc.VideoSource
|
|
2080
2080
|
except Exception as e:
|
|
2081
2081
|
logger.error(f"livekit python sdk not installed: {e}")
|
|
2082
|
-
return None
|
|
2083
|
-
|
|
2084
|
-
if robovision_url:
|
|
2085
|
-
stream_url = f"{robovision_url.rstrip('/')}/video_feed"
|
|
2086
|
-
try:
|
|
2087
|
-
cap = OpencvVideoCapture(stream_url, width, height, fps)
|
|
2088
|
-
logger.info(f"using RoboVisionAI_PI camera stream {stream_url}")
|
|
2089
|
-
return cap
|
|
2090
|
-
except Exception as e:
|
|
2091
|
-
logger.error(f"RoboVision camera stream unavailable: {e}")
|
|
2092
|
-
return None
|
|
2082
|
+
return None
|
|
2083
|
+
|
|
2084
|
+
if robovision_url:
|
|
2085
|
+
stream_url = f"{robovision_url.rstrip('/')}/video_feed"
|
|
2086
|
+
try:
|
|
2087
|
+
cap = OpencvVideoCapture(stream_url, width, height, fps)
|
|
2088
|
+
logger.info(f"using RoboVisionAI_PI camera stream {stream_url}")
|
|
2089
|
+
return cap
|
|
2090
|
+
except Exception as e:
|
|
2091
|
+
logger.error(f"RoboVision camera stream unavailable: {e}")
|
|
2092
|
+
return None
|
|
2093
2093
|
|
|
2094
2094
|
# Auto-detect: prefer first V4L2 device, fall back to picamera2 if available.
|
|
2095
2095
|
if device.lower() in ("auto", "default", "first"):
|
|
@@ -2120,121 +2120,121 @@ def create_video_capture(device: str, width: int, height: int, fps: int,
|
|
|
2120
2120
|
# Audio capture abstraction: PyAudio or sounddevice -> LiveKit AudioFrame.
|
|
2121
2121
|
# -----------------------------------------------------------------------------
|
|
2122
2122
|
|
|
2123
|
-
class AudioCapture:
|
|
2123
|
+
class AudioCapture:
|
|
2124
2124
|
def read(self, samples_per_frame: int) -> Optional["rtc.AudioFrame"]:
|
|
2125
2125
|
raise NotImplementedError
|
|
2126
2126
|
|
|
2127
|
-
def stop(self) -> None:
|
|
2128
|
-
raise NotImplementedError
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
class AlsaAudioCapture(AudioCapture):
|
|
2132
|
-
"""Capture Linux PCM through the same ALSA path used by onsite tests."""
|
|
2133
|
-
|
|
2134
|
-
def __init__(self, device: str):
|
|
2135
|
-
import os
|
|
2136
|
-
import re
|
|
2137
|
-
import select
|
|
2138
|
-
import subprocess
|
|
2139
|
-
from media_lock import media_lock
|
|
2140
|
-
|
|
2141
|
-
match = re.search(r"\b(hw:\d+,\d+)\b", device)
|
|
2142
|
-
if not match:
|
|
2143
|
-
raise ValueError(f"no ALSA hardware address in {device!r}")
|
|
2144
|
-
self.buffer = bytearray()
|
|
2145
|
-
self.media_guard = media_lock("microphone", timeout=1.5).acquire()
|
|
2146
|
-
self.process = None
|
|
2147
|
-
self.source_rate = 48000
|
|
2148
|
-
errors = []
|
|
2149
|
-
# The fleet USB microphone normally accepts 48 kHz through ALSA's
|
|
2150
|
-
# plug layer. Some firmware revisions expose only native 44.1 kHz;
|
|
2151
|
-
# accept that rate and resample below rather than publishing silence.
|
|
2152
|
-
for source_rate in (48000, 44100):
|
|
2153
|
-
alsa_device = f"plug{match.group(1)}"
|
|
2154
|
-
process = None
|
|
2155
|
-
try:
|
|
2156
|
-
process = subprocess.Popen(
|
|
2157
|
-
[
|
|
2158
|
-
"arecord", "-q", "-D", alsa_device, "-t", "raw",
|
|
2159
|
-
"-f", "S16_LE", "-r", str(source_rate), "-c", "1",
|
|
2160
|
-
"--period-size", str(max(256, source_rate // 50)),
|
|
2161
|
-
],
|
|
2162
|
-
stdout=subprocess.PIPE,
|
|
2163
|
-
stderr=subprocess.PIPE,
|
|
2164
|
-
bufsize=0,
|
|
2165
|
-
)
|
|
2166
|
-
if process.stdout is None:
|
|
2167
|
-
raise RuntimeError("arecord did not provide a PCM stream")
|
|
2168
|
-
ready, _, _ = select.select([process.stdout], [], [], 2.0)
|
|
2169
|
-
if not ready:
|
|
2170
|
-
if process.poll() is None:
|
|
2171
|
-
raise TimeoutError("arecord produced no PCM within 2 seconds")
|
|
2172
|
-
detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
|
|
2173
|
-
raise OSError(detail or f"arecord exited {process.returncode}")
|
|
2174
|
-
first = os.read(process.stdout.fileno(), max(2048, source_rate // 25 * 2))
|
|
2175
|
-
if not first:
|
|
2176
|
-
detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
|
|
2177
|
-
raise OSError(detail or "arecord returned an empty PCM frame")
|
|
2178
|
-
self.process = process
|
|
2179
|
-
self.source_rate = source_rate
|
|
2180
|
-
self.alsa_device = alsa_device
|
|
2181
|
-
if source_rate != 48000:
|
|
2182
|
-
first = _pcm16_resample_mono(first, source_rate, 48000)
|
|
2183
|
-
self.buffer.extend(first)
|
|
2184
|
-
break
|
|
2185
|
-
except Exception as exc:
|
|
2186
|
-
errors.append(f"{source_rate}Hz: {exc}")
|
|
2187
|
-
if process is not None:
|
|
2188
|
-
if process.poll() is None:
|
|
2189
|
-
process.terminate()
|
|
2190
|
-
try:
|
|
2191
|
-
process.wait(timeout=1)
|
|
2192
|
-
except subprocess.TimeoutExpired:
|
|
2193
|
-
process.kill()
|
|
2194
|
-
process.wait(timeout=1)
|
|
2195
|
-
if self.process is None:
|
|
2196
|
-
self.media_guard.release()
|
|
2197
|
-
raise OSError(f"ALSA capture failed on plug{match.group(1)} ({'; '.join(errors)})")
|
|
2198
|
-
|
|
2199
|
-
def read(self, samples_per_frame: int):
|
|
2200
|
-
import os
|
|
2201
|
-
from livekit import rtc
|
|
2202
|
-
|
|
2203
|
-
bytes_needed = samples_per_frame * 2
|
|
2204
|
-
source_bytes_needed = max(2, int(samples_per_frame * self.source_rate / 48000) * 2)
|
|
2205
|
-
while len(self.buffer) < bytes_needed:
|
|
2206
|
-
chunk = os.read(self.process.stdout.fileno(), source_bytes_needed)
|
|
2207
|
-
if not chunk:
|
|
2208
|
-
detail = ""
|
|
2209
|
-
if self.process.stderr is not None:
|
|
2210
|
-
detail = self.process.stderr.read().decode("utf-8", errors="replace").strip()
|
|
2211
|
-
raise OSError(f"ALSA capture stopped on {self.alsa_device}: {detail or 'no PCM data'}")
|
|
2212
|
-
if self.source_rate != 48000:
|
|
2213
|
-
chunk = _pcm16_resample_mono(chunk, self.source_rate, 48000)
|
|
2214
|
-
self.buffer.extend(chunk)
|
|
2215
|
-
data = bytes(self.buffer[:bytes_needed])
|
|
2216
|
-
del self.buffer[:bytes_needed]
|
|
2217
|
-
return rtc.AudioFrame(
|
|
2218
|
-
data=data,
|
|
2219
|
-
sample_rate=48000,
|
|
2220
|
-
num_channels=1,
|
|
2221
|
-
samples_per_channel=samples_per_frame,
|
|
2222
|
-
)
|
|
2223
|
-
|
|
2224
|
-
def stop(self):
|
|
2225
|
-
try:
|
|
2226
|
-
if self.process.poll() is None:
|
|
2227
|
-
self.process.terminate()
|
|
2228
|
-
try:
|
|
2229
|
-
self.process.wait(timeout=2)
|
|
2230
|
-
except Exception:
|
|
2231
|
-
self.process.kill()
|
|
2232
|
-
self.process.wait(timeout=1)
|
|
2233
|
-
finally:
|
|
2234
|
-
self.media_guard.release()
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
class PyAudioCapture(AudioCapture):
|
|
2127
|
+
def stop(self) -> None:
|
|
2128
|
+
raise NotImplementedError
|
|
2129
|
+
|
|
2130
|
+
|
|
2131
|
+
class AlsaAudioCapture(AudioCapture):
|
|
2132
|
+
"""Capture Linux PCM through the same ALSA path used by onsite tests."""
|
|
2133
|
+
|
|
2134
|
+
def __init__(self, device: str):
|
|
2135
|
+
import os
|
|
2136
|
+
import re
|
|
2137
|
+
import select
|
|
2138
|
+
import subprocess
|
|
2139
|
+
from media_lock import media_lock
|
|
2140
|
+
|
|
2141
|
+
match = re.search(r"\b(hw:\d+,\d+)\b", device)
|
|
2142
|
+
if not match:
|
|
2143
|
+
raise ValueError(f"no ALSA hardware address in {device!r}")
|
|
2144
|
+
self.buffer = bytearray()
|
|
2145
|
+
self.media_guard = media_lock("microphone", timeout=1.5).acquire()
|
|
2146
|
+
self.process = None
|
|
2147
|
+
self.source_rate = 48000
|
|
2148
|
+
errors = []
|
|
2149
|
+
# The fleet USB microphone normally accepts 48 kHz through ALSA's
|
|
2150
|
+
# plug layer. Some firmware revisions expose only native 44.1 kHz;
|
|
2151
|
+
# accept that rate and resample below rather than publishing silence.
|
|
2152
|
+
for source_rate in (48000, 44100):
|
|
2153
|
+
alsa_device = f"plug{match.group(1)}"
|
|
2154
|
+
process = None
|
|
2155
|
+
try:
|
|
2156
|
+
process = subprocess.Popen(
|
|
2157
|
+
[
|
|
2158
|
+
"arecord", "-q", "-D", alsa_device, "-t", "raw",
|
|
2159
|
+
"-f", "S16_LE", "-r", str(source_rate), "-c", "1",
|
|
2160
|
+
"--period-size", str(max(256, source_rate // 50)),
|
|
2161
|
+
],
|
|
2162
|
+
stdout=subprocess.PIPE,
|
|
2163
|
+
stderr=subprocess.PIPE,
|
|
2164
|
+
bufsize=0,
|
|
2165
|
+
)
|
|
2166
|
+
if process.stdout is None:
|
|
2167
|
+
raise RuntimeError("arecord did not provide a PCM stream")
|
|
2168
|
+
ready, _, _ = select.select([process.stdout], [], [], 2.0)
|
|
2169
|
+
if not ready:
|
|
2170
|
+
if process.poll() is None:
|
|
2171
|
+
raise TimeoutError("arecord produced no PCM within 2 seconds")
|
|
2172
|
+
detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
|
|
2173
|
+
raise OSError(detail or f"arecord exited {process.returncode}")
|
|
2174
|
+
first = os.read(process.stdout.fileno(), max(2048, source_rate // 25 * 2))
|
|
2175
|
+
if not first:
|
|
2176
|
+
detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
|
|
2177
|
+
raise OSError(detail or "arecord returned an empty PCM frame")
|
|
2178
|
+
self.process = process
|
|
2179
|
+
self.source_rate = source_rate
|
|
2180
|
+
self.alsa_device = alsa_device
|
|
2181
|
+
if source_rate != 48000:
|
|
2182
|
+
first = _pcm16_resample_mono(first, source_rate, 48000)
|
|
2183
|
+
self.buffer.extend(first)
|
|
2184
|
+
break
|
|
2185
|
+
except Exception as exc:
|
|
2186
|
+
errors.append(f"{source_rate}Hz: {exc}")
|
|
2187
|
+
if process is not None:
|
|
2188
|
+
if process.poll() is None:
|
|
2189
|
+
process.terminate()
|
|
2190
|
+
try:
|
|
2191
|
+
process.wait(timeout=1)
|
|
2192
|
+
except subprocess.TimeoutExpired:
|
|
2193
|
+
process.kill()
|
|
2194
|
+
process.wait(timeout=1)
|
|
2195
|
+
if self.process is None:
|
|
2196
|
+
self.media_guard.release()
|
|
2197
|
+
raise OSError(f"ALSA capture failed on plug{match.group(1)} ({'; '.join(errors)})")
|
|
2198
|
+
|
|
2199
|
+
def read(self, samples_per_frame: int):
|
|
2200
|
+
import os
|
|
2201
|
+
from livekit import rtc
|
|
2202
|
+
|
|
2203
|
+
bytes_needed = samples_per_frame * 2
|
|
2204
|
+
source_bytes_needed = max(2, int(samples_per_frame * self.source_rate / 48000) * 2)
|
|
2205
|
+
while len(self.buffer) < bytes_needed:
|
|
2206
|
+
chunk = os.read(self.process.stdout.fileno(), source_bytes_needed)
|
|
2207
|
+
if not chunk:
|
|
2208
|
+
detail = ""
|
|
2209
|
+
if self.process.stderr is not None:
|
|
2210
|
+
detail = self.process.stderr.read().decode("utf-8", errors="replace").strip()
|
|
2211
|
+
raise OSError(f"ALSA capture stopped on {self.alsa_device}: {detail or 'no PCM data'}")
|
|
2212
|
+
if self.source_rate != 48000:
|
|
2213
|
+
chunk = _pcm16_resample_mono(chunk, self.source_rate, 48000)
|
|
2214
|
+
self.buffer.extend(chunk)
|
|
2215
|
+
data = bytes(self.buffer[:bytes_needed])
|
|
2216
|
+
del self.buffer[:bytes_needed]
|
|
2217
|
+
return rtc.AudioFrame(
|
|
2218
|
+
data=data,
|
|
2219
|
+
sample_rate=48000,
|
|
2220
|
+
num_channels=1,
|
|
2221
|
+
samples_per_channel=samples_per_frame,
|
|
2222
|
+
)
|
|
2223
|
+
|
|
2224
|
+
def stop(self):
|
|
2225
|
+
try:
|
|
2226
|
+
if self.process.poll() is None:
|
|
2227
|
+
self.process.terminate()
|
|
2228
|
+
try:
|
|
2229
|
+
self.process.wait(timeout=2)
|
|
2230
|
+
except Exception:
|
|
2231
|
+
self.process.kill()
|
|
2232
|
+
self.process.wait(timeout=1)
|
|
2233
|
+
finally:
|
|
2234
|
+
self.media_guard.release()
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
class PyAudioCapture(AudioCapture):
|
|
2238
2238
|
def __init__(self, device: str | int | None):
|
|
2239
2239
|
import pyaudio
|
|
2240
2240
|
self.pa = pyaudio.PyAudio()
|
|
@@ -2313,16 +2313,16 @@ def has_audio() -> bool:
|
|
|
2313
2313
|
return False
|
|
2314
2314
|
|
|
2315
2315
|
|
|
2316
|
-
def create_audio_capture(device: str) -> Optional[AudioCapture]:
|
|
2317
|
-
if device.lower() in ("none", "", "false", "null"):
|
|
2318
|
-
return None
|
|
2319
|
-
if sys.platform.startswith("linux") and "hw:" in device:
|
|
2320
|
-
try:
|
|
2321
|
-
return AlsaAudioCapture(device)
|
|
2322
|
-
except Exception as e:
|
|
2323
|
-
logger.warning(f"ALSA capture unavailable for {device}: {e}")
|
|
2324
|
-
raise
|
|
2325
|
-
try:
|
|
2316
|
+
def create_audio_capture(device: str) -> Optional[AudioCapture]:
|
|
2317
|
+
if device.lower() in ("none", "", "false", "null"):
|
|
2318
|
+
return None
|
|
2319
|
+
if sys.platform.startswith("linux") and "hw:" in device:
|
|
2320
|
+
try:
|
|
2321
|
+
return AlsaAudioCapture(device)
|
|
2322
|
+
except Exception as e:
|
|
2323
|
+
logger.warning(f"ALSA capture unavailable for {device}: {e}")
|
|
2324
|
+
raise
|
|
2325
|
+
try:
|
|
2326
2326
|
import pyaudio # noqa: F401
|
|
2327
2327
|
return PyAudioCapture(device if device != "default" else None)
|
|
2328
2328
|
except Exception as e:
|
|
@@ -2342,9 +2342,9 @@ def main() -> None:
|
|
|
2342
2342
|
parser.add_argument("--device-token", default=os.getenv("DEVICE_TOKEN"))
|
|
2343
2343
|
parser.add_argument("--enrollment-token", default=os.getenv("ENROLLMENT_TOKEN"))
|
|
2344
2344
|
parser.add_argument("--video-device", default=os.getenv("VIDEO_DEVICE", "auto"))
|
|
2345
|
-
parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
|
|
2346
|
-
parser.add_argument("--robovision-url", default=None,
|
|
2347
|
-
help="use RoboVisionAI_PI's /video_feed as the LiveKit camera source")
|
|
2345
|
+
parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
|
|
2346
|
+
parser.add_argument("--robovision-url", default=None,
|
|
2347
|
+
help="use RoboVisionAI_PI's /video_feed as the LiveKit camera source")
|
|
2348
2348
|
parser.add_argument("--width", type=int, default=int(os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
|
|
2349
2349
|
parser.add_argument("--height", type=int, default=int(os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
|
|
2350
2350
|
parser.add_argument("--fps", type=int, default=int(os.getenv("VIDEO_FPS", DEFAULT_FPS)))
|
|
@@ -2367,11 +2367,11 @@ def main() -> None:
|
|
|
2367
2367
|
"scheduler_url": args.scheduler_url,
|
|
2368
2368
|
"robot_id": args.robot_id,
|
|
2369
2369
|
"video_device": args.video_device,
|
|
2370
|
-
"audio_device": args.audio_device,
|
|
2371
|
-
"robovision_url": args.robovision_url or os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"),
|
|
2372
|
-
"use_robovision_camera": bool(args.robovision_url) or os.getenv(
|
|
2373
|
-
"ROBOVISION_CAMERA", "true"
|
|
2374
|
-
).lower() in ("1", "true", "yes", "on"),
|
|
2370
|
+
"audio_device": args.audio_device,
|
|
2371
|
+
"robovision_url": args.robovision_url or os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"),
|
|
2372
|
+
"use_robovision_camera": bool(args.robovision_url) or os.getenv(
|
|
2373
|
+
"ROBOVISION_CAMERA", "true"
|
|
2374
|
+
).lower() in ("1", "true", "yes", "on"),
|
|
2375
2375
|
"video_width": args.width,
|
|
2376
2376
|
"video_height": args.height,
|
|
2377
2377
|
"video_fps": args.fps,
|