Deploy Small Talk: Reachy Mini AI podcast (gradio.Server + three.js + LiveKit)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +52 -0
- app.py +50 -0
- backend/__init__.py +1 -0
- backend/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/__pycache__/audio.cpython-313.pyc +0 -0
- backend/__pycache__/config.cpython-313.pyc +0 -0
- backend/__pycache__/publisher.cpython-313.pyc +0 -0
- backend/__pycache__/server.cpython-313.pyc +0 -0
- backend/__pycache__/tokens.cpython-313.pyc +0 -0
- backend/audio.py +32 -0
- backend/config.py +21 -0
- backend/publisher.py +139 -0
- backend/server.py +209 -0
- backend/tokens.py +40 -0
- frontend/dist/assets/index-CMwLu78T.css +1 -0
- frontend/dist/assets/index-DxhkjW4l.js +0 -0
- frontend/dist/index.html +46 -0
- frontend/dist/moves/reachy-moves.json +0 -0
- frontend/dist/robot-3d/meshes/5w_speaker.stl +3 -0
- frontend/dist/robot-3d/meshes/antenna.stl +3 -0
- frontend/dist/robot-3d/meshes/antenna_body_3dprint.stl +3 -0
- frontend/dist/robot-3d/meshes/antenna_holder_l_3dprint.stl +3 -0
- frontend/dist/robot-3d/meshes/antenna_holder_r_3dprint.stl +3 -0
- frontend/dist/robot-3d/meshes/antenna_interface_3dprint.stl +3 -0
- frontend/dist/robot-3d/meshes/arducam.stl +3 -0
- frontend/dist/robot-3d/meshes/b3b_eh.stl +0 -0
- frontend/dist/robot-3d/meshes/b3b_eh_1.stl +0 -0
- frontend/dist/robot-3d/meshes/bearing_85x110x13.stl +3 -0
- frontend/dist/robot-3d/meshes/big_lens_d40.stl +3 -0
- frontend/dist/robot-3d/meshes/body_down_3dprint.stl +3 -0
- frontend/dist/robot-3d/meshes/body_foot_3dprint.stl +3 -0
- frontend/dist/robot-3d/meshes/body_top_3dprint.stl +3 -0
- frontend/dist/robot-3d/meshes/body_turning_3dprint.stl +3 -0
- frontend/dist/robot-3d/meshes/bts2_m2_6x8.stl +3 -0
- frontend/dist/robot-3d/meshes/dc15_a01_case_b_dummy.stl +3 -0
- frontend/dist/robot-3d/meshes/dc15_a01_case_f_dummy.stl +3 -0
- frontend/dist/robot-3d/meshes/dc15_a01_case_m_dummy.stl +3 -0
- frontend/dist/robot-3d/meshes/dc15_a01_horn_dummy.stl +3 -0
- frontend/dist/robot-3d/meshes/dc15_a01_led_cap2_dummy.stl +0 -0
- frontend/dist/robot-3d/meshes/glasses_dolder_3dprint.stl +3 -0
- frontend/dist/robot-3d/meshes/head_back_3dprint.stl +3 -0
- frontend/dist/robot-3d/meshes/head_front_3dprint.stl +3 -0
- frontend/dist/robot-3d/meshes/head_mic_3dprint.stl +3 -0
- frontend/dist/robot-3d/meshes/lens_cap_d30_3dprint.stl +0 -0
- frontend/dist/robot-3d/meshes/lens_cap_d40_3dprint.stl +0 -0
- frontend/dist/robot-3d/meshes/m12_fisheye_lens_1_8mm.stl +3 -0
- frontend/dist/robot-3d/meshes/mp01062_stewart_arm_3.stl +3 -0
- frontend/dist/robot-3d/meshes/neck_reference_3dprint.stl +3 -0
- frontend/dist/robot-3d/meshes/phs_1_7x20_5_dc10.stl +3 -0
- frontend/dist/robot-3d/meshes/phs_1_7x20_5_dc10_1.stl +3 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,55 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
frontend/dist/robot-3d/meshes/5w_speaker.stl filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
frontend/dist/robot-3d/meshes/antenna.stl filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
frontend/dist/robot-3d/meshes/antenna_body_3dprint.stl filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
frontend/dist/robot-3d/meshes/antenna_holder_l_3dprint.stl filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
frontend/dist/robot-3d/meshes/antenna_holder_r_3dprint.stl filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
frontend/dist/robot-3d/meshes/antenna_interface_3dprint.stl filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
frontend/dist/robot-3d/meshes/arducam.stl filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
frontend/dist/robot-3d/meshes/bearing_85x110x13.stl filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
frontend/dist/robot-3d/meshes/big_lens_d40.stl filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
frontend/dist/robot-3d/meshes/body_down_3dprint.stl filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
frontend/dist/robot-3d/meshes/body_foot_3dprint.stl filter=lfs diff=lfs merge=lfs -text
|
| 47 |
+
frontend/dist/robot-3d/meshes/body_top_3dprint.stl filter=lfs diff=lfs merge=lfs -text
|
| 48 |
+
frontend/dist/robot-3d/meshes/body_turning_3dprint.stl filter=lfs diff=lfs merge=lfs -text
|
| 49 |
+
frontend/dist/robot-3d/meshes/bts2_m2_6x8.stl filter=lfs diff=lfs merge=lfs -text
|
| 50 |
+
frontend/dist/robot-3d/meshes/dc15_a01_case_b_dummy.stl filter=lfs diff=lfs merge=lfs -text
|
| 51 |
+
frontend/dist/robot-3d/meshes/dc15_a01_case_f_dummy.stl filter=lfs diff=lfs merge=lfs -text
|
| 52 |
+
frontend/dist/robot-3d/meshes/dc15_a01_case_m_dummy.stl filter=lfs diff=lfs merge=lfs -text
|
| 53 |
+
frontend/dist/robot-3d/meshes/dc15_a01_horn_dummy.stl filter=lfs diff=lfs merge=lfs -text
|
| 54 |
+
frontend/dist/robot-3d/meshes/glasses_dolder_3dprint.stl filter=lfs diff=lfs merge=lfs -text
|
| 55 |
+
frontend/dist/robot-3d/meshes/head_back_3dprint.stl filter=lfs diff=lfs merge=lfs -text
|
| 56 |
+
frontend/dist/robot-3d/meshes/head_front_3dprint.stl filter=lfs diff=lfs merge=lfs -text
|
| 57 |
+
frontend/dist/robot-3d/meshes/head_mic_3dprint.stl filter=lfs diff=lfs merge=lfs -text
|
| 58 |
+
frontend/dist/robot-3d/meshes/m12_fisheye_lens_1_8mm.stl filter=lfs diff=lfs merge=lfs -text
|
| 59 |
+
frontend/dist/robot-3d/meshes/mp01062_stewart_arm_3.stl filter=lfs diff=lfs merge=lfs -text
|
| 60 |
+
frontend/dist/robot-3d/meshes/neck_reference_3dprint.stl filter=lfs diff=lfs merge=lfs -text
|
| 61 |
+
frontend/dist/robot-3d/meshes/phs_1_7x20_5_dc10.stl filter=lfs diff=lfs merge=lfs -text
|
| 62 |
+
frontend/dist/robot-3d/meshes/phs_1_7x20_5_dc10_1.stl filter=lfs diff=lfs merge=lfs -text
|
| 63 |
+
frontend/dist/robot-3d/meshes/phs_1_7x20_5_dc10_2.stl filter=lfs diff=lfs merge=lfs -text
|
| 64 |
+
frontend/dist/robot-3d/meshes/phs_1_7x20_5_dc10_3.stl filter=lfs diff=lfs merge=lfs -text
|
| 65 |
+
frontend/dist/robot-3d/meshes/pp01102_arducam_carter.stl filter=lfs diff=lfs merge=lfs -text
|
| 66 |
+
frontend/dist/robot-3d/meshes/small_lens_d30.stl filter=lfs diff=lfs merge=lfs -text
|
| 67 |
+
frontend/dist/robot-3d/meshes/stewart_link_ball.stl filter=lfs diff=lfs merge=lfs -text
|
| 68 |
+
frontend/dist/robot-3d/meshes/stewart_link_ball__2.stl filter=lfs diff=lfs merge=lfs -text
|
| 69 |
+
frontend/dist/robot-3d/meshes/stewart_link_rod.stl filter=lfs diff=lfs merge=lfs -text
|
| 70 |
+
frontend/dist/robot-3d/meshes/stewart_main_plate_3dprint.stl filter=lfs diff=lfs merge=lfs -text
|
| 71 |
+
frontend/dist/robot-3d/meshes/stewart_tricap_3dprint.stl filter=lfs diff=lfs merge=lfs -text
|
| 72 |
+
samples/ada-1.wav filter=lfs diff=lfs merge=lfs -text
|
| 73 |
+
samples/ada-2.wav filter=lfs diff=lfs merge=lfs -text
|
| 74 |
+
samples/ada-3.wav filter=lfs diff=lfs merge=lfs -text
|
| 75 |
+
samples/batman-1.wav filter=lfs diff=lfs merge=lfs -text
|
| 76 |
+
samples/batman-2.wav filter=lfs diff=lfs merge=lfs -text
|
| 77 |
+
samples/bode-1.wav filter=lfs diff=lfs merge=lfs -text
|
| 78 |
+
samples/bode-2.wav filter=lfs diff=lfs merge=lfs -text
|
| 79 |
+
samples/bode-3.wav filter=lfs diff=lfs merge=lfs -text
|
| 80 |
+
samples/jack-1.wav filter=lfs diff=lfs merge=lfs -text
|
| 81 |
+
samples/jack-2.wav filter=lfs diff=lfs merge=lfs -text
|
| 82 |
+
samples/jarvis-1.wav filter=lfs diff=lfs merge=lfs -text
|
| 83 |
+
samples/jarvis-2.wav filter=lfs diff=lfs merge=lfs -text
|
| 84 |
+
samples/surfer-1.wav filter=lfs diff=lfs merge=lfs -text
|
| 85 |
+
samples/surfer-2.wav filter=lfs diff=lfs merge=lfs -text
|
| 86 |
+
samples/yoda-1.wav filter=lfs diff=lfs merge=lfs -text
|
| 87 |
+
samples/yoda-2.wav filter=lfs diff=lfs merge=lfs -text
|
app.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small Talk — Hugging Face Space entrypoint.
|
| 2 |
+
|
| 3 |
+
A Gradio app that wears no Gradio: we use `gradio.Server` (a FastAPI server with
|
| 4 |
+
Gradio's backend) purely as the host, then serve our own three.js meeting UI and
|
| 5 |
+
the /api control plane on it. Custom routes take priority over Gradio's, so the
|
| 6 |
+
visitor only ever sees the custom frontend — which also earns the hackathon's
|
| 7 |
+
"Off-Brand" badge.
|
| 8 |
+
|
| 9 |
+
LiveKit (the WebRTC SFU) is LiveKit Cloud; credentials come from Space secrets
|
| 10 |
+
(LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET). The browser connects to
|
| 11 |
+
LiveKit Cloud directly; this app just mints tokens and runs the Reachy
|
| 12 |
+
publishers that stream the pre-rendered voice clips into the room.
|
| 13 |
+
"""
|
| 14 |
+
import os
|
| 15 |
+
import pathlib
|
| 16 |
+
|
| 17 |
+
import gradio as gr
|
| 18 |
+
from fastapi.responses import FileResponse
|
| 19 |
+
|
| 20 |
+
from backend.server import attach
|
| 21 |
+
|
| 22 |
+
DIST = pathlib.Path(__file__).parent / "frontend" / "dist"
|
| 23 |
+
|
| 24 |
+
app = gr.Server()
|
| 25 |
+
attach(app) # /api/* routes — registered before the static mounts below
|
| 26 |
+
|
| 27 |
+
# Serve the built single-page app. These subdirs don't collide with /api or
|
| 28 |
+
# Gradio's own routes, so a simple per-folder static mount is enough.
|
| 29 |
+
from fastapi.staticfiles import StaticFiles # noqa: E402
|
| 30 |
+
|
| 31 |
+
for sub in ("assets", "robot-3d", "moves"):
|
| 32 |
+
d = DIST / sub
|
| 33 |
+
if d.is_dir():
|
| 34 |
+
app.mount(f"/{sub}", StaticFiles(directory=str(d)), name=sub)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@app.get("/")
|
| 38 |
+
async def index():
|
| 39 |
+
return FileResponse(DIST / "index.html")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@app.get("/favicon.ico")
|
| 43 |
+
async def favicon():
|
| 44 |
+
return FileResponse(DIST / "index.html") # SPA injects an emoji favicon
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
if __name__ == "__main__":
|
| 48 |
+
# HF Spaces (sdk: gradio) runs this file; bind to the port it provides.
|
| 49 |
+
port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", 7860)))
|
| 50 |
+
app.launch(server_name="0.0.0.0", server_port=port)
|
backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Reachy Podcast backend — LiveKit room + audio publishing for Reachy Minis."""
|
backend/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (239 Bytes). View file
|
|
|
backend/__pycache__/audio.cpython-313.pyc
ADDED
|
Binary file (1.84 kB). View file
|
|
|
backend/__pycache__/config.cpython-313.pyc
ADDED
|
Binary file (1.14 kB). View file
|
|
|
backend/__pycache__/publisher.cpython-313.pyc
ADDED
|
Binary file (9.59 kB). View file
|
|
|
backend/__pycache__/server.cpython-313.pyc
ADDED
|
Binary file (11.6 kB). View file
|
|
|
backend/__pycache__/tokens.cpython-313.pyc
ADDED
|
Binary file (1.77 kB). View file
|
|
|
backend/audio.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audio decoding helpers.
|
| 2 |
+
|
| 3 |
+
We shell out to ffmpeg so we can ingest *anything* (wav, mp3, flac, ogg, m4a)
|
| 4 |
+
and always get back mono 16-bit PCM at LiveKit's preferred 48 kHz — no
|
| 5 |
+
per-format branching, no resampling surprises.
|
| 6 |
+
"""
|
| 7 |
+
import asyncio
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
SAMPLE_RATE = 48000
|
| 12 |
+
NUM_CHANNELS = 1
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def decode_to_pcm(path: str, sample_rate: int = SAMPLE_RATE) -> np.ndarray:
|
| 16 |
+
"""Decode `path` to a 1-D int16 numpy array (mono, `sample_rate` Hz)."""
|
| 17 |
+
proc = await asyncio.create_subprocess_exec(
|
| 18 |
+
"ffmpeg",
|
| 19 |
+
"-v", "error",
|
| 20 |
+
"-i", str(path),
|
| 21 |
+
"-f", "s16le",
|
| 22 |
+
"-acodec", "pcm_s16le",
|
| 23 |
+
"-ac", str(NUM_CHANNELS),
|
| 24 |
+
"-ar", str(sample_rate),
|
| 25 |
+
"-",
|
| 26 |
+
stdout=asyncio.subprocess.PIPE,
|
| 27 |
+
stderr=asyncio.subprocess.PIPE,
|
| 28 |
+
)
|
| 29 |
+
out, err = await proc.communicate()
|
| 30 |
+
if proc.returncode != 0:
|
| 31 |
+
raise RuntimeError(f"ffmpeg failed decoding {path}: {err.decode(errors='ignore')}")
|
| 32 |
+
return np.frombuffer(out, dtype=np.int16).copy()
|
backend/config.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Runtime configuration, sourced from the environment (.env supported)."""
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
# LiveKit dev server defaults (livekit-server --dev): key=devkey secret=secret.
|
| 9 |
+
LIVEKIT_URL = os.environ.get("LIVEKIT_URL", "ws://localhost:7880")
|
| 10 |
+
LIVEKIT_API_KEY = os.environ.get("LIVEKIT_API_KEY", "devkey")
|
| 11 |
+
LIVEKIT_API_SECRET = os.environ.get("LIVEKIT_API_SECRET", "secret")
|
| 12 |
+
|
| 13 |
+
# The single room the podcast happens in (one per session is plenty for now).
|
| 14 |
+
ROOM_NAME = os.environ.get("REACHY_ROOM", "reachy-podcast")
|
| 15 |
+
|
| 16 |
+
# Where pre-rendered / sample audio clips live (the TTS stage will drop files here).
|
| 17 |
+
import pathlib
|
| 18 |
+
|
| 19 |
+
SAMPLES_DIR = pathlib.Path(
|
| 20 |
+
os.environ.get("REACHY_SAMPLES_DIR", pathlib.Path(__file__).parent.parent / "samples")
|
| 21 |
+
).resolve()
|
backend/publisher.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ReachyPublisher — connects one Reachy Mini into the LiveKit room as a
|
| 2 |
+
participant and streams pre-rendered audio clips into the call as a live
|
| 3 |
+
audio track.
|
| 4 |
+
|
| 5 |
+
Design notes
|
| 6 |
+
------------
|
| 7 |
+
* One `rtc.Room` connection per Reachy (each has its own identity/token/track).
|
| 8 |
+
* Audio is pushed as 10 ms int16 frames at 48 kHz. `AudioSource.capture_frame`
|
| 9 |
+
applies backpressure (its internal queue drains at real-time playback speed),
|
| 10 |
+
so simply awaiting it frame-by-frame paces the stream to wall-clock — no
|
| 11 |
+
manual sleeps needed.
|
| 12 |
+
* `say_file()` enqueues a clip; an async worker plays the queue in order. This
|
| 13 |
+
is the seam the LLM+TTS cascade plugs into later: generate a line -> drop the
|
| 14 |
+
wav -> `say_file()`, while the next line is already being generated.
|
| 15 |
+
"""
|
| 16 |
+
import argparse
|
| 17 |
+
import asyncio
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
from livekit import rtc
|
| 21 |
+
|
| 22 |
+
from . import config, tokens
|
| 23 |
+
from .audio import NUM_CHANNELS, SAMPLE_RATE, decode_to_pcm
|
| 24 |
+
|
| 25 |
+
SAMPLES_PER_FRAME = SAMPLE_RATE // 100 # 10 ms
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ReachyPublisher:
|
| 29 |
+
def __init__(
|
| 30 |
+
self,
|
| 31 |
+
identity: str,
|
| 32 |
+
name: str | None = None,
|
| 33 |
+
room: str | None = None,
|
| 34 |
+
*,
|
| 35 |
+
color: str = "#c98a3c",
|
| 36 |
+
persona: str | None = None,
|
| 37 |
+
):
|
| 38 |
+
self.identity = identity
|
| 39 |
+
self.name = name or identity
|
| 40 |
+
self.room_name = room or config.ROOM_NAME
|
| 41 |
+
self.metadata = {"role": "reachy", "color": color, "persona": persona}
|
| 42 |
+
|
| 43 |
+
self.room = rtc.Room()
|
| 44 |
+
self.source = rtc.AudioSource(SAMPLE_RATE, NUM_CHANNELS)
|
| 45 |
+
self.track = rtc.LocalAudioTrack.create_audio_track(
|
| 46 |
+
f"{self.identity}-voice", self.source
|
| 47 |
+
)
|
| 48 |
+
self._queue: asyncio.Queue = asyncio.Queue()
|
| 49 |
+
self._worker: asyncio.Task | None = None
|
| 50 |
+
|
| 51 |
+
async def connect(self) -> "ReachyPublisher":
|
| 52 |
+
token = tokens.make_token(
|
| 53 |
+
self.identity,
|
| 54 |
+
self.name,
|
| 55 |
+
self.room_name,
|
| 56 |
+
can_publish=True,
|
| 57 |
+
can_subscribe=True,
|
| 58 |
+
metadata=self.metadata,
|
| 59 |
+
)
|
| 60 |
+
await self.room.connect(
|
| 61 |
+
config.LIVEKIT_URL, token, options=rtc.RoomOptions(auto_subscribe=True)
|
| 62 |
+
)
|
| 63 |
+
opts = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE)
|
| 64 |
+
await self.room.local_participant.publish_track(self.track, opts)
|
| 65 |
+
self._worker = asyncio.create_task(self._run())
|
| 66 |
+
print(f"[{self.identity}] connected to '{self.room_name}' and publishing")
|
| 67 |
+
return self
|
| 68 |
+
|
| 69 |
+
async def say_file(self, path: str) -> None:
|
| 70 |
+
"""Queue an audio clip to be spoken. Returns immediately."""
|
| 71 |
+
await self._queue.put(str(path))
|
| 72 |
+
|
| 73 |
+
async def wait_until_idle(self) -> None:
|
| 74 |
+
"""Block until every queued clip has finished playing."""
|
| 75 |
+
await self._queue.join()
|
| 76 |
+
|
| 77 |
+
async def _run(self) -> None:
|
| 78 |
+
while True:
|
| 79 |
+
path = await self._queue.get()
|
| 80 |
+
try:
|
| 81 |
+
pcm = await decode_to_pcm(path, SAMPLE_RATE)
|
| 82 |
+
await self._stream_pcm(pcm)
|
| 83 |
+
except Exception as e: # keep the worker alive across bad clips
|
| 84 |
+
print(f"[{self.identity}] failed to play {path}: {e}")
|
| 85 |
+
finally:
|
| 86 |
+
self._queue.task_done()
|
| 87 |
+
|
| 88 |
+
async def _stream_pcm(self, pcm: np.ndarray) -> None:
|
| 89 |
+
frame = rtc.AudioFrame.create(SAMPLE_RATE, NUM_CHANNELS, SAMPLES_PER_FRAME)
|
| 90 |
+
buf = np.frombuffer(frame.data, dtype=np.int16)
|
| 91 |
+
for start in range(0, len(pcm), SAMPLES_PER_FRAME):
|
| 92 |
+
chunk = pcm[start : start + SAMPLES_PER_FRAME]
|
| 93 |
+
if len(chunk) < SAMPLES_PER_FRAME: # zero-pad the final frame
|
| 94 |
+
padded = np.zeros(SAMPLES_PER_FRAME, dtype=np.int16)
|
| 95 |
+
padded[: len(chunk)] = chunk
|
| 96 |
+
chunk = padded
|
| 97 |
+
np.copyto(buf, chunk)
|
| 98 |
+
await self.source.capture_frame(frame) # backpressure paces playback
|
| 99 |
+
await self.source.wait_for_playout() # don't clip the tail
|
| 100 |
+
|
| 101 |
+
async def aclose(self) -> None:
|
| 102 |
+
if self._worker:
|
| 103 |
+
self._worker.cancel()
|
| 104 |
+
await self.room.disconnect()
|
| 105 |
+
print(f"[{self.identity}] disconnected")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# --------------------------------------------------------------------------- CLI
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
async def _cli_main(args: argparse.Namespace) -> None:
|
| 112 |
+
pub = ReachyPublisher(
|
| 113 |
+
args.identity, args.name, args.room, color=args.color, persona=args.persona
|
| 114 |
+
)
|
| 115 |
+
await pub.connect()
|
| 116 |
+
try:
|
| 117 |
+
while True:
|
| 118 |
+
await pub.say_file(args.file)
|
| 119 |
+
await pub.wait_until_idle()
|
| 120 |
+
if not args.loop:
|
| 121 |
+
break
|
| 122 |
+
finally:
|
| 123 |
+
await pub.aclose()
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def cli() -> None:
|
| 127 |
+
p = argparse.ArgumentParser(description="Publish an audio file into the Reachy podcast room")
|
| 128 |
+
p.add_argument("--identity", required=True, help="unique participant id, e.g. reachy-1")
|
| 129 |
+
p.add_argument("--name", help="display name, e.g. Ada")
|
| 130 |
+
p.add_argument("--room", help=f"room name (default: {config.ROOM_NAME})")
|
| 131 |
+
p.add_argument("--file", required=True, help="path to wav/mp3/etc to play")
|
| 132 |
+
p.add_argument("--color", default="#c98a3c", help="card accent colour (hex)")
|
| 133 |
+
p.add_argument("--persona", help="short persona blurb")
|
| 134 |
+
p.add_argument("--loop", action="store_true", help="replay the file on a loop")
|
| 135 |
+
asyncio.run(_cli_main(p.parse_args()))
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
if __name__ == "__main__":
|
| 139 |
+
cli()
|
backend/server.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI control plane for the Reachy podcast.
|
| 2 |
+
|
| 3 |
+
Responsibilities (Task 1 scope):
|
| 4 |
+
* Hand browser viewers a LiveKit token so they can join and watch/listen.
|
| 5 |
+
* Spawn / stop Reachy publishers on demand.
|
| 6 |
+
* Trigger a Reachy to "say" an audio clip (the seam the TTS stage plugs into).
|
| 7 |
+
* A /demo route that stands up two Reachies trading sample lines, so the whole
|
| 8 |
+
pipe (audio file -> LiveKit -> browser grid) can be verified end-to-end.
|
| 9 |
+
"""
|
| 10 |
+
import asyncio
|
| 11 |
+
import pathlib
|
| 12 |
+
import uuid
|
| 13 |
+
|
| 14 |
+
from fastapi import FastAPI, HTTPException
|
| 15 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 16 |
+
from pydantic import BaseModel
|
| 17 |
+
|
| 18 |
+
from . import config, tokens
|
| 19 |
+
from .publisher import ReachyPublisher
|
| 20 |
+
|
| 21 |
+
# identity -> live publisher
|
| 22 |
+
PUBLISHERS: dict[str, ReachyPublisher] = {}
|
| 23 |
+
# background task driving the looping demo conversation
|
| 24 |
+
DEMO_TASK: asyncio.Task | None = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _resolve_clip(name_or_path: str) -> pathlib.Path:
|
| 28 |
+
"""Resolve a clip reference to a real file, sandboxed to SAMPLES_DIR unless
|
| 29 |
+
an absolute path is given."""
|
| 30 |
+
p = pathlib.Path(name_or_path)
|
| 31 |
+
if not p.is_absolute():
|
| 32 |
+
p = (config.SAMPLES_DIR / name_or_path).resolve()
|
| 33 |
+
if config.SAMPLES_DIR not in p.parents and p != config.SAMPLES_DIR:
|
| 34 |
+
raise HTTPException(400, "clip path escapes samples dir")
|
| 35 |
+
if not p.exists():
|
| 36 |
+
raise HTTPException(404, f"clip not found: {p}")
|
| 37 |
+
return p
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# --------------------------------------------------------------------------- models
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class TokenRequest(BaseModel):
|
| 44 |
+
identity: str | None = None
|
| 45 |
+
name: str | None = None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class JoinRequest(BaseModel):
|
| 49 |
+
identity: str
|
| 50 |
+
name: str | None = None
|
| 51 |
+
color: str = "#c98a3c"
|
| 52 |
+
persona: str | None = None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class SayRequest(BaseModel):
|
| 56 |
+
identity: str
|
| 57 |
+
clip: str # filename under samples/ or an absolute path
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# --------------------------------------------------------------------------- routes
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
async def get_config():
|
| 64 |
+
return {"livekitUrl": config.LIVEKIT_URL, "room": config.ROOM_NAME}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
async def post_token(req: TokenRequest):
|
| 68 |
+
identity = req.identity or f"viewer-{uuid.uuid4().hex[:8]}"
|
| 69 |
+
token = tokens.make_token(identity, req.name, can_publish=False, can_subscribe=True)
|
| 70 |
+
return {"token": token, "url": config.LIVEKIT_URL, "room": config.ROOM_NAME, "identity": identity}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
async def reachy_join(req: JoinRequest):
|
| 74 |
+
if req.identity in PUBLISHERS:
|
| 75 |
+
return {"ok": True, "already": True}
|
| 76 |
+
pub = ReachyPublisher(req.identity, req.name, color=req.color, persona=req.persona)
|
| 77 |
+
await pub.connect()
|
| 78 |
+
PUBLISHERS[req.identity] = pub
|
| 79 |
+
return {"ok": True}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def reachy_say(req: SayRequest):
|
| 83 |
+
pub = PUBLISHERS.get(req.identity)
|
| 84 |
+
if not pub:
|
| 85 |
+
raise HTTPException(404, f"no such reachy: {req.identity}")
|
| 86 |
+
clip = _resolve_clip(req.clip)
|
| 87 |
+
await pub.say_file(str(clip))
|
| 88 |
+
return {"ok": True}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
async def reachy_leave(req: TokenRequest):
|
| 92 |
+
pub = PUBLISHERS.pop(req.identity or "", None)
|
| 93 |
+
if pub:
|
| 94 |
+
await pub.aclose()
|
| 95 |
+
return {"ok": True}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# A character: (identity, display name, card colour, persona blurb, clip prefix).
|
| 99 |
+
CASTS: dict[str, list[tuple[str, str, str, str, str]]] = {
|
| 100 |
+
"duo": [
|
| 101 |
+
("reachy-ada", "Ada", "#c98a3c", "curious, warm, asks big questions", "ada"),
|
| 102 |
+
("reachy-bode", "Bode", "#5a6b3a", "dry wit, contrarian, loves a tangent", "bode"),
|
| 103 |
+
],
|
| 104 |
+
"groupchat": [
|
| 105 |
+
("reachy-batman", "Batman", "#5b6b8c", "broods, works alone, encased-meat justice", "batman"),
|
| 106 |
+
("reachy-jarvis", "JARVIS", "#37b3c9", "polite British AI butler, cites the data", "jarvis"),
|
| 107 |
+
("reachy-jack", "Jack Sparrow", "#3fae9a", "rum-soaked pirate, picks whichever side has the rum", "jack"),
|
| 108 |
+
("reachy-yoda", "Yoda", "#7bbf6a", "speaks in riddles, perpetually hungry", "yoda"),
|
| 109 |
+
("reachy-surfer", "Surfer", "#ff8c42", "chill, everything is just vibes, brah", "surfer"),
|
| 110 |
+
],
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
async def _start_conversation(cast: list[tuple[str, str, str, str, str]]) -> dict:
|
| 115 |
+
"""Switch the room to `cast`: stop any running loop, drop publishers not in
|
| 116 |
+
the cast, connect the ones that are, and start a looping group conversation
|
| 117 |
+
where each character speaks its own clips (prefix-*.wav) in turn."""
|
| 118 |
+
global DEMO_TASK
|
| 119 |
+
|
| 120 |
+
if not any(config.SAMPLES_DIR.glob("*.wav")):
|
| 121 |
+
raise HTTPException(
|
| 122 |
+
400,
|
| 123 |
+
f"no .wav files in {config.SAMPLES_DIR} "
|
| 124 |
+
"(run scripts/make-qwen-samples.py or scripts/make-samples.sh)",
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
# Stop the current loop before reshaping the cast.
|
| 128 |
+
if DEMO_TASK and not DEMO_TASK.done():
|
| 129 |
+
DEMO_TASK.cancel()
|
| 130 |
+
DEMO_TASK = None
|
| 131 |
+
|
| 132 |
+
wanted = {c[0] for c in cast}
|
| 133 |
+
for identity in list(PUBLISHERS):
|
| 134 |
+
if identity not in wanted:
|
| 135 |
+
await PUBLISHERS.pop(identity).aclose()
|
| 136 |
+
for identity, name, color, persona, _prefix in cast:
|
| 137 |
+
if identity not in PUBLISHERS:
|
| 138 |
+
pub = ReachyPublisher(identity, name, color=color, persona=persona)
|
| 139 |
+
await pub.connect()
|
| 140 |
+
PUBLISHERS[identity] = pub
|
| 141 |
+
|
| 142 |
+
# Round-robin the cast, each character cycling through its own clips, so it
|
| 143 |
+
# reads like everyone taking turns in a group chat.
|
| 144 |
+
clips = {c[0]: sorted(config.SAMPLES_DIR.glob(f"{c[4]}*.wav")) for c in cast}
|
| 145 |
+
speakers = [c[0] for c in cast if clips[c[0]]]
|
| 146 |
+
sequence: list[tuple[str, str]] = []
|
| 147 |
+
rounds = max((len(clips[s]) for s in speakers), default=0)
|
| 148 |
+
for k in range(rounds):
|
| 149 |
+
for s in speakers:
|
| 150 |
+
if k < len(clips[s]):
|
| 151 |
+
sequence.append((s, str(clips[s][k])))
|
| 152 |
+
|
| 153 |
+
if not sequence:
|
| 154 |
+
raise HTTPException(400, "no clips found for this cast")
|
| 155 |
+
|
| 156 |
+
async def run_loop():
|
| 157 |
+
i = 0
|
| 158 |
+
while True: # loop forever so there's always something live
|
| 159 |
+
identity, clip = sequence[i % len(sequence)]
|
| 160 |
+
pub = PUBLISHERS.get(identity)
|
| 161 |
+
if not pub:
|
| 162 |
+
break
|
| 163 |
+
await pub.say_file(clip)
|
| 164 |
+
await pub.wait_until_idle()
|
| 165 |
+
await asyncio.sleep(0.35)
|
| 166 |
+
i += 1
|
| 167 |
+
|
| 168 |
+
DEMO_TASK = asyncio.create_task(run_loop())
|
| 169 |
+
return {"ok": True, "cast": [c[0] for c in cast]}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
async def demo():
|
| 173 |
+
"""Two Reachies (Ada & Bode) trading a podcast, looping forever."""
|
| 174 |
+
return await _start_conversation(CASTS["duo"])
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
async def groupchat():
|
| 178 |
+
"""Five chaotic personalities arguing about lunch, looping forever."""
|
| 179 |
+
return await _start_conversation(CASTS["groupchat"])
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def attach(app) -> None:
|
| 183 |
+
"""Register CORS + the /api routes onto `app` (works for a plain FastAPI in
|
| 184 |
+
local dev or a gradio.Server on the Space)."""
|
| 185 |
+
app.add_middleware(
|
| 186 |
+
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
| 187 |
+
)
|
| 188 |
+
app.add_api_route("/api/config", get_config, methods=["GET"])
|
| 189 |
+
app.add_api_route("/api/token", post_token, methods=["POST"])
|
| 190 |
+
app.add_api_route("/api/reachy/join", reachy_join, methods=["POST"])
|
| 191 |
+
app.add_api_route("/api/reachy/say", reachy_say, methods=["POST"])
|
| 192 |
+
app.add_api_route("/api/reachy/leave", reachy_leave, methods=["POST"])
|
| 193 |
+
app.add_api_route("/api/demo", demo, methods=["POST"])
|
| 194 |
+
app.add_api_route("/api/groupchat", groupchat, methods=["POST"])
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# Local dev app (the Space uses app.py / gradio.Server instead).
|
| 198 |
+
app = FastAPI(title="Reachy Podcast")
|
| 199 |
+
attach(app)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def run() -> None:
|
| 203 |
+
import uvicorn
|
| 204 |
+
|
| 205 |
+
uvicorn.run("backend.server:app", host="0.0.0.0", port=8000, reload=False)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
if __name__ == "__main__":
|
| 209 |
+
run()
|
backend/tokens.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LiveKit access-token minting (server-side)."""
|
| 2 |
+
import json
|
| 3 |
+
|
| 4 |
+
from livekit import api
|
| 5 |
+
|
| 6 |
+
from . import config
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def make_token(
|
| 10 |
+
identity: str,
|
| 11 |
+
name: str | None = None,
|
| 12 |
+
room: str | None = None,
|
| 13 |
+
*,
|
| 14 |
+
can_publish: bool = False,
|
| 15 |
+
can_subscribe: bool = True,
|
| 16 |
+
metadata: dict | None = None,
|
| 17 |
+
) -> str:
|
| 18 |
+
"""Mint a JWT for `identity` to join `room`.
|
| 19 |
+
|
| 20 |
+
Viewers (browsers) get can_publish=False; Reachy publishers get True.
|
| 21 |
+
`metadata` is attached to the participant and is readable by all clients —
|
| 22 |
+
we use it to carry the Reachy's display colour / persona.
|
| 23 |
+
"""
|
| 24 |
+
room = room or config.ROOM_NAME
|
| 25 |
+
grants = api.VideoGrants(
|
| 26 |
+
room_join=True,
|
| 27 |
+
room=room,
|
| 28 |
+
can_publish=can_publish,
|
| 29 |
+
can_subscribe=can_subscribe,
|
| 30 |
+
can_publish_data=True,
|
| 31 |
+
)
|
| 32 |
+
token = (
|
| 33 |
+
api.AccessToken(config.LIVEKIT_API_KEY, config.LIVEKIT_API_SECRET)
|
| 34 |
+
.with_identity(identity)
|
| 35 |
+
.with_name(name or identity)
|
| 36 |
+
.with_grants(grants)
|
| 37 |
+
)
|
| 38 |
+
if metadata:
|
| 39 |
+
token = token.with_metadata(json.dumps(metadata))
|
| 40 |
+
return token.to_jwt()
|
frontend/dist/assets/index-CMwLu78T.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
:root{--bg:#16130f;--panel:#211c16;--cream:#f6efe1;--muted:#b9ad97;--accent:#c98a3c;--rust:#8a4a2b}*{box-sizing:border-box}html,body{background:radial-gradient(ellipse at 20% 0%, #2a2218, var(--bg));height:100%;color:var(--cream);margin:0;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}.hidden{display:none!important}.hidden-audio{opacity:0;width:0;height:0;position:absolute}#lobby{background:radial-gradient(at 75% 80%,#49e6c829,#0000 55%),radial-gradient(at 15% 15%,#6c7bff24,#0000 55%),#05070d;min-height:100%;position:relative;overflow:hidden}#hero3d{z-index:0;position:absolute;inset:0}.scanlines{z-index:1;pointer-events:none;opacity:.6;background:repeating-linear-gradient(0deg,#49e6c80a 0 1px,#0000 1px 4px);animation:8s linear infinite scan;position:absolute;inset:0;-webkit-mask-image:linear-gradient(#0000,#000 30% 70%,#0000);mask-image:linear-gradient(#0000,#000 30% 70%,#0000)}@keyframes scan{0%{background-position:0 0}to{background-position:0 -400px}}.lobby-inner{z-index:2;pointer-events:none;flex-direction:column;justify-content:center;max-width:620px;min-height:100%;padding:clamp(28px,7vw,90px);display:flex;position:relative}.lobby-inner>*{pointer-events:auto}.eyebrow{text-transform:uppercase;letter-spacing:.34em;width:fit-content;color:var(--cream);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);background:#49e6c80f;border:1px solid #49e6c866;border-radius:999px;align-items:center;gap:9px;padding:7px 14px;font-size:12px;font-weight:700;display:inline-flex}.eyebrow .pulse{background:#49e6c8;border-radius:50%;width:8px;height:8px;animation:1.5s infinite pulse;box-shadow:0 0 10px 2px #49e6c8}#lobby h1{letter-spacing:-.02em;color:#f4f8ff;text-shadow:0 2px 40px #49e6c840;margin:20px 0 14px;font-size:clamp(40px,7vw,76px);font-weight:800;line-height:.98}#lobby h1 em{background:linear-gradient(100deg,#49e6c8,#6c7bff 70%);color:#0000;-webkit-background-clip:text;background-clip:text;font-style:normal}#lobby p{color:#aeb8c8;max-width:30em;font-size:clamp(15px,1.6vw,18px);line-height:1.6}.lobby-actions{flex-wrap:wrap;gap:14px;margin-top:30px;display:flex}.btn{cursor:pointer;border:1px solid #0000;border-radius:12px;padding:14px 22px;font-size:15px;font-weight:700;transition:transform .18s,box-shadow .18s,background .18s,border-color .18s}.btn:disabled{opacity:.55;cursor:default}.btn-primary{color:#051018;background:linear-gradient(100deg,#49e6c8,#6c7bff);box-shadow:0 8px 30px -8px #49e6c8b3}.btn-primary:not(:disabled):hover{transform:translateY(-2px);box-shadow:0 12px 36px -8px #49e6c8e6}.btn-ghost{color:#eaf1f8;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);background:#ffffff0a;border-color:#ffffff29}.btn-ghost:not(:disabled):hover{border-color:#49e6c8cc;box-shadow:0 0 0 1px #49e6c866,0 8px 24px -10px #49e6c880}.status{color:#7f8aa0;letter-spacing:.02em;min-height:18px;margin-top:18px;font-family:ui-monospace,monospace;font-size:13px}#stage{flex-direction:column;min-height:100%;display:flex}.topbar{border-bottom:1px solid #33291d;align-items:center;gap:14px;padding:14px 22px;display:flex}.brand{font-weight:700}.live-pill{letter-spacing:.1em;color:#ff6b5e;align-items:center;gap:7px;font-size:12px;font-weight:700;display:inline-flex}.live-pill .dot{background:#ff6b5e;border-radius:50%;width:8px;height:8px;animation:1.4s infinite pulse}@keyframes pulse{0%,to{opacity:1}50%{opacity:.3}}.grid{flex:1;align-content:center;gap:16px;padding:20px;display:grid}.spotlight{flex:1;gap:16px;min-height:0;padding:20px;display:flex}.spot-main{flex:1;min-width:0;display:flex}.spot-main .card{aspect-ratio:auto;width:100%;height:100%}.spot-strip{flex-direction:column;flex-shrink:0;gap:12px;width:clamp(150px,17vw,240px);display:flex;overflow-y:auto}.spot-strip .card{aspect-ratio:4/3;flex:none;width:100%}.card{aspect-ratio:4/3;background:linear-gradient(160deg,#241e17,#19150f);border:1px solid #382c1f;border-radius:16px;transition:border-color .2s,box-shadow .2s;position:relative;overflow:hidden}.card.talking{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent), 0 0 32px -8px var(--accent)}.card .twin{position:absolute;inset:0}.speaking-ring{pointer-events:none;box-shadow:inset 0 0 60px -30px var(--accent);opacity:0;border-radius:16px;transition:opacity .2s;position:absolute;inset:0}.card.talking .speaking-ring{opacity:1}.nameplate{-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);background:#0a080699;border-radius:999px;align-items:center;gap:8px;padding:6px 12px;font-size:13px;display:flex;position:absolute;bottom:12px;left:12px}.nameplate .dot{background:#5a4a35;border-radius:50%;width:8px;height:8px;transition:background .15s}.card.talking .nameplate .dot{background:var(--accent)}.nameplate .name{font-weight:700}.nameplate .persona{color:var(--muted);font-size:12px}
|
frontend/dist/assets/index-DxhkjW4l.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/dist/index.html
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Small Talk · an AI-to-AI robot podcast</title>
|
| 7 |
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎙️</text></svg>" />
|
| 8 |
+
<script type="module" crossorigin src="/assets/index-DxhkjW4l.js"></script>
|
| 9 |
+
<link rel="stylesheet" crossorigin href="/assets/index-CMwLu78T.css">
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div id="lobby">
|
| 13 |
+
<div id="hero3d"></div>
|
| 14 |
+
<div class="scanlines"></div>
|
| 15 |
+
<div class="lobby-inner">
|
| 16 |
+
<div class="eyebrow"><span class="pulse"></span> SMALL TALK</div>
|
| 17 |
+
<h1>The robots are<br /><em>talking back.</em></h1>
|
| 18 |
+
<p>
|
| 19 |
+
An AI-to-AI podcast hosted by Reachy Minis — live over WebRTC, each with
|
| 20 |
+
its own voice, personality, and a 3D digital twin that emotes as it speaks.
|
| 21 |
+
</p>
|
| 22 |
+
<div class="lobby-actions">
|
| 23 |
+
<button id="join" class="btn btn-primary">Own a Reachy? Join the call →</button>
|
| 24 |
+
<button id="demo" class="btn btn-ghost">▶ Podcast</button>
|
| 25 |
+
<button id="chaos" class="btn btn-ghost">🎭 Group chat</button>
|
| 26 |
+
</div>
|
| 27 |
+
<div id="status" class="status"></div>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<div id="stage" class="hidden">
|
| 32 |
+
<header class="topbar">
|
| 33 |
+
<span class="brand">🎙️ Reachy Podcast</span>
|
| 34 |
+
<span class="live-pill"><span class="dot"></span> LIVE</span>
|
| 35 |
+
<button id="demo2" class="btn btn-ghost">▶ Podcast</button>
|
| 36 |
+
<button id="chaos2" class="btn btn-ghost">🎭 Group chat</button>
|
| 37 |
+
</header>
|
| 38 |
+
<div id="grid" class="grid"></div>
|
| 39 |
+
<div id="spotlight" class="spotlight hidden">
|
| 40 |
+
<div class="spot-main" id="spotMain"></div>
|
| 41 |
+
<div class="spot-strip" id="spotStrip"></div>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
</body>
|
| 46 |
+
</html>
|
frontend/dist/moves/reachy-moves.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/dist/robot-3d/meshes/5w_speaker.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:89c220ac5fa6dd559fcd2f3faccb7197904ab6b882ccf5d2ad04b17a09d58b98
|
| 3 |
+
size 229884
|
frontend/dist/robot-3d/meshes/antenna.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:01f894d2eb9cdc94e7c45944cabb6fa84074e33fb45c3ab9974744bc0e43a9cb
|
| 3 |
+
size 1048584
|
frontend/dist/robot-3d/meshes/antenna_body_3dprint.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3f760ace60b1a368ba7c51bcd01a152a85e66a587fb24d55886cb9d9ffb9008f
|
| 3 |
+
size 241784
|
frontend/dist/robot-3d/meshes/antenna_holder_l_3dprint.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5c2f0efae648fa0174dbc374fb63f4b48f55dea18f16f407540d255d8c91a426
|
| 3 |
+
size 449784
|
frontend/dist/robot-3d/meshes/antenna_holder_r_3dprint.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c7cd59ec8fd200294880c062b9190f223eb7fc7c207caecbb5de28fd4accbbe1
|
| 3 |
+
size 465984
|
frontend/dist/robot-3d/meshes/antenna_interface_3dprint.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:662384ed2de2c37fff6d5bab1ba0ccb76ee03f5d1680043d255e33e879b481bb
|
| 3 |
+
size 179984
|
frontend/dist/robot-3d/meshes/arducam.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:dedb2f78b82216790ad43483c832744b86108dfab076a0f21938e6931beb1927
|
| 3 |
+
size 1048584
|
frontend/dist/robot-3d/meshes/b3b_eh.stl
ADDED
|
Binary file (9.88 kB). View file
|
|
|
frontend/dist/robot-3d/meshes/b3b_eh_1.stl
ADDED
|
Binary file (9.88 kB). View file
|
|
|
frontend/dist/robot-3d/meshes/bearing_85x110x13.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f79e3b8a1cf2da7be17907f6761acfa202fab9f759da196431712b7e20938fed
|
| 3 |
+
size 1048584
|
frontend/dist/robot-3d/meshes/big_lens_d40.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4df711c8e29a677f62622d57c5cac01257ae9a44f46adb67fefb54b8fe52a6eb
|
| 3 |
+
size 151284
|
frontend/dist/robot-3d/meshes/body_down_3dprint.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:737e674299655be49d6b37a196d21cf8b9fd95c2837667cf4e4ed8bc4a8e89fa
|
| 3 |
+
size 1048584
|
frontend/dist/robot-3d/meshes/body_foot_3dprint.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f021859da8f2b5dd663f36b7f4dd9b5e1c52485556a106b1ad66a3fed83281b8
|
| 3 |
+
size 639584
|
frontend/dist/robot-3d/meshes/body_top_3dprint.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:60f406a0ff783d18bd20c0441d1e03243d11403a71e7f37ee6aa444da9110039
|
| 3 |
+
size 1048584
|
frontend/dist/robot-3d/meshes/body_turning_3dprint.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:805db06f0b88bd1d2bef6bd9c5b8afeaa364681fa8eff02465ceb14298283ae6
|
| 3 |
+
size 393084
|
frontend/dist/robot-3d/meshes/bts2_m2_6x8.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:303a706276ef0a3dcf8b6deeabf73969d2c48cb6628209bf0fed825b622deccb
|
| 3 |
+
size 269184
|
frontend/dist/robot-3d/meshes/dc15_a01_case_b_dummy.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0b108f9390168a9052b408b292e316112f28e11d5ca51e28a374f29ce9ef2790
|
| 3 |
+
size 248884
|
frontend/dist/robot-3d/meshes/dc15_a01_case_f_dummy.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6ba4e280134154b64d2c5dd2f78f95c27e682a69e77f47895ac1fb3221d8c296
|
| 3 |
+
size 326984
|
frontend/dist/robot-3d/meshes/dc15_a01_case_m_dummy.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:716c7f05cb780b5eb1dc7e614b8376e587683f2922fcb386d0b8152a73b5af68
|
| 3 |
+
size 162184
|
frontend/dist/robot-3d/meshes/dc15_a01_horn_dummy.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e8990dd416d5d6603e10b6d0fd05d8f552ef045fcebdcdeebf589d881c0185fd
|
| 3 |
+
size 445084
|
frontend/dist/robot-3d/meshes/dc15_a01_led_cap2_dummy.stl
ADDED
|
Binary file (1.68 kB). View file
|
|
|
frontend/dist/robot-3d/meshes/glasses_dolder_3dprint.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:92f37eb75055678325c86ee614e3aef8aaa2bbdbaba518824045818edff3c1b9
|
| 3 |
+
size 607884
|
frontend/dist/robot-3d/meshes/head_back_3dprint.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:724385740aee0c18d3ccfe5df1b79049ff84c736cf652b53f5625ac5cc83c95c
|
| 3 |
+
size 1048584
|
frontend/dist/robot-3d/meshes/head_front_3dprint.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6b4938a455866612ec3915da422d5e01037844fb3424c55200eea8e056363f81
|
| 3 |
+
size 1048584
|
frontend/dist/robot-3d/meshes/head_mic_3dprint.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ac3f4978f945345336481bde7b87aea91aeb97c3ce7e9433df8340923f5665bd
|
| 3 |
+
size 662084
|
frontend/dist/robot-3d/meshes/lens_cap_d30_3dprint.stl
ADDED
|
Binary file (94.3 kB). View file
|
|
|
frontend/dist/robot-3d/meshes/lens_cap_d40_3dprint.stl
ADDED
|
Binary file (93.7 kB). View file
|
|
|
frontend/dist/robot-3d/meshes/m12_fisheye_lens_1_8mm.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:bf2e973bd164cd66b747f16e0ad6c4aafc9492e0b053fd40c283f4beba1b6015
|
| 3 |
+
size 259284
|
frontend/dist/robot-3d/meshes/mp01062_stewart_arm_3.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7b64f2de2a35b6feb9ed8ef728219f1a9600dfe6fcb70256a2e555cd6c49401e
|
| 3 |
+
size 155584
|
frontend/dist/robot-3d/meshes/neck_reference_3dprint.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5e619d224f39690c029b9e44bee7882e3650a8828e554d25c469bce3ab371ce5
|
| 3 |
+
size 605084
|
frontend/dist/robot-3d/meshes/phs_1_7x20_5_dc10.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c3e9d4bddfe4a99c206feba0fe648c1e5caf0aefca6347c7a91ff8f425b58d35
|
| 3 |
+
size 253184
|
frontend/dist/robot-3d/meshes/phs_1_7x20_5_dc10_1.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f9cd84a44f18b39aae56220bfcdfdbf8e39a809c57e4d07c3d7a718c7ab5669e
|
| 3 |
+
size 253184
|