diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..44e1e6fc8cafb2796074d4360a18616aeced8ad5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,11 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +build/lib/monte_python_insult_robot/assets/coconuts.m4a filter=lfs diff=lfs merge=lfs -text +build/lib/monte_python_insult_robot/assets/coconuts.wav filter=lfs diff=lfs merge=lfs -text +build/lib/monte_python_insult_robot/assets/insults/suspicious_bridgeofdeath.wav filter=lfs diff=lfs merge=lfs -text +build/lib/monte_python_insult_robot/assets/insults/suspicious_peril.wav filter=lfs diff=lfs merge=lfs -text +monte_python_insult_robot/assets/coconuts.m4a filter=lfs diff=lfs merge=lfs -text +monte_python_insult_robot/assets/coconuts.wav filter=lfs diff=lfs merge=lfs -text +monte_python_insult_robot/assets/insults/suspicious_bridgeofdeath.wav filter=lfs diff=lfs merge=lfs -text +monte_python_insult_robot/assets/insults/suspicious_peril.wav filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..0598f9f6118555a3256f22a17494883de8254de8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Virtual environments (this repo keeps them inside the app folder) +.venv/ +reachy_mini_env/ + +# Python caches & build artifacts +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ + +# OS cruft +.DS_Store diff --git a/README.md b/README.md index 57345a68034e287a3aeca8654f57af17f0f1ac17..f572cc6bd2ee5f4f1877427efb8e1f2f14bdb66d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,37 @@ --- title: Monte Python Insult Robot -emoji: 🌍 -colorFrom: green +emoji: 🏰 +colorFrom: yellow colorTo: green sdk: static pinned: false +short_description: French castle guard insult robot — Monty Python style +tags: + - reachy_mini + - reachy_mini_python_app --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# Monte Python Insult Robot 🏰 + +A Reachy Mini app that turns the robot into the French castle guard from Monty Python and the Holy Grail. + +## What it does + +- Listens for nearby speech using the ReSpeaker mic array (or laptop mic in simulation) +- Plays coconut clip-clop sounds with head-bobbing horse riding animation +- Delivers a random pre-recorded insult WAV with a matching physical expression +- Turns head and body toward the speaker + +## Insult audio files + +Place WAV or MP3 files in `monte_python_insult_robot/assets/insults/` using the naming convention: + +``` +{expression_tag}_{name}.wav +``` + +Supported expression tags: `contempt`, `dismissive`, `mock_shock`, `haughty`, `rage`, `sneer`, `exasperated`, `gleeful`, `suspicious`, `pompous` + +## Requirements + +See `monte_python_insult_robot/requirements.txt`. Key dependencies: `pydub`, `sounddevice`, `webrtcvad-wheels`, `scipy`. diff --git a/build/lib/monte_python_insult_robot/__init__.py b/build/lib/monte_python_insult_robot/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/build/lib/monte_python_insult_robot/assets/coconuts.m4a b/build/lib/monte_python_insult_robot/assets/coconuts.m4a new file mode 100644 index 0000000000000000000000000000000000000000..7f3ae6068b8993b811497ade1d2d49a9608156cf --- /dev/null +++ b/build/lib/monte_python_insult_robot/assets/coconuts.m4a @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48e5db3f97430adad06e5d9416e866bcd6a2e848edbb09ace1822f64f542e254 +size 330442 diff --git a/build/lib/monte_python_insult_robot/assets/coconuts.wav b/build/lib/monte_python_insult_robot/assets/coconuts.wav new file mode 100644 index 0000000000000000000000000000000000000000..9f248abaeecb9381ad1766047ee7abaef0611ddf --- /dev/null +++ b/build/lib/monte_python_insult_robot/assets/coconuts.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32db8ba781d35ba0b5a47faeddc8959f4c4af4cdc9630b51abd5fade20d64c15 +size 7697052 diff --git a/build/lib/monte_python_insult_robot/assets/insults/contempt_hamster.wav b/build/lib/monte_python_insult_robot/assets/insults/contempt_hamster.wav new file mode 100644 index 0000000000000000000000000000000000000000..4f594f6088a12ae308b20ab3329c1d8a86beb657 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/contempt_hamster.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/contempt_wipe.wav b/build/lib/monte_python_insult_robot/assets/insults/contempt_wipe.wav new file mode 100644 index 0000000000000000000000000000000000000000..739562e94a21835a306ef7c1bfc85c4bffae0dd1 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/contempt_wipe.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/dismissive_Looney.wav b/build/lib/monte_python_insult_robot/assets/insults/dismissive_Looney.wav new file mode 100644 index 0000000000000000000000000000000000000000..05b30294f3cb3d7deecce859f649e296330f3393 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/dismissive_Looney.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/exasperated_bleed.wav b/build/lib/monte_python_insult_robot/assets/insults/exasperated_bleed.wav new file mode 100644 index 0000000000000000000000000000000000000000..e22fc49609b81a31facedbd4a7cc8405ffe41677 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/exasperated_bleed.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/exasperated_dead.wav b/build/lib/monte_python_insult_robot/assets/insults/exasperated_dead.wav new file mode 100644 index 0000000000000000000000000000000000000000..72e724d01fac72e8d65cd499e1c6cc5682f707ed Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/exasperated_dead.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/exasperated_repressed.wav b/build/lib/monte_python_insult_robot/assets/insults/exasperated_repressed.wav new file mode 100644 index 0000000000000000000000000000000000000000..bc07a545a80a9a79cdc246995cf08d1c47f3d41c Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/exasperated_repressed.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/gleeful_bitelegs.wav b/build/lib/monte_python_insult_robot/assets/insults/gleeful_bitelegs.wav new file mode 100644 index 0000000000000000000000000000000000000000..eeb90930b63e857a810b5bef051826d4c089f4d3 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/gleeful_bitelegs.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/gleeful_happy.wav b/build/lib/monte_python_insult_robot/assets/insults/gleeful_happy.wav new file mode 100644 index 0000000000000000000000000000000000000000..c782583d5ad30edf14326be74b6a82de29f5329c Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/gleeful_happy.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/gleeful_much_rejoicing.mp3 b/build/lib/monte_python_insult_robot/assets/insults/gleeful_much_rejoicing.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..7fa7c1ac21aa78264299b7ef7bfa6bcc11273059 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/gleeful_much_rejoicing.mp3 differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/gleeful_privparts.wav b/build/lib/monte_python_insult_robot/assets/insults/gleeful_privparts.wav new file mode 100644 index 0000000000000000000000000000000000000000..973cb176760ed63ffedead6130a72d899b6103fc Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/gleeful_privparts.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/gleeful_spam.mp3 b/build/lib/monte_python_insult_robot/assets/insults/gleeful_spam.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..699838453e4ff93058e9175c0ba4127231baf7fb Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/gleeful_spam.mp3 differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/haughty_french.wav b/build/lib/monte_python_insult_robot/assets/insults/haughty_french.wav new file mode 100644 index 0000000000000000000000000000000000000000..2d67586feb19842a6fb3568ce0b018b0a5d4980c Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/haughty_french.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/haughty_mp_grail.wav b/build/lib/monte_python_insult_robot/assets/insults/haughty_mp_grail.wav new file mode 100644 index 0000000000000000000000000000000000000000..f50779a0eac6b9c724dfb96c58bc11b7803cc1f1 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/haughty_mp_grail.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/mock_shock_Newt.wav b/build/lib/monte_python_insult_robot/assets/insults/mock_shock_Newt.wav new file mode 100644 index 0000000000000000000000000000000000000000..b7852280071347021b16ce0151e8e3c03c2bc52b Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/mock_shock_Newt.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/mock_shock_runawayrun.wav b/build/lib/monte_python_insult_robot/assets/insults/mock_shock_runawayrun.wav new file mode 100644 index 0000000000000000000000000000000000000000..ab9d076b164e3836465c4df913541279c2e8bba3 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/mock_shock_runawayrun.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/pompous_King.wav b/build/lib/monte_python_insult_robot/assets/insults/pompous_King.wav new file mode 100644 index 0000000000000000000000000000000000000000..adb8f45b33573d1197582e4bd9f2ec00ba0e5403 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/pompous_King.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/pompous_britains.wav b/build/lib/monte_python_insult_robot/assets/insults/pompous_britains.wav new file mode 100644 index 0000000000000000000000000000000000000000..b9b94cc273727f6419f210debde33db6a8023570 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/pompous_britains.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/pompous_consult.mp3 b/build/lib/monte_python_insult_robot/assets/insults/pompous_consult.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..850304b17d0c0909d32882f9f040c0a752f69f7b Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/pompous_consult.mp3 differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/pompous_mustbe.wav b/build/lib/monte_python_insult_robot/assets/insults/pompous_mustbe.wav new file mode 100644 index 0000000000000000000000000000000000000000..7ded93494fb9c3c9f653de652cbc650307678130 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/pompous_mustbe.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/rage_Ni.wav b/build/lib/monte_python_insult_robot/assets/insults/rage_Ni.wav new file mode 100644 index 0000000000000000000000000000000000000000..daa3328dc1728ce905fac9b27632f4241134ac23 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/rage_Ni.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/rage_fart.wav b/build/lib/monte_python_insult_robot/assets/insults/rage_fart.wav new file mode 100644 index 0000000000000000000000000000000000000000..a784926866db01bf79eee8fc5c8d6796661e5449 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/rage_fart.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/rage_fart_direction.mp3 b/build/lib/monte_python_insult_robot/assets/insults/rage_fart_direction.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..3f9913da640633192bce43fb3261e15307c4c66b Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/rage_fart_direction.mp3 differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/sneer_mp_taunt.mp3 b/build/lib/monte_python_insult_robot/assets/insults/sneer_mp_taunt.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..069a4c51519af6a3cea076a39bf38ea75181e336 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/sneer_mp_taunt.mp3 differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/sneer_taunt.wav b/build/lib/monte_python_insult_robot/assets/insults/sneer_taunt.wav new file mode 100644 index 0000000000000000000000000000000000000000..c7a1694aff48b1011b278259f9e2a43cd78e2aa0 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/sneer_taunt.wav differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/suspicious_bridgeofdeath.wav b/build/lib/monte_python_insult_robot/assets/insults/suspicious_bridgeofdeath.wav new file mode 100644 index 0000000000000000000000000000000000000000..e8bd58031c793ec95c3c1d78a1a2a3e1e0d9bcfb --- /dev/null +++ b/build/lib/monte_python_insult_robot/assets/insults/suspicious_bridgeofdeath.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d7c12fe58db361039f17b2029cbaa28554213a2d33adae747e9145c8dfa7763 +size 102934 diff --git a/build/lib/monte_python_insult_robot/assets/insults/suspicious_duck.mp3 b/build/lib/monte_python_insult_robot/assets/insults/suspicious_duck.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..f668dbd75f234049857d245ef3865d59bbada982 Binary files /dev/null and b/build/lib/monte_python_insult_robot/assets/insults/suspicious_duck.mp3 differ diff --git a/build/lib/monte_python_insult_robot/assets/insults/suspicious_peril.wav b/build/lib/monte_python_insult_robot/assets/insults/suspicious_peril.wav new file mode 100644 index 0000000000000000000000000000000000000000..28fd8051b95e249c27a39cbb3e2d60c11c193063 --- /dev/null +++ b/build/lib/monte_python_insult_robot/assets/insults/suspicious_peril.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1cfc8b2935b93c57ee8c61b90c85566779376d4db4858f9b168bbd359d7f23f1 +size 116538 diff --git a/build/lib/monte_python_insult_robot/attention.py b/build/lib/monte_python_insult_robot/attention.py new file mode 100644 index 0000000000000000000000000000000000000000..40938de2ef4ee1455de1d2c96a699b46221fb96d --- /dev/null +++ b/build/lib/monte_python_insult_robot/attention.py @@ -0,0 +1,167 @@ +"""Attention loop: auto-detects simulation vs real hardware at startup. + +Simulation (mockup_sim_enabled=True or hardware_id=None): + - Captures laptop mic via sounddevice at 16 kHz + - webrtcvad detects speech in 30 ms frames + - No direction info — robot looks straight ahead + +Real hardware (ReSpeaker present): + - Polls /api/state/doa at 5 Hz + - Turns head toward speaker immediately, body follows 1 second later +""" + +import math +import threading +import time + +import requests + +from monte_python_insult_robot import audio + +DAEMON_URL = "http://localhost:8000" +SPEECH_COOLDOWN = 3.0 + +# --- Simulation mic sensitivity tuning ------------------------------------- +# Raise these to make the robot LESS sensitive to background noise. +VAD_AGGRESSIVENESS = 3 # webrtcvad 0-3; 3 filters the most non-speech +RMS_THRESHOLD = 500 # int16 RMS energy floor; frames quieter are ignored +MIN_VOICED_FRAMES = 8 # consecutive 30 ms voiced frames (~240 ms) to trigger + +# --- Real-hardware DOA sensitivity tuning ---------------------------------- +DOA_POLL_HZ = 5 # how often to poll /api/state/doa +MIN_VOICED_POLLS = 3 # consecutive speech_detected polls (~0.6 s) to trigger + + +def is_simulation() -> bool: + try: + resp = requests.get(f"{DAEMON_URL}/api/daemon/status", timeout=2) + data = resp.json() + return data.get("mockup_sim_enabled", False) or data.get("hardware_id") is None + except Exception: + return True # safe default + + +class AttentionLoop: + def __init__(self, reachy, on_speech_detected): + self._reachy = reachy + self._on_speech_detected = on_speech_detected + self._running = False + self._thread = None + self._last_trigger = 0.0 + self._sim = is_simulation() + audio._on_playback_done = lambda: setattr(self, "_last_trigger", time.time()) + + if self._sim: + print("Attention: simulation detected — using laptop mic") + else: + print("Attention: real hardware detected — using ReSpeaker DOA") + + def start(self) -> None: + self._running = True + target = self._loop_sim if self._sim else self._loop_doa + self._thread = threading.Thread(target=target, daemon=True) + self._thread.start() + + def stop(self) -> None: + self._running = False + + # ------------------------------------------------------------------ + # Simulation path — laptop mic + webrtcvad + # ------------------------------------------------------------------ + + def _loop_sim(self) -> None: + import numpy as np + import sounddevice as sd + import webrtcvad + + SAMPLE_RATE = 16000 + FRAME_SAMPLES = int(SAMPLE_RATE * 30 / 1000) # 30 ms + vad = webrtcvad.Vad(VAD_AGGRESSIVENESS) + buffer = np.zeros(0, dtype=np.int16) + voiced_run = 0 # consecutive frames that passed the energy + VAD gates + + def callback(indata, frames, time_info, status): + nonlocal buffer + buffer = np.append(buffer, (indata[:, 0] * 32767).astype(np.int16)) + + with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, + dtype="float32", callback=callback): + while self._running: + if len(buffer) >= FRAME_SAMPLES: + frame_i16 = buffer[:FRAME_SAMPLES] + buffer = buffer[FRAME_SAMPLES:] + if audio.is_playing: + voiced_run = 0 + continue + # Energy gate first: cheap, and keeps quiet background hum + # from ever reaching the VAD. + rms = float(np.sqrt(np.mean(frame_i16.astype(np.float32) ** 2))) + try: + speech = rms >= RMS_THRESHOLD and vad.is_speech( + frame_i16.tobytes(), SAMPLE_RATE + ) + except Exception: + speech = False + # Require sustained speech, not a single transient blip. + voiced_run = voiced_run + 1 if speech else 0 + if voiced_run >= MIN_VOICED_FRAMES: + voiced_run = 0 + self._trigger(face_fn=self._face_forward) + else: + time.sleep(0.005) + + # ------------------------------------------------------------------ + # Real hardware path — ReSpeaker DOA polling + # ------------------------------------------------------------------ + + def _loop_doa(self) -> None: + voiced_polls = 0 # consecutive polls reporting speech_detected + while self._running: + try: + resp = requests.get(f"{DAEMON_URL}/api/state/doa", timeout=1) + if resp.status_code == 200: + data = resp.json() + if data and data.get("speech_detected"): + # Require sustained detection, not a single noisy poll. + voiced_polls += 1 + if voiced_polls >= MIN_VOICED_POLLS: + voiced_polls = 0 + angle = data["angle"] + self._trigger(face_fn=lambda a=angle: self._face_speaker(a)) + else: + voiced_polls = 0 + except Exception: + pass # dropped poll: keep the counter, don't treat as silence + time.sleep(1.0 / DOA_POLL_HZ) + + # ------------------------------------------------------------------ + # Shared trigger logic + # ------------------------------------------------------------------ + + def _trigger(self, face_fn) -> None: + if audio.is_playing: + return + now = time.time() + if now - self._last_trigger < SPEECH_COOLDOWN: + return + self._last_trigger = now + face_fn() + threading.Thread(target=self._on_speech_detected, daemon=True).start() + + def _face_forward(self) -> None: + """Simulation: look straight ahead, body follows 1s later.""" + self._reachy.look_at_world(1.0, 0.0, 0.05, duration=0.4) + threading.Thread(target=lambda: self._rotate_body(0.0), daemon=True).start() + + def _face_speaker(self, angle_rad: float) -> None: + """Real hardware: turn head toward DOA angle, body follows 1s later.""" + adjusted = angle_rad - math.pi / 2 + x = math.cos(adjusted) + y = -math.sin(adjusted) + self._reachy.look_at_world(x, y, 0.05, duration=0.4) + body_yaw = max(-2.79, min(2.79, -adjusted)) + threading.Thread(target=lambda: self._rotate_body(body_yaw), daemon=True).start() + + def _rotate_body(self, yaw: float) -> None: + time.sleep(1.0) + self._reachy.goto_target(body_yaw=yaw, duration=0.8) diff --git a/build/lib/monte_python_insult_robot/audio.py b/build/lib/monte_python_insult_robot/audio.py new file mode 100644 index 0000000000000000000000000000000000000000..890f62490afaeb86b39809f5d4c4629469d5eb4d --- /dev/null +++ b/build/lib/monte_python_insult_robot/audio.py @@ -0,0 +1,60 @@ +"""Insult audio player: picks a random WAV from assets/insults/ and plays it.""" + +import os +import random + +import threading + +import monte_python_insult_robot.expressions as expressions +from monte_python_insult_robot.playback import play_file + +INSULTS_DIR = os.path.join(os.path.dirname(__file__), "assets", "insults") +VOLUME = 0.5 +AUDIO_EXTS = (".wav", ".mp3") + +# Set by attention.py to suppress VAD triggers during playback +is_playing = False +_on_playback_done = None # optional callback set by AttentionLoop +_speak_lock = threading.Lock() +_last_played = None + + +def _tag_from_filename(filename: str) -> str: + """Parse expression tag from '{tag}_{name}.ext' convention. Falls back to 'contempt'.""" + stem = os.path.splitext(filename)[0] + tag = stem.split("_")[0] + return tag + + +def speak(reachy) -> None: + """Pick a random insult, loop matching expression for its duration, and play the audio.""" + global is_playing, _last_played + if not _speak_lock.acquire(blocking=False): + return # already playing, drop this call + try: + files = [f for f in os.listdir(INSULTS_DIR) if f.lower().endswith(AUDIO_EXTS)] + if not files: + print(f"No audio files found in {INSULTS_DIR}") + return + candidates = [f for f in files if f != _last_played] or files + filename = random.choice(candidates) + _last_played = filename + path = os.path.join(INSULTS_DIR, filename) + tag = _tag_from_filename(filename) + print(f"Playing: {filename} (expression: {tag})") + stop_event = threading.Event() + expr_thread = threading.Thread( + target=expressions.loop_expression, args=(reachy, tag, stop_event), daemon=True + ) + expr_thread.start() + is_playing = True + try: + play_file(path, volume=VOLUME) + finally: + stop_event.set() + expr_thread.join(timeout=2.0) + is_playing = False + if _on_playback_done: + _on_playback_done() + finally: + _speak_lock.release() diff --git a/build/lib/monte_python_insult_robot/expressions.py b/build/lib/monte_python_insult_robot/expressions.py new file mode 100644 index 0000000000000000000000000000000000000000..4b79f27cf493271e5b1b40502810cc4451f8de8a --- /dev/null +++ b/build/lib/monte_python_insult_robot/expressions.py @@ -0,0 +1,111 @@ +"""Physical expressions mapped to Monty Python insult tags. + +Each expression is a callable that takes a ReachyMini instance and performs +the appropriate head/antenna movement. +""" + +import math +import numpy as np +from scipy.spatial.transform import Rotation as R + + +def _pose(roll_deg=0, pitch_deg=0, yaw_deg=0) -> np.ndarray: + pose = np.eye(4) + pose[:3, :3] = R.from_euler("xyz", [roll_deg, pitch_deg, yaw_deg], degrees=True).as_matrix() + return pose + + +# Antenna positions [right_rad, left_rad] +ANTENNAS_NEUTRAL = [-0.1745, 0.1745] +ANTENNAS_DROOP = [-0.8, 0.8] # drooping — dismissive +ANTENNAS_PERK = [ 0.3, -0.3] # perked up — mock shock +ANTENNAS_RAGE = [-1.2, 1.2] # fully down — rage +ANTENNAS_HAUGHTY = [ 0.0, 0.0] # straight up — haughty + + +def contempt(reachy) -> None: + """Slow head tilt to one side, antennas droop slightly.""" + reachy.goto_target(head=_pose(roll_deg=15, pitch_deg=-10), antennas=ANTENNAS_DROOP, duration=0.6) + + +def dismissive(reachy) -> None: + """Head turns away then snaps back.""" + reachy.goto_target(head=_pose(yaw_deg=40), antennas=ANTENNAS_DROOP, duration=0.5) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.4) + + +def mock_shock(reachy) -> None: + """Head rears back, antennas perk up.""" + reachy.goto_target(head=_pose(pitch_deg=20), antennas=ANTENNAS_PERK, duration=0.3) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.5) + + +def haughty(reachy) -> None: + """Head tilts up, antennas straight — looking down the nose.""" + reachy.goto_target(head=_pose(pitch_deg=-20), antennas=ANTENNAS_HAUGHTY, duration=0.7) + + +def rage(reachy) -> None: + """Rapid head shake side to side, antennas slam down.""" + reachy.goto_target(head=_pose(yaw_deg=20), antennas=ANTENNAS_RAGE, duration=0.2) + reachy.goto_target(head=_pose(yaw_deg=-20), duration=0.2) + reachy.goto_target(head=_pose(yaw_deg=20), duration=0.2) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.3) + + +def sneer(reachy) -> None: + """Slow roll to one side, antenna droops — withering sneer.""" + reachy.goto_target(head=_pose(roll_deg=-18, yaw_deg=-15), antennas=[-0.5, 0.2], duration=0.5) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.6) + + +def exasperated(reachy) -> None: + """Head drops forward then snaps back up — long-suffering exasperation.""" + reachy.goto_target(head=_pose(pitch_deg=25), antennas=ANTENNAS_DROOP, duration=0.4) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.5) + + +def gleeful(reachy) -> None: + """Quick bouncy nod, antennas perk — delighted malice.""" + reachy.goto_target(head=_pose(pitch_deg=-10), antennas=ANTENNAS_PERK, duration=0.2) + reachy.goto_target(head=_pose(pitch_deg=10), duration=0.2) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.3) + + +def suspicious(reachy) -> None: + """Head tilts and leans forward — squinting suspicion.""" + reachy.goto_target(head=_pose(roll_deg=10, pitch_deg=8), antennas=[-0.3, 0.3], duration=0.5) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.5) + + +def pompous(reachy) -> None: + """Head sweeps up and holds — insufferable self-importance.""" + reachy.goto_target(head=_pose(pitch_deg=-25, yaw_deg=10), antennas=ANTENNAS_HAUGHTY, duration=0.6) + reachy.goto_target(head=_pose(pitch_deg=-25, yaw_deg=-10), duration=0.4) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.5) + + +EXPRESSIONS = { + "contempt": contempt, + "dismissive": dismissive, + "mock_shock": mock_shock, + "haughty": haughty, + "rage": rage, + "sneer": sneer, + "exasperated": exasperated, + "gleeful": gleeful, + "suspicious": suspicious, + "pompous": pompous, +} + + +def perform(reachy, tag: str) -> None: + EXPRESSIONS.get(tag, contempt)(reachy) + + +def loop_expression(reachy, tag: str, stop_event) -> None: + """Repeat the expression until stop_event is set, then return to neutral.""" + fn = EXPRESSIONS.get(tag, contempt) + while not stop_event.is_set(): + fn(reachy) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.4) diff --git a/build/lib/monte_python_insult_robot/insults.py b/build/lib/monte_python_insult_robot/insults.py new file mode 100644 index 0000000000000000000000000000000000000000..c91257bc2059f268f2ea84727b53287abaf8a786 --- /dev/null +++ b/build/lib/monte_python_insult_robot/insults.py @@ -0,0 +1,6 @@ +"""Insult library — expression tags are now encoded in WAV/MP3 filenames. + +Filename convention: {expression_tag}_{name}.{ext} +Supported tags: contempt, dismissive, mock_shock, haughty, rage, + sneer, exasperated, gleeful, suspicious, pompous +""" diff --git a/build/lib/monte_python_insult_robot/main.py b/build/lib/monte_python_insult_robot/main.py new file mode 100644 index 0000000000000000000000000000000000000000..0c4b582628e782c910ed6eccd22086b2851c171e --- /dev/null +++ b/build/lib/monte_python_insult_robot/main.py @@ -0,0 +1,42 @@ +"""Monte Python Insult Robot — main entry point.""" + +import threading + +from reachy_mini import ReachyMini, ReachyMiniApp + +from monte_python_insult_robot.audio import speak +from monte_python_insult_robot.sounds import ensure_coconut, play_coconut +from monte_python_insult_robot.attention import AttentionLoop + + +class MontePythonInsultRobot(ReachyMiniApp): + custom_app_url: str | None = None + request_media_backend: str | None = None + + def run(self, reachy_mini: ReachyMini, stop_event: threading.Event): + reachy_mini.wake_up() + ensure_coconut() + + def deliver_insult(): + play_coconut(reachy_mini) + speak(reachy_mini) + + attention = AttentionLoop( + reachy=reachy_mini, + on_speech_detected=deliver_insult, + ) + attention.start() + print("Monte Python Insult Robot is ready. Waiting for victims...") + + stop_event.wait() + + attention.stop() + reachy_mini.goto_sleep() + + +if __name__ == "__main__": + app = MontePythonInsultRobot() + try: + app.wrapped_run() + except KeyboardInterrupt: + app.stop() diff --git a/build/lib/monte_python_insult_robot/playback.py b/build/lib/monte_python_insult_robot/playback.py new file mode 100644 index 0000000000000000000000000000000000000000..d7cae3b9f049c835cf5315f4edc921620c608807 --- /dev/null +++ b/build/lib/monte_python_insult_robot/playback.py @@ -0,0 +1,50 @@ +"""Shared GStreamer audio playback helpers. + +Centralizes the platform-specific sink selection and the play-until-EOS loop +that were previously duplicated across audio.py, sounds.py, and tts.py. +""" + +import platform + + +def get_audio_sink() -> str: + """Return a GStreamer sink pipeline fragment for the current platform.""" + if platform.system() == "Darwin": + return "osxaudiosink unique-id=BuiltInSpeakerDevice" + return "autoaudiosink" + + +def _decoder_for(path: str) -> str: + """Return the GStreamer decode element for the file type.""" + if path.lower().endswith(".mp3"): + return "decodebin" + return "wavparse" + + +def play_file(path: str, volume: float | None = None) -> None: + """Play an audio file synchronously via GStreamer, blocking until EOS/error. + + Auto-selects the decoder from the file extension. If *volume* is given, a + ``volume`` element is inserted (0.0–1.0); otherwise playback is unattenuated. + """ + import gi + gi.require_version("Gst", "1.0") + from gi.repository import Gst + Gst.init(None) + + decoder = _decoder_for(path) + volume_stage = f"volume volume={volume} ! " if volume is not None else "" + pipeline = Gst.parse_launch( + f"filesrc location={path} ! {decoder} ! audioconvert ! audioresample " + f"! {volume_stage}{get_audio_sink()}" + ) + pipeline.set_state(Gst.State.PLAYING) + bus = pipeline.get_bus() + while True: + msg = bus.timed_pop_filtered( + 100 * Gst.MSECOND, + Gst.MessageType.EOS | Gst.MessageType.ERROR, + ) + if msg: + break + pipeline.set_state(Gst.State.NULL) diff --git a/build/lib/monte_python_insult_robot/requirements.txt b/build/lib/monte_python_insult_robot/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..393bc82541ee826185eee9a72d5e01f9eed9bbeb --- /dev/null +++ b/build/lib/monte_python_insult_robot/requirements.txt @@ -0,0 +1,7 @@ +reachy_mini>=1.8.4 +pydub>=0.25.1 +requests>=2.31.0 +sounddevice>=0.5.0 +webrtcvad-wheels>=2.0.14 +scipy>=1.10.0 +numpy>=1.24.0 diff --git a/build/lib/monte_python_insult_robot/sounds.py b/build/lib/monte_python_insult_robot/sounds.py new file mode 100644 index 0000000000000000000000000000000000000000..153595bef57c62bc7464708ac2e0116bd344d0bf --- /dev/null +++ b/build/lib/monte_python_insult_robot/sounds.py @@ -0,0 +1,74 @@ +"""Coconut clip-clop sound: convert local M4A asset, play a random 2s slice directly via GStreamer.""" + +import io +import os +import random +from pydub import AudioSegment + +from monte_python_insult_robot.playback import play_file + +ASSET_M4A = os.path.join(os.path.dirname(__file__), "assets", "coconuts.m4a") +ASSET_WAV = os.path.join(os.path.dirname(__file__), "assets", "coconuts.wav") +CLIP_DURATION_MS = 2000 + + +def ensure_coconut() -> None: + """Convert M4A to WAV if needed.""" + if not os.path.exists(ASSET_WAV): + print("Converting coconuts.m4a to WAV...") + AudioSegment.from_file(ASSET_M4A, format="m4a").export(ASSET_WAV, format="wav") + print(f"Saved to {ASSET_WAV}") + + +def _bob_head(reachy, duration: float) -> None: + """Bounce head up/down for the given duration to mimic horse riding.""" + import threading + import numpy as np + from scipy.spatial.transform import Rotation as R + + BOB_INTERVAL = 0.25 # seconds per half-bob (~2 clops/sec) + BOB_PITCH = 12 # degrees + + def _pose(pitch_deg): + pose = np.eye(4) + pose[:3, :3] = R.from_euler("xyz", [0, pitch_deg, 0], degrees=True).as_matrix() + return pose + + stop = threading.Event() + threading.Timer(duration, stop.set).start() + + down = True + while not stop.is_set(): + pitch = BOB_PITCH if down else -BOB_PITCH + reachy.goto_target(head=_pose(pitch), duration=BOB_INTERVAL) + down = not down + + reachy.goto_target(head=_pose(0), duration=0.3) + + +def play_coconut(reachy) -> None: + """Play a random 2s slice of the coconut sound with head bobbing.""" + import tempfile + import threading + + audio = AudioSegment.from_wav(ASSET_WAV) + max_start = len(audio) - CLIP_DURATION_MS + start_ms = random.randint(0, max_start) + clip = audio[start_ms : start_ms + CLIP_DURATION_MS] + + buf = io.BytesIO() + clip.export(buf, format="wav") + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + f.write(buf.getvalue()) + tmp_path = f.name + + try: + duration_s = CLIP_DURATION_MS / 1000 + bob_thread = threading.Thread(target=_bob_head, args=(reachy, duration_s), daemon=True) + bob_thread.start() + + play_file(tmp_path) + bob_thread.join(timeout=1.0) + finally: + os.unlink(tmp_path) diff --git a/build/lib/monte_python_insult_robot/static/index.html b/build/lib/monte_python_insult_robot/static/index.html new file mode 100644 index 0000000000000000000000000000000000000000..71fc5395b2253aa2e14a20b51edde90fd4c57aa4 --- /dev/null +++ b/build/lib/monte_python_insult_robot/static/index.html @@ -0,0 +1,27 @@ + + + +
+ +You can modify this app directly by editing index.html in the Files and versions tab.
-- Also don't forget to check the - Spaces documentation. -
-Your mother was a hamster and your father smelt of elderberries!
+This app runs on the robot. No web interface required.
+ diff --git a/monte_python_insult_robot.egg-info/PKG-INFO b/monte_python_insult_robot.egg-info/PKG-INFO new file mode 100644 index 0000000000000000000000000000000000000000..d97172304d97036ab7e7340c8ce199c57f9c1427 --- /dev/null +++ b/monte_python_insult_robot.egg-info/PKG-INFO @@ -0,0 +1,52 @@ +Metadata-Version: 2.4 +Name: monte_python_insult_robot +Version: 1.0.0 +Summary: French castle guard insult robot — Monty Python style +Keywords: reachy-mini-app +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +Requires-Dist: reachy-mini>=1.8.4 +Requires-Dist: pydub>=0.25.1 +Requires-Dist: requests>=2.31.0 +Requires-Dist: sounddevice>=0.5.0 +Requires-Dist: webrtcvad-wheels>=2.0.14 +Requires-Dist: scipy>=1.10.0 +Requires-Dist: numpy>=1.24.0 + +--- +title: Monte Python Insult Robot +emoji: 🏰 +colorFrom: yellow +colorTo: green +sdk: static +pinned: false +short_description: French castle guard insult robot — Monty Python style +tags: + - reachy_mini + - reachy_mini_python_app +--- + +# Monte Python Insult Robot 🏰 + +A Reachy Mini app that turns the robot into the French castle guard from Monty Python and the Holy Grail. + +## What it does + +- Listens for nearby speech using the ReSpeaker mic array (or laptop mic in simulation) +- Plays coconut clip-clop sounds with head-bobbing horse riding animation +- Delivers a random pre-recorded insult WAV with a matching physical expression +- Turns head and body toward the speaker + +## Insult audio files + +Place WAV or MP3 files in `monte_python_insult_robot/assets/insults/` using the naming convention: + +``` +{expression_tag}_{name}.wav +``` + +Supported expression tags: `contempt`, `dismissive`, `mock_shock`, `haughty`, `rage`, `sneer`, `exasperated`, `gleeful`, `suspicious`, `pompous` + +## Requirements + +See `monte_python_insult_robot/requirements.txt`. Key dependencies: `pydub`, `sounddevice`, `webrtcvad-wheels`, `scipy`. diff --git a/monte_python_insult_robot.egg-info/SOURCES.txt b/monte_python_insult_robot.egg-info/SOURCES.txt new file mode 100644 index 0000000000000000000000000000000000000000..cb238e9a497364074e1a23a666664d2d0498c163 --- /dev/null +++ b/monte_python_insult_robot.egg-info/SOURCES.txt @@ -0,0 +1,90 @@ +README.md +pyproject.toml +./monte_python_insult_robot/__init__.py +./monte_python_insult_robot/attention.py +./monte_python_insult_robot/audio.py +./monte_python_insult_robot/expressions.py +./monte_python_insult_robot/insults.py +./monte_python_insult_robot/main.py +./monte_python_insult_robot/playback.py +./monte_python_insult_robot/requirements.txt +./monte_python_insult_robot/sounds.py +./monte_python_insult_robot/assets/coconuts.m4a +./monte_python_insult_robot/assets/coconuts.wav +./monte_python_insult_robot/assets/insults/contempt_hamster.wav +./monte_python_insult_robot/assets/insults/contempt_wipe.wav +./monte_python_insult_robot/assets/insults/dismissive_Looney.wav +./monte_python_insult_robot/assets/insults/exasperated_bleed.wav +./monte_python_insult_robot/assets/insults/exasperated_dead.wav +./monte_python_insult_robot/assets/insults/exasperated_repressed.wav +./monte_python_insult_robot/assets/insults/gleeful_bitelegs.wav +./monte_python_insult_robot/assets/insults/gleeful_happy.wav +./monte_python_insult_robot/assets/insults/gleeful_much_rejoicing.mp3 +./monte_python_insult_robot/assets/insults/gleeful_privparts.wav +./monte_python_insult_robot/assets/insults/gleeful_spam.mp3 +./monte_python_insult_robot/assets/insults/haughty_french.wav +./monte_python_insult_robot/assets/insults/haughty_mp_grail.wav +./monte_python_insult_robot/assets/insults/mock_shock_Newt.wav +./monte_python_insult_robot/assets/insults/mock_shock_runawayrun.wav +./monte_python_insult_robot/assets/insults/pompous_King.wav +./monte_python_insult_robot/assets/insults/pompous_britains.wav +./monte_python_insult_robot/assets/insults/pompous_consult.mp3 +./monte_python_insult_robot/assets/insults/pompous_mustbe.wav +./monte_python_insult_robot/assets/insults/rage_Ni.wav +./monte_python_insult_robot/assets/insults/rage_fart.wav +./monte_python_insult_robot/assets/insults/rage_fart_direction.mp3 +./monte_python_insult_robot/assets/insults/sneer_mp_taunt.mp3 +./monte_python_insult_robot/assets/insults/sneer_taunt.wav +./monte_python_insult_robot/assets/insults/suspicious_bridgeofdeath.wav +./monte_python_insult_robot/assets/insults/suspicious_duck.mp3 +./monte_python_insult_robot/assets/insults/suspicious_peril.wav +./monte_python_insult_robot/static/index.html +./monte_python_insult_robot/static/main.js +./monte_python_insult_robot/static/style.css +monte_python_insult_robot/__init__.py +monte_python_insult_robot/attention.py +monte_python_insult_robot/audio.py +monte_python_insult_robot/expressions.py +monte_python_insult_robot/insults.py +monte_python_insult_robot/main.py +monte_python_insult_robot/playback.py +monte_python_insult_robot/requirements.txt +monte_python_insult_robot/sounds.py +monte_python_insult_robot.egg-info/PKG-INFO +monte_python_insult_robot.egg-info/SOURCES.txt +monte_python_insult_robot.egg-info/dependency_links.txt +monte_python_insult_robot.egg-info/entry_points.txt +monte_python_insult_robot.egg-info/requires.txt +monte_python_insult_robot.egg-info/top_level.txt +monte_python_insult_robot/assets/coconuts.m4a +monte_python_insult_robot/assets/coconuts.wav +monte_python_insult_robot/assets/insults/contempt_hamster.wav +monte_python_insult_robot/assets/insults/contempt_wipe.wav +monte_python_insult_robot/assets/insults/dismissive_Looney.wav +monte_python_insult_robot/assets/insults/exasperated_bleed.wav +monte_python_insult_robot/assets/insults/exasperated_dead.wav +monte_python_insult_robot/assets/insults/exasperated_repressed.wav +monte_python_insult_robot/assets/insults/gleeful_bitelegs.wav +monte_python_insult_robot/assets/insults/gleeful_happy.wav +monte_python_insult_robot/assets/insults/gleeful_much_rejoicing.mp3 +monte_python_insult_robot/assets/insults/gleeful_privparts.wav +monte_python_insult_robot/assets/insults/gleeful_spam.mp3 +monte_python_insult_robot/assets/insults/haughty_french.wav +monte_python_insult_robot/assets/insults/haughty_mp_grail.wav +monte_python_insult_robot/assets/insults/mock_shock_Newt.wav +monte_python_insult_robot/assets/insults/mock_shock_runawayrun.wav +monte_python_insult_robot/assets/insults/pompous_King.wav +monte_python_insult_robot/assets/insults/pompous_britains.wav +monte_python_insult_robot/assets/insults/pompous_consult.mp3 +monte_python_insult_robot/assets/insults/pompous_mustbe.wav +monte_python_insult_robot/assets/insults/rage_Ni.wav +monte_python_insult_robot/assets/insults/rage_fart.wav +monte_python_insult_robot/assets/insults/rage_fart_direction.mp3 +monte_python_insult_robot/assets/insults/sneer_mp_taunt.mp3 +monte_python_insult_robot/assets/insults/sneer_taunt.wav +monte_python_insult_robot/assets/insults/suspicious_bridgeofdeath.wav +monte_python_insult_robot/assets/insults/suspicious_duck.mp3 +monte_python_insult_robot/assets/insults/suspicious_peril.wav +monte_python_insult_robot/static/index.html +monte_python_insult_robot/static/main.js +monte_python_insult_robot/static/style.css \ No newline at end of file diff --git a/monte_python_insult_robot.egg-info/dependency_links.txt b/monte_python_insult_robot.egg-info/dependency_links.txt new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/monte_python_insult_robot.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/monte_python_insult_robot.egg-info/entry_points.txt b/monte_python_insult_robot.egg-info/entry_points.txt new file mode 100644 index 0000000000000000000000000000000000000000..1fedaf019de1dff70d4890cf19d55650cde9d82a --- /dev/null +++ b/monte_python_insult_robot.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[reachy_mini_apps] +monte_python_insult_robot = monte_python_insult_robot.main:MontePythonInsultRobot diff --git a/monte_python_insult_robot.egg-info/requires.txt b/monte_python_insult_robot.egg-info/requires.txt new file mode 100644 index 0000000000000000000000000000000000000000..b815e780c9d6098fe3329f1a401d22fd034a2518 --- /dev/null +++ b/monte_python_insult_robot.egg-info/requires.txt @@ -0,0 +1,7 @@ +reachy-mini>=1.8.4 +pydub>=0.25.1 +requests>=2.31.0 +sounddevice>=0.5.0 +webrtcvad-wheels>=2.0.14 +scipy>=1.10.0 +numpy>=1.24.0 diff --git a/monte_python_insult_robot.egg-info/top_level.txt b/monte_python_insult_robot.egg-info/top_level.txt new file mode 100644 index 0000000000000000000000000000000000000000..f0e7f38bb2cae91c745f2c574404f9f93803066a --- /dev/null +++ b/monte_python_insult_robot.egg-info/top_level.txt @@ -0,0 +1 @@ +monte_python_insult_robot diff --git a/monte_python_insult_robot/__init__.py b/monte_python_insult_robot/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/monte_python_insult_robot/assets/coconuts.m4a b/monte_python_insult_robot/assets/coconuts.m4a new file mode 100644 index 0000000000000000000000000000000000000000..7f3ae6068b8993b811497ade1d2d49a9608156cf --- /dev/null +++ b/monte_python_insult_robot/assets/coconuts.m4a @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48e5db3f97430adad06e5d9416e866bcd6a2e848edbb09ace1822f64f542e254 +size 330442 diff --git a/monte_python_insult_robot/assets/coconuts.wav b/monte_python_insult_robot/assets/coconuts.wav new file mode 100644 index 0000000000000000000000000000000000000000..9f248abaeecb9381ad1766047ee7abaef0611ddf --- /dev/null +++ b/monte_python_insult_robot/assets/coconuts.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32db8ba781d35ba0b5a47faeddc8959f4c4af4cdc9630b51abd5fade20d64c15 +size 7697052 diff --git a/monte_python_insult_robot/assets/insults/contempt_hamster.wav b/monte_python_insult_robot/assets/insults/contempt_hamster.wav new file mode 100644 index 0000000000000000000000000000000000000000..4f594f6088a12ae308b20ab3329c1d8a86beb657 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/contempt_hamster.wav differ diff --git a/monte_python_insult_robot/assets/insults/contempt_wipe.wav b/monte_python_insult_robot/assets/insults/contempt_wipe.wav new file mode 100644 index 0000000000000000000000000000000000000000..739562e94a21835a306ef7c1bfc85c4bffae0dd1 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/contempt_wipe.wav differ diff --git a/monte_python_insult_robot/assets/insults/dismissive_Looney.wav b/monte_python_insult_robot/assets/insults/dismissive_Looney.wav new file mode 100644 index 0000000000000000000000000000000000000000..05b30294f3cb3d7deecce859f649e296330f3393 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/dismissive_Looney.wav differ diff --git a/monte_python_insult_robot/assets/insults/exasperated_bleed.wav b/monte_python_insult_robot/assets/insults/exasperated_bleed.wav new file mode 100644 index 0000000000000000000000000000000000000000..e22fc49609b81a31facedbd4a7cc8405ffe41677 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/exasperated_bleed.wav differ diff --git a/monte_python_insult_robot/assets/insults/exasperated_dead.wav b/monte_python_insult_robot/assets/insults/exasperated_dead.wav new file mode 100644 index 0000000000000000000000000000000000000000..72e724d01fac72e8d65cd499e1c6cc5682f707ed Binary files /dev/null and b/monte_python_insult_robot/assets/insults/exasperated_dead.wav differ diff --git a/monte_python_insult_robot/assets/insults/exasperated_repressed.wav b/monte_python_insult_robot/assets/insults/exasperated_repressed.wav new file mode 100644 index 0000000000000000000000000000000000000000..bc07a545a80a9a79cdc246995cf08d1c47f3d41c Binary files /dev/null and b/monte_python_insult_robot/assets/insults/exasperated_repressed.wav differ diff --git a/monte_python_insult_robot/assets/insults/gleeful_bitelegs.wav b/monte_python_insult_robot/assets/insults/gleeful_bitelegs.wav new file mode 100644 index 0000000000000000000000000000000000000000..eeb90930b63e857a810b5bef051826d4c089f4d3 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/gleeful_bitelegs.wav differ diff --git a/monte_python_insult_robot/assets/insults/gleeful_happy.wav b/monte_python_insult_robot/assets/insults/gleeful_happy.wav new file mode 100644 index 0000000000000000000000000000000000000000..c782583d5ad30edf14326be74b6a82de29f5329c Binary files /dev/null and b/monte_python_insult_robot/assets/insults/gleeful_happy.wav differ diff --git a/monte_python_insult_robot/assets/insults/gleeful_much_rejoicing.mp3 b/monte_python_insult_robot/assets/insults/gleeful_much_rejoicing.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..7fa7c1ac21aa78264299b7ef7bfa6bcc11273059 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/gleeful_much_rejoicing.mp3 differ diff --git a/monte_python_insult_robot/assets/insults/gleeful_privparts.wav b/monte_python_insult_robot/assets/insults/gleeful_privparts.wav new file mode 100644 index 0000000000000000000000000000000000000000..973cb176760ed63ffedead6130a72d899b6103fc Binary files /dev/null and b/monte_python_insult_robot/assets/insults/gleeful_privparts.wav differ diff --git a/monte_python_insult_robot/assets/insults/gleeful_spam.mp3 b/monte_python_insult_robot/assets/insults/gleeful_spam.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..699838453e4ff93058e9175c0ba4127231baf7fb Binary files /dev/null and b/monte_python_insult_robot/assets/insults/gleeful_spam.mp3 differ diff --git a/monte_python_insult_robot/assets/insults/haughty_french.wav b/monte_python_insult_robot/assets/insults/haughty_french.wav new file mode 100644 index 0000000000000000000000000000000000000000..2d67586feb19842a6fb3568ce0b018b0a5d4980c Binary files /dev/null and b/monte_python_insult_robot/assets/insults/haughty_french.wav differ diff --git a/monte_python_insult_robot/assets/insults/haughty_mp_grail.wav b/monte_python_insult_robot/assets/insults/haughty_mp_grail.wav new file mode 100644 index 0000000000000000000000000000000000000000..f50779a0eac6b9c724dfb96c58bc11b7803cc1f1 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/haughty_mp_grail.wav differ diff --git a/monte_python_insult_robot/assets/insults/mock_shock_Newt.wav b/monte_python_insult_robot/assets/insults/mock_shock_Newt.wav new file mode 100644 index 0000000000000000000000000000000000000000..b7852280071347021b16ce0151e8e3c03c2bc52b Binary files /dev/null and b/monte_python_insult_robot/assets/insults/mock_shock_Newt.wav differ diff --git a/monte_python_insult_robot/assets/insults/mock_shock_runawayrun.wav b/monte_python_insult_robot/assets/insults/mock_shock_runawayrun.wav new file mode 100644 index 0000000000000000000000000000000000000000..ab9d076b164e3836465c4df913541279c2e8bba3 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/mock_shock_runawayrun.wav differ diff --git a/monte_python_insult_robot/assets/insults/pompous_King.wav b/monte_python_insult_robot/assets/insults/pompous_King.wav new file mode 100644 index 0000000000000000000000000000000000000000..adb8f45b33573d1197582e4bd9f2ec00ba0e5403 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/pompous_King.wav differ diff --git a/monte_python_insult_robot/assets/insults/pompous_britains.wav b/monte_python_insult_robot/assets/insults/pompous_britains.wav new file mode 100644 index 0000000000000000000000000000000000000000..b9b94cc273727f6419f210debde33db6a8023570 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/pompous_britains.wav differ diff --git a/monte_python_insult_robot/assets/insults/pompous_consult.mp3 b/monte_python_insult_robot/assets/insults/pompous_consult.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..850304b17d0c0909d32882f9f040c0a752f69f7b Binary files /dev/null and b/monte_python_insult_robot/assets/insults/pompous_consult.mp3 differ diff --git a/monte_python_insult_robot/assets/insults/pompous_mustbe.wav b/monte_python_insult_robot/assets/insults/pompous_mustbe.wav new file mode 100644 index 0000000000000000000000000000000000000000..7ded93494fb9c3c9f653de652cbc650307678130 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/pompous_mustbe.wav differ diff --git a/monte_python_insult_robot/assets/insults/rage_Ni.wav b/monte_python_insult_robot/assets/insults/rage_Ni.wav new file mode 100644 index 0000000000000000000000000000000000000000..daa3328dc1728ce905fac9b27632f4241134ac23 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/rage_Ni.wav differ diff --git a/monte_python_insult_robot/assets/insults/rage_fart.wav b/monte_python_insult_robot/assets/insults/rage_fart.wav new file mode 100644 index 0000000000000000000000000000000000000000..a784926866db01bf79eee8fc5c8d6796661e5449 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/rage_fart.wav differ diff --git a/monte_python_insult_robot/assets/insults/rage_fart_direction.mp3 b/monte_python_insult_robot/assets/insults/rage_fart_direction.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..3f9913da640633192bce43fb3261e15307c4c66b Binary files /dev/null and b/monte_python_insult_robot/assets/insults/rage_fart_direction.mp3 differ diff --git a/monte_python_insult_robot/assets/insults/sneer_mp_taunt.mp3 b/monte_python_insult_robot/assets/insults/sneer_mp_taunt.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..069a4c51519af6a3cea076a39bf38ea75181e336 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/sneer_mp_taunt.mp3 differ diff --git a/monte_python_insult_robot/assets/insults/sneer_taunt.wav b/monte_python_insult_robot/assets/insults/sneer_taunt.wav new file mode 100644 index 0000000000000000000000000000000000000000..c7a1694aff48b1011b278259f9e2a43cd78e2aa0 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/sneer_taunt.wav differ diff --git a/monte_python_insult_robot/assets/insults/suspicious_bridgeofdeath.wav b/monte_python_insult_robot/assets/insults/suspicious_bridgeofdeath.wav new file mode 100644 index 0000000000000000000000000000000000000000..e8bd58031c793ec95c3c1d78a1a2a3e1e0d9bcfb --- /dev/null +++ b/monte_python_insult_robot/assets/insults/suspicious_bridgeofdeath.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d7c12fe58db361039f17b2029cbaa28554213a2d33adae747e9145c8dfa7763 +size 102934 diff --git a/monte_python_insult_robot/assets/insults/suspicious_duck.mp3 b/monte_python_insult_robot/assets/insults/suspicious_duck.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..f668dbd75f234049857d245ef3865d59bbada982 Binary files /dev/null and b/monte_python_insult_robot/assets/insults/suspicious_duck.mp3 differ diff --git a/monte_python_insult_robot/assets/insults/suspicious_peril.wav b/monte_python_insult_robot/assets/insults/suspicious_peril.wav new file mode 100644 index 0000000000000000000000000000000000000000..28fd8051b95e249c27a39cbb3e2d60c11c193063 --- /dev/null +++ b/monte_python_insult_robot/assets/insults/suspicious_peril.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1cfc8b2935b93c57ee8c61b90c85566779376d4db4858f9b168bbd359d7f23f1 +size 116538 diff --git a/monte_python_insult_robot/attention.py b/monte_python_insult_robot/attention.py new file mode 100644 index 0000000000000000000000000000000000000000..40938de2ef4ee1455de1d2c96a699b46221fb96d --- /dev/null +++ b/monte_python_insult_robot/attention.py @@ -0,0 +1,167 @@ +"""Attention loop: auto-detects simulation vs real hardware at startup. + +Simulation (mockup_sim_enabled=True or hardware_id=None): + - Captures laptop mic via sounddevice at 16 kHz + - webrtcvad detects speech in 30 ms frames + - No direction info — robot looks straight ahead + +Real hardware (ReSpeaker present): + - Polls /api/state/doa at 5 Hz + - Turns head toward speaker immediately, body follows 1 second later +""" + +import math +import threading +import time + +import requests + +from monte_python_insult_robot import audio + +DAEMON_URL = "http://localhost:8000" +SPEECH_COOLDOWN = 3.0 + +# --- Simulation mic sensitivity tuning ------------------------------------- +# Raise these to make the robot LESS sensitive to background noise. +VAD_AGGRESSIVENESS = 3 # webrtcvad 0-3; 3 filters the most non-speech +RMS_THRESHOLD = 500 # int16 RMS energy floor; frames quieter are ignored +MIN_VOICED_FRAMES = 8 # consecutive 30 ms voiced frames (~240 ms) to trigger + +# --- Real-hardware DOA sensitivity tuning ---------------------------------- +DOA_POLL_HZ = 5 # how often to poll /api/state/doa +MIN_VOICED_POLLS = 3 # consecutive speech_detected polls (~0.6 s) to trigger + + +def is_simulation() -> bool: + try: + resp = requests.get(f"{DAEMON_URL}/api/daemon/status", timeout=2) + data = resp.json() + return data.get("mockup_sim_enabled", False) or data.get("hardware_id") is None + except Exception: + return True # safe default + + +class AttentionLoop: + def __init__(self, reachy, on_speech_detected): + self._reachy = reachy + self._on_speech_detected = on_speech_detected + self._running = False + self._thread = None + self._last_trigger = 0.0 + self._sim = is_simulation() + audio._on_playback_done = lambda: setattr(self, "_last_trigger", time.time()) + + if self._sim: + print("Attention: simulation detected — using laptop mic") + else: + print("Attention: real hardware detected — using ReSpeaker DOA") + + def start(self) -> None: + self._running = True + target = self._loop_sim if self._sim else self._loop_doa + self._thread = threading.Thread(target=target, daemon=True) + self._thread.start() + + def stop(self) -> None: + self._running = False + + # ------------------------------------------------------------------ + # Simulation path — laptop mic + webrtcvad + # ------------------------------------------------------------------ + + def _loop_sim(self) -> None: + import numpy as np + import sounddevice as sd + import webrtcvad + + SAMPLE_RATE = 16000 + FRAME_SAMPLES = int(SAMPLE_RATE * 30 / 1000) # 30 ms + vad = webrtcvad.Vad(VAD_AGGRESSIVENESS) + buffer = np.zeros(0, dtype=np.int16) + voiced_run = 0 # consecutive frames that passed the energy + VAD gates + + def callback(indata, frames, time_info, status): + nonlocal buffer + buffer = np.append(buffer, (indata[:, 0] * 32767).astype(np.int16)) + + with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, + dtype="float32", callback=callback): + while self._running: + if len(buffer) >= FRAME_SAMPLES: + frame_i16 = buffer[:FRAME_SAMPLES] + buffer = buffer[FRAME_SAMPLES:] + if audio.is_playing: + voiced_run = 0 + continue + # Energy gate first: cheap, and keeps quiet background hum + # from ever reaching the VAD. + rms = float(np.sqrt(np.mean(frame_i16.astype(np.float32) ** 2))) + try: + speech = rms >= RMS_THRESHOLD and vad.is_speech( + frame_i16.tobytes(), SAMPLE_RATE + ) + except Exception: + speech = False + # Require sustained speech, not a single transient blip. + voiced_run = voiced_run + 1 if speech else 0 + if voiced_run >= MIN_VOICED_FRAMES: + voiced_run = 0 + self._trigger(face_fn=self._face_forward) + else: + time.sleep(0.005) + + # ------------------------------------------------------------------ + # Real hardware path — ReSpeaker DOA polling + # ------------------------------------------------------------------ + + def _loop_doa(self) -> None: + voiced_polls = 0 # consecutive polls reporting speech_detected + while self._running: + try: + resp = requests.get(f"{DAEMON_URL}/api/state/doa", timeout=1) + if resp.status_code == 200: + data = resp.json() + if data and data.get("speech_detected"): + # Require sustained detection, not a single noisy poll. + voiced_polls += 1 + if voiced_polls >= MIN_VOICED_POLLS: + voiced_polls = 0 + angle = data["angle"] + self._trigger(face_fn=lambda a=angle: self._face_speaker(a)) + else: + voiced_polls = 0 + except Exception: + pass # dropped poll: keep the counter, don't treat as silence + time.sleep(1.0 / DOA_POLL_HZ) + + # ------------------------------------------------------------------ + # Shared trigger logic + # ------------------------------------------------------------------ + + def _trigger(self, face_fn) -> None: + if audio.is_playing: + return + now = time.time() + if now - self._last_trigger < SPEECH_COOLDOWN: + return + self._last_trigger = now + face_fn() + threading.Thread(target=self._on_speech_detected, daemon=True).start() + + def _face_forward(self) -> None: + """Simulation: look straight ahead, body follows 1s later.""" + self._reachy.look_at_world(1.0, 0.0, 0.05, duration=0.4) + threading.Thread(target=lambda: self._rotate_body(0.0), daemon=True).start() + + def _face_speaker(self, angle_rad: float) -> None: + """Real hardware: turn head toward DOA angle, body follows 1s later.""" + adjusted = angle_rad - math.pi / 2 + x = math.cos(adjusted) + y = -math.sin(adjusted) + self._reachy.look_at_world(x, y, 0.05, duration=0.4) + body_yaw = max(-2.79, min(2.79, -adjusted)) + threading.Thread(target=lambda: self._rotate_body(body_yaw), daemon=True).start() + + def _rotate_body(self, yaw: float) -> None: + time.sleep(1.0) + self._reachy.goto_target(body_yaw=yaw, duration=0.8) diff --git a/monte_python_insult_robot/audio.py b/monte_python_insult_robot/audio.py new file mode 100644 index 0000000000000000000000000000000000000000..890f62490afaeb86b39809f5d4c4629469d5eb4d --- /dev/null +++ b/monte_python_insult_robot/audio.py @@ -0,0 +1,60 @@ +"""Insult audio player: picks a random WAV from assets/insults/ and plays it.""" + +import os +import random + +import threading + +import monte_python_insult_robot.expressions as expressions +from monte_python_insult_robot.playback import play_file + +INSULTS_DIR = os.path.join(os.path.dirname(__file__), "assets", "insults") +VOLUME = 0.5 +AUDIO_EXTS = (".wav", ".mp3") + +# Set by attention.py to suppress VAD triggers during playback +is_playing = False +_on_playback_done = None # optional callback set by AttentionLoop +_speak_lock = threading.Lock() +_last_played = None + + +def _tag_from_filename(filename: str) -> str: + """Parse expression tag from '{tag}_{name}.ext' convention. Falls back to 'contempt'.""" + stem = os.path.splitext(filename)[0] + tag = stem.split("_")[0] + return tag + + +def speak(reachy) -> None: + """Pick a random insult, loop matching expression for its duration, and play the audio.""" + global is_playing, _last_played + if not _speak_lock.acquire(blocking=False): + return # already playing, drop this call + try: + files = [f for f in os.listdir(INSULTS_DIR) if f.lower().endswith(AUDIO_EXTS)] + if not files: + print(f"No audio files found in {INSULTS_DIR}") + return + candidates = [f for f in files if f != _last_played] or files + filename = random.choice(candidates) + _last_played = filename + path = os.path.join(INSULTS_DIR, filename) + tag = _tag_from_filename(filename) + print(f"Playing: {filename} (expression: {tag})") + stop_event = threading.Event() + expr_thread = threading.Thread( + target=expressions.loop_expression, args=(reachy, tag, stop_event), daemon=True + ) + expr_thread.start() + is_playing = True + try: + play_file(path, volume=VOLUME) + finally: + stop_event.set() + expr_thread.join(timeout=2.0) + is_playing = False + if _on_playback_done: + _on_playback_done() + finally: + _speak_lock.release() diff --git a/monte_python_insult_robot/expressions.py b/monte_python_insult_robot/expressions.py new file mode 100644 index 0000000000000000000000000000000000000000..4b79f27cf493271e5b1b40502810cc4451f8de8a --- /dev/null +++ b/monte_python_insult_robot/expressions.py @@ -0,0 +1,111 @@ +"""Physical expressions mapped to Monty Python insult tags. + +Each expression is a callable that takes a ReachyMini instance and performs +the appropriate head/antenna movement. +""" + +import math +import numpy as np +from scipy.spatial.transform import Rotation as R + + +def _pose(roll_deg=0, pitch_deg=0, yaw_deg=0) -> np.ndarray: + pose = np.eye(4) + pose[:3, :3] = R.from_euler("xyz", [roll_deg, pitch_deg, yaw_deg], degrees=True).as_matrix() + return pose + + +# Antenna positions [right_rad, left_rad] +ANTENNAS_NEUTRAL = [-0.1745, 0.1745] +ANTENNAS_DROOP = [-0.8, 0.8] # drooping — dismissive +ANTENNAS_PERK = [ 0.3, -0.3] # perked up — mock shock +ANTENNAS_RAGE = [-1.2, 1.2] # fully down — rage +ANTENNAS_HAUGHTY = [ 0.0, 0.0] # straight up — haughty + + +def contempt(reachy) -> None: + """Slow head tilt to one side, antennas droop slightly.""" + reachy.goto_target(head=_pose(roll_deg=15, pitch_deg=-10), antennas=ANTENNAS_DROOP, duration=0.6) + + +def dismissive(reachy) -> None: + """Head turns away then snaps back.""" + reachy.goto_target(head=_pose(yaw_deg=40), antennas=ANTENNAS_DROOP, duration=0.5) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.4) + + +def mock_shock(reachy) -> None: + """Head rears back, antennas perk up.""" + reachy.goto_target(head=_pose(pitch_deg=20), antennas=ANTENNAS_PERK, duration=0.3) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.5) + + +def haughty(reachy) -> None: + """Head tilts up, antennas straight — looking down the nose.""" + reachy.goto_target(head=_pose(pitch_deg=-20), antennas=ANTENNAS_HAUGHTY, duration=0.7) + + +def rage(reachy) -> None: + """Rapid head shake side to side, antennas slam down.""" + reachy.goto_target(head=_pose(yaw_deg=20), antennas=ANTENNAS_RAGE, duration=0.2) + reachy.goto_target(head=_pose(yaw_deg=-20), duration=0.2) + reachy.goto_target(head=_pose(yaw_deg=20), duration=0.2) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.3) + + +def sneer(reachy) -> None: + """Slow roll to one side, antenna droops — withering sneer.""" + reachy.goto_target(head=_pose(roll_deg=-18, yaw_deg=-15), antennas=[-0.5, 0.2], duration=0.5) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.6) + + +def exasperated(reachy) -> None: + """Head drops forward then snaps back up — long-suffering exasperation.""" + reachy.goto_target(head=_pose(pitch_deg=25), antennas=ANTENNAS_DROOP, duration=0.4) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.5) + + +def gleeful(reachy) -> None: + """Quick bouncy nod, antennas perk — delighted malice.""" + reachy.goto_target(head=_pose(pitch_deg=-10), antennas=ANTENNAS_PERK, duration=0.2) + reachy.goto_target(head=_pose(pitch_deg=10), duration=0.2) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.3) + + +def suspicious(reachy) -> None: + """Head tilts and leans forward — squinting suspicion.""" + reachy.goto_target(head=_pose(roll_deg=10, pitch_deg=8), antennas=[-0.3, 0.3], duration=0.5) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.5) + + +def pompous(reachy) -> None: + """Head sweeps up and holds — insufferable self-importance.""" + reachy.goto_target(head=_pose(pitch_deg=-25, yaw_deg=10), antennas=ANTENNAS_HAUGHTY, duration=0.6) + reachy.goto_target(head=_pose(pitch_deg=-25, yaw_deg=-10), duration=0.4) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.5) + + +EXPRESSIONS = { + "contempt": contempt, + "dismissive": dismissive, + "mock_shock": mock_shock, + "haughty": haughty, + "rage": rage, + "sneer": sneer, + "exasperated": exasperated, + "gleeful": gleeful, + "suspicious": suspicious, + "pompous": pompous, +} + + +def perform(reachy, tag: str) -> None: + EXPRESSIONS.get(tag, contempt)(reachy) + + +def loop_expression(reachy, tag: str, stop_event) -> None: + """Repeat the expression until stop_event is set, then return to neutral.""" + fn = EXPRESSIONS.get(tag, contempt) + while not stop_event.is_set(): + fn(reachy) + reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.4) diff --git a/monte_python_insult_robot/insults.py b/monte_python_insult_robot/insults.py new file mode 100644 index 0000000000000000000000000000000000000000..c91257bc2059f268f2ea84727b53287abaf8a786 --- /dev/null +++ b/monte_python_insult_robot/insults.py @@ -0,0 +1,6 @@ +"""Insult library — expression tags are now encoded in WAV/MP3 filenames. + +Filename convention: {expression_tag}_{name}.{ext} +Supported tags: contempt, dismissive, mock_shock, haughty, rage, + sneer, exasperated, gleeful, suspicious, pompous +""" diff --git a/monte_python_insult_robot/main.py b/monte_python_insult_robot/main.py new file mode 100644 index 0000000000000000000000000000000000000000..0c4b582628e782c910ed6eccd22086b2851c171e --- /dev/null +++ b/monte_python_insult_robot/main.py @@ -0,0 +1,42 @@ +"""Monte Python Insult Robot — main entry point.""" + +import threading + +from reachy_mini import ReachyMini, ReachyMiniApp + +from monte_python_insult_robot.audio import speak +from monte_python_insult_robot.sounds import ensure_coconut, play_coconut +from monte_python_insult_robot.attention import AttentionLoop + + +class MontePythonInsultRobot(ReachyMiniApp): + custom_app_url: str | None = None + request_media_backend: str | None = None + + def run(self, reachy_mini: ReachyMini, stop_event: threading.Event): + reachy_mini.wake_up() + ensure_coconut() + + def deliver_insult(): + play_coconut(reachy_mini) + speak(reachy_mini) + + attention = AttentionLoop( + reachy=reachy_mini, + on_speech_detected=deliver_insult, + ) + attention.start() + print("Monte Python Insult Robot is ready. Waiting for victims...") + + stop_event.wait() + + attention.stop() + reachy_mini.goto_sleep() + + +if __name__ == "__main__": + app = MontePythonInsultRobot() + try: + app.wrapped_run() + except KeyboardInterrupt: + app.stop() diff --git a/monte_python_insult_robot/playback.py b/monte_python_insult_robot/playback.py new file mode 100644 index 0000000000000000000000000000000000000000..d7cae3b9f049c835cf5315f4edc921620c608807 --- /dev/null +++ b/monte_python_insult_robot/playback.py @@ -0,0 +1,50 @@ +"""Shared GStreamer audio playback helpers. + +Centralizes the platform-specific sink selection and the play-until-EOS loop +that were previously duplicated across audio.py, sounds.py, and tts.py. +""" + +import platform + + +def get_audio_sink() -> str: + """Return a GStreamer sink pipeline fragment for the current platform.""" + if platform.system() == "Darwin": + return "osxaudiosink unique-id=BuiltInSpeakerDevice" + return "autoaudiosink" + + +def _decoder_for(path: str) -> str: + """Return the GStreamer decode element for the file type.""" + if path.lower().endswith(".mp3"): + return "decodebin" + return "wavparse" + + +def play_file(path: str, volume: float | None = None) -> None: + """Play an audio file synchronously via GStreamer, blocking until EOS/error. + + Auto-selects the decoder from the file extension. If *volume* is given, a + ``volume`` element is inserted (0.0–1.0); otherwise playback is unattenuated. + """ + import gi + gi.require_version("Gst", "1.0") + from gi.repository import Gst + Gst.init(None) + + decoder = _decoder_for(path) + volume_stage = f"volume volume={volume} ! " if volume is not None else "" + pipeline = Gst.parse_launch( + f"filesrc location={path} ! {decoder} ! audioconvert ! audioresample " + f"! {volume_stage}{get_audio_sink()}" + ) + pipeline.set_state(Gst.State.PLAYING) + bus = pipeline.get_bus() + while True: + msg = bus.timed_pop_filtered( + 100 * Gst.MSECOND, + Gst.MessageType.EOS | Gst.MessageType.ERROR, + ) + if msg: + break + pipeline.set_state(Gst.State.NULL) diff --git a/monte_python_insult_robot/requirements.txt b/monte_python_insult_robot/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..393bc82541ee826185eee9a72d5e01f9eed9bbeb --- /dev/null +++ b/monte_python_insult_robot/requirements.txt @@ -0,0 +1,7 @@ +reachy_mini>=1.8.4 +pydub>=0.25.1 +requests>=2.31.0 +sounddevice>=0.5.0 +webrtcvad-wheels>=2.0.14 +scipy>=1.10.0 +numpy>=1.24.0 diff --git a/monte_python_insult_robot/sounds.py b/monte_python_insult_robot/sounds.py new file mode 100644 index 0000000000000000000000000000000000000000..153595bef57c62bc7464708ac2e0116bd344d0bf --- /dev/null +++ b/monte_python_insult_robot/sounds.py @@ -0,0 +1,74 @@ +"""Coconut clip-clop sound: convert local M4A asset, play a random 2s slice directly via GStreamer.""" + +import io +import os +import random +from pydub import AudioSegment + +from monte_python_insult_robot.playback import play_file + +ASSET_M4A = os.path.join(os.path.dirname(__file__), "assets", "coconuts.m4a") +ASSET_WAV = os.path.join(os.path.dirname(__file__), "assets", "coconuts.wav") +CLIP_DURATION_MS = 2000 + + +def ensure_coconut() -> None: + """Convert M4A to WAV if needed.""" + if not os.path.exists(ASSET_WAV): + print("Converting coconuts.m4a to WAV...") + AudioSegment.from_file(ASSET_M4A, format="m4a").export(ASSET_WAV, format="wav") + print(f"Saved to {ASSET_WAV}") + + +def _bob_head(reachy, duration: float) -> None: + """Bounce head up/down for the given duration to mimic horse riding.""" + import threading + import numpy as np + from scipy.spatial.transform import Rotation as R + + BOB_INTERVAL = 0.25 # seconds per half-bob (~2 clops/sec) + BOB_PITCH = 12 # degrees + + def _pose(pitch_deg): + pose = np.eye(4) + pose[:3, :3] = R.from_euler("xyz", [0, pitch_deg, 0], degrees=True).as_matrix() + return pose + + stop = threading.Event() + threading.Timer(duration, stop.set).start() + + down = True + while not stop.is_set(): + pitch = BOB_PITCH if down else -BOB_PITCH + reachy.goto_target(head=_pose(pitch), duration=BOB_INTERVAL) + down = not down + + reachy.goto_target(head=_pose(0), duration=0.3) + + +def play_coconut(reachy) -> None: + """Play a random 2s slice of the coconut sound with head bobbing.""" + import tempfile + import threading + + audio = AudioSegment.from_wav(ASSET_WAV) + max_start = len(audio) - CLIP_DURATION_MS + start_ms = random.randint(0, max_start) + clip = audio[start_ms : start_ms + CLIP_DURATION_MS] + + buf = io.BytesIO() + clip.export(buf, format="wav") + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + f.write(buf.getvalue()) + tmp_path = f.name + + try: + duration_s = CLIP_DURATION_MS / 1000 + bob_thread = threading.Thread(target=_bob_head, args=(reachy, duration_s), daemon=True) + bob_thread.start() + + play_file(tmp_path) + bob_thread.join(timeout=1.0) + finally: + os.unlink(tmp_path) diff --git a/monte_python_insult_robot/static/index.html b/monte_python_insult_robot/static/index.html new file mode 100644 index 0000000000000000000000000000000000000000..71fc5395b2253aa2e14a20b51edde90fd4c57aa4 --- /dev/null +++ b/monte_python_insult_robot/static/index.html @@ -0,0 +1,27 @@ + + + + + +