jrubiosainz commited on
Commit
e089244
·
verified ·
1 Parent(s): 6d6ed5d

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -34,3 +34,4 @@ saved_model/**/* 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
  gentleman_icon.png 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
  gentleman_icon.png filter=lfs diff=lfs merge=lfs -text
37
+ demo.mp4 filter=lfs diff=lfs merge=lfs -text
demo.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:22e6912378900da532d21c1400cfdd0592ae724b231602364ceeeae400210cf9
3
+ size 792374
demo_video.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Talkie Gentleman Reachy Mini demo video.
2
+
3
+ Split-screen: left = MuJoCo 3D robot, right = Victorian chat overlay.
4
+ 15 seconds, 720p, 24fps.
5
+
6
+ Usage: GST_PLUGIN_SCANNER="" python3.13 demo_video.py
7
+ """
8
+ import os, sys, math, subprocess
9
+ from pathlib import Path
10
+
11
+ os.environ["GST_PLUGIN_SCANNER"] = ""
12
+ os.environ["GST_REGISTRY_UPDATE"] = "no"
13
+
14
+ import mujoco
15
+ import numpy as np
16
+ from PIL import Image, ImageDraw, ImageFont
17
+
18
+ # --- Config ---
19
+ SCENE_XML = Path(__file__).parent.parent / "reachy_mini/src/reachy_mini/descriptions/reachy_mini/mjcf/scenes/minimal.xml"
20
+ OUTPUT_MP4 = Path(__file__).parent / "demo.mp4"
21
+ TOTAL_W, TOTAL_H = 1280, 720
22
+ ROBOT_W = TOTAL_W // 2 # 640
23
+ CHAT_W = TOTAL_W - ROBOT_W
24
+ FPS = 24
25
+ TOTAL_DURATION = 15.0
26
+
27
+ # Actuator indices
28
+ YAW, S1, S2, S3, S4, S5, S6, R_ANT, L_ANT = 0, 1, 2, 3, 4, 5, 6, 7, 8
29
+
30
+ # --- Chat content ---
31
+ CHAT_EVENTS = [
32
+ # (start_time, role, text)
33
+ (0.5, "header", "~ Talkie Gentleman ~"),
34
+ (1.0, "user", "Good evening, what are your\nthoughts on modern inventions?"),
35
+ (4.5, "bot", "Ah, a most splendid inquiry!\nThe telegraph astounds me —\nto send words across vast\ndistances in mere moments.\nTruly, we live in an age\nof marvels, dear friend."),
36
+ (9.0, "user", "What music do you enjoy?"),
37
+ (11.5, "bot", "Beethoven, without question.\nHis symphonies stir the very\nsoul. The Moonlight Sonata\nis a masterwork of the\nhighest order."),
38
+ ]
39
+
40
+ # --- Robot motion timeline ---
41
+ # (start, end, gesture_name)
42
+ GESTURES = [
43
+ # Idle breathing at start
44
+ (0.0, 1.0, "idle"),
45
+ # Listen to first question - slight attentive tilt
46
+ (1.0, 2.5, "attentive_listen"),
47
+ # Thinking head tilt before answering
48
+ (2.5, 4.5, "thinking_tilt"),
49
+ # Speaking - gentle nods while responding
50
+ (4.5, 8.5, "speaking_nods"),
51
+ # Brief return to neutral
52
+ (8.5, 9.0, "idle"),
53
+ # Listen to second question
54
+ (9.0, 10.0, "attentive_listen"),
55
+ # Enthusiastic nod about Beethoven
56
+ (10.0, 11.5, "enthusiastic_think"),
57
+ # Speaking with conviction
58
+ (11.5, 14.5, "speaking_nods"),
59
+ # Elegant settle
60
+ (14.5, 15.0, "idle"),
61
+ ]
62
+
63
+
64
+ def get_robot_pose(t: float) -> dict:
65
+ """Return target ctrl values for time t."""
66
+ gesture = "idle"
67
+ gesture_t = 0.0
68
+ for gs, ge, gn in GESTURES:
69
+ if gs <= t < ge:
70
+ gesture = gn
71
+ gesture_t = (t - gs) / max(0.01, ge - gs) # normalized 0-1
72
+ break
73
+
74
+ ctrl = {YAW: 0, S1: 0, S2: 0, S3: 0, S4: 0, S5: 0, S6: 0, R_ANT: 0, L_ANT: 0}
75
+
76
+ if gesture == "idle":
77
+ # Gentle breathing - subtle vertical oscillation
78
+ breath = math.sin(t * 1.8) * 0.02
79
+ ctrl[S3] = breath
80
+ ctrl[R_ANT] = math.sin(t * 0.7) * 0.05
81
+ ctrl[L_ANT] = math.sin(t * 0.7 + 0.5) * 0.05
82
+
83
+ elif gesture == "attentive_listen":
84
+ # Gentle head tilt to the right, antenna perk
85
+ ease = math.sin(gesture_t * math.pi) # smooth in-out
86
+ ctrl[S5] = math.radians(12) * ease # roll tilt
87
+ ctrl[S4] = math.radians(-5) * ease # slight pitch down (attentive)
88
+ ctrl[R_ANT] = 0.3 * ease
89
+ ctrl[L_ANT] = 0.15 * ease
90
+
91
+ elif gesture == "thinking_tilt":
92
+ # Head tilts left, one antenna raises - pondering
93
+ ease = min(1.0, gesture_t * 2.5) # quick settle
94
+ hold = math.sin(gesture_t * math.pi * 0.8)
95
+ ctrl[S5] = math.radians(-15) * ease # tilt left
96
+ ctrl[S4] = math.radians(8) * ease # slight look up
97
+ ctrl[YAW] = math.radians(5) * ease # slight turn
98
+ ctrl[R_ANT] = -0.2 * ease
99
+ ctrl[L_ANT] = 0.5 * ease # one antenna raised = thinking
100
+ # Subtle micro-movement
101
+ ctrl[S4] += math.sin(t * 3) * 0.01
102
+
103
+ elif gesture == "speaking_nods":
104
+ # Gentle periodic nods with slight body sway
105
+ nod_cycle = math.sin(gesture_t * math.pi * 5) # ~2.5 nods over the gesture
106
+ sway = math.sin(gesture_t * math.pi * 2) * 0.3
107
+ ctrl[S4] = math.radians(6) * nod_cycle # pitch nod
108
+ ctrl[S5] = math.radians(3) * sway # gentle roll sway
109
+ ctrl[YAW] = math.radians(2) * math.sin(gesture_t * math.pi * 1.5)
110
+ # Antennas follow speech rhythm
111
+ ctrl[R_ANT] = 0.2 * nod_cycle
112
+ ctrl[L_ANT] = 0.2 * nod_cycle
113
+ # Subtle vertical
114
+ ctrl[S3] = 0.01 * nod_cycle
115
+
116
+ elif gesture == "enthusiastic_think":
117
+ # More energetic thinking - tilt + antenna waggle
118
+ ease = min(1.0, gesture_t * 3)
119
+ ctrl[S5] = math.radians(10) * ease
120
+ ctrl[S4] = math.radians(10) * ease
121
+ ctrl[R_ANT] = 0.4 * math.sin(gesture_t * math.pi * 4)
122
+ ctrl[L_ANT] = 0.4 * math.cos(gesture_t * math.pi * 4)
123
+
124
+ return ctrl
125
+
126
+
127
+ def render_chat_panel(t: float) -> Image.Image:
128
+ """Render the Victorian chat panel for time t."""
129
+ img = Image.new("RGB", (CHAT_W, TOTAL_H), (28, 22, 18))
130
+ draw = ImageDraw.Draw(img)
131
+
132
+ # Try to get a nice font, fall back to default
133
+ try:
134
+ font_title = ImageFont.truetype("/System/Library/Fonts/Supplemental/Times New Roman.ttf", 26)
135
+ font_msg = ImageFont.truetype("/System/Library/Fonts/Supplemental/Times New Roman.ttf", 18)
136
+ font_label = ImageFont.truetype("/System/Library/Fonts/Supplemental/Times New Roman.ttf", 14)
137
+ except:
138
+ font_title = ImageFont.load_default()
139
+ font_msg = font_title
140
+ font_label = font_title
141
+
142
+ # Colors
143
+ BG_DARK = (28, 22, 18)
144
+ GOLD = (198, 166, 100)
145
+ CREAM = (230, 218, 195)
146
+ USER_BG = (48, 40, 32)
147
+ BOT_BG = (42, 35, 28)
148
+ BORDER = (100, 82, 58)
149
+ DIM = (140, 120, 90)
150
+
151
+ # Ornamental border
152
+ draw.rectangle([0, 0, CHAT_W-1, TOTAL_H-1], outline=BORDER, width=2)
153
+ draw.rectangle([4, 4, CHAT_W-5, TOTAL_H-5], outline=(60, 50, 38), width=1)
154
+
155
+ # Decorative top line
156
+ draw.line([(20, 55), (CHAT_W-20, 55)], fill=BORDER, width=1)
157
+ # Small ornaments
158
+ draw.text((CHAT_W//2 - 10, 48), "◆", fill=GOLD, font=font_label)
159
+
160
+ y = 70
161
+ for evt_t, role, text in CHAT_EVENTS:
162
+ if t < evt_t:
163
+ break
164
+
165
+ if role == "header":
166
+ # Title
167
+ bbox = draw.textbbox((0, 0), text, font=font_title)
168
+ tw = bbox[2] - bbox[0]
169
+ draw.text(((CHAT_W - tw) // 2, 18), text, fill=GOLD, font=font_title)
170
+ continue
171
+
172
+ # Typewriter effect for messages appearing
173
+ elapsed = t - evt_t
174
+ chars_visible = int(elapsed * 35) # 35 chars/sec typing speed
175
+ visible_text = text[:chars_visible]
176
+ if not visible_text:
177
+ continue
178
+
179
+ # Message bubble
180
+ margin = 15
181
+ pad = 10
182
+
183
+ if role == "user":
184
+ label = "You"
185
+ label_color = DIM
186
+ bg = USER_BG
187
+ text_color = CREAM
188
+ else:
189
+ label = "Gentleman"
190
+ label_color = GOLD
191
+ bg = BOT_BG
192
+ text_color = CREAM
193
+
194
+ # Label
195
+ draw.text((margin + 5, y), label, fill=label_color, font=font_label)
196
+ y += 18
197
+
198
+ # Calculate text height
199
+ bbox = draw.textbbox((0, 0), visible_text, font=font_msg)
200
+ th = bbox[3] - bbox[1]
201
+ tw = bbox[2] - bbox[0]
202
+
203
+ # Bubble background
204
+ bubble_h = th + pad * 2 + 4
205
+ draw.rounded_rectangle(
206
+ [margin, y, CHAT_W - margin, y + bubble_h],
207
+ radius=6, fill=bg, outline=BORDER
208
+ )
209
+
210
+ # Text
211
+ draw.text((margin + pad, y + pad), visible_text, fill=text_color, font=font_msg)
212
+
213
+ y += bubble_h + 12
214
+
215
+ # Typing indicator for bot messages still typing
216
+ if role == "bot" and chars_visible < len(text):
217
+ dots = "..." [:int((t * 3) % 4)]
218
+ draw.text((margin + pad, y - 5), f"✎ {dots}", fill=DIM, font=font_label)
219
+
220
+ # Bottom ornament
221
+ draw.line([(20, TOTAL_H - 25), (CHAT_W - 20, TOTAL_H - 25)], fill=BORDER, width=1)
222
+ draw.text((CHAT_W // 2 - 30, TOTAL_H - 20), "⚙ Anno 1842", fill=DIM, font=font_label)
223
+
224
+ return img
225
+
226
+
227
+ def main():
228
+ print(f"Loading MuJoCo scene: {SCENE_XML}")
229
+ model = mujoco.MjModel.from_xml_path(str(SCENE_XML))
230
+ model.vis.global_.offwidth = ROBOT_W
231
+ model.vis.global_.offheight = TOTAL_H
232
+ data = mujoco.MjData(model)
233
+ renderer = mujoco.Renderer(model, TOTAL_H, ROBOT_W)
234
+
235
+ cam = mujoco.MjvCamera()
236
+ cam.type = mujoco.mjtCamera.mjCAMERA_FREE
237
+ cam.distance = 0.48
238
+ cam.azimuth = 175
239
+ cam.elevation = -8
240
+ cam.lookat[:] = [0, 0, 0.14]
241
+
242
+ stp = max(1, int(1.0 / (model.opt.timestep * FPS)))
243
+ n_frames = int(TOTAL_DURATION * FPS)
244
+
245
+ frames = []
246
+ print(f"Rendering {n_frames} frames ({TOTAL_DURATION:.0f}s @ {FPS}fps)...")
247
+
248
+ for i in range(n_frames):
249
+ t = i / FPS
250
+
251
+ # Set robot pose
252
+ pose = get_robot_pose(t)
253
+ for k, v in pose.items():
254
+ data.ctrl[k] = v
255
+
256
+ # Step physics
257
+ for _ in range(stp):
258
+ mujoco.mj_step(model, data)
259
+
260
+ # Render robot view
261
+ renderer.update_scene(data, cam)
262
+ robot_rgb = renderer.render().copy() # (H, W, 3)
263
+
264
+ # Render chat panel
265
+ chat_img = render_chat_panel(t)
266
+ chat_rgb = np.array(chat_img)
267
+
268
+ # Composite split-screen
269
+ composite = np.concatenate([robot_rgb, chat_rgb], axis=1)
270
+ frames.append(composite)
271
+
272
+ if (i + 1) % (FPS * 3) == 0:
273
+ print(f" {i+1}/{n_frames} frames...")
274
+
275
+ # Encode video
276
+ print(f"\nEncoding {len(frames)} frames to {OUTPUT_MP4}...")
277
+ proc = subprocess.Popen([
278
+ 'ffmpeg', '-y', '-f', 'rawvideo', '-vcodec', 'rawvideo',
279
+ '-s', f'{TOTAL_W}x{TOTAL_H}', '-pix_fmt', 'rgb24', '-r', str(FPS),
280
+ '-i', '-', '-c:v', 'libx264', '-pix_fmt', 'yuv420p',
281
+ '-preset', 'fast', '-crf', '18', str(OUTPUT_MP4)
282
+ ], stdin=subprocess.PIPE, stderr=subprocess.PIPE)
283
+
284
+ for frame in frames:
285
+ proc.stdin.write(frame.tobytes())
286
+ proc.stdin.close()
287
+ _, stderr = proc.communicate()
288
+
289
+ if proc.returncode == 0:
290
+ size = os.path.getsize(str(OUTPUT_MP4))
291
+ print(f"✅ {OUTPUT_MP4} ({size/1024:.0f}KB, {TOTAL_DURATION:.0f}s)")
292
+ else:
293
+ print(f"❌ ffmpeg error: {stderr.decode()[:500]}")
294
+ sys.exit(1)
295
+
296
+
297
+ if __name__ == "__main__":
298
+ main()
talkie_gentleman/main.py CHANGED
@@ -9,6 +9,7 @@ A Victorian/Edwardian speaking robot using:
9
 
10
  import asyncio
11
  import logging
 
12
  import tempfile
13
  from pathlib import Path
14
 
@@ -16,6 +17,7 @@ from .stt_engine import transcribe
16
  from .talkie_inference import generate_response
17
  from .tts_engine import speak_british
18
  from .robot_behavior import GentlemanBehavior
 
19
 
20
  logger = logging.getLogger(__name__)
21
 
@@ -105,17 +107,44 @@ def main():
105
  """CLI entry point for testing."""
106
  import sys
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  app = TalkieGentleman()
109
 
110
  if len(sys.argv) > 1:
111
  text = " ".join(sys.argv[1:])
112
  result = asyncio.run(app.process_text(text))
113
- print(f"🎩 {result['response_text']}")
114
  print(f"🔊 Audio: {result['audio_path']}")
115
  else:
116
- print("🎩 Talkie Gentleman — Victorian Conversationalist")
117
- print("Usage: python -m talkie_gentleman 'Good evening, sir!'")
118
-
119
-
120
- if __name__ == "__main__":
121
- main()
 
9
 
10
  import asyncio
11
  import logging
12
+ import os
13
  import tempfile
14
  from pathlib import Path
15
 
 
17
  from .talkie_inference import generate_response
18
  from .tts_engine import speak_british
19
  from .robot_behavior import GentlemanBehavior
20
+ from .setup_wizard import SetupServer, get_setup_status, _read_key
21
 
22
  logger = logging.getLogger(__name__)
23
 
 
107
  """CLI entry point for testing."""
108
  import sys
109
 
110
+ # Always start setup wizard
111
+ setup = SetupServer(port=8889)
112
+ setup.start()
113
+
114
+ status = get_setup_status()
115
+ if not status["ready"]:
116
+ print("\n🎩 Talkie Gentleman — Setup Required")
117
+ print(f" Open http://localhost:8889/setup to configure.\n")
118
+ if not status["hf_token_set"]:
119
+ print(" ❌ HuggingFace token not set")
120
+ if not status["piper_available"] and not status["elevenlabs_key_set"]:
121
+ print(" ❌ No voice engine available")
122
+ print()
123
+ # Keep alive for setup
124
+ import signal
125
+ signal.signal(signal.SIGINT, lambda *a: sys.exit(0))
126
+ signal.pause()
127
+ return
128
+
129
+ # Load saved keys into env
130
+ hf = _read_key("hf_token")
131
+ el = _read_key("elevenlabs_key")
132
+ if hf:
133
+ os.environ["HF_TOKEN"] = hf
134
+ if el:
135
+ os.environ["ELEVENLABS_API_KEY"] = el
136
+
137
  app = TalkieGentleman()
138
 
139
  if len(sys.argv) > 1:
140
  text = " ".join(sys.argv[1:])
141
  result = asyncio.run(app.process_text(text))
142
+ print(f"\n🎩 {result['response_text']}")
143
  print(f"🔊 Audio: {result['audio_path']}")
144
  else:
145
+ print("\n🎩 Talkie Gentleman — Victorian Conversationalist")
146
+ print(f" Setup wizard: http://localhost:8889/setup")
147
+ print(f" Usage: python -m talkie_gentleman 'Good evening, sir!'\n")
148
+ import signal
149
+ signal.signal(signal.SIGINT, lambda *a: sys.exit(0))
150
+ signal.pause()
talkie_gentleman/setup_wizard.py ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Setup wizard for Talkie Gentleman — guides users through HF Token + TTS setup."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import threading
9
+ import urllib.request
10
+ from http.server import HTTPServer, BaseHTTPRequestHandler
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ PIPER_VOICE_DIR = Path.home() / ".cache" / "talkie_gentleman" / "voices"
17
+ PIPER_MODEL_NAME = "en_GB-alan-medium"
18
+ PIPER_MODEL_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/alan/medium/en_GB-alan-medium.onnx"
19
+ PIPER_CONFIG_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/alan/medium/en_GB-alan-medium.onnx.json"
20
+ CONFIG_DIR = Path.home() / ".config" / "talkie_gentleman"
21
+
22
+ SETUP_HTML = r"""<!DOCTYPE html>
23
+ <html lang="en">
24
+ <head>
25
+ <meta charset="utf-8">
26
+ <meta name="viewport" content="width=device-width, initial-scale=1">
27
+ <title>Talkie Gentleman — Setup</title>
28
+ <style>
29
+ * { box-sizing: border-box; margin: 0; padding: 0; }
30
+ body { font-family: 'Inter', -apple-system, sans-serif; background: #0f0e17; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
31
+ .container { max-width: 900px; width: 100%; padding: 2rem; }
32
+ h1 { font-size: 1.8rem; margin-bottom: 0.5rem; color: #c9a84c; }
33
+ .subtitle { color: #888; margin-bottom: 2rem; }
34
+ .main-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; }
35
+ .steps-col { display: flex; flex-direction: column; gap: 1rem; }
36
+ @media (max-width: 700px) { .main-layout { grid-template-columns: 1fr; } }
37
+ .step { background: #1a1825; border-radius: 12px; padding: 1.5rem; margin-bottom: 0rem; border: 1px solid #2d2b3a; }
38
+ .step.done { border-color: #c9a84c; }
39
+ .step.active { border-color: #7c5cfc; }
40
+ .step.skip { opacity: 0.7; }
41
+ .step-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
42
+ .step-num { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.85rem; font-weight: 600; background: #2d2b3a; }
43
+ .done .step-num { background: #c9a84c; color: #000; }
44
+ .active .step-num { background: #7c5cfc; color: #fff; }
45
+ .step-title { font-weight: 600; }
46
+ .step-body { margin-left: 2.5rem; color: #aaa; font-size: 0.9rem; line-height: 1.6; }
47
+ button { background: #c9a84c; color: #000; border: none; padding: 0.7rem 1.5rem; border-radius: 8px; font-weight: 600; cursor: pointer; font-size: 0.9rem; margin-top: 0.75rem; }
48
+ button:hover { background: #d4b45f; }
49
+ button:disabled { background: #333; color: #666; cursor: not-allowed; }
50
+ button.secondary { background: #2d2b3a; color: #e0e0e0; }
51
+ button.secondary:hover { background: #3d3b4a; }
52
+ .status { margin-top: 0.5rem; font-size: 0.85rem; padding: 0.5rem; border-radius: 6px; }
53
+ .status.ok { background: rgba(201,168,76,0.15); color: #c9a84c; }
54
+ .status.err { background: #ff444422; color: #ff6666; }
55
+ .status.info { background: rgba(124,92,252,0.15); color: #9d7fff; }
56
+ .input-row { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
57
+ .input-row input { flex: 1; background: #222; border: 1px solid #444; border-radius: 6px; padding: 0.5rem 0.75rem; color: #e0e0e0; font-size: 0.85rem; }
58
+ a { color: #c9a84c; }
59
+ code { background: rgba(201,168,76,0.15); color: #c9a84c; padding: 2px 6px; border-radius: 4px; font-size: 0.8rem; }
60
+ .ready-banner { background: rgba(201,168,76,0.12); border: 1px solid #c9a84c; border-radius: 12px; padding: 2rem; text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px; }
61
+ .ready-banner h2 { color: #c9a84c; margin-bottom: 0.5rem; font-size: 1.6rem; }
62
+ .ready-banner p { color: #aaa; margin-bottom: 0.3rem; }
63
+ .ready-banner .big { font-size: 1.1rem; color: #e0e0e0; margin-bottom: 0.5rem; }
64
+ .spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid #444; border-top-color: #c9a84c; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 0.5rem; vertical-align: middle; }
65
+ @keyframes spin { to { transform: rotate(360deg); } }
66
+ </style>
67
+ </head>
68
+ <body>
69
+ <div class="container">
70
+ <h1>🎩 Talkie Gentleman — Setup</h1>
71
+ <p class="subtitle">Get your Victorian robot companion ready in 3 steps</p>
72
+
73
+ <div class="main-layout">
74
+ <div class="steps-col">
75
+
76
+ <div class="step active" id="step1">
77
+ <div class="step-header"><div class="step-num">1</div><span class="step-title">HuggingFace Token (required)</span></div>
78
+ <div class="step-body">
79
+ <p>The gentleman needs a HuggingFace token to think. It's free — create one at <a href="https://huggingface.co/settings/tokens" target="_blank">huggingface.co/settings/tokens</a> (read access is enough).</p>
80
+ <div class="input-row">
81
+ <input type="text" id="hf-token" placeholder="hf_xxxxxxxxxx...">
82
+ <button onclick="saveHfToken()">Save</button>
83
+ </div>
84
+ <div class="status" id="status-hf" style="display:none"></div>
85
+ </div>
86
+ </div>
87
+
88
+ <div class="step" id="step2">
89
+ <div class="step-header"><div class="step-num">2</div><span class="step-title">Voice engine</span></div>
90
+ <div class="step-body">
91
+ <p>The app auto-downloads a free British voice (Piper TTS, ~60MB) on first use. No setup needed.</p>
92
+ <p style="margin-top:0.5rem"><strong>Optional upgrade:</strong> For a premium natural voice, paste your <a href="https://elevenlabs.io/app/settings/api-keys" target="_blank">ElevenLabs API key</a> (free tier: 10K chars/month).</p>
93
+ <div class="input-row">
94
+ <input type="text" id="el-key" placeholder="sk_xxxxxxxxxx... (optional)">
95
+ <button class="secondary" onclick="saveElKey()">Save</button>
96
+ </div>
97
+ <div class="status" id="status-el" style="display:none"></div>
98
+ <button onclick="testVoice()" style="margin-top:0.5rem" class="secondary">🔊 Test voice</button>
99
+ <div class="status" id="status-voice" style="display:none"></div>
100
+ </div>
101
+ </div>
102
+
103
+ <div class="step" id="step3">
104
+ <div class="step-header"><div class="step-num">3</div><span class="step-title">Dependencies check</span></div>
105
+ <div class="step-body">
106
+ <p>Checking that everything needed is installed on this device...</p>
107
+ <button onclick="checkDeps()">Check dependencies</button>
108
+ <div class="status" id="status-deps" style="display:none"></div>
109
+ </div>
110
+ </div>
111
+
112
+ </div>
113
+
114
+ <div class="ready-banner" id="ready-banner" style="display:none">
115
+ <h2>🎩 Ready, old chap!</h2>
116
+ <p class="big">Your gentleman is prepared to converse.</p>
117
+ <p>Speak to your Reachy Mini and enjoy a conversation from a century ago.</p>
118
+ <p id="voice-mode" style="margin-top:1rem; color:#c9a84c;"></p>
119
+ </div>
120
+
121
+ </div>
122
+ </div>
123
+
124
+ <script>
125
+ async function api(endpoint, body) {
126
+ const r = await fetch('/api/' + endpoint, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body || {}) });
127
+ return r.json();
128
+ }
129
+
130
+ function showStatus(id, cls, msg) {
131
+ const el = document.getElementById(id);
132
+ el.style.display = 'block';
133
+ el.className = 'status ' + cls;
134
+ el.innerHTML = msg;
135
+ }
136
+
137
+ function markStepDone(n) {
138
+ document.getElementById('step'+n).className = 'step done';
139
+ }
140
+
141
+ async function checkStatus() {
142
+ const data = await api('status');
143
+ if (data.hf_token_set) {
144
+ markStepDone(1);
145
+ document.getElementById('hf-token').value = '••••••••';
146
+ document.getElementById('hf-token').disabled = true;
147
+ }
148
+ if (data.elevenlabs_key_set) {
149
+ showStatus('status-el', 'ok', 'ElevenLabs key saved — premium voice active');
150
+ }
151
+ if (data.piper_available || data.elevenlabs_key_set) {
152
+ markStepDone(2);
153
+ }
154
+ if (data.ready) {
155
+ markStepDone(3);
156
+ showStatus('status-deps', 'ok', data.deps_detail || 'All dependencies OK');
157
+ document.getElementById('ready-banner').style.display = 'flex';
158
+ const mode = data.elevenlabs_key_set ? '🎙️ Premium voice (ElevenLabs Daniel)' : '🔊 Built-in British voice (Piper)';
159
+ document.getElementById('voice-mode').textContent = mode;
160
+ }
161
+ }
162
+
163
+ async function saveHfToken() {
164
+ const token = document.getElementById('hf-token').value.trim();
165
+ if (!token) return;
166
+ showStatus('status-hf', 'info', '<span class="spinner"></span> Verifying token...');
167
+ const r = await api('save_hf_token', { token });
168
+ if (r.success) {
169
+ showStatus('status-hf', 'ok', '✅ Token verified — ' + (r.username || 'authenticated'));
170
+ markStepDone(1);
171
+ document.getElementById('step2').className = 'step active';
172
+ } else {
173
+ showStatus('status-hf', 'err', '❌ ' + (r.error || 'Invalid token'));
174
+ }
175
+ }
176
+
177
+ async function saveElKey() {
178
+ const key = document.getElementById('el-key').value.trim();
179
+ if (!key) return;
180
+ showStatus('status-el', 'info', '<span class="spinner"></span> Verifying...');
181
+ const r = await api('save_el_key', { key });
182
+ if (r.success) {
183
+ showStatus('status-el', 'ok', '✅ ElevenLabs connected — premium voice active');
184
+ markStepDone(2);
185
+ } else {
186
+ showStatus('status-el', 'err', '❌ ' + (r.error || 'Invalid key'));
187
+ }
188
+ }
189
+
190
+ async function testVoice() {
191
+ showStatus('status-voice', 'info', '<span class="spinner"></span> Generating test audio...');
192
+ const r = await api('test_voice');
193
+ if (r.success) {
194
+ showStatus('status-voice', 'ok', '✅ Voice works! Engine: ' + (r.engine || 'unknown'));
195
+ } else {
196
+ showStatus('status-voice', 'err', '❌ ' + (r.error || 'Voice test failed'));
197
+ }
198
+ }
199
+
200
+ async function checkDeps() {
201
+ showStatus('status-deps', 'info', '<span class="spinner"></span> Checking and installing dependencies...');
202
+ const r = await api('check_deps');
203
+ if (r.success) {
204
+ showStatus('status-deps', 'ok', '✅ ' + r.detail);
205
+ markStepDone(3);
206
+ checkStatus();
207
+ } else {
208
+ showStatus('status-deps', 'err', '❌ ' + r.detail);
209
+ }
210
+ }
211
+
212
+ checkStatus();
213
+ </script>
214
+ </body>
215
+ </html>"""
216
+
217
+
218
+ def _config_path(name: str) -> Path:
219
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
220
+ return CONFIG_DIR / name
221
+
222
+
223
+ def _save_key(name: str, value: str):
224
+ path = _config_path(name)
225
+ path.write_text(value)
226
+ path.chmod(0o600)
227
+
228
+
229
+ def _read_key(name: str) -> Optional[str]:
230
+ path = _config_path(name)
231
+ if path.exists():
232
+ v = path.read_text().strip()
233
+ return v if v else None
234
+ # Also check env
235
+ env_map = {"hf_token": "HF_TOKEN", "elevenlabs_key": "ELEVENLABS_API_KEY"}
236
+ return os.environ.get(env_map.get(name, ""), None)
237
+
238
+
239
+ def _verify_hf_token(token: str) -> dict:
240
+ """Verify HF token by calling whoami endpoint."""
241
+ try:
242
+ req = urllib.request.Request(
243
+ "https://huggingface.co/api/whoami-v2",
244
+ headers={"Authorization": f"Bearer {token}"}
245
+ )
246
+ with urllib.request.urlopen(req, timeout=10) as resp:
247
+ data = json.loads(resp.read())
248
+ return {"success": True, "username": data.get("name", "ok")}
249
+ except Exception as e:
250
+ return {"success": False, "error": str(e)}
251
+
252
+
253
+ def _verify_el_key(key: str) -> dict:
254
+ """Verify ElevenLabs key."""
255
+ try:
256
+ req = urllib.request.Request(
257
+ "https://api.elevenlabs.io/v1/voices",
258
+ headers={"xi-api-key": key}
259
+ )
260
+ with urllib.request.urlopen(req, timeout=10) as resp:
261
+ return {"success": True}
262
+ except Exception as e:
263
+ err = str(e)
264
+ if "401" in err or "403" in err:
265
+ return {"success": False, "error": "Invalid key or missing TTS permission"}
266
+ return {"success": False, "error": err}
267
+
268
+
269
+ def _check_dependencies() -> dict:
270
+ """Check and try to install missing dependencies."""
271
+ results = []
272
+ all_ok = True
273
+
274
+ # Check piper or sag
275
+ has_sag = shutil.which("sag") is not None
276
+ has_piper = shutil.which("piper") is not None
277
+ el_key = _read_key("elevenlabs_key")
278
+
279
+ if has_sag and el_key:
280
+ results.append("TTS: ElevenLabs (sag) ✓")
281
+ elif has_piper:
282
+ results.append("TTS: Piper ✓")
283
+ else:
284
+ # Try to install piper
285
+ try:
286
+ subprocess.run(["pip3", "install", "piper-tts"], capture_output=True, timeout=120)
287
+ if shutil.which("piper"):
288
+ results.append("TTS: Piper installed ✓")
289
+ else:
290
+ results.append("TTS: Piper install failed ✗")
291
+ all_ok = False
292
+ except Exception:
293
+ results.append("TTS: No TTS engine available ✗")
294
+ all_ok = False
295
+
296
+ # Check piper voice
297
+ voice_path = PIPER_VOICE_DIR / f"{PIPER_MODEL_NAME}.onnx"
298
+ if not voice_path.exists() and not (has_sag and el_key):
299
+ try:
300
+ PIPER_VOICE_DIR.mkdir(parents=True, exist_ok=True)
301
+ urllib.request.urlretrieve(PIPER_MODEL_URL, str(voice_path))
302
+ config_path = PIPER_VOICE_DIR / f"{PIPER_MODEL_NAME}.onnx.json"
303
+ urllib.request.urlretrieve(PIPER_CONFIG_URL, str(config_path))
304
+ results.append("British voice: downloaded ✓")
305
+ except Exception as e:
306
+ results.append(f"British voice: download failed ✗ ({e})")
307
+ all_ok = False
308
+ elif voice_path.exists():
309
+ results.append("British voice: ready ✓")
310
+ else:
311
+ results.append("British voice: not needed (ElevenLabs active) ✓")
312
+
313
+ # HF token
314
+ hf = _read_key("hf_token")
315
+ if hf:
316
+ results.append("HF Token: set ✓")
317
+ else:
318
+ results.append("HF Token: missing ✗")
319
+ all_ok = False
320
+
321
+ return {"success": all_ok, "detail": " | ".join(results)}
322
+
323
+
324
+ def _test_voice() -> dict:
325
+ """Generate a short test audio."""
326
+ try:
327
+ from talkie_gentleman.tts_engine import speak_british
328
+ # Temporarily set env vars from config
329
+ hf = _read_key("hf_token")
330
+ el = _read_key("elevenlabs_key")
331
+ if hf:
332
+ os.environ["HF_TOKEN"] = hf
333
+ if el:
334
+ os.environ["ELEVENLABS_API_KEY"] = el
335
+
336
+ path = speak_british("I say, what a pleasure to make your acquaintance.")
337
+ engine = "ElevenLabs" if el and shutil.which("sag") else "Piper"
338
+ return {"success": True, "engine": engine, "path": path}
339
+ except Exception as e:
340
+ return {"success": False, "error": str(e)}
341
+
342
+
343
+ def get_setup_status() -> dict:
344
+ hf = _read_key("hf_token")
345
+ el = _read_key("elevenlabs_key")
346
+ has_piper = shutil.which("piper") is not None
347
+ voice_exists = (PIPER_VOICE_DIR / f"{PIPER_MODEL_NAME}.onnx").exists()
348
+
349
+ tts_ok = (el and shutil.which("sag")) or has_piper or voice_exists
350
+ ready = bool(hf) and tts_ok
351
+
352
+ return {
353
+ "hf_token_set": bool(hf),
354
+ "elevenlabs_key_set": bool(el),
355
+ "piper_available": has_piper or voice_exists,
356
+ "ready": ready,
357
+ }
358
+
359
+
360
+ class SetupHandler(BaseHTTPRequestHandler):
361
+ def do_GET(self):
362
+ if self.path in ("/", "/setup"):
363
+ self.send_response(200)
364
+ self.send_header("Content-Type", "text/html")
365
+ self.end_headers()
366
+ self.wfile.write(SETUP_HTML.encode())
367
+ else:
368
+ self.send_response(404)
369
+ self.end_headers()
370
+
371
+ def do_POST(self):
372
+ content_len = int(self.headers.get("Content-Length", 0))
373
+ body = json.loads(self.rfile.read(content_len)) if content_len else {}
374
+
375
+ result = {}
376
+ if self.path == "/api/status":
377
+ result = get_setup_status()
378
+ elif self.path == "/api/save_hf_token":
379
+ token = body.get("token", "").strip()
380
+ if not token:
381
+ result = {"success": False, "error": "Token is empty"}
382
+ else:
383
+ verify = _verify_hf_token(token)
384
+ if verify["success"]:
385
+ _save_key("hf_token", token)
386
+ os.environ["HF_TOKEN"] = token
387
+ result = verify
388
+ else:
389
+ result = verify
390
+ elif self.path == "/api/save_el_key":
391
+ key = body.get("key", "").strip()
392
+ if not key:
393
+ result = {"success": False, "error": "Key is empty"}
394
+ else:
395
+ verify = _verify_el_key(key)
396
+ if verify["success"]:
397
+ _save_key("elevenlabs_key", key)
398
+ os.environ["ELEVENLABS_API_KEY"] = key
399
+ result = verify
400
+ else:
401
+ result = verify
402
+ elif self.path == "/api/test_voice":
403
+ result = _test_voice()
404
+ elif self.path == "/api/check_deps":
405
+ result = _check_dependencies()
406
+ else:
407
+ self.send_response(404)
408
+ self.end_headers()
409
+ return
410
+
411
+ self.send_response(200)
412
+ self.send_header("Content-Type", "application/json")
413
+ self.end_headers()
414
+ self.wfile.write(json.dumps(result).encode())
415
+
416
+ def log_message(self, format, *args):
417
+ logger.debug(f"Setup: {args[0]}")
418
+
419
+
420
+ class SetupServer:
421
+ def __init__(self, port: int = 8889):
422
+ self.port = port
423
+ self._server: Optional[HTTPServer] = None
424
+ self._thread: Optional[threading.Thread] = None
425
+
426
+ def start(self):
427
+ self._server = HTTPServer(("0.0.0.0", self.port), SetupHandler)
428
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
429
+ self._thread.start()
430
+ logger.info(f"Setup wizard at http://localhost:{self.port}/setup")
431
+
432
+ def stop(self):
433
+ if self._server:
434
+ self._server.shutdown()