Compare commits
15 Commits
bf3e197a20
...
5977e6fc7d
| Author | SHA1 | Date | |
|---|---|---|---|
| 5977e6fc7d | |||
| 6a97da3b86 | |||
| 17c7da2125 | |||
| b30fc10648 | |||
| f806fa8efc | |||
| e1b5e3ab76 | |||
| 929687dea5 | |||
| a6767bf154 | |||
| f4140328e6 | |||
| c6b9cb0718 | |||
| ca196f5f1e | |||
| 158372e528 | |||
| ab67b128c1 | |||
| cdb2e1f90f | |||
| 99f2d6a9cd |
Binary file not shown.
+41
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: "Refactoring Python project files and code"
|
||||
tags: []
|
||||
author: claude
|
||||
count: 2
|
||||
exporter: 3.1.0
|
||||
date: 2026-07-16T17-38-58+0000
|
||||
url: https://claude.ai/chat/313cfd88-e6b9-4256-b155-0b389d2b86c4
|
||||
---
|
||||
|
||||
# Refactoring Python project files and code
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [1: make a requirements.txt, readme.md, and also remove the seek function…](#chat-1)
|
||||
- [2: how much faster can i make it walk, can we make it adjustable during r…](#chat-2)
|
||||
|
||||
## chat-1
|
||||
|
||||
> make a requirements.txt, readme.md, and also remove the seek function completely and reduce the lines of code without hurting stability
|
||||
|
||||
Now the `pause()` method — simplify to a plain stop/restart toggle (no seek, no position math):
|
||||
|
||||
Now let's add the `_safe` helper to `NaoTeleop` and use it to collapse the repeated try/except boilerplate:
|
||||
|
||||
Now the shutdown block — this is where `_safe` pays off the most:
|
||||
|
||||
Good — that's stable. Now let's view the final file once more end-to-end to make sure everything reads cleanly before shipping it.
|
||||
|
||||
This looks clean and correct. Now let's write the `requirements.txt` and `README.md`.
|
||||
|
||||
Good, all confirmed. Now the README:
|
||||
|
||||
Now let's copy everything to outputs and present the files:
|
||||
|
||||
___
|
||||
###### [top](#table-of-contents)
|
||||
## chat-2
|
||||
|
||||
> how much faster can i make it walk, can we make it adjustable during runtime? we might need to change its posture so its less likely to fall and more balanced for carpet
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: "Your first chat with Claude"
|
||||
tags: []
|
||||
author: claude
|
||||
count: 5
|
||||
exporter: 3.1.0
|
||||
date: 2026-07-16T20-40-30+0000
|
||||
url: https://claude.ai/chat/838b001f-8bf8-4539-98ec-467513fb9860
|
||||
---
|
||||
|
||||
# Your first chat with Claude
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [1: add the 6 7 meme as a third gesture](#chat-1)
|
||||
- [2: kinda looks like hes flailing his arms around, isnt it just the elbows…](#chat-2)
|
||||
- [3: right motions, but the nao currently extends his arms out, then moves…](#chat-3)
|
||||
- [4: perfect, now just rotate the hands so the palm faces up, everything el…](#chat-4)
|
||||
- [5: make him tts say six seven, also remove the input delay on starting th…](#chat-5)
|
||||
|
||||
## chat-1
|
||||
|
||||
> add the 6 7 meme as a third gesture
|
||||
|
||||
## chat-2
|
||||
|
||||
> kinda looks like hes flailing his arms around, isnt it just the elbows moving in the meme? with the arms in front
|
||||
|
||||
Now let me replace the whole method with a version where the shoulders lock the arms up in front and only the elbows do the seesaw motion.
|
||||
|
||||
___
|
||||
###### [top](#table-of-contents)
|
||||
## chat-3
|
||||
|
||||
> right motions, but the nao currently extends his arms out, then moves his elbows to push the forearms close to his chest. i think the rotation is wrong on the elbow
|
||||
|
||||
That makes sense — at `ElbowYaw=0` the elbow's bend axis is oriented so flexing curls the forearm in across the body toward the chest, not up/down in place. Rotating `ElbowYaw` about 90° turns that same hinge into a front-facing "tip" instead, and I'll shrink the swing so it's a wobble, not a full extend-then-fold.
|
||||
|
||||
___
|
||||
###### [top](#table-of-contents)
|
||||
## chat-4
|
||||
|
||||
> perfect, now just rotate the hands so the palm faces up, everything else is perfect
|
||||
|
||||
## chat-5
|
||||
|
||||
> make him tts say six seven, also remove the input delay on starting the 3rd gesture
|
||||
|
||||
`self.tts.say(...)` is a blocking call, so it was speaking first and only starting the arm motion once the phrase finished — that's the delay. Making it fire on its own thread lets the gesture start immediately while the speech plays alongside it.
|
||||
|
||||
___
|
||||
###### [top](#table-of-contents)
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
# naomusic.py
|
||||
# -*- encoding: UTF-8 -*-
|
||||
|
||||
import subprocess
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
|
||||
class NaoMusicPlayer:
|
||||
def __init__(self, session, nao_ip):
|
||||
self.session = session
|
||||
self.player = session.service("ALAudioPlayer")
|
||||
self.nao_ip = nao_ip
|
||||
self.music_dir = "/home/nao/music"
|
||||
self.local_path = "/tmp/song.mp3"
|
||||
self.remote_path = f"{self.music_dir}/song.mp3"
|
||||
try:
|
||||
subprocess.run(["sshpass", "-p", "nao", "ssh", f"nao@{nao_ip}", f"mkdir -p {self.music_dir}"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except: pass
|
||||
self.current_file = None
|
||||
self.current_task_id = None
|
||||
self.is_playing = False
|
||||
self.current_title = ""
|
||||
self.is_loading = False
|
||||
self.load_error = None
|
||||
self.status = ""
|
||||
self.progress = 0.0
|
||||
|
||||
def search(self, query):
|
||||
try:
|
||||
cmd = ["yt-dlp", f"ytsearch5:{query}", "--flat-playlist", "--dump-json",
|
||||
"--no-warnings", "--quiet"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
entries = []
|
||||
for line in result.stdout.strip().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
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 []
|
||||
|
||||
_DL_PROGRESS_RE = re.compile(r'\[download\]\s+([\d.]+)%')
|
||||
|
||||
def play(self, url, title):
|
||||
self.stop()
|
||||
self.current_title = title
|
||||
self.is_loading = True
|
||||
self.load_error = None
|
||||
self.status = "Starting download..."
|
||||
self.progress = 0.0
|
||||
try:
|
||||
if os.path.exists(self.local_path):
|
||||
os.remove(self.local_path)
|
||||
except: pass
|
||||
|
||||
try:
|
||||
cmd = ["yt-dlp", url,
|
||||
"-x", "--audio-format", "mp3",
|
||||
"-o", self.local_path,
|
||||
"--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
|
||||
|
||||
size_mb = os.path.getsize(self.local_path) / (1024 * 1024)
|
||||
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
|
||||
print(f"♪ Playing on NAO: {title} (task {self.current_task_id})")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Playback error: {e}")
|
||||
self.load_error = str(e)
|
||||
return False
|
||||
finally:
|
||||
self.is_loading = False
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
self.player.stopAll()
|
||||
except Exception as e:
|
||||
print(f"Stop error: {e}")
|
||||
self.is_playing = False
|
||||
self.current_file = None
|
||||
self.current_task_id = None
|
||||
self.current_title = ""
|
||||
+354
-177
@@ -14,6 +14,10 @@ import subprocess
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
|
||||
# Import the new music module
|
||||
from naomusic import NaoMusicPlayer
|
||||
|
||||
# --- Audio for mic ---
|
||||
try:
|
||||
@@ -22,6 +26,13 @@ try:
|
||||
except ImportError:
|
||||
HAS_AUDIO = False
|
||||
|
||||
# --- SSH for live mic relay (push-to-talk) ---
|
||||
try:
|
||||
import paramiko
|
||||
HAS_SSH = True
|
||||
except ImportError:
|
||||
HAS_SSH = False
|
||||
|
||||
# ==========================================
|
||||
# NAO MIC STREAM
|
||||
# ==========================================
|
||||
@@ -29,7 +40,7 @@ class SoundReceiver:
|
||||
PREBUFFER_CHUNKS = 3
|
||||
SILENCE_CHUNK = b"\x00" * 4096
|
||||
|
||||
def __init__(self, output_device_index=None, mic_gain=8.0):
|
||||
def __init__(self, output_device_index=None, mic_gain=8.0, pyaudio_instance=None):
|
||||
self.running = True
|
||||
self.underrun_count = 0
|
||||
self.chunks_received = 0
|
||||
@@ -37,9 +48,12 @@ class SoundReceiver:
|
||||
self._peak_since_print = 0
|
||||
self._last_status_print = 0
|
||||
self.mic_gain = float(mic_gain)
|
||||
self.muted = False
|
||||
self._owns_pyaudio = False
|
||||
|
||||
if HAS_AUDIO:
|
||||
self.p = pyaudio.PyAudio()
|
||||
self.p = pyaudio_instance if pyaudio_instance is not None else pyaudio.PyAudio()
|
||||
self._owns_pyaudio = pyaudio_instance is None
|
||||
print("🔈 Available output devices:")
|
||||
for i in range(self.p.get_device_count()):
|
||||
info = self.p.get_device_info_by_index(i)
|
||||
@@ -106,6 +120,9 @@ class SoundReceiver:
|
||||
|
||||
def _write_chunk(self, raw_bytes):
|
||||
try:
|
||||
if self.muted:
|
||||
self.stream.write(b"\x00" * len(raw_bytes))
|
||||
return
|
||||
samples = np.frombuffer(raw_bytes, dtype=np.int16).astype(np.float32)
|
||||
if samples.size:
|
||||
amplified = np.clip(samples * self.mic_gain, -32768, 32767)
|
||||
@@ -125,180 +142,176 @@ class SoundReceiver:
|
||||
try:
|
||||
self.stream.stop_stream()
|
||||
self.stream.close()
|
||||
if self._owns_pyaudio:
|
||||
self.p.terminate()
|
||||
except: pass
|
||||
|
||||
# ==========================================
|
||||
# LIVE MIC RELAY (push-to-talk over SSH)
|
||||
# ==========================================
|
||||
class LiveMicRelay:
|
||||
"""
|
||||
Push-to-talk: streams the local mic straight to NAO's own speaker.
|
||||
|
||||
# ==========================================
|
||||
# MUSIC PLAYER ON NAO (scp)
|
||||
# ==========================================
|
||||
class NaoMusicPlayer:
|
||||
def __init__(self, session, nao_ip):
|
||||
self.session = session
|
||||
self.player = session.service("ALAudioPlayer")
|
||||
connect() (call once at startup, off the hot path) SSHes into the
|
||||
robot and launches a self-restarting `nc -l | aplay` loop that stays
|
||||
up for the whole session. Each start()/stop() (per push-to-talk
|
||||
press) then just opens/closes a plain TCP socket to that already-
|
||||
running listener and pipes raw mic audio into it - no SSH handshake
|
||||
per press. Closing the socket on stop() ends that iteration's nc,
|
||||
which ends aplay with it, and the remote loop immediately relaunches
|
||||
nc so it's ready for the next press. close() (call once at shutdown)
|
||||
tears down the persistent SSH connection and kills the remote loop.
|
||||
"""
|
||||
def __init__(self, nao_ip, ssh_user="nao", ssh_password="nao",
|
||||
port=5555, rate=16000, input_device_index=None, pyaudio_instance=None):
|
||||
self.nao_ip = nao_ip
|
||||
self.music_dir = "/home/nao/music"
|
||||
self.local_path = "/tmp/song.mp3"
|
||||
self.remote_path = f"{self.music_dir}/song.mp3"
|
||||
self.ssh_user = ssh_user
|
||||
self.ssh_password = ssh_password
|
||||
self.port = port
|
||||
self.rate = rate
|
||||
self.input_device_index = input_device_index
|
||||
|
||||
self.active = False
|
||||
self.connected = False # remote listener is up, ready for fast local connects
|
||||
# "idle" | "connecting" | "live" - read by the HUD to show status
|
||||
self.state = "idle"
|
||||
self._ssh_client = None
|
||||
self._ssh_channel = None
|
||||
self._sock = None
|
||||
self._stream = None
|
||||
self._thread = None
|
||||
# Reuse the app's single PyAudio context - a second independent one
|
||||
# opened from a background thread crashes PortAudio's PulseAudio
|
||||
# backend (pa_atomic_load assertion).
|
||||
self._p = pyaudio_instance if pyaudio_instance is not None else (pyaudio.PyAudio() if HAS_AUDIO else None)
|
||||
|
||||
def connect(self):
|
||||
"""One-time setup: SSH in and launch a self-restarting remote
|
||||
listener (`nc | aplay` in a loop) that stays up for the whole app
|
||||
session. Call this once at startup, in a background thread so it
|
||||
doesn't block. After this, start()/stop() per push-to-talk press
|
||||
only do a fast local socket connect - no SSH in the hot path.
|
||||
"""
|
||||
if not HAS_SSH:
|
||||
print("🎙️ Live mic needs paramiko: pip install paramiko")
|
||||
return
|
||||
try:
|
||||
subprocess.run(["sshpass", "-p", "nao", "ssh", f"nao@{nao_ip}", f"mkdir -p {self.music_dir}"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except: pass
|
||||
self.current_file = None
|
||||
self.current_task_id = None
|
||||
self.is_playing = False
|
||||
self.current_title = ""
|
||||
self.is_loading = False
|
||||
self.load_error = None
|
||||
self.status = ""
|
||||
self.progress = 0.0
|
||||
|
||||
def search(self, query):
|
||||
try:
|
||||
cmd = ["yt-dlp", f"ytsearch5:{query}", "--flat-playlist", "--dump-json",
|
||||
"--no-warnings", "--quiet"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
entries = []
|
||||
for line in result.stdout.strip().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
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 []
|
||||
|
||||
_DL_PROGRESS_RE = re.compile(r'\[download\]\s+([\d.]+)%')
|
||||
|
||||
def play(self, url, title):
|
||||
self.stop()
|
||||
self.current_title = title
|
||||
self.is_loading = True
|
||||
self.load_error = None
|
||||
self.status = "Starting download..."
|
||||
self.progress = 0.0
|
||||
try:
|
||||
if os.path.exists(self.local_path):
|
||||
os.remove(self.local_path)
|
||||
except: pass
|
||||
|
||||
try:
|
||||
cmd = ["yt-dlp", url,
|
||||
"-x", "--audio-format", "mp3",
|
||||
"-o", self.local_path,
|
||||
"--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
|
||||
|
||||
size_mb = os.path.getsize(self.local_path) / (1024 * 1024)
|
||||
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
|
||||
self._ssh_client = paramiko.SSHClient()
|
||||
self._ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
self._ssh_client.connect(self.nao_ip, username=self.ssh_user,
|
||||
password=self.ssh_password, timeout=5)
|
||||
self._ssh_channel = self._ssh_client.get_transport().open_session()
|
||||
# A pty ties the remote process group to this channel, so
|
||||
# closing it in close() reliably kills the loop/nc/aplay
|
||||
# instead of leaving an orphaned process on the robot.
|
||||
self._ssh_channel.get_pty()
|
||||
remote_cmd = (
|
||||
f"while true; do nc -l -p {self.port} | "
|
||||
f"aplay -q -r {self.rate} -f S16_LE -c 1 -t raw -; done"
|
||||
)
|
||||
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._ssh_channel.exec_command(remote_cmd)
|
||||
time.sleep(0.4) # one-time wait for the first nc to bind
|
||||
self.connected = True
|
||||
print("🎙️ Talk relay: remote listener ready")
|
||||
except Exception as e:
|
||||
print(f"🎙️ Talk relay: couldn't set up remote listener ({e})")
|
||||
self.connected = 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
|
||||
print(f"♪ Playing on NAO: {title} (task {self.current_task_id})")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Playback error: {e}")
|
||||
self.load_error = str(e)
|
||||
return False
|
||||
finally:
|
||||
self.is_loading = False
|
||||
def close(self):
|
||||
"""Tear down the persistent SSH connection/listener. Call once at
|
||||
app shutdown - not per push-to-talk press."""
|
||||
if self._ssh_channel:
|
||||
try: self._ssh_channel.close()
|
||||
except: pass
|
||||
self._ssh_channel = None
|
||||
if self._ssh_client:
|
||||
try: self._ssh_client.close()
|
||||
except: pass
|
||||
self._ssh_client = None
|
||||
self.connected = False
|
||||
|
||||
def pause(self):
|
||||
"""Toggle playback. No position tracking: resuming restarts the track from the top."""
|
||||
if self.current_task_id is None or not self.current_file:
|
||||
print("⏸ Nothing loaded to pause/resume")
|
||||
def start(self):
|
||||
if self.active:
|
||||
return
|
||||
if self.is_playing:
|
||||
try:
|
||||
self.player.stopAll()
|
||||
except Exception as e:
|
||||
print(f"stopAll error while pausing: {e}")
|
||||
self.load_error = f"Pause error: {e}"
|
||||
if not HAS_AUDIO:
|
||||
print("🎙️ Live mic needs pyaudio installed")
|
||||
return
|
||||
self.is_playing = False
|
||||
print("♪ Paused")
|
||||
else:
|
||||
try:
|
||||
self.current_task_id = self.player.playFile(self.current_file)
|
||||
self.is_playing = True
|
||||
print(f"♪ Resumed from start (task {self.current_task_id})")
|
||||
except Exception as e:
|
||||
print(f"Resume error: {e}")
|
||||
self.load_error = f"Resume error: {e}"
|
||||
if not self.connected:
|
||||
print("🎙️ Talk relay: remote listener not ready yet")
|
||||
return
|
||||
# If a previous relay thread is still tearing itself down, wait for
|
||||
# it to finish before reusing self._stream/_sock for a new one.
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=2.0)
|
||||
self.active = True
|
||||
self.state = "connecting"
|
||||
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
self.player.stopAll()
|
||||
except Exception as e:
|
||||
print(f"Stop error: {e}")
|
||||
self.is_playing = False
|
||||
self.current_file = None
|
||||
self.current_task_id = None
|
||||
self.current_title = ""
|
||||
"""Signal the relay thread to stop and wait for it to exit.
|
||||
|
||||
Deliberately does NOT touch self._stream/_sock here: this is
|
||||
called from the main/render thread on key-up, and closing a
|
||||
PortAudio stream from a different thread while the relay thread is
|
||||
mid blocking-read() on it is what caused the ALSA segfault on
|
||||
release. Only _run() (via _cleanup, in its own thread) closes them.
|
||||
"""
|
||||
self.active = False
|
||||
self.state = "idle"
|
||||
if self._thread and self._thread.is_alive() and threading.current_thread() is not self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
|
||||
def _cleanup(self):
|
||||
"""Per-press teardown: only the local socket/mic stream. The SSH
|
||||
connection is persistent now - close() (at app shutdown) is what
|
||||
tears that down, not this."""
|
||||
if self._sock:
|
||||
try: self._sock.close()
|
||||
except: pass
|
||||
self._sock = None
|
||||
if self._stream:
|
||||
try:
|
||||
self._stream.stop_stream()
|
||||
self._stream.close()
|
||||
except: pass
|
||||
self._stream = None
|
||||
|
||||
def _run(self):
|
||||
try:
|
||||
# Remote listener is already up (from connect()) - just make a
|
||||
# fast local socket connection, no SSH handshake here.
|
||||
self._sock = socket.create_connection((self.nao_ip, self.port), timeout=5)
|
||||
|
||||
self._stream = self._p.open(format=pyaudio.paInt16, channels=1,
|
||||
rate=self.rate, input=True,
|
||||
input_device_index=self.input_device_index,
|
||||
frames_per_buffer=1024)
|
||||
print("🎙️ Talk relay: live")
|
||||
if self.active: # could've been stopped while we were still connecting
|
||||
self.state = "live"
|
||||
|
||||
while self.active:
|
||||
try:
|
||||
chunk = self._stream.read(1024, exception_on_overflow=False)
|
||||
self._sock.sendall(chunk)
|
||||
except Exception:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"🎙️ Talk relay error: {e}")
|
||||
finally:
|
||||
self.active = False
|
||||
self.state = "idle"
|
||||
self._cleanup()
|
||||
print("🎙️ Talk relay: stopped")
|
||||
|
||||
# ==========================================
|
||||
# MAIN TELEOP
|
||||
# ==========================================
|
||||
class NaoTeleop:
|
||||
def __init__(self, session, nao_ip, audio_device_index=None, mic_channel=1, mic_gain=8.0, video_scale=2.0):
|
||||
def __init__(self, session, nao_ip, audio_device_index=None, mic_channel=1, mic_gain=8.0, video_scale=2.0,
|
||||
nao_ssh_user="nao", nao_ssh_password="nao", relay_port=5555, relay_rate=16000):
|
||||
self.session = session
|
||||
self.nao_ip = nao_ip
|
||||
self.motion = session.service("ALMotion")
|
||||
@@ -319,12 +332,22 @@ class NaoTeleop:
|
||||
self.mic_channel = mic_channel
|
||||
self.mic_gain = mic_gain
|
||||
|
||||
# One shared PyAudio context for the whole app - SoundReceiver (mic
|
||||
# playback) and LiveMicRelay (push-to-talk capture) both use it,
|
||||
# since two separate contexts crash PortAudio's PulseAudio backend.
|
||||
self._pyaudio = pyaudio.PyAudio() if HAS_AUDIO else None
|
||||
|
||||
self.mic_relay = LiveMicRelay(nao_ip, ssh_user=nao_ssh_user, ssh_password=nao_ssh_password,
|
||||
port=relay_port, rate=relay_rate, pyaudio_instance=self._pyaudio)
|
||||
self.ptt_active = False
|
||||
|
||||
self.running = True
|
||||
self.head_yaw = 0.0
|
||||
self.head_pitch = 0.0
|
||||
self.battery_level = 100
|
||||
self.last_batt_check = 0
|
||||
self.volume = 50
|
||||
self.walk_speed = 0.6
|
||||
|
||||
self.typing_mode = False
|
||||
self.chat_message = ""
|
||||
@@ -349,7 +372,6 @@ class NaoTeleop:
|
||||
self.update_title()
|
||||
|
||||
def _safe(self, label, fn):
|
||||
"""Run fn(), log+swallow any exception so one failure can't crash the loop."""
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e:
|
||||
@@ -377,7 +399,8 @@ class NaoTeleop:
|
||||
def _init_mic_stream(self):
|
||||
if not self.audio_device: return
|
||||
try:
|
||||
self.sound_receiver = SoundReceiver(self.audio_device_index, self.mic_gain)
|
||||
self.sound_receiver = SoundReceiver(self.audio_device_index, self.mic_gain,
|
||||
pyaudio_instance=self._pyaudio)
|
||||
self.session.registerService(self.audio_service_name, self.sound_receiver)
|
||||
time.sleep(0.5)
|
||||
self.audio_device.setClientPreferences(self.audio_service_name, 16000, self.mic_channel, 0)
|
||||
@@ -412,10 +435,84 @@ class NaoTeleop:
|
||||
self.motion.angleInterpolation(names, angles, times, True)
|
||||
neutral = [1.5, -0.15, 1.2, 0.5, 0.0, 0.0]
|
||||
self.motion.angleInterpolationWithSpeed(names, neutral, 0.3)
|
||||
self.motion.setStiffnesses("RArm", 0.6)
|
||||
# Relaxed stiffness after completion
|
||||
self.motion.setStiffnesses("RArm", 0.3)
|
||||
except Exception as e:
|
||||
print(f"Wave error: {e}")
|
||||
|
||||
def cena_gesture(self):
|
||||
if self.typing_mode: return
|
||||
print("👋 YOU CAN'T SEE ME!")
|
||||
try:
|
||||
self.tts.say("You can't see me")
|
||||
|
||||
self.motion.setStiffnesses("RArm", 1.0)
|
||||
names = ["RShoulderPitch", "RShoulderRoll", "RElbowYaw", "RElbowRoll", "RWristYaw", "RHand"]
|
||||
|
||||
# Setup for tight, face-level sweep
|
||||
tight_setup = [-0.4, -0.2, 0.0, 1.2, 1.5, 1.0]
|
||||
self.motion.angleInterpolation(names, tight_setup, [0.5]*6, True)
|
||||
|
||||
# The horizontal "backhand" sweep
|
||||
self.motion.angleInterpolation(["RElbowYaw"], [0.8], 0.4, True)
|
||||
self.motion.angleInterpolation(["RElbowYaw"], [-0.8], 0.6, True)
|
||||
self.motion.angleInterpolation(["RElbowYaw"], [0.0], 0.4, True)
|
||||
|
||||
# Return to neutral
|
||||
neutral = [1.5, -0.15, 1.2, 0.5, 0.0, 0.0]
|
||||
self.motion.angleInterpolationWithSpeed(names, neutral, 0.3)
|
||||
# Relaxed stiffness after completion
|
||||
self.motion.setStiffnesses("RArm", 0.3)
|
||||
except Exception as e:
|
||||
print(f"Cena gesture error: {e}")
|
||||
|
||||
def six_seven_gesture(self):
|
||||
if self.typing_mode: return
|
||||
print("🖐️ SIX... SEVEN...")
|
||||
try:
|
||||
threading.Thread(target=self.tts.say, args=("six seven",), daemon=True).start()
|
||||
|
||||
self.motion.setStiffnesses(["RArm", "LArm"], 1.0)
|
||||
|
||||
# Lock both arms up in front of the body, hands held out like a scale.
|
||||
# ElbowYaw is rotated ~90° off zero here - that's what points the elbow's
|
||||
# bend axis so flexing tips the forearm forward/down instead of curling
|
||||
# it in across the chest. These joints don't move again until the return.
|
||||
hold_names = [
|
||||
"RShoulderPitch", "RShoulderRoll", "RElbowYaw", "RWristYaw", "RHand",
|
||||
"LShoulderPitch", "LShoulderRoll", "LElbowYaw", "LWristYaw", "LHand"
|
||||
]
|
||||
hold_angles = [
|
||||
0.2, -0.2, 1.4, 1.57, 0.6,
|
||||
0.2, 0.2, -1.4, -1.57, 0.6
|
||||
]
|
||||
self.motion.angleInterpolationWithSpeed(hold_names, hold_angles, 0.25)
|
||||
|
||||
# Only the elbows seesaw now - a small tip back and forth, one arm
|
||||
# rising while the other falls, like weighing something in each hand
|
||||
# ("six... seven..."). Small amplitude so it stays a wobble, not a fold.
|
||||
elbow_names = ["RElbowRoll", "LElbowRoll"]
|
||||
r_low, r_high = 0.6, 1.0
|
||||
l_low, l_high = -0.6, -1.0
|
||||
|
||||
elbow_angles = [
|
||||
[r_low, r_high, r_low, r_high, r_low, r_high],
|
||||
[l_high, l_low, l_high, l_low, l_high, l_low],
|
||||
]
|
||||
times = [[0.35, 0.7, 1.05, 1.4, 1.75, 2.1]] * 2
|
||||
self.motion.angleInterpolation(elbow_names, elbow_angles, times, True)
|
||||
|
||||
# Return everything to neutral
|
||||
neutral_names = hold_names + elbow_names
|
||||
neutral = [1.5, -0.15, 1.2, 0.0, 0.0,
|
||||
1.5, 0.15, -1.2, 0.0, 0.0,
|
||||
0.5, -0.5]
|
||||
self.motion.angleInterpolationWithSpeed(neutral_names, neutral, 0.3)
|
||||
# Relaxed stiffness after completion
|
||||
self.motion.setStiffnesses(["RArm", "LArm"], 0.3)
|
||||
except Exception as e:
|
||||
print(f"Six seven gesture error: {e}")
|
||||
|
||||
def handle_head_movement(self):
|
||||
if self.typing_mode or self.music_search_mode: return
|
||||
keys = pygame.key.get_pressed()
|
||||
@@ -436,7 +533,7 @@ class NaoTeleop:
|
||||
|
||||
def update_title(self):
|
||||
mode = "[TYPING] " if self.typing_mode else ""
|
||||
pygame.display.set_caption(f"NAO Teleop | {mode}Battery: {self.battery_level}% | Vol: {self.volume}%")
|
||||
pygame.display.set_caption(f"NAO Teleop | {mode}Battery: {self.battery_level}% | Vol: {self.volume}% | Speed: {self.walk_speed:.2f}")
|
||||
|
||||
def change_volume(self, delta):
|
||||
self.volume = int(min(100, max(0, self.volume + delta)))
|
||||
@@ -456,18 +553,24 @@ class NaoTeleop:
|
||||
self.last_batt_check = now
|
||||
|
||||
def run(self):
|
||||
self.motion.wakeUp()
|
||||
self.posture.goToPosture("StandInit", 0.5)
|
||||
time.sleep(1.0)
|
||||
print("🤖 Enabling stiffness (no wakeUp stand-up)...")
|
||||
self.motion.setStiffnesses("Body", 1.0)
|
||||
|
||||
# Enable walk arms BEFORE forcing stiffness
|
||||
print("💪 Enabling walk arms...")
|
||||
self.motion.setMoveArmsEnabled(True, True)
|
||||
|
||||
# Force relaxed stiffness AFTER engine is active
|
||||
self.motion.setStiffnesses(["RArm", "LArm"], 0.3)
|
||||
|
||||
print("🦶 Initializing walk engine...")
|
||||
self._safe("moveInit", self.motion.moveInit)
|
||||
print("🦶 Walk engine ready")
|
||||
|
||||
carpet_config = [["StepHeight", 0.028], ["MaxStepFrequency", 0.6], ["TorsoWy", 0.05]]
|
||||
if getattr(self, "mic_relay", None):
|
||||
threading.Thread(target=self.mic_relay.connect, daemon=True).start()
|
||||
|
||||
print("\n🎮 Controls: WASD/Arrows=Walk | IJKL=Head | SPACE=Reset head | 1=Wave | M=Music search (↑↓ select, Enter confirm) | P=Pause | X=Stop | -/==Volume | T=TTS | ESC=Quit")
|
||||
print("\n🎮 Controls: WASD/Arrows=Walk | [/]=Speed | IJKL=Head | SPACE=Reset head | 1=Wave | 2=Cena | 3=6-7 | M=Music search | X=Stop | -/==Volume | T=TTS | V=Push-to-talk (hold) | ESC=Quit")
|
||||
|
||||
try:
|
||||
while self.running:
|
||||
@@ -545,14 +648,16 @@ class NaoTeleop:
|
||||
self.running = False
|
||||
elif event.key == pygame.K_1:
|
||||
self.wave_gesture()
|
||||
elif event.key == pygame.K_2:
|
||||
self.cena_gesture()
|
||||
elif event.key == pygame.K_3:
|
||||
self.six_seven_gesture()
|
||||
elif event.key == pygame.K_SPACE:
|
||||
self.reset_head()
|
||||
elif event.key == pygame.K_m:
|
||||
self.music_search_mode = True
|
||||
self.music_search_text = ""
|
||||
self.music_results = []
|
||||
elif event.key == pygame.K_p:
|
||||
self.music.pause()
|
||||
elif event.key == pygame.K_x:
|
||||
self.music.stop()
|
||||
elif event.key in (pygame.K_MINUS, pygame.K_KP_MINUS):
|
||||
@@ -565,15 +670,43 @@ class NaoTeleop:
|
||||
|
||||
if not self.typing_mode and not self.music_search_mode:
|
||||
keys = pygame.key.get_pressed()
|
||||
|
||||
if keys[pygame.K_v] and not self.ptt_active:
|
||||
self.ptt_active = True
|
||||
self.mic_relay.start()
|
||||
if getattr(self, "sound_receiver", None):
|
||||
self.sound_receiver.muted = True
|
||||
elif not keys[pygame.K_v] and self.ptt_active:
|
||||
self.ptt_active = False
|
||||
self.mic_relay.stop()
|
||||
if getattr(self, "sound_receiver", None):
|
||||
self.sound_receiver.muted = False
|
||||
|
||||
speed_changed = False
|
||||
if keys[pygame.K_LEFTBRACKET]:
|
||||
self.walk_speed = max(0.1, self.walk_speed - 0.01)
|
||||
speed_changed = True
|
||||
if keys[pygame.K_RIGHTBRACKET]:
|
||||
self.walk_speed = min(1.0, self.walk_speed + 0.01)
|
||||
speed_changed = True
|
||||
if speed_changed:
|
||||
self.update_title()
|
||||
|
||||
x = y = theta = 0.0
|
||||
if keys[pygame.K_w] or keys[pygame.K_UP]: x = 0.6
|
||||
if keys[pygame.K_s] or keys[pygame.K_DOWN]: x = -0.6
|
||||
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_w] or keys[pygame.K_UP]: x = self.walk_speed
|
||||
if keys[pygame.K_s] or keys[pygame.K_DOWN]: x = -self.walk_speed
|
||||
if keys[pygame.K_a] or keys[pygame.K_LEFT]: theta = self.walk_speed * 0.8
|
||||
if keys[pygame.K_d] or keys[pygame.K_RIGHT]: theta = -self.walk_speed * 0.8
|
||||
|
||||
try:
|
||||
if abs(x) > 0.1 or abs(theta) > 0.1:
|
||||
self.motion.moveToward(x, y, theta, carpet_config)
|
||||
if abs(x) > 0.05 or abs(theta) > 0.05:
|
||||
stable_config = [
|
||||
["StepHeight", 0.02],
|
||||
["MaxStepFrequency", 0.5],
|
||||
["TorsoWy", 0.08],
|
||||
["MaxStepX", 0.03]
|
||||
]
|
||||
self.motion.moveToward(x, y, theta, stable_config)
|
||||
else:
|
||||
self.motion.stopMove()
|
||||
except Exception as e:
|
||||
@@ -602,10 +735,37 @@ class NaoTeleop:
|
||||
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 ''}"
|
||||
text = self.font.render(status, True, (0, 255, 100))
|
||||
text = self.font.render(f"♪ {self.music.current_title[:45]}", True, (0, 255, 100))
|
||||
self.screen.blit(text, (10, 10))
|
||||
|
||||
# Voice chat HUD (top-right) - only shown while V is held / connecting
|
||||
relay_state = getattr(self.mic_relay, "state", "idle")
|
||||
if relay_state == "connecting":
|
||||
label, color = "VOICE: CONNECTING...", (255, 200, 0)
|
||||
elif relay_state == "live":
|
||||
label, color = "VOICE: LIVE - speak now", (0, 255, 100)
|
||||
else:
|
||||
label, color = None, None
|
||||
|
||||
if label:
|
||||
text = self.font.render(label, True, color)
|
||||
pad = 8
|
||||
box_w, box_h = text.get_width() + pad * 2 + 18, text.get_height() + pad
|
||||
box_x = self.display_width - box_w - 10
|
||||
box_y = 10
|
||||
s = pygame.Surface((box_w, box_h))
|
||||
s.set_alpha(180)
|
||||
s.fill((0, 0, 0))
|
||||
self.screen.blit(s, (box_x, box_y))
|
||||
# pulsing dot for "live" so it's easy to catch out of the corner of your eye
|
||||
if relay_state == "live":
|
||||
pulse = 0.5 + 0.5 * abs((pygame.time.get_ticks() % 1000) / 500.0 - 1.0)
|
||||
dot_color = tuple(int(c * pulse) for c in color)
|
||||
else:
|
||||
dot_color = color
|
||||
pygame.draw.circle(self.screen, dot_color, (box_x + pad + 5, box_y + box_h // 2), 5)
|
||||
self.screen.blit(text, (box_x + pad + 18, box_y + pad // 2))
|
||||
|
||||
# Music search interface
|
||||
if self.music_search_mode:
|
||||
box_h = min(self.display_height - 80, 260)
|
||||
@@ -654,12 +814,17 @@ class NaoTeleop:
|
||||
print("🛑 Shutting down...")
|
||||
self._safe("stopMove", self.motion.stopMove)
|
||||
self._safe("music stop", self.music.stop)
|
||||
if getattr(self, "mic_relay", None):
|
||||
self._safe("mic relay stop", self.mic_relay.stop)
|
||||
self._safe("mic relay close", self.mic_relay.close)
|
||||
if self.audio_device:
|
||||
self._safe("audio unsubscribe", lambda: self.audio_device.unsubscribe(self.audio_service_name))
|
||||
if getattr(self, "sound_receiver", None):
|
||||
self._safe("sound receiver close", self.sound_receiver.close)
|
||||
if hasattr(self, 'video') and self.video_client:
|
||||
self._safe("video unsubscribe", lambda: self.video.unsubscribe(self.video_client))
|
||||
if getattr(self, "_pyaudio", None):
|
||||
self._safe("pyaudio terminate", self._pyaudio.terminate)
|
||||
self._safe("rest", self.motion.rest)
|
||||
pygame.quit()
|
||||
|
||||
@@ -674,6 +839,14 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--mic-channel", choices=MIC_CHANNELS.keys(), default="left")
|
||||
parser.add_argument("--mic-gain", type=float, default=8.0)
|
||||
parser.add_argument("--video-scale", type=float, default=2.0)
|
||||
parser.add_argument("--nao-ssh-user", type=str, default="nao",
|
||||
help="SSH login for the live mic relay (push-to-talk)")
|
||||
parser.add_argument("--nao-ssh-password", type=str, default="nao",
|
||||
help="SSH password for the live mic relay (push-to-talk)")
|
||||
parser.add_argument("--relay-port", type=int, default=5555,
|
||||
help="TCP port used to stream mic audio to the robot's nc listener")
|
||||
parser.add_argument("--relay-rate", type=int, default=16000,
|
||||
help="Sample rate for the live mic relay - try 48000 if audio sounds sped up/slow")
|
||||
args = parser.parse_args()
|
||||
|
||||
session = qi.Session()
|
||||
@@ -688,4 +861,8 @@ if __name__ == "__main__":
|
||||
audio_device_index=args.audio_device_index,
|
||||
mic_channel=MIC_CHANNELS[args.mic_channel],
|
||||
mic_gain=args.mic_gain,
|
||||
video_scale=args.video_scale).run()
|
||||
video_scale=args.video_scale,
|
||||
nao_ssh_user=args.nao_ssh_user,
|
||||
nao_ssh_password=args.nao_ssh_password,
|
||||
relay_port=args.relay_port,
|
||||
relay_rate=args.relay_rate).run()
|
||||
|
||||
Reference in New Issue
Block a user