refined the music player, probably going to gut the pause and seek functionality since everything else about it works perfectly. now i need to fix the start of the walk cycle toppling the robot and the low battery errors being so loud and repetitive they drown out the music

This commit is contained in:
Lucca Pirovano
2026-07-15 18:55:56 -04:00
parent 043c4c3f09
commit c69f8ca7d0
+225 -63
View File
@@ -10,9 +10,10 @@ import queue
import cv2 import cv2
import numpy as np import numpy as np
import pygame import pygame
import yt_dlp
import subprocess import subprocess
import os import os
import json
import re
# --- Audio for mic --- # --- Audio for mic ---
try: try:
@@ -137,87 +138,168 @@ class NaoMusicPlayer:
self.player = session.service("ALAudioPlayer") self.player = session.service("ALAudioPlayer")
self.nao_ip = nao_ip self.nao_ip = nao_ip
self.music_dir = "/home/nao/music" self.music_dir = "/home/nao/music"
self.local_path = "/tmp/song.mp3"
self.remote_path = f"{self.music_dir}/song.mp3"
try: try:
subprocess.run(["sshpass", "-p", "nao", "ssh", f"nao@{nao_ip}", f"mkdir -p {self.music_dir}"], subprocess.run(["sshpass", "-p", "nao", "ssh", f"nao@{nao_ip}", f"mkdir -p {self.music_dir}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except: pass except: pass
self.current_file = None self.current_file = None
self.current_task_id = None
self.is_playing = False self.is_playing = False
self.paused_position = 0.0
self.current_title = "" self.current_title = ""
self.is_loading = False
self.load_error = None
self.status = ""
self.progress = 0.0
def search(self, query): def search(self, query):
try: try:
ydl_opts = { cmd = ["yt-dlp", f"ytsearch5:{query}", "--flat-playlist", "--dump-json",
'format': 'bestaudio/best', "--no-warnings", "--quiet"]
'quiet': True, result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
'noplaylist': True, entries = []
'extract_flat': True, for line in result.stdout.strip().splitlines():
} if not line.strip():
with yt_dlp.YoutubeDL(ydl_opts) as ydl: continue
info = ydl.extract_info(f"ytsearch5:{query}", download=False) try:
return info.get('entries', []) data = json.loads(line)
except: except json.JSONDecodeError:
continue
vid = data.get("id")
url = data.get("url")
if not url or not url.startswith("http"):
url = f"https://www.youtube.com/watch?v={vid}" if vid else None
if url:
entries.append({"url": url, "title": data.get("title", "Unknown")})
return entries
except Exception as e:
print(f"Search error: {e}")
return [] return []
_DL_PROGRESS_RE = re.compile(r'\[download\]\s+([\d.]+)%')
def play(self, url, title): def play(self, url, title):
self.stop() self.stop()
self.current_title = title self.current_title = title
self.is_loading = True
self.load_error = None
self.status = "Starting download..."
self.progress = 0.0
try: try:
ydl_opts = { if os.path.exists(self.local_path):
'format': 'bestaudio/best', os.remove(self.local_path)
'outtmpl': '/tmp/%(id)s.%(ext)s', except: pass
'quiet': True,
'noplaylist': True,
'extractaudio': True,
'postprocessors': [{'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3'}],
'ignoreerrors': True,
'no_warnings': True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
if not info:
print("Download failed")
return False
downloaded = ydl.prepare_filename(info)
remote_path = f"{self.music_dir}/{int(time.time())}.mp3" try:
result = subprocess.run(["sshpass", "-p", "nao", "scp", downloaded, f"nao@{self.nao_ip}:{remote_path}"], cmd = ["yt-dlp", url,
capture_output=True, text=True) "-x", "--audio-format", "mp3",
if result.returncode != 0: "-o", self.local_path,
print(f"SCP failed: {result.stderr}") "--no-playlist", "--no-warnings", "--newline"]
print(f"\n▶ yt-dlp {url}")
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1)
for line in proc.stdout:
line = line.rstrip()
if not line:
continue
print(f" [yt-dlp] {line}")
m = self._DL_PROGRESS_RE.search(line)
if m:
self.progress = float(m.group(1))
self.status = f"Downloading... {self.progress:.0f}%"
elif "ExtractAudio" in line or "Extracting audio" in line:
self.status = "Converting to mp3..."
elif line.startswith("[download] Destination"):
self.status = "Downloading..."
proc.wait(timeout=120)
if proc.returncode != 0 or not os.path.exists(self.local_path):
print(f"Download failed (exit {proc.returncode})")
self.load_error = "Download failed"
return False return False
self.current_file = remote_path size_mb = os.path.getsize(self.local_path) / (1024 * 1024)
self.player.playFile(remote_path) print(f"▶ scp {self.local_path} -> nao@{self.nao_ip}:{self.remote_path} ({size_mb:.1f} MB)")
self.progress = 0.0
upload_start = time.time()
stop_ticker = threading.Event()
def ticker():
while not stop_ticker.is_set():
elapsed = time.time() - upload_start
self.status = f"Uploading to NAO ({size_mb:.1f} MB)... {elapsed:.0f}s"
stop_ticker.wait(1.0)
t = threading.Thread(target=ticker, daemon=True)
t.start()
scp_result = subprocess.run(
["sshpass", "-p", "nao", "scp", self.local_path, f"nao@{self.nao_ip}:{self.remote_path}"],
capture_output=True, text=True, timeout=60
)
stop_ticker.set()
t.join(timeout=1.0)
print(f" [scp] exit {scp_result.returncode}" +
(f": {scp_result.stderr.strip()}" if scp_result.stderr.strip() else ""))
if scp_result.returncode != 0:
print(f"SCP failed: {scp_result.stderr.strip()}")
self.load_error = "SCP failed"
return False
self.current_file = self.remote_path
self.status = "Playing"
self.progress = 100.0
self.current_task_id = self.player.playFile(self.remote_path)
self.is_playing = True self.is_playing = True
print(f"♪ Playing on NAO: {title}") print(f"♪ Playing on NAO: {title} (task {self.current_task_id})")
return True return True
except Exception as e: except Exception as e:
print(f"Playback error: {e}") print(f"Playback error: {e}")
self.load_error = str(e)
return False return False
finally:
self.is_loading = False
def pause(self): def pause(self):
if not self.current_file: return if self.current_task_id is None or not self.current_file:
print("⏸ Nothing loaded to pause/resume")
return
try:
if self.is_playing: if self.is_playing:
self.player.pause() pos = self.player.getCurrentPosition(self.current_task_id)
self.player.stopAll()
self.paused_position = max(0.0, pos)
self.is_playing = False self.is_playing = False
print(f"♪ Paused at {self.paused_position:.1f}s")
else: else:
self.player.play(self.current_file) self.current_task_id = self.player.playFileFromPosition(
self.current_file, self.paused_position, 1.0, 0.0
)
self.is_playing = True self.is_playing = True
print(f"♪ Resumed from {self.paused_position:.1f}s (task {self.current_task_id})")
except Exception as e:
print(f"Pause/resume error: {e}")
self.load_error = f"Pause error: {e}"
def stop(self): def stop(self):
try: try:
self.player.stopAll() self.player.stopAll()
except: except Exception as e:
pass print(f"Stop error: {e}")
self.is_playing = False self.is_playing = False
self.current_file = None self.current_file = None
self.current_task_id = None
self.paused_position = 0.0
def seek(self, seconds): def seek(self, seconds):
if self.current_task_id is None:
return
try: try:
pos = self.player.getCurrentPosition() / 1000.0 pos = self.player.getCurrentPosition(self.current_task_id)
self.player.goTo(pos + seconds) self.player.goTo(self.current_task_id, max(0.0, pos + seconds))
except: pass except Exception as e:
print(f"Seek error: {e}")
# ========================================== # ==========================================
@@ -256,6 +338,7 @@ class NaoTeleop:
self.chat_message = "" self.chat_message = ""
self.music_search_mode = False self.music_search_mode = False
self.music_search_text = "" self.music_search_text = ""
self.music_search_pending = False
self.video_scale = video_scale self.video_scale = video_scale
self.display_width = int(320 * video_scale) self.display_width = int(320 * video_scale)
@@ -343,12 +426,18 @@ class NaoTeleop:
if keys[pygame.K_j]: self.head_yaw = min(self.head_yaw + speed, 2.0); changed = True if keys[pygame.K_j]: self.head_yaw = min(self.head_yaw + speed, 2.0); changed = True
if keys[pygame.K_l]: self.head_yaw = max(self.head_yaw - speed, -2.0); changed = True if keys[pygame.K_l]: self.head_yaw = max(self.head_yaw - speed, -2.0); changed = True
if changed: if changed:
try:
self.motion.setAngles(["HeadYaw", "HeadPitch"], [self.head_yaw, self.head_pitch], 0.3) self.motion.setAngles(["HeadYaw", "HeadPitch"], [self.head_yaw, self.head_pitch], 0.3)
except Exception as e:
print(f"Head move error: {e}")
def reset_head(self): def reset_head(self):
self.head_yaw = 0.0 self.head_yaw = 0.0
self.head_pitch = 0.0 self.head_pitch = 0.0
try:
self.motion.setAngles(["HeadYaw", "HeadPitch"], [0.0, 0.0], 0.4) self.motion.setAngles(["HeadYaw", "HeadPitch"], [0.0, 0.0], 0.4)
except Exception as e:
print(f"Head reset error: {e}")
def update_title(self): def update_title(self):
mode = "[TYPING] " if self.typing_mode else "" mode = "[TYPING] " if self.typing_mode else ""
@@ -359,7 +448,8 @@ class NaoTeleop:
if self.audio_device: if self.audio_device:
try: try:
self.audio_device.setOutputVolume(self.volume) self.audio_device.setOutputVolume(self.volume)
except: pass except Exception as e:
print(f"Volume set error: {e}")
print(f"🔊 NAO volume: {self.volume}%") print(f"🔊 NAO volume: {self.volume}%")
self.update_title() self.update_title()
@@ -381,9 +471,11 @@ class NaoTeleop:
carpet_config = [["StepHeight", 0.028], ["MaxStepFrequency", 0.6], ["TorsoWy", 0.05]] carpet_config = [["StepHeight", 0.028], ["MaxStepFrequency", 0.6], ["TorsoWy", 0.05]]
print("\n🎮 Controls: WASD=Walk | IJKL=Head | SPACE=Reset head | 1=Wave | M=Music search | P=Pause | X=Stop | ←→=Seek | T=TTS | ESC=Quit") print("\n🎮 Controls: WASD/Arrows=Walk | IJKL=Head | SPACE=Reset head | 1=Wave | M=Music search (↑↓ select, Enter confirm) | P=Pause | X=Stop | ,/.=Seek | -/==Volume | T=TTS | ESC=Quit")
try:
while self.running: while self.running:
try:
self.check_battery() self.check_battery()
for event in pygame.event.get(): for event in pygame.event.get():
@@ -392,31 +484,57 @@ class NaoTeleop:
elif event.type == pygame.KEYDOWN: elif event.type == pygame.KEYDOWN:
if self.music_search_mode: if self.music_search_mode:
if event.key == pygame.K_RETURN: if event.key == pygame.K_RETURN:
if self.music_search_text.strip():
self.music_results = self.music.search(self.music_search_text)
if self.music_results: if self.music_results:
self.selected_result = 0 entry = self.music_results[self.selected_result]
else: threading.Thread(
target=self.music.play,
args=(entry['url'], entry.get('title', 'Unknown')),
daemon=True
).start()
self.music_search_mode = False self.music_search_mode = False
elif self.music_search_text.strip() and not self.music_search_pending:
query = self.music_search_text
self.music_search_pending = True
def do_search(q=query):
results = self.music.search(q)
self.music_results = results
self.selected_result = 0
self.music_search_pending = False
if not results:
self.music_search_mode = False
threading.Thread(target=do_search, daemon=True).start()
else: else:
self.music_search_mode = False self.music_search_mode = False
elif event.key == pygame.K_ESCAPE: elif event.key == pygame.K_ESCAPE:
self.music_search_mode = False self.music_search_mode = False
self.music_search_text = "" self.music_search_text = ""
self.music_results = []
elif event.key == pygame.K_UP:
if self.music_results:
self.selected_result = (self.selected_result - 1) % len(self.music_results[:5])
elif event.key == pygame.K_DOWN:
if self.music_results:
self.selected_result = (self.selected_result + 1) % len(self.music_results[:5])
elif event.key == pygame.K_BACKSPACE: elif event.key == pygame.K_BACKSPACE:
self.music_search_text = self.music_search_text[:-1] self.music_search_text = self.music_search_text[:-1]
self.music_results = []
elif event.key in (pygame.K_1, pygame.K_2, pygame.K_3, pygame.K_4, pygame.K_5): elif event.key in (pygame.K_1, pygame.K_2, pygame.K_3, pygame.K_4, pygame.K_5):
idx = int(event.unicode) - 1 idx = int(event.unicode) - 1
if idx < len(self.music_results): if idx < len(self.music_results):
entry = self.music_results[idx] entry = self.music_results[idx]
self.music.play(entry['url'], entry.get('title', 'Unknown')) threading.Thread(
target=self.music.play,
args=(entry['url'], entry.get('title', 'Unknown')),
daemon=True
).start()
self.music_search_mode = False self.music_search_mode = False
else: elif event.unicode.isprintable():
self.music_search_text += event.unicode self.music_search_text += event.unicode
self.music_results = []
elif self.typing_mode: elif self.typing_mode:
if event.key == pygame.K_RETURN: if event.key == pygame.K_RETURN:
if self.chat_message.strip(): if self.chat_message.strip():
threading.Thread(target=self.tts.say, args=(self.chat_message,)).start() threading.Thread(target=self.tts.say, args=(self.chat_message,), daemon=True).start()
self.chat_message = "" self.chat_message = ""
self.typing_mode = False self.typing_mode = False
elif event.key == pygame.K_ESCAPE: elif event.key == pygame.K_ESCAPE:
@@ -441,13 +559,20 @@ class NaoTeleop:
self.music.pause() self.music.pause()
elif event.key == pygame.K_x: elif event.key == pygame.K_x:
self.music.stop() self.music.stop()
elif event.key == pygame.K_LEFT: elif event.key == pygame.K_COMMA:
self.music.seek(-10) self.music.seek(-10)
elif event.key == pygame.K_RIGHT: elif event.key == pygame.K_PERIOD:
self.music.seek(10) self.music.seek(10)
elif event.key in (pygame.K_MINUS, pygame.K_KP_MINUS):
self.change_volume(-5)
elif event.key in (pygame.K_EQUALS, pygame.K_KP_PLUS):
self.change_volume(5)
elif event.key == pygame.K_t: elif event.key == pygame.K_t:
self.typing_mode = True self.typing_mode = True
try:
self.motion.stopMove() self.motion.stopMove()
except Exception as e:
print(f"stopMove error: {e}")
if not self.typing_mode and not self.music_search_mode: if not self.typing_mode and not self.music_search_mode:
keys = pygame.key.get_pressed() keys = pygame.key.get_pressed()
@@ -457,10 +582,13 @@ class NaoTeleop:
if keys[pygame.K_a] or keys[pygame.K_LEFT]: theta = 0.5 if keys[pygame.K_a] or keys[pygame.K_LEFT]: theta = 0.5
if keys[pygame.K_d] or keys[pygame.K_RIGHT]: theta = -0.5 if keys[pygame.K_d] or keys[pygame.K_RIGHT]: theta = -0.5
try:
if abs(x) > 0.1 or abs(theta) > 0.1: if abs(x) > 0.1 or abs(theta) > 0.1:
self.motion.moveToward(x, y, theta, carpet_config) self.motion.moveToward(x, y, theta, carpet_config)
else: else:
self.motion.stopMove() self.motion.stopMove()
except Exception as e:
print(f"Move error: {e}")
self.handle_head_movement() self.handle_head_movement()
frame = self.get_image() frame = self.get_image()
@@ -473,14 +601,26 @@ class NaoTeleop:
self.screen.fill((10, 10, 30)) self.screen.fill((10, 10, 30))
# Music status # Music status
if self.music.current_title: if self.music.is_loading:
text = self.font.render(f"{self.music.status}", True, (255, 200, 0))
self.screen.blit(text, (10, 10))
if self.music.progress > 0:
bar_w = 220
pct = min(self.music.progress, 100) / 100.0
pygame.draw.rect(self.screen, (70, 70, 70), (10, 32, bar_w, 8))
pygame.draw.rect(self.screen, (255, 200, 0), (10, 32, int(bar_w * pct), 8))
elif self.music.load_error:
text = self.font.render(f"♪ Error: {self.music.load_error}", True, (255, 80, 80))
self.screen.blit(text, (10, 10))
elif self.music.current_title:
status = f"{self.music.current_title[:45]} {'(Paused)' if not self.music.is_playing else ''}" status = f"{self.music.current_title[:45]} {'(Paused)' if not self.music.is_playing else ''}"
text = self.font.render(status, True, (0, 255, 100)) text = self.font.render(status, True, (0, 255, 100))
self.screen.blit(text, (10, 10)) self.screen.blit(text, (10, 10))
# Music search interface # Music search interface
if self.music_search_mode: if self.music_search_mode:
s = pygame.Surface((self.display_width, self.display_height//2)) box_h = min(self.display_height - 80, 260)
s = pygame.Surface((self.display_width, box_h))
s.set_alpha(220) s.set_alpha(220)
s.fill((0, 0, 40)) s.fill((0, 0, 40))
self.screen.blit(s, (0, 80)) self.screen.blit(s, (0, 80))
@@ -489,14 +629,24 @@ class NaoTeleop:
self.screen.blit(text, (20, 100)) self.screen.blit(text, (20, 100))
if self.music_results: if self.music_results:
for i, entry in enumerate(self.music_results[:5]): shown = self.music_results[:5]
for i, entry in enumerate(shown):
row_y = 140 + i * 30
if i == self.selected_result:
pygame.draw.rect(self.screen, (90, 90, 0), (16, row_y - 3, self.display_width - 32, 26))
color = (255, 255, 0) if i == self.selected_result else (200, 200, 200) color = (255, 255, 0) if i == self.selected_result else (200, 200, 200)
title = entry.get('title', 'No title')[:60] title = entry.get('title', 'No title')[:60]
line = self.font.render(f"{i+1}. {title}", True, color) line = self.font.render(f"{i+1}. {title}", True, color)
self.screen.blit(line, (20, 140 + i*30)) self.screen.blit(line, (20, row_y))
else: hint_y = 140 + len(shown) * 30 + 6
hint = self.font.render("\u2191\u2193 Select Enter Confirm 1-5 Quick pick Esc Cancel", True, (150, 150, 150))
self.screen.blit(hint, (20, hint_y))
elif self.music_search_pending:
text = self.font.render("Searching...", True, (200, 200, 200)) text = self.font.render("Searching...", True, (200, 200, 200))
self.screen.blit(text, (20, 140)) self.screen.blit(text, (20, 140))
else:
text = self.font.render("No results. Type to search again, Enter to search", True, (200, 200, 200))
self.screen.blit(text, (20, 140))
# TTS box # TTS box
if self.typing_mode: if self.typing_mode:
@@ -509,19 +659,31 @@ class NaoTeleop:
pygame.display.flip() pygame.display.flip()
self.clock.tick(0) self.clock.tick(0)
except Exception as e:
print(f"\u26a0\ufe0f Frame error (continuing): {e}")
finally:
print("🛑 Shutting down...") print("🛑 Shutting down...")
try:
self.motion.stopMove() self.motion.stopMove()
except Exception as e:
print(f"stopMove error: {e}")
try:
self.music.stop() self.music.stop()
except Exception as e:
print(f"music stop error: {e}")
if self.audio_device: if self.audio_device:
try: self.audio_device.unsubscribe(self.audio_service_name) try: self.audio_device.unsubscribe(self.audio_service_name)
except: pass except Exception as e: print(f"audio unsubscribe error: {e}")
if getattr(self, "sound_receiver", None): if getattr(self, "sound_receiver", None):
self.sound_receiver.close() try: self.sound_receiver.close()
except Exception as e: print(f"sound receiver close error: {e}")
if hasattr(self, 'video') and self.video_client: if hasattr(self, 'video') and self.video_client:
try: self.video.unsubscribe(self.video_client) try: self.video.unsubscribe(self.video_client)
except: pass except Exception as e: print(f"video unsubscribe error: {e}")
try:
self.motion.rest() self.motion.rest()
except Exception as e:
print(f"rest error: {e}")
pygame.quit() pygame.quit()