made the streams good even on an iffy network... audio latency just needs some work now
This commit is contained in:
+103
-60
@@ -13,7 +13,7 @@ import pygame
|
||||
import subprocess
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import socket
|
||||
|
||||
# Import the new music module
|
||||
@@ -33,12 +33,37 @@ try:
|
||||
except ImportError:
|
||||
HAS_SSH = False
|
||||
|
||||
# --- HTTP client for the on-robot MJPEG relay (see nao_video_server.py) ---
|
||||
try:
|
||||
import requests
|
||||
HAS_REQUESTS = True
|
||||
except ImportError:
|
||||
HAS_REQUESTS = False
|
||||
# ==========================================
|
||||
# MEDIA RELAY WIRE PROTOCOL (see nao_video_server.py)
|
||||
# One TCP connection, length-framed: [1 byte type][4 byte length][payload].
|
||||
# Replaces the old HTTP MJPEG pull - no multipart parsing, and now
|
||||
# carries mu-law-compressed mic audio too, not just video.
|
||||
# ==========================================
|
||||
TYPE_HELLO = 0x00
|
||||
TYPE_VIDEO = 0x01
|
||||
TYPE_AUDIO = 0x02
|
||||
|
||||
|
||||
def decode_ulaw(q):
|
||||
"""uint8 mu-law samples -> int16 PCM. Must match encode_ulaw() in
|
||||
nao_video_server.py exactly, or this just produces noise."""
|
||||
mu = 255.0
|
||||
y = (q.astype(np.float64) / 255.0) * 2.0 - 1.0
|
||||
x = np.sign(y) * (1.0 / mu) * (np.power(1.0 + mu, np.abs(y)) - 1.0)
|
||||
return np.clip(x * 32768.0, -32768, 32767).astype(np.int16)
|
||||
|
||||
|
||||
def _recv_exact(sock, n):
|
||||
"""Read exactly n bytes from a TCP socket (recv() can return short
|
||||
reads/chunks at any point - never assume one call gives you n)."""
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise IOError("media relay connection closed")
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
# ==========================================
|
||||
# NAO MIC STREAM
|
||||
@@ -89,14 +114,22 @@ class SoundReceiver:
|
||||
except: pass
|
||||
|
||||
def processRemote(self, nbOfChannels, nbOfSamplesByChannel, timeStamp, buffer):
|
||||
self.feed(bytes(buffer))
|
||||
|
||||
def feed(self, raw_pcm_bytes):
|
||||
"""Push one chunk of 16-bit PCM into the playback queue,
|
||||
dropping the oldest chunk on overflow instead of blocking.
|
||||
Used both by the direct qi audio callback (processRemote,
|
||||
above) and by the TCP media relay path (which mu-law-decodes
|
||||
back to PCM before calling this)."""
|
||||
if not HAS_AUDIO: return
|
||||
self.chunks_received += 1
|
||||
try:
|
||||
self.queue.put_nowait(bytes(buffer))
|
||||
self.queue.put_nowait(raw_pcm_bytes)
|
||||
except queue.Full:
|
||||
try:
|
||||
self.queue.get_nowait()
|
||||
self.queue.put_nowait(bytes(buffer))
|
||||
self.queue.put_nowait(raw_pcm_bytes)
|
||||
except: pass
|
||||
|
||||
def _playback_loop(self):
|
||||
@@ -320,7 +353,7 @@ class LiveMicRelay:
|
||||
class NaoTeleop:
|
||||
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", nao_ssh_port=22, relay_port=5555, relay_rate=16000,
|
||||
video_fps=25, video_http_url=None):
|
||||
video_fps=25, media_relay=None):
|
||||
self.session = session
|
||||
self.nao_ip = nao_ip
|
||||
self.motion = session.service("ALMotion")
|
||||
@@ -367,7 +400,12 @@ class NaoTeleop:
|
||||
|
||||
self.video_scale = video_scale
|
||||
self.video_fps = video_fps
|
||||
self.video_http_url = video_http_url
|
||||
self.media_relay = media_relay
|
||||
# True only if we actually subscribed to ALAudioDevice ourselves
|
||||
# (i.e. not using the relay, where nao_video_server.py owns the
|
||||
# mic subscription instead) - used at shutdown so we don't try
|
||||
# to unsubscribe from something we never subscribed to.
|
||||
self._qi_audio_subscribed = False
|
||||
self.display_width = int(320 * video_scale)
|
||||
self.display_height = int(240 * video_scale)
|
||||
|
||||
@@ -410,15 +448,11 @@ class NaoTeleop:
|
||||
self.audio_device = None
|
||||
|
||||
def _init_video_maxfps(self):
|
||||
if self.video_http_url:
|
||||
if not HAS_REQUESTS:
|
||||
print("❌ --video-http-url given but 'requests' isn't installed (pip install requests)")
|
||||
self.video_client = None
|
||||
return
|
||||
if self.media_relay:
|
||||
self.video = None
|
||||
self.video_client = "http" # truthy sentinel, unsubscribe() is a no-op for this path
|
||||
print("✅ Camera via on-robot MJPEG relay: %s" % self.video_http_url)
|
||||
threading.Thread(target=self._video_loop_http, daemon=True).start()
|
||||
self.video_client = "relay" # truthy sentinel, unsubscribe() is a no-op for this path
|
||||
print(f"✅ Camera + mic via on-robot media relay: {self.media_relay}")
|
||||
threading.Thread(target=self._media_relay_loop, daemon=True).start()
|
||||
return
|
||||
try:
|
||||
self.video = self.session.service("ALVideoDevice")
|
||||
@@ -448,45 +482,44 @@ class NaoTeleop:
|
||||
except Exception:
|
||||
time.sleep(0.05)
|
||||
|
||||
def _video_loop_http(self):
|
||||
"""Same role as _video_loop, but pulls an already-JPEG-encoded
|
||||
MJPEG stream from nao_video_server.py running on the robot,
|
||||
instead of raw RGB frames over the qi RPC session - this is
|
||||
the whole point of doing the re-encoding on-robot rather than
|
||||
shipping raw pixels across the phone tunnel."""
|
||||
boundary = b"--frame"
|
||||
def _media_relay_loop(self):
|
||||
"""Same role as _video_loop, but speaks the lean length-framed
|
||||
TCP protocol from nao_video_server.py instead of pulling an
|
||||
HTTP MJPEG stream - and now demuxes BOTH video and mu-law-
|
||||
compressed mic audio off the same connection, since the mic
|
||||
subscription lives on the robot too now instead of shipping
|
||||
raw PCM through the qi RPC session."""
|
||||
host, _, port_str = self.media_relay.partition(":")
|
||||
port = int(port_str) if port_str else 8000
|
||||
while not self._video_stop_event.is_set():
|
||||
sock = None
|
||||
try:
|
||||
resp = requests.get(self.video_http_url, stream=True, timeout=5)
|
||||
buf = b""
|
||||
for chunk in resp.iter_content(chunk_size=4096):
|
||||
if self._video_stop_event.is_set():
|
||||
return
|
||||
buf += chunk
|
||||
while True:
|
||||
start = buf.find(boundary)
|
||||
if start == -1:
|
||||
break
|
||||
hdr_end = buf.find(b"\r\n\r\n", start)
|
||||
if hdr_end == -1:
|
||||
break
|
||||
m = re.search(rb"Content-Length:\s*(\d+)", buf[start:hdr_end])
|
||||
if not m:
|
||||
buf = buf[hdr_end + 4:]
|
||||
continue
|
||||
length = int(m.group(1))
|
||||
frame_start = hdr_end + 4
|
||||
if len(buf) < frame_start + length:
|
||||
break
|
||||
jpeg = buf[frame_start:frame_start + length]
|
||||
buf = buf[frame_start + length:]
|
||||
arr = np.frombuffer(jpeg, dtype=np.uint8)
|
||||
sock = socket.create_connection((host, port), timeout=5)
|
||||
sock.settimeout(5.0)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
print(f"✅ Media relay connected: {host}:{port}")
|
||||
while not self._video_stop_event.is_set():
|
||||
msg_type, length = struct.unpack(">BI", _recv_exact(sock, 5))
|
||||
payload = _recv_exact(sock, length) if length else b""
|
||||
|
||||
if msg_type == TYPE_VIDEO:
|
||||
arr = np.frombuffer(payload, dtype=np.uint8)
|
||||
frame = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||
if frame is not None:
|
||||
self._latest_frame = cv2.resize(frame, (320, 240), interpolation=cv2.INTER_NEAREST)
|
||||
self._latest_frame_ts = time.time()
|
||||
elif msg_type == TYPE_AUDIO:
|
||||
if getattr(self, "sound_receiver", None):
|
||||
pcm = decode_ulaw(np.frombuffer(payload, dtype=np.uint8))
|
||||
self.sound_receiver.feed(pcm.tobytes())
|
||||
# TYPE_HELLO carries {"rate", "channels"} for future use - nothing to do with it yet.
|
||||
except Exception as e:
|
||||
print(f"video relay error: {e}")
|
||||
print(f"media relay error: {e}")
|
||||
finally:
|
||||
if sock:
|
||||
try: sock.close()
|
||||
except: pass
|
||||
if not self._video_stop_event.is_set():
|
||||
time.sleep(0.5)
|
||||
|
||||
def get_image(self):
|
||||
@@ -498,13 +531,20 @@ class NaoTeleop:
|
||||
def _init_mic_stream(self):
|
||||
if not self.audio_device: return
|
||||
try:
|
||||
# Playback machinery is needed either way - only the source
|
||||
# of audio chunks differs (qi pub/sub below, vs mu-law
|
||||
# chunks arriving over the media relay in _media_relay_loop).
|
||||
self.sound_receiver = SoundReceiver(self.audio_device_index, self.mic_gain,
|
||||
pyaudio_instance=self._pyaudio)
|
||||
if self.media_relay:
|
||||
print(f"✅ Mic stream ready (gain={self.mic_gain}x, via media relay - compressed)")
|
||||
return
|
||||
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)
|
||||
self.audio_device.subscribe(self.audio_service_name)
|
||||
print(f"✅ Mic stream ready (gain={self.mic_gain}x)")
|
||||
self._qi_audio_subscribed = True
|
||||
print(f"✅ Mic stream ready (gain={self.mic_gain}x, uncompressed direct - use --media-relay on a tunneled link)")
|
||||
except Exception as e:
|
||||
print(f"❌ Mic init failed: {e}")
|
||||
|
||||
@@ -930,7 +970,7 @@ class NaoTeleop:
|
||||
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:
|
||||
if self.audio_device and self._qi_audio_subscribed:
|
||||
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)
|
||||
@@ -956,12 +996,15 @@ if __name__ == "__main__":
|
||||
help="Camera fps requested from NAO. Video is uncompressed RGB at "
|
||||
"160x120, so this is roughly 460KB/s per fps - lower it "
|
||||
"(e.g. 10-12) when tunneling over a constrained link. "
|
||||
"Ignored when --video-http-url is set (the server controls fps).")
|
||||
parser.add_argument("--video-http-url", type=str, default=None,
|
||||
help="URL of the on-robot MJPEG relay (nao_video_server.py), e.g. "
|
||||
"http://127.0.0.1:8000/video when tunneled through the phone. "
|
||||
"When set, video comes from this compressed HTTP stream instead "
|
||||
"of raw frames over the qi session - much lighter over a relay.")
|
||||
"Ignored when --media-relay is set (the server controls fps).")
|
||||
parser.add_argument("--media-relay", type=str, default=None,
|
||||
help="host:port of the on-robot media relay (nao_video_server.py), "
|
||||
"e.g. 127.0.0.1:8000 when tunneled through the phone. When set, "
|
||||
"BOTH video and mic audio come compressed over this one TCP "
|
||||
"connection (JPEG + mu-law) instead of raw frames/PCM over the "
|
||||
"qi session - use this on a tunneled/shoddy link. --mic-channel "
|
||||
"is ignored in this mode (set MIC_CHANNEL in nao_video_server.py "
|
||||
"instead, since the mic subscription lives there now).")
|
||||
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",
|
||||
@@ -989,7 +1032,7 @@ if __name__ == "__main__":
|
||||
mic_gain=args.mic_gain,
|
||||
video_scale=args.video_scale,
|
||||
video_fps=args.video_fps,
|
||||
video_http_url=args.video_http_url,
|
||||
media_relay=args.media_relay,
|
||||
nao_ssh_user=args.nao_ssh_user,
|
||||
nao_ssh_password=args.nao_ssh_password,
|
||||
nao_ssh_port=args.nao_ssh_port,
|
||||
|
||||
Reference in New Issue
Block a user