CreaturesDigital commited on
Commit
bd10c6b
·
verified ·
1 Parent(s): c73c192

Upload folder using huggingface_hub

Browse files
Files changed (6) hide show
  1. README.md +132 -4
  2. index.html +57 -18
  3. pyproject.toml +43 -0
  4. spotify_dancer/__init__.py +6 -0
  5. spotify_dancer/main.py +583 -0
  6. style.css +88 -18
README.md CHANGED
@@ -1,10 +1,138 @@
1
  ---
2
  title: Spotify Dancer
3
- emoji: 👁
4
- colorFrom: indigo
5
- colorTo: indigo
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 Dancer
3
+ emoji: 🎵
4
+ colorFrom: green
5
+ colorTo: gray
6
  sdk: static
7
  pinned: false
8
+ license: mit
9
+ tags:
10
+ - reachy-mini
11
+ - robotics
12
+ - spotify
13
+ - dance
14
  ---
15
 
16
+ # Spotify Dancer for Reachy Mini
17
+
18
+ Make your Reachy Mini dance to Spotify music! This app analyzes audio in real-time and generates expressive robot movements synchronized to the beat.
19
+
20
+ **Based on [DJ Reactor](https://huggingface.co/spaces/RyeCatcher/dj_reactor)** by RyeCatcher, enhanced with Spotify Connect integration and ALSA loopback for best audio detection quality.
21
+
22
+ ## Features
23
+
24
+ - **Spotify Connect** - Reachy Mini appears as a speaker in your Spotify app
25
+ - **Real-time Beat Detection** - BPM estimation and beat tracking
26
+ - **Dance Styles** - Energetic, Groovy, Chill, and Hip-Hop modes
27
+ - **Stream Analysis** - Direct audio capture via ALSA loopback (best quality)
28
+ - **Web Control Panel** - Live visualizer with adjustable settings
29
+
30
+ ## Requirements
31
+
32
+ - Reachy Mini with wireless connectivity
33
+ - Python 3.10+
34
+ - Spotify account (Free or Premium)
35
+
36
+ ## Installation
37
+
38
+ ### From Reachy Mini Dashboard
39
+
40
+ 1. Go to `http://reachy-mini.local:8000`
41
+ 2. Navigate to Apps
42
+ 3. Search for "Spotify Dancer"
43
+ 4. Click Install
44
+
45
+ ### Manual Installation
46
+
47
+ ```bash
48
+ pip install spotify_dancer
49
+ ```
50
+
51
+ ## Setup
52
+
53
+ ### 1. Install Spotify Connect (raspotify)
54
+
55
+ ```bash
56
+ curl -sL https://dtcooper.github.io/raspotify/install.sh | sh
57
+
58
+ # Configure
59
+ sudo nano /etc/raspotify/conf
60
+ # Set: LIBRESPOT_NAME="Reachy Mini"
61
+ # Set: LIBRESPOT_DEVICE=spotify
62
+ ```
63
+
64
+ ### 2. Enable ALSA Loopback (Recommended)
65
+
66
+ For best audio detection, use ALSA loopback:
67
+
68
+ ```bash
69
+ sudo modprobe snd-aloop pcm_substreams=1
70
+ echo 'snd-aloop' | sudo tee /etc/modules-load.d/loopback.conf
71
+ ```
72
+
73
+ See the [full ALSA setup guide](docs/spotify_setup.md) for multi-output configuration.
74
+
75
+ ### 3. Fix Service Permissions
76
+
77
+ Create `/etc/systemd/system/raspotify.service.d/override.conf`:
78
+
79
+ ```ini
80
+ [Service]
81
+ PrivateUsers=false
82
+ PrivateTmp=false
83
+ ProtectSystem=false
84
+ RemoveIPC=false
85
+ User=pollen
86
+ Group=audio
87
+ ```
88
+
89
+ Then: `sudo systemctl daemon-reload && sudo systemctl restart raspotify`
90
+
91
+ ## Usage
92
+
93
+ 1. Start the app from Reachy Mini dashboard
94
+ 2. Open control panel: `http://reachy-mini.local:7860`
95
+ 3. Select **loopback_in** as Audio Input
96
+ 4. Choose a dance style
97
+ 5. Click **Start Dancing**
98
+ 6. Play music on Spotify → Select "Reachy Mini" as speaker
99
+
100
+ ## Dance Styles
101
+
102
+ | Style | Best For | Movement |
103
+ |-------|----------|----------|
104
+ | Energetic | Rock, EDM | Strong head bobs, wide sway |
105
+ | Groovy | Pop, Funk | Classic nods, balanced movement |
106
+ | Chill | Jazz, Lo-Fi | Smooth tilts, gentle sway |
107
+ | Hip-Hop | Rap, R&B | Rhythmic nods, bass-driven |
108
+
109
+ ## Audio Input Options
110
+
111
+ | Device | Quality | Notes |
112
+ |--------|---------|-------|
113
+ | `loopback_in` | ⭐ Best | Direct stream capture |
114
+ | `reachymini_audio_src` | Poor | Microphone with echo cancellation issues |
115
+
116
+ ## Architecture
117
+
118
+ ```
119
+ Spotify App → raspotify → ALSA multi-output
120
+ ├──→ Speaker
121
+ └──→ Loopback → Spotify Dancer → Robot
122
+ ```
123
+
124
+ ## Troubleshooting
125
+
126
+ - **No loopback_in device**: Run `sudo modprobe snd-aloop` and restart the app
127
+ - **Poor detection**: Use loopback_in, increase sensitivity, turn up volume
128
+ - **Tracks skip**: Check ALSA config and IPC permissions (see setup guide)
129
+
130
+ ## License
131
+
132
+ MIT License
133
+
134
+ ## Credits
135
+
136
+ - **DJ Reactor** by [RyeCatcher](https://huggingface.co/spaces/RyeCatcher/dj_reactor) - Original dance movement system
137
+ - **Spotify Connect** via [raspotify](https://github.com/dtcooper/raspotify) (librespot wrapper)
138
+ - Built for the Reachy Mini community
index.html CHANGED
@@ -1,19 +1,58 @@
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 Dancer - Reachy Mini App</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ </head>
9
+ <body>
10
+ <div class="container">
11
+ <div class="header">
12
+ <h1>🎵 Spotify Dancer</h1>
13
+ <p>Make your Reachy Mini dance to Spotify music!</p>
14
+ </div>
15
+
16
+ <div class="features">
17
+ <div class="feature">
18
+ <h3>🎧 Spotify Connect</h3>
19
+ <p>Reachy Mini appears as a speaker in your Spotify app</p>
20
+ </div>
21
+ <div class="feature">
22
+ <h3>🎵 Beat Detection</h3>
23
+ <p>Real-time audio analysis with BPM estimation</p>
24
+ </div>
25
+ <div class="feature">
26
+ <h3>💃 Dance Styles</h3>
27
+ <p>Energetic, Groovy, Chill, and Hip-Hop modes</p>
28
+ </div>
29
+ <div class="feature">
30
+ <h3>🔊 Stream Analysis</h3>
31
+ <p>Direct audio capture via ALSA loopback</p>
32
+ </div>
33
+ </div>
34
+
35
+ <div class="install">
36
+ <h2>Installation</h2>
37
+ <p>Install this app on your Reachy Mini from the dashboard, or run:</p>
38
+ <code>pip install spotify_dancer</code>
39
+ </div>
40
+
41
+ <div class="setup">
42
+ <h2>Quick Setup</h2>
43
+ <ol>
44
+ <li>Install raspotify for Spotify Connect</li>
45
+ <li>Enable ALSA loopback for best detection</li>
46
+ <li>Start the app from Reachy Mini dashboard</li>
47
+ <li>Select 'loopback_in' as audio input</li>
48
+ <li>Play music on Spotify → Select "Reachy Mini"</li>
49
+ <li>Click "Start Dancing" and enjoy!</li>
50
+ </ol>
51
+ </div>
52
+
53
+ <div class="footer">
54
+ <p>Built for the Reachy Mini community</p>
55
+ </div>
56
+ </div>
57
+ </body>
58
  </html>
pyproject.toml ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "spotify_dancer"
7
+ version = "1.0.0"
8
+ description = "Spotify Dance Mode for Reachy Mini - Robot dances to your music"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "Reachy Community"}
14
+ ]
15
+ keywords = ["reachy", "robot", "spotify", "dance", "music", "reachy-mini"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ ]
24
+ dependencies = [
25
+ "reachy-mini",
26
+ "numpy",
27
+ "gradio>=4.0.0",
28
+ "sounddevice",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://huggingface.co/spaces/reachy-community/spotify_dancer"
33
+ Repository = "https://github.com/pollen-robotics/reachy_mini"
34
+
35
+ [project.entry-points.reachy_mini_apps]
36
+ spotify_dancer = "spotify_dancer.main:SpotifyDancer"
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["."]
40
+ include = ["spotify_dancer*"]
41
+
42
+ [tool.setuptools.package-data]
43
+ spotify_dancer = ["*.html", "*.css", "*.js"]
spotify_dancer/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Spotify Dancer - Make Reachy Mini dance to your Spotify music."""
2
+
3
+ from spotify_dancer.main import SpotifyDancer
4
+
5
+ __version__ = "1.0.0"
6
+ __all__ = ["SpotifyDancer"]
spotify_dancer/main.py ADDED
@@ -0,0 +1,583 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Spotify Dancer - Reachy Mini dances to your Spotify music.
3
+
4
+ Based on DJ Reactor by RyeCatcher (https://huggingface.co/spaces/RyeCatcher/dj_reactor)
5
+ Enhanced with Spotify Connect integration and ALSA loopback for best audio detection.
6
+
7
+ Usage:
8
+ 1. Install raspotify for Spotify Connect
9
+ 2. Configure ALSA loopback (see README)
10
+ 3. Run this app
11
+ 4. Select 'loopback_in' as audio input
12
+ 5. Play music on Spotify, select 'Reachy Mini' as speaker
13
+ 6. Click 'Start Dancing'
14
+ """
15
+
16
+ import math
17
+ import time
18
+ import threading
19
+ import logging
20
+ import subprocess
21
+ from dataclasses import dataclass
22
+ from typing import Optional
23
+ from collections import deque
24
+
25
+ import numpy as np
26
+ import gradio as gr
27
+
28
+ try:
29
+ import sounddevice as sd
30
+ SOUNDDEVICE_AVAILABLE = True
31
+ except ImportError:
32
+ sd = None
33
+ SOUNDDEVICE_AVAILABLE = False
34
+
35
+ from reachy_mini import ReachyMini, ReachyMiniApp
36
+ from reachy_mini.utils import create_head_pose
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # =============================================================================
41
+ # Audio Analysis Constants
42
+ # =============================================================================
43
+
44
+ BASS_RANGE = (20, 250)
45
+ MID_RANGE = (250, 2000)
46
+ TREBLE_RANGE = (2000, 8000)
47
+
48
+
49
+ @dataclass
50
+ class AudioFeatures:
51
+ """Real-time audio features extracted from the stream."""
52
+ bass: float = 0.0
53
+ mid: float = 0.0
54
+ treble: float = 0.0
55
+ rms: float = 0.0
56
+ bpm: float = 120.0
57
+ beat_detected: bool = False
58
+
59
+
60
+ @dataclass
61
+ class DanceStyle:
62
+ """Movement characteristics for different music styles."""
63
+ name: str
64
+ display_name: str
65
+ head_bob_amplitude: float = 12.0
66
+ head_bob_speed: float = 1.0
67
+ body_sway_amplitude: float = 60.0
68
+ body_sway_speed: float = 1.0
69
+ antenna_amplitude: float = 0.6
70
+ emphasis: str = "nod" # nod, headbang, tilt
71
+ smoothing: float = 0.3
72
+
73
+
74
+ DANCE_STYLES = {
75
+ "energetic": DanceStyle(
76
+ name="energetic", display_name="Energetic (Rock/EDM)",
77
+ head_bob_amplitude=18.0, body_sway_amplitude=70.0,
78
+ antenna_amplitude=1.0, emphasis="headbang", smoothing=0.2,
79
+ ),
80
+ "groovy": DanceStyle(
81
+ name="groovy", display_name="Groovy (Pop/Funk)",
82
+ head_bob_amplitude=14.0, body_sway_amplitude=65.0,
83
+ antenna_amplitude=0.8, emphasis="nod", smoothing=0.25,
84
+ ),
85
+ "chill": DanceStyle(
86
+ name="chill", display_name="Chill (Jazz/Lo-Fi)",
87
+ head_bob_amplitude=10.0, head_bob_speed=1.3,
88
+ body_sway_amplitude=75.0, body_sway_speed=1.3,
89
+ antenna_amplitude=0.5, emphasis="tilt", smoothing=0.4,
90
+ ),
91
+ "hiphop": DanceStyle(
92
+ name="hiphop", display_name="Hip-Hop",
93
+ head_bob_amplitude=16.0, head_bob_speed=0.9,
94
+ body_sway_amplitude=60.0, antenna_amplitude=0.7,
95
+ emphasis="nod", smoothing=0.2,
96
+ ),
97
+ }
98
+
99
+
100
+ # =============================================================================
101
+ # Audio Analyzer
102
+ # =============================================================================
103
+
104
+ class AudioAnalyzer:
105
+ """Real-time audio analysis for beat detection and frequency bands."""
106
+
107
+ def __init__(self, sample_rate: int = 44100, chunk_size: int = 1024,
108
+ device_index: Optional[int] = None, sensitivity: float = 0.7):
109
+ self.sample_rate = sample_rate
110
+ self.chunk_size = chunk_size
111
+ self.device_index = device_index
112
+ self.sensitivity = sensitivity
113
+
114
+ # FFT setup
115
+ freqs = np.fft.rfftfreq(chunk_size, 1.0 / sample_rate)
116
+ self.bass_bins = np.where((freqs >= BASS_RANGE[0]) & (freqs <= BASS_RANGE[1]))[0]
117
+ self.mid_bins = np.where((freqs >= MID_RANGE[0]) & (freqs <= MID_RANGE[1]))[0]
118
+ self.treble_bins = np.where((freqs >= TREBLE_RANGE[0]) & (freqs <= TREBLE_RANGE[1]))[0]
119
+
120
+ # Beat tracking
121
+ self.energy_history = deque(maxlen=20)
122
+ self.beat_times = deque(maxlen=50)
123
+ self.last_beat_time = 0.0
124
+ self.estimated_bpm = 120.0
125
+
126
+ # State
127
+ self.is_running = False
128
+ self.stream = None
129
+ self.latest_features = AudioFeatures()
130
+ self.start_time = 0.0
131
+
132
+ def _audio_callback(self, indata, frames, time_info, status):
133
+ """Process incoming audio data."""
134
+ if len(indata.shape) > 1:
135
+ audio = np.mean(indata, axis=1)
136
+ else:
137
+ audio = indata.flatten()
138
+
139
+ current_time = time.time() - self.start_time
140
+
141
+ # RMS energy
142
+ rms = np.sqrt(np.mean(audio ** 2))
143
+ is_silent = rms < 0.0001 # Lowered threshold
144
+
145
+ # FFT analysis
146
+ windowed = audio * np.hanning(len(audio))
147
+ if len(windowed) < self.chunk_size:
148
+ windowed = np.pad(windowed, (0, self.chunk_size - len(windowed)))
149
+ spectrum = np.abs(np.fft.rfft(windowed[:self.chunk_size]))
150
+
151
+ # Extract band energies (boosted for better detection)
152
+ bass = min(np.mean(spectrum[self.bass_bins]) / 1.5, 1.0) if len(self.bass_bins) > 0 else 0
153
+ mid = min(np.mean(spectrum[self.mid_bins]) / 1.0, 1.0) if len(self.mid_bins) > 0 else 0
154
+ treble = min(np.mean(spectrum[self.treble_bins]) / 2.0, 1.0) if len(self.treble_bins) > 0 else 0
155
+
156
+ # Beat detection
157
+ self.energy_history.append(bass + mid * 0.5)
158
+ beat_detected = False
159
+
160
+ if len(self.energy_history) >= 5 and not is_silent:
161
+ avg_energy = np.mean(list(self.energy_history)[:-1])
162
+ current_energy = self.energy_history[-1]
163
+
164
+ onset_threshold = 1.1 + (1.0 - self.sensitivity) * 0.5
165
+ min_interval = 0.15 + (1.0 - self.sensitivity) * 0.15
166
+
167
+ if current_energy > avg_energy * onset_threshold:
168
+ if current_time - self.last_beat_time > min_interval:
169
+ beat_detected = True
170
+ self.beat_times.append(current_time)
171
+ self.last_beat_time = current_time
172
+
173
+ # Estimate BPM
174
+ if len(self.beat_times) >= 4:
175
+ intervals = np.diff(list(self.beat_times)[-8:])
176
+ valid = intervals[(intervals > 0.25) & (intervals < 2.0)]
177
+ if len(valid) > 0:
178
+ avg_interval = np.mean(valid)
179
+ self.estimated_bpm = 60.0 / avg_interval
180
+
181
+ self.latest_features = AudioFeatures(
182
+ bass=float(bass),
183
+ mid=float(mid),
184
+ treble=float(treble),
185
+ rms=min(float(rms) * 5, 1.0),
186
+ bpm=float(np.clip(self.estimated_bpm, 60, 200)),
187
+ beat_detected=beat_detected,
188
+ )
189
+
190
+ def start(self):
191
+ """Start audio capture."""
192
+ if not SOUNDDEVICE_AVAILABLE:
193
+ logger.error("sounddevice not available")
194
+ return
195
+
196
+ self.start_time = time.time()
197
+ self.is_running = True
198
+
199
+ try:
200
+ self.stream = sd.InputStream(
201
+ device=self.device_index,
202
+ channels=2,
203
+ samplerate=self.sample_rate,
204
+ blocksize=self.chunk_size,
205
+ callback=self._audio_callback,
206
+ )
207
+ self.stream.start()
208
+ logger.info(f"Audio stream started (device: {self.device_index})")
209
+ except Exception as e:
210
+ logger.error(f"Failed to start audio stream: {e}")
211
+ self.is_running = False
212
+
213
+ def stop(self):
214
+ """Stop audio capture."""
215
+ self.is_running = False
216
+ if self.stream:
217
+ self.stream.stop()
218
+ self.stream.close()
219
+ self.stream = None
220
+
221
+ def update_sensitivity(self, sensitivity: float):
222
+ """Update beat detection sensitivity."""
223
+ self.sensitivity = max(0.1, min(1.0, sensitivity))
224
+
225
+
226
+ # =============================================================================
227
+ # Dance Controller
228
+ # =============================================================================
229
+
230
+ class DanceController:
231
+ """Converts audio features into robot movements."""
232
+
233
+ def __init__(self, style: DanceStyle, intensity: float = 0.7):
234
+ self.style = style
235
+ self.intensity = intensity
236
+ self.phase = 0.0
237
+ self.last_beat_phase = 0.0
238
+
239
+ # Smoothed values
240
+ self.smooth_bass = 0.0
241
+ self.smooth_mid = 0.0
242
+ self.smooth_energy = 0.0
243
+
244
+ def update(self, features: AudioFeatures, dt: float) -> dict:
245
+ """Generate movement commands from audio features."""
246
+ # Smooth the inputs
247
+ alpha = 1.0 - self.style.smoothing
248
+ self.smooth_bass = self.smooth_bass * (1 - alpha) + features.bass * alpha
249
+ self.smooth_mid = self.smooth_mid * (1 - alpha) + features.mid * alpha
250
+ self.smooth_energy = self.smooth_energy * (1 - alpha) + features.rms * alpha
251
+
252
+ # Update phase based on BPM
253
+ bpm = features.bpm
254
+ beat_freq = bpm / 60.0
255
+ self.phase += dt * beat_freq * 2 * math.pi * self.style.head_bob_speed
256
+
257
+ # Beat emphasis
258
+ beat_boost = 1.5 if features.beat_detected else 1.0
259
+
260
+ # Calculate movements based on style
261
+ energy = self.smooth_energy * self.intensity * beat_boost
262
+
263
+ if self.style.emphasis == "headbang":
264
+ # Strong up-down motion
265
+ head_z = self.style.head_bob_amplitude * energy * math.sin(self.phase)
266
+ head_roll = self.style.head_bob_amplitude * 0.3 * energy * math.sin(self.phase * 0.5)
267
+ elif self.style.emphasis == "tilt":
268
+ # Smooth side-to-side
269
+ head_z = self.style.head_bob_amplitude * 0.5 * energy * math.sin(self.phase)
270
+ head_roll = self.style.head_bob_amplitude * energy * math.sin(self.phase * 0.7)
271
+ else: # nod
272
+ # Classic head bob
273
+ head_z = self.style.head_bob_amplitude * energy * abs(math.sin(self.phase))
274
+ head_roll = self.style.head_bob_amplitude * 0.5 * energy * math.sin(self.phase * 0.3)
275
+
276
+ # Body sway follows bass
277
+ body_yaw = self.style.body_sway_amplitude * self.smooth_bass * self.intensity * math.sin(self.phase * 0.5)
278
+
279
+ # Antennas react to treble
280
+ antenna_base = self.style.antenna_amplitude * self.intensity
281
+ antenna_left = antenna_base * (0.5 + features.treble * 0.5) * math.sin(self.phase * 1.5)
282
+ antenna_right = antenna_base * (0.5 + features.treble * 0.5) * math.sin(self.phase * 1.5 + math.pi)
283
+
284
+ return {
285
+ 'head_z': head_z,
286
+ 'head_roll': head_roll,
287
+ 'body_yaw': body_yaw,
288
+ 'antenna_left': antenna_left,
289
+ 'antenna_right': antenna_right,
290
+ }
291
+
292
+ def update_style(self, style: DanceStyle):
293
+ """Change dance style."""
294
+ self.style = style
295
+
296
+ def update_intensity(self, intensity: float):
297
+ """Change movement intensity."""
298
+ self.intensity = max(0.1, min(1.0, intensity))
299
+
300
+
301
+ # =============================================================================
302
+ # Utility Functions
303
+ # =============================================================================
304
+
305
+ def list_audio_devices() -> list:
306
+ """List available audio input devices."""
307
+ if not SOUNDDEVICE_AVAILABLE:
308
+ return []
309
+
310
+ devices = []
311
+ try:
312
+ for i, dev in enumerate(sd.query_devices()):
313
+ if dev['max_input_channels'] > 0:
314
+ devices.append({
315
+ 'index': i,
316
+ 'name': dev['name'],
317
+ 'channels': dev['max_input_channels'],
318
+ 'sample_rate': dev['default_samplerate'],
319
+ })
320
+ except Exception as e:
321
+ logger.error(f"Failed to list audio devices: {e}")
322
+
323
+ return devices
324
+
325
+
326
+ def setup_loopback() -> bool:
327
+ """Attempt to load ALSA loopback module."""
328
+ try:
329
+ result = subprocess.run(
330
+ ['sudo', 'modprobe', 'snd-aloop', 'pcm_substreams=1'],
331
+ capture_output=True, timeout=5
332
+ )
333
+ return result.returncode == 0
334
+ except Exception:
335
+ return False
336
+
337
+
338
+ # =============================================================================
339
+ # Main App
340
+ # =============================================================================
341
+
342
+ class SpotifyDancer(ReachyMiniApp):
343
+ """Reachy Mini dances to your Spotify music."""
344
+
345
+ custom_app_url: str | None = "http://0.0.0.0:7860"
346
+ dont_start_webserver: bool = True # We handle Gradio ourselves
347
+ request_media_backend: str | None = "no_media" # Don't need camera
348
+
349
+ def __init__(self):
350
+ super().__init__()
351
+ self.is_dancing = False
352
+ self.analyzer: Optional[AudioAnalyzer] = None
353
+ self.controller: Optional[DanceController] = None
354
+ self.current_style = "groovy"
355
+ self.intensity = 0.7
356
+ self.sensitivity = 0.7
357
+ self.latest_features = AudioFeatures()
358
+
359
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
360
+ """Main app loop."""
361
+ # Start UI in background
362
+ ui_thread = threading.Thread(
363
+ target=self._run_ui,
364
+ args=(reachy_mini, stop_event),
365
+ daemon=True
366
+ )
367
+ ui_thread.start()
368
+
369
+ # Movement loop
370
+ last_time = time.time()
371
+
372
+ while not stop_event.is_set():
373
+ current_time = time.time()
374
+ dt = current_time - last_time
375
+ last_time = current_time
376
+
377
+ if self.is_dancing and self.analyzer and self.controller:
378
+ self.latest_features = self.analyzer.latest_features
379
+ movement = self.controller.update(self.latest_features, dt)
380
+
381
+ try:
382
+ head_pose = create_head_pose(
383
+ z=movement['head_z'],
384
+ roll=movement['head_roll'],
385
+ mm=True,
386
+ degrees=True
387
+ )
388
+ reachy_mini.goto_target(
389
+ head=head_pose,
390
+ antennas=[movement['antenna_left'], movement['antenna_right']],
391
+ body_yaw=np.deg2rad(movement['body_yaw']),
392
+ duration=0.1,
393
+ method="minjerk"
394
+ )
395
+ except Exception as e:
396
+ logger.debug(f"Movement error: {e}")
397
+
398
+ time.sleep(0.05)
399
+
400
+ # Cleanup
401
+ if self.analyzer:
402
+ self.analyzer.stop()
403
+
404
+ def _run_ui(self, reachy_mini: ReachyMini, stop_event: threading.Event):
405
+ """Run Gradio UI."""
406
+ devices = list_audio_devices()
407
+ device_names = [d['name'] for d in devices]
408
+ style_choices = [(s.display_name, name) for name, s in DANCE_STYLES.items()]
409
+
410
+ # Check for loopback
411
+ has_loopback = any('loopback' in d['name'].lower() for d in devices)
412
+
413
+ def start_dancing(device_name, style, intensity, sensitivity):
414
+ if self.is_dancing:
415
+ return get_status()
416
+
417
+ device_idx = None
418
+ for d in devices:
419
+ if d['name'] == device_name:
420
+ device_idx = d['index']
421
+ break
422
+
423
+ self.current_style = style
424
+ self.intensity = intensity
425
+ self.sensitivity = sensitivity
426
+
427
+ dance_style = DANCE_STYLES.get(style, DANCE_STYLES["groovy"])
428
+ self.controller = DanceController(dance_style, intensity)
429
+ self.analyzer = AudioAnalyzer(device_index=device_idx, sensitivity=sensitivity)
430
+ self.analyzer.start()
431
+ self.is_dancing = True
432
+
433
+ return get_status()
434
+
435
+ def stop_dancing():
436
+ self.is_dancing = False
437
+ if self.analyzer:
438
+ self.analyzer.stop()
439
+ self.analyzer = None
440
+ return get_status()
441
+
442
+ def change_style(style):
443
+ self.current_style = style
444
+ if self.controller:
445
+ self.controller.update_style(DANCE_STYLES.get(style, DANCE_STYLES["groovy"]))
446
+ return get_status()
447
+
448
+ def update_intensity(val):
449
+ self.intensity = val
450
+ if self.controller:
451
+ self.controller.update_intensity(val)
452
+
453
+ def update_sensitivity(val):
454
+ self.sensitivity = val
455
+ if self.analyzer:
456
+ self.analyzer.update_sensitivity(val)
457
+
458
+ def try_setup_loopback():
459
+ if setup_loopback():
460
+ return "Loopback enabled! Refresh page to see new devices."
461
+ return "Failed to enable loopback (may need sudo)"
462
+
463
+ def bar(value, color):
464
+ width = int(value * 100)
465
+ return f'<div style="background:#e0e0e0;border-radius:4px;overflow:hidden;"><div style="width:{width}%;height:12px;background:{color};transition:width 0.1s;"></div></div>'
466
+
467
+ def get_status():
468
+ f = self.latest_features
469
+ status = "Dancing!" if self.is_dancing else "Ready"
470
+ color = "#4CAF50" if self.is_dancing else "#666"
471
+ beat = " *" if f.beat_detected and self.is_dancing else ""
472
+ style_name = DANCE_STYLES.get(self.current_style, DANCE_STYLES["groovy"]).display_name
473
+
474
+ return f"""
475
+ <div style="padding:15px;">
476
+ <div style="text-align:center;margin-bottom:15px;">
477
+ <div style="font-size:18px;font-weight:bold;color:{color};">{status}{beat}</div>
478
+ <div style="font-size:12px;color:#888;margin-top:4px;">{style_name}</div>
479
+ </div>
480
+ <div style="text-align:center;margin-bottom:20px;">
481
+ <div style="font-size:48px;font-family:monospace;font-weight:bold;">{f.bpm:.0f}</div>
482
+ <div style="font-size:14px;color:#666;">BPM</div>
483
+ </div>
484
+ <div style="background:#f5f5f5;padding:15px;border-radius:10px;">
485
+ <div style="margin-bottom:12px;">
486
+ <div style="font-size:12px;color:#666;margin-bottom:4px;">Bass</div>
487
+ {bar(f.bass, '#e91e63')}
488
+ </div>
489
+ <div style="margin-bottom:12px;">
490
+ <div style="font-size:12px;color:#666;margin-bottom:4px;">Mid</div>
491
+ {bar(f.mid, '#9c27b0')}
492
+ </div>
493
+ <div style="margin-bottom:12px;">
494
+ <div style="font-size:12px;color:#666;margin-bottom:4px;">Treble</div>
495
+ {bar(f.treble, '#3f51b5')}
496
+ </div>
497
+ <div>
498
+ <div style="font-size:12px;color:#666;margin-bottom:4px;">Energy</div>
499
+ {bar(f.rms, '#4CAF50')}
500
+ </div>
501
+ </div>
502
+ </div>
503
+ """
504
+
505
+ with gr.Blocks(title="Spotify Dancer", theme=gr.themes.Soft()) as demo:
506
+ gr.HTML("""
507
+ <div style="background:linear-gradient(135deg,#1DB954 0%,#191414 100%);
508
+ color:white;padding:25px;border-radius:15px;text-align:center;margin-bottom:15px;">
509
+ <h1 style="margin:0;font-size:32px;">Spotify Dancer</h1>
510
+ <p style="margin:8px 0 0 0;opacity:0.9;">Reachy Mini dances to your music</p>
511
+ </div>
512
+ """)
513
+
514
+ if not has_loopback:
515
+ gr.HTML("""
516
+ <div style="background:#fff3cd;border:1px solid #ffc107;padding:10px;border-radius:8px;margin-bottom:15px;">
517
+ <b>Tip:</b> For best results, use 'loopback_in' as audio input.
518
+ This captures the audio stream directly instead of using the microphone.
519
+ </div>
520
+ """)
521
+
522
+ with gr.Row():
523
+ with gr.Column(scale=1):
524
+ device_dropdown = gr.Dropdown(
525
+ choices=device_names,
526
+ value=next((d for d in device_names if 'loopback_in' in d.lower()), device_names[0] if device_names else None),
527
+ label="Audio Input",
528
+ info="Use 'loopback_in' for best results"
529
+ )
530
+
531
+ style_radio = gr.Radio(
532
+ choices=style_choices,
533
+ value="groovy",
534
+ label="Dance Style"
535
+ )
536
+
537
+ intensity_slider = gr.Slider(
538
+ 0.1, 1.0, value=0.7, step=0.1,
539
+ label="Movement Intensity"
540
+ )
541
+
542
+ sensitivity_slider = gr.Slider(
543
+ 0.3, 1.0, value=0.7, step=0.1,
544
+ label="Beat Sensitivity"
545
+ )
546
+
547
+ with gr.Row():
548
+ start_btn = gr.Button("Start Dancing", variant="primary", size="lg")
549
+ stop_btn = gr.Button("Stop", size="lg")
550
+
551
+ if not has_loopback:
552
+ loopback_btn = gr.Button("Enable Loopback", size="sm")
553
+ loopback_status = gr.Textbox(label="", visible=False)
554
+ loopback_btn.click(fn=try_setup_loopback, outputs=[loopback_status])
555
+
556
+ with gr.Column(scale=1):
557
+ status_html = gr.HTML(value=get_status())
558
+
559
+ timer = gr.Timer(value=0.2)
560
+ timer.tick(fn=get_status, outputs=[status_html])
561
+
562
+ start_btn.click(
563
+ fn=start_dancing,
564
+ inputs=[device_dropdown, style_radio, intensity_slider, sensitivity_slider],
565
+ outputs=[status_html]
566
+ )
567
+ stop_btn.click(fn=stop_dancing, outputs=[status_html])
568
+ style_radio.change(fn=change_style, inputs=[style_radio], outputs=[status_html])
569
+ intensity_slider.change(fn=update_intensity, inputs=[intensity_slider])
570
+ sensitivity_slider.change(fn=update_sensitivity, inputs=[sensitivity_slider])
571
+
572
+ demo.launch(server_name="0.0.0.0", server_port=7860, quiet=True, prevent_thread_lock=True)
573
+
574
+ while not stop_event.is_set():
575
+ time.sleep(1)
576
+
577
+
578
+ if __name__ == "__main__":
579
+ app = SpotifyDancer()
580
+ try:
581
+ app.wrapped_run()
582
+ except KeyboardInterrupt:
583
+ app.stop()
style.css CHANGED
@@ -1,28 +1,98 @@
 
 
 
 
 
 
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
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
  body {
8
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
9
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
10
+ color: #fff;
11
+ min-height: 100vh;
12
+ padding: 20px;
13
+ }
14
+
15
+ .container {
16
+ max-width: 800px;
17
+ margin: 0 auto;
18
+ }
19
+
20
+ .header {
21
+ text-align: center;
22
+ padding: 40px 20px;
23
+ background: linear-gradient(135deg, #1DB954 0%, #191414 100%);
24
+ border-radius: 20px;
25
+ margin-bottom: 30px;
26
+ }
27
+
28
+ .header h1 {
29
+ font-size: 2.5em;
30
+ margin-bottom: 10px;
31
+ }
32
+
33
+ .header p {
34
+ font-size: 1.2em;
35
+ opacity: 0.9;
36
+ }
37
+
38
+ .features {
39
+ display: grid;
40
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
41
+ gap: 20px;
42
+ margin-bottom: 30px;
43
+ }
44
+
45
+ .feature {
46
+ background: rgba(255, 255, 255, 0.1);
47
+ padding: 20px;
48
+ border-radius: 15px;
49
+ text-align: center;
50
+ }
51
+
52
+ .feature h3 {
53
+ font-size: 1.3em;
54
+ margin-bottom: 10px;
55
+ color: #1DB954;
56
+ }
57
+
58
+ .feature p {
59
+ opacity: 0.8;
60
+ font-size: 0.95em;
61
+ }
62
+
63
+ .install, .setup {
64
+ background: rgba(255, 255, 255, 0.05);
65
+ padding: 25px;
66
+ border-radius: 15px;
67
+ margin-bottom: 20px;
68
+ }
69
+
70
+ .install h2, .setup h2 {
71
+ color: #1DB954;
72
+ margin-bottom: 15px;
73
  }
74
 
75
+ code {
76
+ background: #000;
77
+ padding: 10px 15px;
78
+ border-radius: 8px;
79
+ display: inline-block;
80
+ font-family: 'Monaco', 'Consolas', monospace;
81
+ margin-top: 10px;
82
  }
83
 
84
+ .setup ol {
85
+ padding-left: 25px;
 
 
 
86
  }
87
 
88
+ .setup li {
89
+ margin-bottom: 10px;
90
+ line-height: 1.6;
 
 
 
91
  }
92
 
93
+ .footer {
94
+ text-align: center;
95
+ padding: 20px;
96
+ opacity: 0.6;
97
+ font-size: 0.9em;
98
  }