From 7cc738fba792631871d195dc82943ba8c8771e8b Mon Sep 17 00:00:00 2001 From: Lucca Pirovano Date: Wed, 15 Jul 2026 16:56:25 -0400 Subject: [PATCH] amplified the audio stream and added a --mic-gain parameter that defaults to 8 --- naowalk.py | 150 +++++++++++++++++------------------------------------ 1 file changed, 49 insertions(+), 101 deletions(-) diff --git a/naowalk.py b/naowalk.py index f0f1518..09cac82 100644 --- a/naowalk.py +++ b/naowalk.py @@ -24,15 +24,10 @@ except ImportError: # AUDIO RECEIVER SERVICE (Runs in background) # ========================================== class SoundReceiver: - # How many chunks to buffer before we start playback. This absorbs Wi-Fi - # jitter so a late/slow packet doesn't starve ALSA the instant it lands. PREBUFFER_CHUNKS = 3 - # Silence written to the output stream whenever the queue runs dry, so we - # feed ALSA continuously instead of letting it hard-underrun (which is - # what produces clicking/silence). SILENCE_CHUNK = b"\x00" * 4096 - def __init__(self, output_device_index=None): + def __init__(self, output_device_index=None, mic_gain=8.0): self.running = True self.underrun_count = 0 self.chunks_received = 0 @@ -40,12 +35,12 @@ class SoundReceiver: self._peak_since_print = 0 self._last_status_print = 0 + # NEW: Amplification (higher = louder) + self.mic_gain = float(mic_gain) + if HAS_AUDIO: self.p = pyaudio.PyAudio() - # Print every playback-capable device so you can tell whether - # PortAudio's "default" is actually your laptop speakers (on - # Linux it very often picks an HDMI or dummy device instead). print("🔈 Available output devices:") for i in range(self.p.get_device_count()): info = self.p.get_device_info_by_index(i) @@ -60,7 +55,6 @@ class SoundReceiver: except Exception as e: print(f"⚠️ Could not resolve output device: {e}") - # NAO front mic is usually 16000Hz, 1 channel, 16-bit. self.stream = self.p.open(format=pyaudio.paInt16, channels=1, rate=16000, @@ -70,33 +64,20 @@ class SoundReceiver: self._play_test_tone() - # The queue decouples the NAOqi network thread (which calls - # processRemote) from the actual blocking audio write. Without - # this, a slow/blocking stream.write() call inside processRemote - # stalls NAOqi's callback thread, which delays the next packet, - # which starves the speaker further - a feedback loop that shows - # up as underruns and dropouts. self.queue = queue.Queue(maxsize=40) self.playback_thread = threading.Thread(target=self._playback_loop, daemon=True) self.playback_thread.start() def _play_test_tone(self): - # Plays a short beep straight to the output device, independent of - # any data from the robot. If you don't hear this, the problem is - # your laptop's audio routing/volume, not the robot or the network - - # troubleshoot with alsamixer / your system's sound settings first. try: duration, freq, rate = 0.3, 440.0, 16000 t = np.linspace(0, duration, int(rate * duration), False) tone = (np.sin(freq * t * 2 * np.pi) * 12000).astype(np.int16) self.stream.write(tone.tobytes()) - print("🔔 Played a test beep. If you didn't hear it, this is a laptop " - "audio output/device problem, not a robot/network problem.") + print("🔔 Played a test beep. If you didn't hear it, fix laptop audio first.") except Exception as e: - print(f"⚠️ Test tone failed to play: {e}") + print(f"⚠️ Test tone failed: {e}") - # ALAudioDevice strictly requires this exact method signature to send data. - # Keep this method as fast as possible - it runs on NAOqi's network thread. def processRemote(self, nbOfChannels, nbOfSamplesByChannel, timeStamp, buffer): if not HAS_AUDIO: return @@ -104,9 +85,6 @@ class SoundReceiver: try: self.queue.put_nowait(bytes(buffer)) except queue.Full: - # We're falling behind - drop the oldest chunk rather than - # blocking the NAOqi thread (blocking here is what causes - # cascading underruns). try: self.queue.get_nowait() self.queue.put_nowait(bytes(buffer)) @@ -114,8 +92,6 @@ class SoundReceiver: pass def _playback_loop(self): - # Wait for a small pile of chunks before we start playing, so the - # very first sounds you hear aren't immediately starved. primed = [] while self.running and len(primed) < self.PREBUFFER_CHUNKS: try: @@ -130,8 +106,6 @@ class SoundReceiver: chunk = self.queue.get(timeout=0.2) self._write_chunk(chunk) except queue.Empty: - # Nothing arrived in time - feed silence instead of letting - # the ALSA buffer run completely dry. self.underrun_count += 1 try: self.stream.write(self.SILENCE_CHUNK) @@ -141,19 +115,26 @@ class SoundReceiver: now = time.time() if now - self._last_status_print > 10: self._last_status_print = now - print(f"🎤 audio: {self.chunks_received} chunks received, " - f"{self.underrun_count} underruns, {self._write_errors} write errors, " - f"queue depth {self.queue.qsize()}, peak level {self._peak_since_print}/32767") + print(f"🎤 audio: {self.chunks_received} chunks, {self.underrun_count} underruns, " + f"peak {self._peak_since_print}/32767 | gain={self.mic_gain}x") self._peak_since_print = 0 def _write_chunk(self, raw_bytes): try: - self.stream.write(raw_bytes) - samples = np.frombuffer(raw_bytes, dtype=np.int16) + # === AMPLIFICATION === + samples = np.frombuffer(raw_bytes, dtype=np.int16).astype(np.float32) if samples.size: - peak = int(np.abs(samples).max()) + amplified = samples * self.mic_gain + amplified = np.clip(amplified, -32768, 32767) # prevent clipping distortion + boosted_bytes = amplified.astype(np.int16).tobytes() + + peak = int(np.abs(amplified).max()) if peak > self._peak_since_print: self._peak_since_print = peak + + self.stream.write(boosted_bytes) + else: + self.stream.write(raw_bytes) except Exception as e: self._write_errors += 1 if self._write_errors <= 3: @@ -178,7 +159,7 @@ class SoundReceiver: # MAIN TELEOP CLASS # ========================================== class NaoTeleop: - def __init__(self, session, audio_device_index=None, mic_channel=1): + def __init__(self, session, audio_device_index=None, mic_channel=1, mic_gain=8.0): self.session = session self.motion = session.service("ALMotion") self.posture = session.service("ALRobotPosture") @@ -192,11 +173,11 @@ class NaoTeleop: self.video = None self.video_client = None - # Audio setup variables self.audio_device = None self.audio_service_name = "SoundReceiver" - self.audio_device_index = audio_device_index # which laptop output device to play the mic feed on - self.mic_channel = mic_channel # which NAO mic to stream: LEFTCHANNEL(1)/RIGHTCHANNEL(2)/FRONTCHANNEL(3)/REARCHANNEL(4) + self.audio_device_index = audio_device_index + self.mic_channel = mic_channel + self.mic_gain = mic_gain # NEW self.running = True self.head_yaw = 0.0 @@ -204,27 +185,17 @@ class NaoTeleop: self.battery_level = 100 self.last_batt_check = 0 - # Volume of the NAO's own onboard speakers (0-100), controlled via - # ALAudioDevice.setOutputVolume. This is separate from - and has - # nothing to do with - the laptop playback of the robot's mic feed. self.volume = 50 - # Chatbox variables self.typing_mode = False self.chat_message = "" - # Get ALAudioDevice up front - this is needed for speaker volume - # control regardless of whether PyAudio/mic-listening is available. self._init_audio_device() - - # Initialize Video self._init_video_maxfps() - # Initialize mic listening (PyAudio -> laptop speakers) if HAS_AUDIO: self._init_mic_stream() - # Initialize Pygame pygame.init() self.screen = pygame.display.set_mode((320, 240)) pygame.display.set_caption("NAO Teleop | Battery: Checking...") @@ -238,7 +209,7 @@ class NaoTeleop: self.volume = self.audio_device.getOutputVolume() print(f"🔊 NAO speaker volume: {self.volume}%") except Exception as e: - print(f"⚠️ Could not reach ALAudioDevice for speaker volume control: {e}") + print(f"⚠️ Could not reach ALAudioDevice: {e}") self.audio_device = None def _init_video_maxfps(self): @@ -250,30 +221,24 @@ class NaoTeleop: print("❌ Camera not available") def _init_mic_stream(self): - if not self.audio_device: - print("❌ Can't start mic listening - ALAudioDevice unavailable") - return - try: - self.sound_receiver = SoundReceiver(output_device_index=self.audio_device_index) - - # 1. Register the local service - self.session.registerService(self.audio_service_name, self.sound_receiver) - - # 2. Add a tiny delay to let the network handshake complete - time.sleep(0.5) - - # 3. Which physical mic to stream. FRONTCHANNEL(3) was the - # original default, but on this robot the front and rear mics - # test dead (flat energy readings even up close) while left - # and right respond normally - see nao_audio_diagnostic.py. - # Defaulting to LEFTCHANNEL(1) accordingly; override with - # --mic-channel if you want to compare. - self.audio_device.setClientPreferences(self.audio_service_name, 16000, self.mic_channel, 0) - self.audio_device.subscribe(self.audio_service_name) - - print("✅ Live Audio Stream ready") - except Exception as e: - print(f"❌ Audio init failed: {e}") + if not self.audio_device: + print("❌ Can't start mic listening - ALAudioDevice unavailable") + return + try: + self.sound_receiver = SoundReceiver( + output_device_index=self.audio_device_index, + mic_gain=self.mic_gain + ) + + 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"✅ Live Audio Stream ready (mic gain = {self.mic_gain}x)") + except Exception as e: + print(f"❌ Audio init failed: {e}") def get_image(self): if not self.video or not self.video_client: @@ -356,7 +321,6 @@ class NaoTeleop: self.motion.wakeUp() self.posture.goToPosture("StandInit", 0.5) - # Settle delay for carpet time.sleep(1.0) self.motion.setMoveArmsEnabled(True, True) @@ -382,17 +346,13 @@ class NaoTeleop: if event.type == pygame.QUIT: self.running = False elif event.type == pygame.KEYDOWN: - - # --- CHAT / TTS MODE --- if self.typing_mode: if event.key == pygame.K_RETURN: if self.chat_message.strip(): print(f"🗣️ NAO saying: {self.chat_message}") - # Fire TTS in a background thread so the camera doesn't freeze threading.Thread(target=self.tts.say, args=(self.chat_message,)).start() self.chat_message = "" self.typing_mode = False - # Force update title bar self.last_batt_check = 0 elif event.key == pygame.K_ESCAPE: self.chat_message = "" @@ -402,8 +362,6 @@ class NaoTeleop: self.chat_message = self.chat_message[:-1] else: self.chat_message += event.unicode - - # --- NORMAL DRIVING MODE --- else: if event.key == pygame.K_ESCAPE: self.running = False @@ -420,7 +378,6 @@ class NaoTeleop: elif event.key == pygame.K_0: self.change_volume(-self.volume) - # Walking (Only if not typing) if not self.typing_mode: keys = pygame.key.get_pressed() x = y = theta = 0.0 @@ -436,7 +393,6 @@ class NaoTeleop: self.handle_head_movement() - # Camera Render frame = self.get_image() if frame is not None: rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) @@ -445,24 +401,20 @@ class NaoTeleop: else: self.screen.fill((10, 10, 30)) - # Chatbox Render if self.typing_mode: s = pygame.Surface((320, 40)) s.set_alpha(180) s.fill((0, 0, 0)) self.screen.blit(s, (0, 200)) - text_surface = self.font.render(f"Say: {self.chat_message}", True, (255, 255, 255)) self.screen.blit(text_surface, (10, 210)) pygame.display.flip() self.clock.tick(0) - # Shutdown sequence print("🛑 Shutting down...") self.motion.stopMove() - # Clean up Audio if self.audio_device: try: self.audio_device.unsubscribe(self.audio_service_name) @@ -471,8 +423,6 @@ class NaoTeleop: if getattr(self, "sound_receiver", None): self.sound_receiver.close() - - # Clean up Video if self.video and self.video_client: try: self.video.unsubscribe(self.video_client) @@ -482,20 +432,17 @@ class NaoTeleop: self.motion.rest() pygame.quit() + if __name__ == "__main__": MIC_CHANNELS = {"left": 1, "right": 2, "front": 3, "rear": 4} parser = argparse.ArgumentParser() parser.add_argument("--ip", type=str, default="127.0.0.1") parser.add_argument("--port", type=int, default=9559) - parser.add_argument("--audio-device-index", type=int, default=None, - help="Force a specific PyAudio output device index for the robot's " - "mic feed (see the '🔈 Available output devices' list printed " - "at startup) if the system default isn't your speakers.") - parser.add_argument("--mic-channel", choices=MIC_CHANNELS.keys(), default="left", - help="Which NAO mic to stream. Defaults to 'left' since " - "nao_audio_diagnostic.py confirmed front/rear are dead on " - "this unit while left/right respond normally.") + parser.add_argument("--audio-device-index", type=int, default=None) + parser.add_argument("--mic-channel", choices=MIC_CHANNELS.keys(), default="left") + parser.add_argument("--mic-gain", type=float, default=8.0, + help="Microphone amplification (try 6-12). Higher = louder.") args = parser.parse_args() session = qi.Session() @@ -507,4 +454,5 @@ if __name__ == "__main__": sys.exit(1) NaoTeleop(session, audio_device_index=args.audio_device_index, - mic_channel=MIC_CHANNELS[args.mic_channel]).run() + mic_channel=MIC_CHANNELS[args.mic_channel], + mic_gain=args.mic_gain).run()