From 5977e6fc7d919839d53107123dffe27741ab26af Mon Sep 17 00:00:00 2001 From: Lucca Pirovano Date: Mon, 20 Jul 2026 15:26:57 -0400 Subject: [PATCH] reduced latency by not having ssh run on every V press and just sending to tcp aplay when v is held --- naowalk.py | 102 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 69 insertions(+), 33 deletions(-) diff --git a/naowalk.py b/naowalk.py index 42869ce..eb18c89 100644 --- a/naowalk.py +++ b/naowalk.py @@ -152,10 +152,16 @@ class SoundReceiver: class LiveMicRelay: """ Push-to-talk: streams the local mic straight to NAO's own speaker. - On start(), SSHes into the robot and launches `nc -l | aplay` there, - then opens a plain TCP socket to that listener and pipes raw mic - audio into it for as long as the key is held. Closing the socket on - stop() ends the remote nc process, which ends aplay with it. + + 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): @@ -167,6 +173,7 @@ class LiveMicRelay: 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 @@ -179,17 +186,62 @@ class LiveMicRelay: # 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: + 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" + ) + 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 + + 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 start(self): if self.active: return if not HAS_AUDIO: print("šŸŽ™ļø Live mic needs pyaudio installed") return - if not HAS_SSH: - print("šŸŽ™ļø Live mic needs paramiko: pip install paramiko") + 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/_ssh_* for a new one. + # 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 @@ -200,7 +252,7 @@ class LiveMicRelay: def stop(self): """Signal the relay thread to stop and wait for it to exit. - Deliberately does NOT touch self._stream/_sock/_ssh_* here: this is + 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 @@ -212,6 +264,9 @@ class LiveMicRelay: 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 @@ -222,34 +277,11 @@ class LiveMicRelay: self._stream.close() except: pass self._stream = None - 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 def _run(self): try: - 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) - - # Raw 16-bit mono PCM in, straight to ALSA out. If pitch/speed - # sounds off, the robot's ALSA default rate doesn't match - # self.rate - pass --relay-rate to match it (try 48000). - remote_cmd = ( - f"nc -l -p {self.port} | " - f"aplay -q -r {self.rate} -f S16_LE -c 1 -t raw -" - ) - print("šŸŽ™ļø Talk relay: starting remote listener...") - self._ssh_channel = self._ssh_client.get_transport().open_session() - self._ssh_channel.exec_command(remote_cmd) - time.sleep(0.4) # give nc a moment to bind before we connect - + # 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, @@ -535,6 +567,9 @@ class NaoTeleop: self._safe("moveInit", self.motion.moveInit) print("🦶 Walk engine ready") + if getattr(self, "mic_relay", None): + threading.Thread(target=self.mic_relay.connect, daemon=True).start() + 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: @@ -781,6 +816,7 @@ class NaoTeleop: 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):