GauravGosain commited on
Commit
d2794cb
·
verified ·
1 Parent(s): 5d66c82

Deploy Small Talk: Reachy Mini AI podcast (gradio.Server + three.js + LiveKit)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +52 -0
  2. app.py +50 -0
  3. backend/__init__.py +1 -0
  4. backend/__pycache__/__init__.cpython-313.pyc +0 -0
  5. backend/__pycache__/audio.cpython-313.pyc +0 -0
  6. backend/__pycache__/config.cpython-313.pyc +0 -0
  7. backend/__pycache__/publisher.cpython-313.pyc +0 -0
  8. backend/__pycache__/server.cpython-313.pyc +0 -0
  9. backend/__pycache__/tokens.cpython-313.pyc +0 -0
  10. backend/audio.py +32 -0
  11. backend/config.py +21 -0
  12. backend/publisher.py +139 -0
  13. backend/server.py +209 -0
  14. backend/tokens.py +40 -0
  15. frontend/dist/assets/index-CMwLu78T.css +1 -0
  16. frontend/dist/assets/index-DxhkjW4l.js +0 -0
  17. frontend/dist/index.html +46 -0
  18. frontend/dist/moves/reachy-moves.json +0 -0
  19. frontend/dist/robot-3d/meshes/5w_speaker.stl +3 -0
  20. frontend/dist/robot-3d/meshes/antenna.stl +3 -0
  21. frontend/dist/robot-3d/meshes/antenna_body_3dprint.stl +3 -0
  22. frontend/dist/robot-3d/meshes/antenna_holder_l_3dprint.stl +3 -0
  23. frontend/dist/robot-3d/meshes/antenna_holder_r_3dprint.stl +3 -0
  24. frontend/dist/robot-3d/meshes/antenna_interface_3dprint.stl +3 -0
  25. frontend/dist/robot-3d/meshes/arducam.stl +3 -0
  26. frontend/dist/robot-3d/meshes/b3b_eh.stl +0 -0
  27. frontend/dist/robot-3d/meshes/b3b_eh_1.stl +0 -0
  28. frontend/dist/robot-3d/meshes/bearing_85x110x13.stl +3 -0
  29. frontend/dist/robot-3d/meshes/big_lens_d40.stl +3 -0
  30. frontend/dist/robot-3d/meshes/body_down_3dprint.stl +3 -0
  31. frontend/dist/robot-3d/meshes/body_foot_3dprint.stl +3 -0
  32. frontend/dist/robot-3d/meshes/body_top_3dprint.stl +3 -0
  33. frontend/dist/robot-3d/meshes/body_turning_3dprint.stl +3 -0
  34. frontend/dist/robot-3d/meshes/bts2_m2_6x8.stl +3 -0
  35. frontend/dist/robot-3d/meshes/dc15_a01_case_b_dummy.stl +3 -0
  36. frontend/dist/robot-3d/meshes/dc15_a01_case_f_dummy.stl +3 -0
  37. frontend/dist/robot-3d/meshes/dc15_a01_case_m_dummy.stl +3 -0
  38. frontend/dist/robot-3d/meshes/dc15_a01_horn_dummy.stl +3 -0
  39. frontend/dist/robot-3d/meshes/dc15_a01_led_cap2_dummy.stl +0 -0
  40. frontend/dist/robot-3d/meshes/glasses_dolder_3dprint.stl +3 -0
  41. frontend/dist/robot-3d/meshes/head_back_3dprint.stl +3 -0
  42. frontend/dist/robot-3d/meshes/head_front_3dprint.stl +3 -0
  43. frontend/dist/robot-3d/meshes/head_mic_3dprint.stl +3 -0
  44. frontend/dist/robot-3d/meshes/lens_cap_d30_3dprint.stl +0 -0
  45. frontend/dist/robot-3d/meshes/lens_cap_d40_3dprint.stl +0 -0
  46. frontend/dist/robot-3d/meshes/m12_fisheye_lens_1_8mm.stl +3 -0
  47. frontend/dist/robot-3d/meshes/mp01062_stewart_arm_3.stl +3 -0
  48. frontend/dist/robot-3d/meshes/neck_reference_3dprint.stl +3 -0
  49. frontend/dist/robot-3d/meshes/phs_1_7x20_5_dc10.stl +3 -0
  50. 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&nbsp;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