jrubiosainz commited on
Commit
9e29ba2
·
verified ·
1 Parent(s): 9d7cbf2

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ 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
+ caelum_spotify_manager.mp4 filter=lfs diff=lfs merge=lfs -text
37
+ reachy_spotify.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .venv/
README.md CHANGED
@@ -1,10 +1,82 @@
1
  ---
2
  title: Spotify Manager
3
- emoji: 📚
4
- colorFrom: yellow
5
- colorTo: pink
6
  sdk: static
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Spotify Manager
3
+ emoji: 🎵
4
+ colorFrom: green
5
+ colorTo: gray
6
  sdk: static
7
  pinned: false
8
+ tags:
9
+ - reachy_mini
10
  ---
11
 
12
+ # Spotify Manager
13
+
14
+ 🎵 Your robot DJ — control Spotify with hand gestures, voice commands, and full robot personality feedback.
15
+
16
+ ## Features
17
+
18
+ - **🖐️ Gesture Control** — Wave to skip, thumbs up to like, open palm to play/pause, point to navigate, fist for volume down
19
+ - **🎤 Voice Commands** — "Pon Arctic Monkeys", "siguiente", "pause", "sube volumen" (Spanish + English)
20
+ - **🤖 Robot Feedback** — Head flicks, antenna spreads, nods, and tilts react to every action
21
+ - **🔄 Now Playing Detection** — Robot notices track changes with a curious tilt
22
+
23
+ ## Gestures
24
+
25
+ | Gesture | Action |
26
+ |---------|--------|
27
+ | 🖐️ Open Palm | Play / Pause toggle |
28
+ | 👆 Point Right | Next track |
29
+ | 👈 Point Left | Previous track |
30
+ | 👍 Thumbs Up | Like current track |
31
+ | ✊ Fist | Volume down |
32
+ | 👋 Wave | Skip track |
33
+
34
+ ## Requirements
35
+
36
+ - [spogo](https://github.com/nicholasgasior/spogo) CLI installed and authenticated
37
+ - Camera for gesture detection
38
+ - Microphone for voice commands (optional)
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ cd spotify_manager
44
+ pip install -e .
45
+ ```
46
+
47
+ ## Test locally
48
+
49
+ ```bash
50
+ # Check spogo works
51
+ spogo status --json
52
+
53
+ # Run the app in simulator
54
+ python -m spotify_manager.demo
55
+ ```
56
+
57
+ ## Voice Commands (ES/EN)
58
+
59
+ - "siguiente" / "next" / "skip"
60
+ - "anterior" / "prev"
61
+ - "pausa" / "pause"
62
+ - "play" / "reproduce" / "dale"
63
+ - "sube volumen" / "baja volumen"
64
+ - "me gusta" / "like"
65
+ - "pon [artista/canción]" / "play [query]"
66
+ - "qué suena" / "what's playing"
67
+
68
+ ## Architecture
69
+
70
+ ```
71
+ spotify_manager/
72
+ ├── main.py # ReachyMiniApp entry point
73
+ ├── spotify_control.py # spogo CLI wrapper
74
+ ├── gesture_controller.py # MediaPipe hand gesture → command
75
+ ├── voice_controller.py # Whisper STT → intent parser
76
+ ├── robot_feedback.py # Physical robot reactions
77
+ └── demo.py # MuJoCo simulator demo + video recording
78
+ ```
79
+
80
+ ## HuggingFace Space
81
+
82
+ Will be deployed to `jrubiosainz/spotify_manager` once spogo integration is validated.
caelum_spotify_manager.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:08efa1cf73ddfbfb8368fbaf677a9a368d486882e36f9c7b3380cb786d50d219
3
+ size 689202
index.html CHANGED
@@ -1,19 +1,116 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
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>Spotify Manager</title>
7
+ <link rel="stylesheet" href="style.css"/>
8
+ </head>
9
+ <body>
10
+ <nav>
11
+ <a href="#" class="nav-brand"><span class="dot"></span> jrubiosainz</a>
12
+ <div class="nav-links">
13
+ <a href="#features">Features</a>
14
+ <a href="#gestures">Gestures</a>
15
+ <a href="#install">Install</a>
16
+ <a href="https://huggingface.co/reachy-mini#/apps">App Store</a>
17
+ </div>
18
+ </nav>
19
+
20
+ <section class="hero">
21
+ <div class="badge">Reachy Mini App</div>
22
+ <h1>🎵 Spotify Manager</h1>
23
+ <p class="tagline">Your robot DJ — control Spotify with gestures, voice, and personality.</p>
24
+ </section>
25
+
26
+ <div class="video-showcase">
27
+ <div class="video-frame">
28
+ <video autoplay loop muted playsinline>
29
+ <source src="demo.mp4" type="video/mp4">
30
+ </video>
31
+ </div>
32
+ <p class="video-label">MuJoCo simulator — gesture control, voice commands, robot feedback</p>
33
+ </div>
34
+
35
+ <section class="features" id="features">
36
+ <h2>Features</h2>
37
+ <p class="subtitle">Three ways to control your music</p>
38
+ <div class="feature-grid">
39
+ <div class="feature-card">
40
+ <div class="icon">🖐️</div>
41
+ <h3>Gesture Control</h3>
42
+ <p>6 hand gestures via MediaPipe — wave to skip, thumbs up to like, open palm to play/pause.</p>
43
+ </div>
44
+ <div class="feature-card">
45
+ <div class="icon">🎤</div>
46
+ <h3>Voice Commands</h3>
47
+ <p>Say "pon Arctic Monkeys" or "next" — Whisper STT understands Spanish and English naturally.</p>
48
+ </div>
49
+ <div class="feature-card">
50
+ <div class="icon">🤖</div>
51
+ <h3>Robot Personality</h3>
52
+ <p>Every action triggers unique physical feedback — head flicks, antenna spreads, curious tilts, and beat pulses.</p>
53
+ </div>
54
+ </div>
55
+ </section>
56
+
57
+ <section class="gestures" id="gestures">
58
+ <h2>Gesture Map</h2>
59
+ <p class="subtitle">Your hands are the remote</p>
60
+ <div class="feature-grid">
61
+ <div class="feature-card">
62
+ <div class="icon">🖐️</div>
63
+ <h3>Open Palm</h3>
64
+ <p>Play / Pause toggle</p>
65
+ </div>
66
+ <div class="feature-card">
67
+ <div class="icon">👆</div>
68
+ <h3>Point Right</h3>
69
+ <p>Next track</p>
70
+ </div>
71
+ <div class="feature-card">
72
+ <div class="icon">👈</div>
73
+ <h3>Point Left</h3>
74
+ <p>Previous track</p>
75
+ </div>
76
+ <div class="feature-card">
77
+ <div class="icon">👍</div>
78
+ <h3>Thumbs Up</h3>
79
+ <p>Like current track</p>
80
+ </div>
81
+ <div class="feature-card">
82
+ <div class="icon">✊</div>
83
+ <h3>Fist</h3>
84
+ <p>Volume down</p>
85
+ </div>
86
+ <div class="feature-card">
87
+ <div class="icon">👋</div>
88
+ <h3>Wave</h3>
89
+ <p>Skip track</p>
90
+ </div>
91
+ </div>
92
+ </section>
93
+
94
+ <section class="install" id="install">
95
+ <h2>Get Started</h2>
96
+ <div class="steps">
97
+ <div class="step">
98
+ <div class="step-num">1</div>
99
+ <div><h4>Install spogo</h4><p>The CLI handles Spotify auth via browser cookies — no OAuth dance needed.</p></div>
100
+ </div>
101
+ <div class="step">
102
+ <div class="step-num">2</div>
103
+ <div><h4>Install App</h4><p>From the Reachy Mini dashboard, search "Spotify Manager" and install.</p></div>
104
+ </div>
105
+ <div class="step">
106
+ <div class="step-num">3</div>
107
+ <div><h4>Play Music</h4><p>Wave your hand or say "play" — your robot takes over as DJ.</p></div>
108
+ </div>
109
+ </div>
110
+ </section>
111
+
112
+ <footer>
113
+ 🎵 Spotify Manager · Built by <a href="https://huggingface.co/jrubiosainz">jrubiosainz</a> · <a href="https://huggingface.co/reachy-mini#/apps">Browse More Apps</a>
114
+ </footer>
115
+ </body>
116
  </html>
pyproject.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "spotify_manager"
7
+ version = "0.1.0"
8
+ description = "Your robot DJ — control Spotify with gestures, voice, and personality"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "reachy-mini",
13
+ "mediapipe",
14
+ "opencv-python",
15
+ "numpy",
16
+ "openai-whisper",
17
+ ]
18
+ keywords = ["reachy-mini-app"]
19
+
20
+ [project.entry-points."reachy_mini_apps"]
21
+ spotify_manager = "spotify_manager.main:SpotifyManager"
22
+
23
+ [tool.setuptools]
24
+ package-dir = { "" = "." }
25
+ include-package-data = true
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["."]
29
+
30
+ [tool.setuptools.package-data]
31
+ spotify_manager = ["**/*"]
reachy_spotify.png ADDED

Git LFS Details

  • SHA256: a425960fcc1ffc49ab685b8ebfa768018bf496d181121036f8cbdbe7dd967a76
  • Pointer size: 131 Bytes
  • Size of remote file: 565 kB
spotify_manager/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Spotify Manager — Your robot DJ that controls Spotify with gestures, voice, and personality."""
2
+
3
+ __version__ = "0.1.0"
spotify_manager/demo.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spotify Manager — MuJoCo simulator demo with video recording.
2
+
3
+ Usage:
4
+ GST_PLUGIN_SCANNER="" python3.13 -m spotify_manager.demo
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import subprocess
11
+ import time
12
+ from pathlib import Path
13
+
14
+ import mujoco
15
+ import numpy as np
16
+
17
+ SCENE_XML = Path(__file__).parents[1] / ".." / "reachy_mini" / "src" / "reachy_mini" / "descriptions" / "reachy_mini" / "mjcf" / "scenes" / "minimal.xml"
18
+ OUTPUT_MP4 = Path(__file__).parents[1] / "caelum_spotify_manager.mp4"
19
+ WIDTH, HEIGHT = 800, 600
20
+ FPS = 30
21
+
22
+ # Joint indices (from reachy_mini MJCF) — adjust if needed
23
+ PITCH = 0 # head pitch
24
+ ROLL = 1 # head roll
25
+ YAW = 2 # head yaw
26
+ L_ANTENNA = 3
27
+ R_ANTENNA = 4
28
+
29
+
30
+ def _smooth(data, target: dict, duration_s: float, frames: list, model, renderer, cam, stp):
31
+ """Smoothly interpolate controls to target over duration."""
32
+ n_frames = max(1, int(duration_s * FPS))
33
+ start_ctrl = data.ctrl.copy()
34
+ target_ctrl = start_ctrl.copy()
35
+ for k, v in target.items():
36
+ target_ctrl[k] = v
37
+
38
+ for f in range(n_frames):
39
+ t = (f + 1) / n_frames
40
+ # Smooth step
41
+ t = t * t * (3 - 2 * t)
42
+ data.ctrl[:] = start_ctrl + (target_ctrl - start_ctrl) * t
43
+ for _ in range(stp):
44
+ mujoco.mj_step(model, data)
45
+ renderer.update_scene(data, cam)
46
+ frames.append(renderer.render().copy())
47
+
48
+
49
+ def _hold(data, duration_s: float, frames: list, model, renderer, cam, stp):
50
+ """Hold current pose."""
51
+ n = max(1, int(duration_s * FPS))
52
+ for _ in range(n):
53
+ for _ in range(stp):
54
+ mujoco.mj_step(model, data)
55
+ renderer.update_scene(data, cam)
56
+ frames.append(renderer.render().copy())
57
+
58
+
59
+ def run_demo():
60
+ """Generate a demo video showcasing Spotify Manager robot reactions."""
61
+ scene_path = SCENE_XML.resolve()
62
+ if not scene_path.exists():
63
+ # Try alternate path
64
+ scene_path = Path(__file__).resolve().parents[2] / "reachy_mini" / "src" / "reachy_mini" / "descriptions" / "reachy_mini" / "mjcf" / "scenes" / "minimal.xml"
65
+
66
+ print(f"Loading scene: {scene_path}")
67
+ model = mujoco.MjModel.from_xml_path(str(scene_path))
68
+ model.vis.global_.offwidth = WIDTH
69
+ model.vis.global_.offheight = HEIGHT
70
+ data = mujoco.MjData(model)
71
+ renderer = mujoco.Renderer(model, HEIGHT, WIDTH)
72
+
73
+ cam = mujoco.MjvCamera()
74
+ cam.type = mujoco.mjtCamera.mjCAMERA_FREE
75
+ cam.distance = 0.48
76
+ cam.azimuth = 175
77
+ cam.elevation = -8
78
+ cam.lookat[:] = [0, 0, 0.14]
79
+
80
+ stp = max(1, int(1.0 / (model.opt.timestep * FPS)))
81
+ frames = []
82
+
83
+ # Helpers
84
+ deg = math.radians
85
+
86
+ def smooth(target, dur=0.3):
87
+ _smooth(data, target, dur, frames, model, renderer, cam, stp)
88
+
89
+ def hold(dur=0.5):
90
+ _hold(data, dur, frames, model, renderer, cam, stp)
91
+
92
+ def reset(dur=0.4):
93
+ smooth({PITCH: 0, ROLL: 0, YAW: 0, L_ANTENNA: 0, R_ANTENNA: 0}, dur)
94
+
95
+ print(f"Recording Spotify Manager demo at {WIDTH}x{HEIGHT} @ {FPS}fps")
96
+ mujoco.mj_resetData(model, data)
97
+
98
+ # --- Start ---
99
+ hold(0.8)
100
+
101
+ # 1. PLAY — antenna spread + head up (energized)
102
+ print("▶️ Play")
103
+ smooth({L_ANTENNA: deg(-30), R_ANTENNA: deg(30), PITCH: deg(-10)}, 0.3)
104
+ hold(0.5)
105
+ reset()
106
+ hold(0.4)
107
+
108
+ # 2. NEXT TRACK — head flick right
109
+ print("⏭️ Next track")
110
+ smooth({YAW: deg(-25)}, 0.2)
111
+ hold(0.15)
112
+ smooth({YAW: 0}, 0.3)
113
+ hold(0.4)
114
+
115
+ # 3. PREVIOUS TRACK — head flick left
116
+ print("⏮️ Previous track")
117
+ smooth({YAW: deg(25)}, 0.2)
118
+ hold(0.15)
119
+ smooth({YAW: 0}, 0.3)
120
+ hold(0.4)
121
+
122
+ # 4. LIKE — happy double nod
123
+ print("❤️ Like")
124
+ smooth({L_ANTENNA: deg(-20), R_ANTENNA: deg(20)}, 0.2)
125
+ for _ in range(2):
126
+ smooth({PITCH: deg(-8)}, 0.15)
127
+ smooth({PITCH: deg(5)}, 0.15)
128
+ smooth({PITCH: 0, L_ANTENNA: 0, R_ANTENNA: 0}, 0.3)
129
+ hold(0.4)
130
+
131
+ # 5. VOLUME UP — tilt right
132
+ print("🔊 Volume up")
133
+ smooth({ROLL: deg(-15)}, 0.2)
134
+ hold(0.2)
135
+ smooth({ROLL: 0}, 0.3)
136
+ hold(0.3)
137
+
138
+ # 6. VOLUME DOWN — tilt left
139
+ print("🔉 Volume down")
140
+ smooth({ROLL: deg(15)}, 0.2)
141
+ hold(0.2)
142
+ smooth({ROLL: 0}, 0.3)
143
+ hold(0.3)
144
+
145
+ # 7. NEW SONG — curious tilt
146
+ print("🎵 New song detected")
147
+ smooth({PITCH: deg(-5), ROLL: deg(12), L_ANTENNA: deg(-15), R_ANTENNA: deg(15)}, 0.4)
148
+ hold(0.6)
149
+ reset()
150
+ hold(0.4)
151
+
152
+ # 8. SEARCH — look around
153
+ print("🔍 Searching...")
154
+ smooth({YAW: deg(-15), PITCH: deg(-5)}, 0.3)
155
+ hold(0.2)
156
+ smooth({YAW: deg(15), PITCH: deg(-5)}, 0.4)
157
+ hold(0.2)
158
+ smooth({YAW: 0, PITCH: 0}, 0.3)
159
+ hold(0.4)
160
+
161
+ # 9. PAUSE — head down + antenna droop
162
+ print("⏸️ Pause")
163
+ smooth({PITCH: deg(15), L_ANTENNA: deg(10), R_ANTENNA: deg(-10)}, 0.4)
164
+ hold(0.8)
165
+ reset()
166
+ hold(0.4)
167
+
168
+ # 10. IDLE BEAT PULSE — subtle bouncing
169
+ print("💫 Idle beat pulse")
170
+ for i in range(4):
171
+ amp = deg(2 + i)
172
+ smooth({PITCH: -amp}, 0.12)
173
+ smooth({PITCH: 0}, 0.12)
174
+ hold(0.5)
175
+
176
+ # Final hold
177
+ hold(1.0)
178
+
179
+ # --- Encode ---
180
+ total_s = len(frames) / FPS
181
+ print(f"\nEncoding {len(frames)} frames ({total_s:.1f}s)...")
182
+ out = str(OUTPUT_MP4.resolve())
183
+ proc = subprocess.Popen([
184
+ 'ffmpeg', '-y', '-f', 'rawvideo', '-vcodec', 'rawvideo',
185
+ '-s', f'{WIDTH}x{HEIGHT}', '-pix_fmt', 'rgb24', '-r', str(FPS),
186
+ '-i', '-', '-c:v', 'libx264', '-pix_fmt', 'yuv420p',
187
+ '-preset', 'fast', '-crf', '18', out
188
+ ], stdin=subprocess.PIPE, stderr=subprocess.PIPE)
189
+ for frame in frames:
190
+ proc.stdin.write(frame.tobytes())
191
+ proc.stdin.close()
192
+ proc.wait()
193
+ stderr = proc.stderr.read()
194
+
195
+ if proc.returncode == 0:
196
+ size_mb = Path(out).stat().st_size / 1024 / 1024
197
+ print(f"✅ Video saved: {out} ({size_mb:.1f} MB)")
198
+ else:
199
+ print(f"❌ ffmpeg error:\n{stderr.decode()[-500:]}")
200
+
201
+
202
+ if __name__ == "__main__":
203
+ run_demo()
spotify_manager/gesture_controller.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gesture detection → Spotify command mapping via MediaPipe Hands."""
2
+
3
+ import enum
4
+ import time
5
+ from typing import Optional, Callable
6
+
7
+ try:
8
+ import mediapipe as mp
9
+ import cv2
10
+ import numpy as np
11
+ except ImportError:
12
+ mp = None
13
+
14
+
15
+ class SpotifyGesture(str, enum.Enum):
16
+ NONE = "none"
17
+ WAVE = "wave" # Skip track (next)
18
+ THUMBS_UP = "thumbs_up" # Like current track
19
+ POINT_RIGHT = "point_right" # Next track
20
+ POINT_LEFT = "point_left" # Previous track
21
+ OPEN_PALM = "open_palm" # Play/pause toggle
22
+ FIST = "fist" # Volume down
23
+
24
+
25
+ # Landmark indices
26
+ WRIST = 0
27
+ THUMB_TIP, THUMB_MCP = 4, 2
28
+ INDEX_MCP, INDEX_PIP, INDEX_TIP = 5, 6, 8
29
+ MIDDLE_MCP, MIDDLE_PIP, MIDDLE_TIP = 9, 10, 12
30
+ RING_MCP, RING_PIP, RING_TIP = 13, 14, 16
31
+ PINKY_MCP, PINKY_PIP, PINKY_TIP = 17, 18, 20
32
+
33
+
34
+ class GestureDetector:
35
+ """Detects hand gestures and maps them to Spotify commands."""
36
+
37
+ COOLDOWN_S = 1.5 # Prevent rapid-fire commands
38
+
39
+ def __init__(self):
40
+ if mp is None:
41
+ raise RuntimeError("mediapipe not installed")
42
+ self._hands = mp.solutions.hands.Hands(
43
+ static_image_mode=False,
44
+ max_num_hands=1,
45
+ min_detection_confidence=0.7,
46
+ min_tracking_confidence=0.6,
47
+ )
48
+ self._last_gesture = SpotifyGesture.NONE
49
+ self._last_trigger_time: float = 0
50
+ self._stability_count = 0
51
+ self._stability_threshold = 3 # Frames before triggering
52
+
53
+ def _is_extended(self, lms, tip, pip) -> bool:
54
+ return lms[tip].y < lms[pip].y
55
+
56
+ def _thumb_extended(self, lms) -> bool:
57
+ return abs(lms[THUMB_TIP].x - lms[INDEX_MCP].x) > abs(lms[THUMB_MCP].x - lms[INDEX_MCP].x) * 1.2
58
+
59
+ def _classify(self, lms) -> SpotifyGesture:
60
+ if len(lms) < 21:
61
+ return SpotifyGesture.NONE
62
+
63
+ thumb = self._thumb_extended(lms)
64
+ index = self._is_extended(lms, INDEX_TIP, INDEX_PIP)
65
+ middle = self._is_extended(lms, MIDDLE_TIP, MIDDLE_PIP)
66
+ ring = self._is_extended(lms, RING_TIP, RING_PIP)
67
+ pinky = self._is_extended(lms, PINKY_TIP, PINKY_PIP)
68
+
69
+ ext = sum([thumb, index, middle, ring, pinky])
70
+
71
+ if ext == 0:
72
+ return SpotifyGesture.FIST
73
+ elif thumb and not index and not middle and not ring and not pinky:
74
+ return SpotifyGesture.THUMBS_UP
75
+ elif index and not middle and not ring and not pinky:
76
+ # Determine point direction
77
+ if lms[INDEX_TIP].x > lms[WRIST].x + 0.1:
78
+ return SpotifyGesture.POINT_RIGHT
79
+ elif lms[INDEX_TIP].x < lms[WRIST].x - 0.1:
80
+ return SpotifyGesture.POINT_LEFT
81
+ return SpotifyGesture.POINT_RIGHT
82
+ elif ext >= 4:
83
+ # Check for wave (hand moving) vs open palm (stationary)
84
+ return SpotifyGesture.OPEN_PALM
85
+
86
+ return SpotifyGesture.NONE
87
+
88
+ def process_frame(self, frame: np.ndarray) -> Optional[SpotifyGesture]:
89
+ """Process a BGR frame. Returns gesture if newly triggered, None otherwise."""
90
+ rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
91
+ results = self._hands.process(rgb)
92
+
93
+ if not results.multi_hand_landmarks:
94
+ self._stability_count = 0
95
+ self._last_gesture = SpotifyGesture.NONE
96
+ return None
97
+
98
+ lms = results.multi_hand_landmarks[0].landmark
99
+ gesture = self._classify(lms)
100
+
101
+ if gesture == SpotifyGesture.NONE:
102
+ self._stability_count = 0
103
+ self._last_gesture = SpotifyGesture.NONE
104
+ return None
105
+
106
+ if gesture == self._last_gesture:
107
+ self._stability_count += 1
108
+ else:
109
+ self._stability_count = 1
110
+ self._last_gesture = gesture
111
+
112
+ now = time.time()
113
+ if self._stability_count >= self._stability_threshold and (now - self._last_trigger_time) > self.COOLDOWN_S:
114
+ self._last_trigger_time = now
115
+ self._stability_count = 0
116
+ return gesture
117
+
118
+ return None
119
+
120
+ def close(self):
121
+ self._hands.close()
spotify_manager/main.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spotify Manager — Main app entry point for Reachy Mini."""
2
+
3
+ import logging
4
+ import threading
5
+ import time
6
+ from typing import Optional
7
+
8
+ from reachy_mini import ReachyMini, ReachyMiniApp
9
+
10
+ from .spotify_control import SpogoController, TrackInfo
11
+ from .gesture_controller import GestureDetector, SpotifyGesture
12
+ from .voice_controller import VoiceController, VoiceCommand
13
+ from .robot_feedback import RobotFeedback
14
+ from .setup_wizard import is_spogo_installed, is_authenticated
15
+ from .setup_server import SetupServer
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class SpotifyManager(ReachyMiniApp):
21
+ """Your robot DJ — control Spotify with gestures, voice, and personality."""
22
+
23
+ name = "spotify_manager"
24
+ description = "Control Spotify with gestures, voice commands, and robot personality"
25
+
26
+ def __init__(self):
27
+ super().__init__()
28
+ self._spotify: Optional[SpogoController] = None
29
+ self._gesture: Optional[GestureDetector] = None
30
+ self._voice: Optional[VoiceController] = None
31
+ self._feedback: Optional[RobotFeedback] = None
32
+ self._setup_server: Optional[SetupServer] = None
33
+ self._running = False
34
+ self._current_track: Optional[TrackInfo] = None
35
+ self._poll_thread: Optional[threading.Thread] = None
36
+
37
+ def setup(self, robot: ReachyMini):
38
+ """Initialize all controllers."""
39
+ logger.info("🎵 Spotify Manager starting up...")
40
+
41
+ # Start setup wizard server (always available for re-auth)
42
+ self._setup_server = SetupServer(port=8888)
43
+ self._setup_server.start()
44
+
45
+ # Check if spogo is installed and authenticated
46
+ if not is_spogo_installed() or not is_authenticated():
47
+ logger.warning(
48
+ "🔧 Setup required! Open http://ROBOT_IP:8888/setup to connect Spotify"
49
+ )
50
+ # Still start the app — it will work once auth is done
51
+ self._feedback = RobotFeedback(robot)
52
+ self._feedback.on_search() # Curious look while waiting
53
+ self._running = True
54
+ self._poll_thread = threading.Thread(target=self._wait_for_auth, daemon=True)
55
+ self._poll_thread.start()
56
+ return
57
+
58
+ self._init_controllers(robot)
59
+
60
+ def _init_controllers(self, robot: ReachyMini):
61
+ """Initialize all controllers after auth is confirmed."""
62
+ self._spotify = SpogoController()
63
+ self._gesture = GestureDetector()
64
+ self._feedback = RobotFeedback(robot)
65
+
66
+ try:
67
+ self._voice = VoiceController(model_size="base")
68
+ logger.info("🎤 Voice control enabled")
69
+ except Exception as e:
70
+ logger.warning(f"Voice control unavailable: {e}")
71
+ self._voice = None
72
+
73
+ self._running = True
74
+ self._poll_thread = threading.Thread(target=self._poll_now_playing, daemon=True)
75
+ self._poll_thread.start()
76
+
77
+ # Initial greeting
78
+ self._feedback.on_play()
79
+ logger.info("🎵 Spotify Manager ready!")
80
+
81
+ def _wait_for_auth(self):
82
+ """Poll until spogo is installed and authenticated, then init."""
83
+ while self._running:
84
+ if is_spogo_installed() and is_authenticated():
85
+ logger.info("✅ Auth detected! Initializing controllers...")
86
+ self._init_controllers(self._feedback._robot)
87
+ return
88
+ time.sleep(5)
89
+
90
+ def on_frame(self, frame):
91
+ """Process camera frame for gesture detection."""
92
+ if not self._running or self._gesture is None:
93
+ return
94
+
95
+ gesture = self._gesture.process_frame(frame)
96
+ if gesture:
97
+ self._handle_gesture(gesture)
98
+
99
+ # Check for voice commands
100
+ if self._voice:
101
+ cmd = self._voice.get_pending_command()
102
+ if cmd:
103
+ self._handle_voice_command(cmd)
104
+
105
+ def _handle_gesture(self, gesture: SpotifyGesture):
106
+ """Map gesture to Spotify action + robot feedback."""
107
+ logger.info(f"🖐️ Gesture: {gesture.value}")
108
+
109
+ try:
110
+ if gesture == SpotifyGesture.WAVE or gesture == SpotifyGesture.POINT_RIGHT:
111
+ self._spotify.next_track()
112
+ self._feedback.on_next_track()
113
+ elif gesture == SpotifyGesture.POINT_LEFT:
114
+ self._spotify.prev_track()
115
+ self._feedback.on_prev_track()
116
+ elif gesture == SpotifyGesture.THUMBS_UP:
117
+ self._spotify.like()
118
+ self._feedback.on_like()
119
+ elif gesture == SpotifyGesture.OPEN_PALM:
120
+ status = self._spotify.status()
121
+ if status.is_playing:
122
+ self._spotify.pause()
123
+ self._feedback.on_pause()
124
+ else:
125
+ self._spotify.resume()
126
+ self._feedback.on_play()
127
+ elif gesture == SpotifyGesture.FIST:
128
+ self._spotify.volume(max(0, self._get_current_volume() - 10))
129
+ self._feedback.on_volume_change(-1)
130
+ except Exception as e:
131
+ logger.error(f"Gesture action failed: {e}")
132
+
133
+ def _handle_voice_command(self, cmd: VoiceCommand):
134
+ """Execute voice command."""
135
+ logger.info(f"🎤 Voice: {cmd.intent} (query={cmd.query})")
136
+
137
+ try:
138
+ if cmd.intent == "next":
139
+ self._spotify.next_track()
140
+ self._feedback.on_next_track()
141
+ elif cmd.intent == "prev":
142
+ self._spotify.prev_track()
143
+ self._feedback.on_prev_track()
144
+ elif cmd.intent == "pause":
145
+ self._spotify.pause()
146
+ self._feedback.on_pause()
147
+ elif cmd.intent == "play":
148
+ self._spotify.resume()
149
+ self._feedback.on_play()
150
+ elif cmd.intent == "like":
151
+ self._spotify.like()
152
+ self._feedback.on_like()
153
+ elif cmd.intent == "volume_up":
154
+ self._spotify.volume(min(100, self._get_current_volume() + 15))
155
+ self._feedback.on_volume_change(1)
156
+ elif cmd.intent == "volume_down":
157
+ self._spotify.volume(max(0, self._get_current_volume() - 15))
158
+ self._feedback.on_volume_change(-1)
159
+ elif cmd.intent == "search" and cmd.query:
160
+ self._feedback.on_search()
161
+ results = self._spotify.search(cmd.query)
162
+ if results:
163
+ self._spotify.play(uri=results[0].get("uri"))
164
+ self._feedback.on_play()
165
+ elif cmd.intent == "status":
166
+ self._feedback.on_new_song() # Acknowledge
167
+ except Exception as e:
168
+ logger.error(f"Voice command failed: {e}")
169
+
170
+ def _poll_now_playing(self):
171
+ """Background thread to detect track changes."""
172
+ while self._running:
173
+ try:
174
+ status = self._spotify.status()
175
+ if self._current_track and status.uri != self._current_track.uri and status.uri:
176
+ logger.info(f"🎵 Now playing: {status.name} — {status.artist}")
177
+ self._feedback.on_new_song()
178
+ self._current_track = status
179
+ except Exception:
180
+ pass
181
+ time.sleep(3)
182
+
183
+ def _get_current_volume(self) -> int:
184
+ """Get approximate current volume (defaults to 50 if unknown)."""
185
+ return 50 # spogo doesn't expose volume level in status easily
186
+
187
+ def on_audio(self, audio_data):
188
+ """Handle incoming audio for voice commands."""
189
+ if self._voice:
190
+ self._voice.submit_audio(audio_data)
191
+
192
+ def teardown(self):
193
+ """Clean shutdown."""
194
+ self._running = False
195
+ if self._setup_server:
196
+ self._setup_server.stop()
197
+ if self._gesture:
198
+ self._gesture.close()
199
+ if self._voice:
200
+ self._voice.close()
201
+ if self._feedback:
202
+ self._feedback.reset()
203
+ logger.info("🎵 Spotify Manager stopped")
spotify_manager/robot_feedback.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Robot physical feedback for Spotify actions — head moves, antenna, expressions."""
2
+
3
+ import math
4
+ import time
5
+ from typing import Optional
6
+
7
+ from reachy_mini import ReachyMini
8
+ from reachy_mini.utils import create_head_pose
9
+
10
+
11
+ class RobotFeedback:
12
+ """Maps Spotify events to robot physical reactions."""
13
+
14
+ def __init__(self, robot: ReachyMini):
15
+ self._robot = robot
16
+ self._base_pitch = 0.0
17
+ self._base_yaw = 0.0
18
+ self._base_roll = 0.0
19
+
20
+ def _pose(self, pitch: float = 0, yaw: float = 0, roll: float = 0, duration: float = 0.3):
21
+ """Set head pose with smooth transition."""
22
+ pose = create_head_pose(
23
+ pitch=math.radians(pitch),
24
+ yaw=math.radians(yaw),
25
+ roll=math.radians(roll),
26
+ )
27
+ self._robot.send_head_pose(pose, duration=duration)
28
+
29
+ def _antenna(self, left: float, right: float, duration: float = 0.3):
30
+ """Set antenna angles (degrees)."""
31
+ self._robot.send_antenna_pose(
32
+ left=math.radians(left),
33
+ right=math.radians(right),
34
+ duration=duration,
35
+ )
36
+
37
+ # --- Action reactions ---
38
+
39
+ def on_next_track(self):
40
+ """Head flick right — skip gesture."""
41
+ self._pose(yaw=-25, duration=0.2)
42
+ time.sleep(0.25)
43
+ self._pose(yaw=0, duration=0.3)
44
+
45
+ def on_prev_track(self):
46
+ """Head flick left — going back."""
47
+ self._pose(yaw=25, duration=0.2)
48
+ time.sleep(0.25)
49
+ self._pose(yaw=0, duration=0.3)
50
+
51
+ def on_play(self):
52
+ """Antenna spread + head up — energized."""
53
+ self._antenna(left=-30, right=30, duration=0.3)
54
+ self._pose(pitch=-10, duration=0.3)
55
+ time.sleep(0.5)
56
+ self._antenna(left=0, right=0, duration=0.4)
57
+ self._pose(pitch=0, duration=0.4)
58
+
59
+ def on_pause(self):
60
+ """Head down + antenna droop — resting."""
61
+ self._pose(pitch=15, duration=0.4)
62
+ self._antenna(left=10, right=-10, duration=0.4)
63
+ time.sleep(0.3)
64
+
65
+ def on_like(self):
66
+ """Happy nod — liked the track."""
67
+ self._antenna(left=-20, right=20, duration=0.2)
68
+ for _ in range(2):
69
+ self._pose(pitch=-8, duration=0.15)
70
+ time.sleep(0.15)
71
+ self._pose(pitch=5, duration=0.15)
72
+ time.sleep(0.15)
73
+ self._pose(pitch=0, duration=0.3)
74
+ self._antenna(left=0, right=0, duration=0.3)
75
+
76
+ def on_volume_change(self, direction: int):
77
+ """Tilt in volume direction."""
78
+ roll = -15 * direction # +1 up, -1 down
79
+ self._pose(roll=roll, duration=0.2)
80
+ time.sleep(0.3)
81
+ self._pose(roll=0, duration=0.3)
82
+
83
+ def on_new_song(self):
84
+ """Curious tilt — new song detected."""
85
+ self._pose(pitch=-5, roll=12, duration=0.4)
86
+ self._antenna(left=-15, right=15, duration=0.3)
87
+ time.sleep(0.6)
88
+ self._pose(pitch=0, roll=0, duration=0.4)
89
+ self._antenna(left=0, right=0, duration=0.4)
90
+
91
+ def on_search(self):
92
+ """Look around — searching."""
93
+ self._pose(yaw=-15, pitch=-5, duration=0.3)
94
+ time.sleep(0.3)
95
+ self._pose(yaw=15, pitch=-5, duration=0.4)
96
+ time.sleep(0.3)
97
+ self._pose(yaw=0, pitch=0, duration=0.3)
98
+
99
+ def idle_beat_pulse(self, intensity: float = 0.5):
100
+ """Subtle pulse to beat — lightweight idle animation."""
101
+ amp = 3 * intensity
102
+ self._pose(pitch=-amp, duration=0.15)
103
+ time.sleep(0.15)
104
+ self._pose(pitch=0, duration=0.15)
105
+
106
+ def reset(self):
107
+ """Return to neutral."""
108
+ self._pose(0, 0, 0, duration=0.5)
109
+ self._antenna(0, 0, duration=0.5)
spotify_manager/setup_server.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Setup web server — dashboard UI for spogo install + auth."""
2
+
3
+ import json
4
+ import logging
5
+ import threading
6
+ from pathlib import Path
7
+ from http.server import HTTPServer, BaseHTTPRequestHandler
8
+ from typing import Optional
9
+
10
+ from . import setup_wizard
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ SETUP_HTML = """<!DOCTYPE html>
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="utf-8">
18
+ <meta name="viewport" content="width=device-width, initial-scale=1">
19
+ <title>Spotify Manager — Setup</title>
20
+ <style>
21
+ * { box-sizing: border-box; margin: 0; padding: 0; }
22
+ body { font-family: 'Inter', -apple-system, sans-serif; background: #0d0d0d; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
23
+ .container { max-width: 900px; width: 100%; padding: 2rem; }
24
+ .main-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; }
25
+ .steps-col { display: flex; flex-direction: column; gap: 1rem; }
26
+ @media (max-width: 700px) { .main-layout { grid-template-columns: 1fr; } }
27
+ h1 { font-size: 1.8rem; margin-bottom: 0.5rem; color: #1DB954; }
28
+ .subtitle { color: #888; margin-bottom: 2rem; }
29
+ .step { background: #1a1a1a; border-radius: 12px; padding: 1.5rem; margin-bottom: 1rem; border: 1px solid #333; }
30
+ .step.done { border-color: #1DB954; }
31
+ .step.active { border-color: #7c5cfc; }
32
+ .step-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
33
+ .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: #333; }
34
+ .done .step-num { background: #1DB954; color: #000; }
35
+ .active .step-num { background: #7c5cfc; color: #fff; }
36
+ .step-title { font-weight: 600; }
37
+ .step-body { margin-left: 2.5rem; color: #aaa; font-size: 0.9rem; }
38
+ button { background: #1DB954; 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; }
39
+ button:hover { background: #1ed760; }
40
+ button:disabled { background: #333; color: #666; cursor: not-allowed; }
41
+ button.secondary { background: #333; color: #e0e0e0; }
42
+ button.secondary:hover { background: #444; }
43
+ .status { margin-top: 0.5rem; font-size: 0.85rem; padding: 0.5rem; border-radius: 6px; }
44
+ .status.ok { background: #1DB95422; color: #1DB954; }
45
+ .status.err { background: #ff444422; color: #ff6666; }
46
+ .input-row { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
47
+ .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; }
48
+ .ready-banner { background: #1DB95422; border: 1px solid #1DB954; border-radius: 12px; padding: 2rem; text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px; }
49
+ .ready-banner h2 { color: #1DB954; margin-bottom: 0.5rem; }
50
+ </style>
51
+ </head>
52
+ <body>
53
+ <div class="container">
54
+ <h1>🎵 Spotify Manager</h1>
55
+ <p class="subtitle">Setup your Reachy Mini as a Spotify controller</p>
56
+
57
+ <div class="main-layout">
58
+ <div class="steps-col">
59
+
60
+ <div class="step" id="step1">
61
+ <div class="step-header"><div class="step-num">1</div><span class="step-title">Install spogo engine</span></div>
62
+ <div class="step-body">
63
+ <p>Downloads the spogo binary for your platform (~8MB)</p>
64
+ <button id="btn-install" onclick="installSpogo()">Install spogo</button>
65
+ <div class="status" id="status-install" style="display:none"></div>
66
+ </div>
67
+ </div>
68
+
69
+ <div class="step" id="step2">
70
+ <div class="step-header"><div class="step-num">2</div><span class="step-title">Connect your Spotify</span></div>
71
+ <div class="step-body">
72
+ <p style="margin-bottom:0.75rem; line-height:1.5">Spotify Manager connects to your account using your browser's session cookie (<code style="background:#222;padding:2px 5px;border-radius:3px;font-size:0.8rem">sp_dc</code>). This is the same cookie Spotify's web player uses — no API keys, no developer app, no OAuth. Your credentials stay local on this device and are never sent anywhere else.</p>
73
+ <p>Option A: Auto-import from browser (close browser first)</p>
74
+ <button id="btn-import" onclick="importCookies()">Import from Chrome</button>
75
+ <button class="secondary" onclick="importCookies('firefox')">Firefox</button>
76
+ <button class="secondary" onclick="importCookies('safari')">Safari</button>
77
+ <p style="margin-top:1rem">Option B: Paste sp_dc cookie manually</p>
78
+ <div class="input-row">
79
+ <input type="text" id="cookie-input" placeholder="Paste sp_dc value here...">
80
+ <button onclick="pasteCookie()">Save</button>
81
+ </div>
82
+ <div class="status" id="status-auth" style="display:none"></div>
83
+ </div>
84
+ </div>
85
+
86
+ </div>
87
+
88
+ <div class="ready-banner" id="ready-banner" style="display:none">
89
+ <h2 style="font-size:1.6rem">🎵 Connected to Spotify!</h2>
90
+ <p style="font-size:1.1rem; margin-bottom:0.5rem">Your Reachy Mini is now a Spotify controller.</p>
91
+ <p style="color:#aaa">Use gestures, voice, or both to control your music.</p>
92
+ <img src="/reachy_spotify.png" alt="Reachy Mini Spotify" style="width:180px; margin-top:1rem; border-radius:8px;">
93
+ </div>
94
+
95
+ </div>
96
+ </div>
97
+
98
+ <script>
99
+ async function api(endpoint, body) {
100
+ const r = await fetch('/api/' + endpoint, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body || {}) });
101
+ return r.json();
102
+ }
103
+
104
+ function showStatus(id, ok, msg) {
105
+ const el = document.getElementById(id);
106
+ el.style.display = 'block';
107
+ el.className = 'status ' + (ok ? 'ok' : 'err');
108
+ el.textContent = msg;
109
+ }
110
+
111
+ async function checkStatus() {
112
+ const data = await api('status');
113
+ if (data.spogo_installed) {
114
+ markStep1Done();
115
+ }
116
+ if (data.authenticated) {
117
+ document.getElementById('step2').className = 'step done';
118
+ document.getElementById('ready-banner').style.display = 'block';
119
+ } else if (data.spogo_installed) {
120
+ document.getElementById('step2').className = 'step active';
121
+ }
122
+ }
123
+
124
+ async function installSpogo() {
125
+ document.getElementById('btn-install').disabled = true;
126
+ document.getElementById('btn-install').textContent = 'Installing...';
127
+ const r = await api('install');
128
+ if (r.success) {
129
+ showStatus('status-install', true, 'spogo installed at ' + r.path);
130
+ markStep1Done();
131
+ } else {
132
+ showStatus('status-install', false, r.error);
133
+ document.getElementById('btn-install').disabled = false;
134
+ document.getElementById('btn-install').textContent = 'Retry';
135
+ }
136
+ }
137
+
138
+ function markStep1Done() {
139
+ document.getElementById('step1').className = 'step done';
140
+ document.getElementById('btn-install').textContent = '✓ Installed';
141
+ document.getElementById('btn-install').disabled = true;
142
+ document.getElementById('step2').className = 'step active';
143
+ }
144
+
145
+ async function importCookies(browser) {
146
+ const r = await api('import', { browser: browser || 'chrome' });
147
+ if (r.success) {
148
+ const msg = r.message || '';
149
+ const cookieMatch = msg.match(/(\\d+) cookies/);
150
+ const count = cookieMatch ? cookieMatch[1] : '';
151
+ const detail = count ? `Found ${count} session cookies from ${(browser || 'chrome').charAt(0).toUpperCase() + (browser || 'chrome').slice(1)}` : msg;
152
+ showStatus('status-auth', true, detail);
153
+ document.getElementById('step2').className = 'step done';
154
+ document.getElementById('ready-banner').style.display = 'block';
155
+ } else {
156
+ showStatus('status-auth', false, r.error || 'No cookies found. Make sure you are logged in and the browser is closed.');
157
+ }
158
+ }
159
+
160
+ async function pasteCookie() {
161
+ const val = document.getElementById('cookie-input').value.trim();
162
+ if (!val) return;
163
+ const r = await api('paste', { cookie: val });
164
+ if (r.success) {
165
+ showStatus('status-auth', true, '✅ Connected to Spotify!');
166
+ checkStatus();
167
+ } else {
168
+ showStatus('status-auth', false, r.error);
169
+ }
170
+ }
171
+
172
+ checkStatus();
173
+ </script>
174
+ </body>
175
+ </html>"""
176
+
177
+
178
+ class SetupHandler(BaseHTTPRequestHandler):
179
+ def do_GET(self):
180
+ if self.path == "/" or self.path == "/setup":
181
+ self.send_response(200)
182
+ self.send_header("Content-Type", "text/html")
183
+ self.end_headers()
184
+ self.wfile.write(SETUP_HTML.encode())
185
+ elif self.path == "/reachy_spotify.png":
186
+ img_path = Path(__file__).parent.parent / "reachy_spotify.png"
187
+ if img_path.exists():
188
+ self.send_response(200)
189
+ self.send_header("Content-Type", "image/png")
190
+ self.end_headers()
191
+ self.wfile.write(img_path.read_bytes())
192
+ else:
193
+ self.send_response(404)
194
+ self.end_headers()
195
+ else:
196
+ self.send_response(404)
197
+ self.end_headers()
198
+
199
+ def do_POST(self):
200
+ content_len = int(self.headers.get("Content-Length", 0))
201
+ body = json.loads(self.rfile.read(content_len)) if content_len else {}
202
+
203
+ if self.path == "/api/status":
204
+ result = setup_wizard.get_setup_status()
205
+ elif self.path == "/api/install":
206
+ result = setup_wizard.install_spogo()
207
+ elif self.path == "/api/import":
208
+ browser = body.get("browser", "chrome")
209
+ profile = body.get("profile")
210
+ result = setup_wizard.import_browser_cookies(browser, profile)
211
+ elif self.path == "/api/paste":
212
+ cookie = body.get("cookie", "")
213
+ result = setup_wizard.paste_cookie(cookie)
214
+ else:
215
+ self.send_response(404)
216
+ self.end_headers()
217
+ return
218
+
219
+ self.send_response(200)
220
+ self.send_header("Content-Type", "application/json")
221
+ self.end_headers()
222
+ self.wfile.write(json.dumps(result).encode())
223
+
224
+ def log_message(self, format, *args):
225
+ logger.debug(f"Setup server: {args[0]}")
226
+
227
+
228
+ class SetupServer:
229
+ """Runs the setup wizard web UI on a local port."""
230
+
231
+ def __init__(self, port: int = 8888):
232
+ self.port = port
233
+ self._server: Optional[HTTPServer] = None
234
+ self._thread: Optional[threading.Thread] = None
235
+
236
+ def start(self):
237
+ self._server = HTTPServer(("0.0.0.0", self.port), SetupHandler)
238
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
239
+ self._thread.start()
240
+ logger.info(f"Setup wizard running at http://localhost:{self.port}/setup")
241
+
242
+ def stop(self):
243
+ if self._server:
244
+ self._server.shutdown()
spotify_manager/setup_wizard.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Setup wizard — auto-installs spogo and guides browser cookie auth."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import platform
7
+ import shutil
8
+ import stat
9
+ import subprocess
10
+ import tempfile
11
+ import urllib.request
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ SPOGO_VERSION = "latest"
18
+ SPOGO_DATA_DIR = Path.home() / ".config" / "spotify_manager"
19
+ SPOGO_BIN_DIR = SPOGO_DATA_DIR / "bin"
20
+ SPOGO_AUTH_STATUS_FILE = SPOGO_DATA_DIR / "auth_status.json"
21
+
22
+ # Download URLs by platform/arch
23
+ SPOGO_DOWNLOAD_URLS = {
24
+ ("linux", "aarch64"): "https://github.com/steipete/spogo/releases/latest/download/spogo_linux_arm64.tar.gz",
25
+ ("linux", "x86_64"): "https://github.com/steipete/spogo/releases/latest/download/spogo_linux_amd64.tar.gz",
26
+ ("darwin", "arm64"): "https://github.com/steipete/spogo/releases/latest/download/spogo_darwin_arm64.tar.gz",
27
+ ("darwin", "x86_64"): "https://github.com/steipete/spogo/releases/latest/download/spogo_darwin_amd64.tar.gz",
28
+ }
29
+
30
+
31
+ def get_spogo_path() -> Optional[str]:
32
+ """Find spogo binary — bundled or system."""
33
+ # Check bundled location first
34
+ bundled = SPOGO_BIN_DIR / "spogo"
35
+ if bundled.exists() and os.access(bundled, os.X_OK):
36
+ return str(bundled)
37
+ # Fall back to system PATH
38
+ return shutil.which("spogo")
39
+
40
+
41
+ def is_spogo_installed() -> bool:
42
+ return get_spogo_path() is not None
43
+
44
+
45
+ def is_authenticated() -> bool:
46
+ """Check if spogo has valid cookies."""
47
+ spogo = get_spogo_path()
48
+ if not spogo:
49
+ return False
50
+ try:
51
+ result = subprocess.run(
52
+ [spogo, "auth", "status", "--json"],
53
+ capture_output=True, text=True, timeout=5
54
+ )
55
+ if result.returncode == 0:
56
+ data = json.loads(result.stdout) if result.stdout.strip() else {}
57
+ return data.get("authenticated", False) or "valid" in result.stdout.lower()
58
+ # If no --json support, check stderr/stdout for positive signals
59
+ return "authenticated" in (result.stdout + result.stderr).lower()
60
+ except Exception:
61
+ return False
62
+
63
+
64
+ def install_spogo() -> dict:
65
+ """Download and install spogo binary to bundled location."""
66
+ system = platform.system().lower()
67
+ machine = platform.machine()
68
+
69
+ key = (system, machine)
70
+ url = SPOGO_DOWNLOAD_URLS.get(key)
71
+
72
+ if not url:
73
+ return {
74
+ "success": False,
75
+ "error": f"Unsupported platform: {system}/{machine}. "
76
+ f"Supported: {list(SPOGO_DOWNLOAD_URLS.keys())}"
77
+ }
78
+
79
+ SPOGO_BIN_DIR.mkdir(parents=True, exist_ok=True)
80
+ target = SPOGO_BIN_DIR / "spogo"
81
+
82
+ try:
83
+ logger.info(f"Downloading spogo for {system}/{machine}...")
84
+
85
+ with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
86
+ urllib.request.urlretrieve(url, tmp.name)
87
+ tmp_path = tmp.name
88
+
89
+ # Extract
90
+ import tarfile
91
+ with tarfile.open(tmp_path, "r:gz") as tar:
92
+ # Find the spogo binary in the archive
93
+ for member in tar.getmembers():
94
+ if member.name.endswith("spogo") or member.name == "spogo":
95
+ member.name = "spogo"
96
+ tar.extract(member, path=str(SPOGO_BIN_DIR))
97
+ break
98
+
99
+ os.unlink(tmp_path)
100
+
101
+ # Make executable
102
+ target.chmod(target.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
103
+
104
+ if target.exists():
105
+ logger.info(f"spogo installed at {target}")
106
+ return {"success": True, "path": str(target)}
107
+ else:
108
+ return {"success": False, "error": "Binary not found in archive"}
109
+
110
+ except Exception as e:
111
+ return {"success": False, "error": str(e)}
112
+
113
+
114
+ def import_browser_cookies(browser: str = "chrome", profile: Optional[str] = None) -> dict:
115
+ """Import Spotify cookies from a browser."""
116
+ spogo = get_spogo_path()
117
+ if not spogo:
118
+ return {"success": False, "error": "spogo not installed"}
119
+
120
+ cmd = [spogo, "auth", "import", "--browser", browser]
121
+ if profile:
122
+ cmd.extend(["--browser-profile", profile])
123
+
124
+ try:
125
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
126
+ if result.returncode == 0:
127
+ return {"success": True, "message": result.stdout.strip()}
128
+ else:
129
+ return {"success": False, "error": result.stderr.strip() or result.stdout.strip()}
130
+ except Exception as e:
131
+ return {"success": False, "error": str(e)}
132
+
133
+
134
+ def paste_cookie(sp_dc: str) -> dict:
135
+ """Authenticate with a manually provided sp_dc cookie value."""
136
+ spogo = get_spogo_path()
137
+ if not spogo:
138
+ return {"success": False, "error": "spogo not installed"}
139
+
140
+ try:
141
+ result = subprocess.run(
142
+ [spogo, "auth", "paste"],
143
+ input=sp_dc,
144
+ capture_output=True, text=True, timeout=10
145
+ )
146
+ if result.returncode == 0:
147
+ return {"success": True, "message": "Cookie saved"}
148
+ else:
149
+ return {"success": False, "error": result.stderr.strip()}
150
+ except Exception as e:
151
+ return {"success": False, "error": str(e)}
152
+
153
+
154
+ def get_setup_status() -> dict:
155
+ """Full status for the setup wizard UI."""
156
+ spogo_installed = is_spogo_installed()
157
+ authenticated = is_authenticated() if spogo_installed else False
158
+ spogo_path = get_spogo_path()
159
+
160
+ return {
161
+ "spogo_installed": spogo_installed,
162
+ "spogo_path": spogo_path,
163
+ "authenticated": authenticated,
164
+ "platform": f"{platform.system()}/{platform.machine()}",
165
+ "ready": spogo_installed and authenticated,
166
+ }
spotify_manager/spotify_control.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spotify control via spogo CLI — zero OAuth, uses browser cookies."""
2
+
3
+ import json
4
+ import subprocess
5
+ import shutil
6
+ from .setup_wizard import get_spogo_path
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+
10
+
11
+ @dataclass
12
+ class TrackInfo:
13
+ name: str = ""
14
+ artist: str = ""
15
+ album: str = ""
16
+ uri: str = ""
17
+ is_playing: bool = False
18
+ progress_ms: int = 0
19
+ duration_ms: int = 0
20
+
21
+
22
+ class SpogoController:
23
+ """Wraps spogo CLI commands for Spotify control."""
24
+
25
+ def __init__(self):
26
+ self._spogo = get_spogo_path()
27
+ if not self._spogo:
28
+ raise RuntimeError(
29
+ "spogo not found. Run the setup wizard at http://localhost:8888/setup "
30
+ "or install manually: brew install steipete/tap/spogo"
31
+ )
32
+
33
+ def _run(self, args: list[str], json_output: bool = True) -> dict | str:
34
+ cmd = [self._spogo] + args
35
+ if json_output:
36
+ cmd.append("--json")
37
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
38
+ if result.returncode != 0:
39
+ raise RuntimeError(f"spogo error: {result.stderr.strip()}")
40
+ if json_output:
41
+ return json.loads(result.stdout) if result.stdout.strip() else {}
42
+ return result.stdout.strip()
43
+
44
+ def status(self) -> TrackInfo:
45
+ data = self._run(["status"])
46
+ if not data:
47
+ return TrackInfo()
48
+ item = data.get("item", {})
49
+ artists_raw = item.get("artists", [])
50
+ # artists can be list of strings or list of dicts
51
+ if artists_raw and isinstance(artists_raw[0], dict):
52
+ artist_str = ", ".join(a.get("name", "") for a in artists_raw)
53
+ else:
54
+ artist_str = ", ".join(str(a) for a in artists_raw)
55
+ album_raw = item.get("album", "")
56
+ album_str = album_raw.get("name", "") if isinstance(album_raw, dict) else str(album_raw)
57
+ return TrackInfo(
58
+ name=item.get("name", ""),
59
+ artist=artist_str,
60
+ album=album_str,
61
+ uri=item.get("uri", ""),
62
+ is_playing=data.get("is_playing", False),
63
+ progress_ms=data.get("progress_ms", 0),
64
+ duration_ms=item.get("duration_ms", 0),
65
+ )
66
+
67
+ def play(self, query: Optional[str] = None, uri: Optional[str] = None):
68
+ if uri:
69
+ self._run(["play", uri], json_output=False)
70
+ elif query:
71
+ self._run(["play", query], json_output=False)
72
+ else:
73
+ self._run(["resume"], json_output=False)
74
+
75
+ def pause(self):
76
+ self._run(["pause"], json_output=False)
77
+
78
+ def resume(self):
79
+ self._run(["resume"], json_output=False)
80
+
81
+ def next_track(self):
82
+ self._run(["next"], json_output=False)
83
+
84
+ def prev_track(self):
85
+ self._run(["prev"], json_output=False)
86
+
87
+ def volume(self, level: int):
88
+ self._run(["volume", str(max(0, min(100, level)))], json_output=False)
89
+
90
+ def like(self):
91
+ self._run(["like"], json_output=False)
92
+
93
+ def search(self, query: str, search_type: str = "track", limit: int = 5) -> list[dict]:
94
+ data = self._run(["search", search_type, query, "--limit", str(limit)])
95
+ return data.get("items", []) if isinstance(data, dict) else []
96
+
97
+ def queue_add(self, uri: str):
98
+ self._run(["queue", "add", "--uri", uri], json_output=False)
spotify_manager/voice_controller.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Voice control — Whisper STT → intent parsing → Spotify commands."""
2
+
3
+ import re
4
+ import threading
5
+ import queue
6
+ import tempfile
7
+ from typing import Optional
8
+ from dataclasses import dataclass
9
+
10
+ try:
11
+ import whisper
12
+ import numpy as np
13
+ except ImportError:
14
+ whisper = None
15
+
16
+
17
+ @dataclass
18
+ class VoiceCommand:
19
+ intent: str # play, pause, next, prev, volume_up, volume_down, like, search, status
20
+ query: Optional[str] = None
21
+ value: Optional[int] = None
22
+
23
+
24
+ # Intent patterns (Spanish + English)
25
+ INTENT_PATTERNS = [
26
+ (r"(?:siguiente|next|skip)", "next"),
27
+ (r"(?:anterior|prev|previous|atrás)", "prev"),
28
+ (r"(?:pausa|pause|para|stop)", "pause"),
29
+ (r"(?:play|reproduce|continua|resume|dale)", "play"),
30
+ (r"(?:sube|subir|más)\s*(?:volumen|volume|vol)", "volume_up"),
31
+ (r"(?:baja|bajar|menos)\s*(?:volumen|volume|vol)", "volume_down"),
32
+ (r"(?:me gusta|like|favorit)", "like"),
33
+ (r"(?:qué suena|what.s playing|qué es esto|now playing)", "status"),
34
+ (r"(?:pon|play|busca|search|reproduce)\s+(.+)", "search"),
35
+ ]
36
+
37
+
38
+ def parse_intent(text: str) -> Optional[VoiceCommand]:
39
+ """Parse transcribed text into a voice command."""
40
+ text = text.lower().strip()
41
+ if not text:
42
+ return None
43
+
44
+ for pattern, intent in INTENT_PATTERNS:
45
+ m = re.search(pattern, text)
46
+ if m:
47
+ query = m.group(1) if m.lastindex and m.lastindex >= 1 else None
48
+ return VoiceCommand(intent=intent, query=query)
49
+
50
+ return None
51
+
52
+
53
+ class VoiceController:
54
+ """Listens for voice commands using Whisper for transcription."""
55
+
56
+ def __init__(self, model_size: str = "base"):
57
+ if whisper is None:
58
+ raise RuntimeError("openai-whisper not installed")
59
+ self._model = whisper.load_model(model_size)
60
+ self._command_queue: queue.Queue[VoiceCommand] = queue.Queue()
61
+ self._running = False
62
+ self._thread: Optional[threading.Thread] = None
63
+
64
+ def transcribe_audio(self, audio_data: np.ndarray) -> Optional[VoiceCommand]:
65
+ """Transcribe audio numpy array and parse intent."""
66
+ result = self._model.transcribe(audio_data, language=None, fp16=False)
67
+ text = result.get("text", "")
68
+ return parse_intent(text)
69
+
70
+ def get_pending_command(self) -> Optional[VoiceCommand]:
71
+ """Get next pending command (non-blocking)."""
72
+ try:
73
+ return self._command_queue.get_nowait()
74
+ except queue.Empty:
75
+ return None
76
+
77
+ def submit_audio(self, audio_data: np.ndarray):
78
+ """Submit audio for background transcription."""
79
+ def _process():
80
+ cmd = self.transcribe_audio(audio_data)
81
+ if cmd:
82
+ self._command_queue.put(cmd)
83
+ threading.Thread(target=_process, daemon=True).start()
84
+
85
+ def close(self):
86
+ self._running = False
style.css CHANGED
@@ -1,28 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
 
 
 
4
  }
5
 
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
 
 
 
 
 
 
 
 
9
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  }
17
 
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
25
 
26
- .card p:last-child {
27
- margin-bottom: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
 
 
1
+ /* jrubiosainz Reachy Mini Apps — Shared Brand */
2
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
3
+
4
+ * { margin: 0; padding: 0; box-sizing: border-box; }
5
+
6
+ :root {
7
+ --bg: #0c0c14;
8
+ --surface: #15152a;
9
+ --surface-2: #1e1e3a;
10
+ --accent: #7c5cfc;
11
+ --accent-glow: #9d7fff;
12
+ --accent-soft: rgba(124, 92, 252, 0.12);
13
+ --green: #3ddc84;
14
+ --text: #e8e8f0;
15
+ --text-dim: #8888aa;
16
+ --border: rgba(255,255,255,0.06);
17
+ --radius: 16px;
18
+ }
19
+
20
  body {
21
+ font-family: 'Inter', -apple-system, sans-serif;
22
+ background: var(--bg);
23
+ color: var(--text);
24
+ min-height: 100vh;
25
+ -webkit-font-smoothing: antialiased;
26
  }
27
 
28
+ nav {
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: space-between;
32
+ padding: 1rem 2rem;
33
+ border-bottom: 1px solid var(--border);
34
+ backdrop-filter: blur(20px);
35
+ position: sticky;
36
+ top: 0;
37
+ z-index: 100;
38
+ background: rgba(12,12,20,0.85);
39
  }
40
+ .nav-brand {
41
+ display: flex;
42
+ align-items: center;
43
+ gap: 0.5rem;
44
+ font-weight: 700;
45
+ font-size: 1rem;
46
+ color: var(--text);
47
+ text-decoration: none;
48
+ }
49
+ .nav-brand .dot {
50
+ width: 8px; height: 8px;
51
+ background: var(--green);
52
+ border-radius: 50%;
53
+ display: inline-block;
54
+ }
55
+ .nav-links { display: flex; gap: 1.5rem; }
56
+ .nav-links a {
57
+ color: var(--text-dim);
58
+ text-decoration: none;
59
+ font-size: 0.85rem;
60
+ font-weight: 500;
61
+ transition: color 0.2s;
62
+ }
63
+ .nav-links a:hover { color: var(--text); }
64
 
65
+ .hero {
66
+ text-align: center;
67
+ padding: 5rem 2rem 3rem;
68
+ position: relative;
69
+ }
70
+ .hero::before {
71
+ content: '';
72
+ position: absolute;
73
+ top: 0; left: 50%;
74
+ transform: translateX(-50%);
75
+ width: 600px; height: 600px;
76
+ background: radial-gradient(circle, var(--accent-soft) 0%, transparent 70%);
77
+ pointer-events: none;
78
+ }
79
+ .badge {
80
+ display: inline-block;
81
+ background: var(--accent-soft);
82
+ color: var(--accent-glow);
83
+ padding: 0.35rem 1rem;
84
+ border-radius: 20px;
85
+ font-size: 0.75rem;
86
+ font-weight: 600;
87
+ letter-spacing: 0.08em;
88
+ text-transform: uppercase;
89
+ margin-bottom: 1.5rem;
90
+ }
91
+ .hero h1 {
92
+ font-size: 3.5rem;
93
+ font-weight: 800;
94
+ margin-bottom: 1rem;
95
+ position: relative;
96
+ }
97
+ .tagline {
98
+ font-size: 1.2rem;
99
+ color: var(--text-dim);
100
+ max-width: 500px;
101
+ margin: 0 auto;
102
+ line-height: 1.6;
103
  }
104
 
105
+ .video-showcase {
106
+ max-width: 800px;
107
+ margin: 0 auto 4rem;
108
+ padding: 0 2rem;
109
+ }
110
+ .video-frame {
111
+ border-radius: var(--radius);
112
+ overflow: hidden;
113
+ border: 1px solid var(--border);
114
+ background: var(--surface);
115
+ }
116
+ .video-frame video {
117
+ width: 100%;
118
+ display: block;
119
+ }
120
+ .video-label {
121
+ text-align: center;
122
+ font-size: 0.8rem;
123
+ color: var(--text-dim);
124
+ margin-top: 0.75rem;
125
  }
126
 
127
+ .features, .gestures, .install {
128
+ max-width: 900px;
129
+ margin: 0 auto;
130
+ padding: 4rem 2rem;
131
+ }
132
+ .features h2, .gestures h2, .install h2 {
133
+ text-align: center;
134
+ font-size: 2rem;
135
+ font-weight: 700;
136
+ margin-bottom: 0.5rem;
137
+ }
138
+ .subtitle {
139
+ text-align: center;
140
+ color: var(--text-dim);
141
+ margin-bottom: 2.5rem;
142
+ }
143
+ .feature-grid {
144
+ display: grid;
145
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
146
+ gap: 1.5rem;
147
+ }
148
+ .feature-card {
149
+ background: var(--surface);
150
+ border: 1px solid var(--border);
151
+ border-radius: var(--radius);
152
+ padding: 2rem;
153
+ transition: border-color 0.2s, transform 0.2s;
154
+ }
155
+ .feature-card:hover {
156
+ border-color: var(--accent);
157
+ transform: translateY(-2px);
158
+ }
159
+ .feature-card .icon {
160
+ font-size: 2rem;
161
+ margin-bottom: 1rem;
162
+ }
163
+ .feature-card h3 {
164
+ font-size: 1.1rem;
165
+ font-weight: 600;
166
+ margin-bottom: 0.5rem;
167
+ }
168
+ .feature-card p {
169
+ color: var(--text-dim);
170
+ font-size: 0.9rem;
171
+ line-height: 1.5;
172
+ }
173
+
174
+ .steps {
175
+ display: flex;
176
+ flex-direction: column;
177
+ gap: 1.5rem;
178
+ max-width: 600px;
179
+ margin: 0 auto;
180
+ }
181
+ .step {
182
+ display: flex;
183
+ gap: 1.5rem;
184
+ align-items: flex-start;
185
+ }
186
+ .step-num {
187
+ width: 36px; height: 36px;
188
+ background: var(--accent-soft);
189
+ color: var(--accent-glow);
190
+ border-radius: 50%;
191
+ display: flex;
192
+ align-items: center;
193
+ justify-content: center;
194
+ font-weight: 700;
195
+ flex-shrink: 0;
196
+ }
197
+ .step h4 { margin-bottom: 0.25rem; }
198
+ .step p { color: var(--text-dim); font-size: 0.9rem; }
199
+
200
+ footer {
201
+ text-align: center;
202
+ padding: 3rem 2rem;
203
+ border-top: 1px solid var(--border);
204
+ color: var(--text-dim);
205
+ font-size: 0.85rem;
206
+ }
207
+ footer a {
208
+ color: var(--accent-glow);
209
+ text-decoration: none;
210
  }
211
+ footer a:hover { text-decoration: underline; }