reduced latency by not having ssh run on every V press and just sending to tcp aplay when v is held

This commit is contained in:
Lucca Pirovano
2026-07-20 15:26:57 -04:00
parent 6a97da3b86
commit 5977e6fc7d
+69 -33
View File
@@ -152,10 +152,16 @@ class SoundReceiver:
class LiveMicRelay: class LiveMicRelay:
""" """
Push-to-talk: streams the local mic straight to NAO's own speaker. 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 connect() (call once at startup, off the hot path) SSHes into the
audio into it for as long as the key is held. Closing the socket on robot and launches a self-restarting `nc -l | aplay` loop that stays
stop() ends the remote nc process, which ends aplay with it. 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", def __init__(self, nao_ip, ssh_user="nao", ssh_password="nao",
port=5555, rate=16000, input_device_index=None, pyaudio_instance=None): 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.input_device_index = input_device_index
self.active = False 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 # "idle" | "connecting" | "live" - read by the HUD to show status
self.state = "idle" self.state = "idle"
self._ssh_client = None self._ssh_client = None
@@ -179,17 +186,62 @@ class LiveMicRelay:
# backend (pa_atomic_load assertion). # backend (pa_atomic_load assertion).
self._p = pyaudio_instance if pyaudio_instance is not None else (pyaudio.PyAudio() if HAS_AUDIO else None) 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): def start(self):
if self.active: if self.active:
return return
if not HAS_AUDIO: if not HAS_AUDIO:
print("🎙️ Live mic needs pyaudio installed") print("🎙️ Live mic needs pyaudio installed")
return return
if not HAS_SSH: if not self.connected:
print("🎙️ Live mic needs paramiko: pip install paramiko") print("🎙️ Talk relay: remote listener not ready yet")
return return
# If a previous relay thread is still tearing itself down, wait for # 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(): if self._thread and self._thread.is_alive():
self._thread.join(timeout=2.0) self._thread.join(timeout=2.0)
self.active = True self.active = True
@@ -200,7 +252,7 @@ class LiveMicRelay:
def stop(self): def stop(self):
"""Signal the relay thread to stop and wait for it to exit. """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 called from the main/render thread on key-up, and closing a
PortAudio stream from a different thread while the relay thread is PortAudio stream from a different thread while the relay thread is
mid blocking-read() on it is what caused the ALSA segfault on mid blocking-read() on it is what caused the ALSA segfault on
@@ -212,6 +264,9 @@ class LiveMicRelay:
self._thread.join(timeout=2.0) self._thread.join(timeout=2.0)
def _cleanup(self): 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: if self._sock:
try: self._sock.close() try: self._sock.close()
except: pass except: pass
@@ -222,34 +277,11 @@ class LiveMicRelay:
self._stream.close() self._stream.close()
except: pass except: pass
self._stream = None 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): def _run(self):
try: try:
self._ssh_client = paramiko.SSHClient() # Remote listener is already up (from connect()) - just make a
self._ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # fast local socket connection, no SSH handshake here.
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
self._sock = socket.create_connection((self.nao_ip, self.port), timeout=5) self._sock = socket.create_connection((self.nao_ip, self.port), timeout=5)
self._stream = self._p.open(format=pyaudio.paInt16, channels=1, self._stream = self._p.open(format=pyaudio.paInt16, channels=1,
@@ -535,6 +567,9 @@ class NaoTeleop:
self._safe("moveInit", self.motion.moveInit) self._safe("moveInit", self.motion.moveInit)
print("🦶 Walk engine ready") 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") 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: try:
@@ -781,6 +816,7 @@ class NaoTeleop:
self._safe("music stop", self.music.stop) self._safe("music stop", self.music.stop)
if getattr(self, "mic_relay", None): if getattr(self, "mic_relay", None):
self._safe("mic relay stop", self.mic_relay.stop) self._safe("mic relay stop", self.mic_relay.stop)
self._safe("mic relay close", self.mic_relay.close)
if self.audio_device: if self.audio_device:
self._safe("audio unsubscribe", lambda: self.audio_device.unsubscribe(self.audio_service_name)) self._safe("audio unsubscribe", lambda: self.audio_device.unsubscribe(self.audio_service_name))
if getattr(self, "sound_receiver", None): if getattr(self, "sound_receiver", None):