Fix GStreamer 'no element' crash on paths with spaces (v1.0.1)
Browse files- build/lib/monte_python_insult_robot/__init__.py +0 -0
- build/lib/monte_python_insult_robot/assets/coconuts.m4a +3 -0
- build/lib/monte_python_insult_robot/assets/coconuts.wav +3 -0
- build/lib/monte_python_insult_robot/assets/insults/contempt_hamster.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/contempt_wipe.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/dismissive_Looney.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/exasperated_bleed.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/exasperated_dead.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/exasperated_repressed.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/gleeful_bitelegs.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/gleeful_happy.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/gleeful_much_rejoicing.mp3 +0 -0
- build/lib/monte_python_insult_robot/assets/insults/gleeful_privparts.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/gleeful_spam.mp3 +0 -0
- build/lib/monte_python_insult_robot/assets/insults/haughty_french.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/haughty_mp_grail.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/mock_shock_Newt.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/mock_shock_runawayrun.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/pompous_King.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/pompous_britains.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/pompous_consult.mp3 +0 -0
- build/lib/monte_python_insult_robot/assets/insults/pompous_mustbe.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/rage_Ni.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/rage_fart.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/rage_fart_direction.mp3 +0 -0
- build/lib/monte_python_insult_robot/assets/insults/sneer_mp_taunt.mp3 +0 -0
- build/lib/monte_python_insult_robot/assets/insults/sneer_taunt.wav +0 -0
- build/lib/monte_python_insult_robot/assets/insults/suspicious_bridgeofdeath.wav +3 -0
- build/lib/monte_python_insult_robot/assets/insults/suspicious_duck.mp3 +0 -0
- build/lib/monte_python_insult_robot/assets/insults/suspicious_peril.wav +3 -0
- build/lib/monte_python_insult_robot/attention.py +167 -0
- build/lib/monte_python_insult_robot/audio.py +60 -0
- build/lib/monte_python_insult_robot/expressions.py +111 -0
- build/lib/monte_python_insult_robot/insults.py +6 -0
- build/lib/monte_python_insult_robot/main.py +42 -0
- build/lib/monte_python_insult_robot/playback.py +67 -0
- build/lib/monte_python_insult_robot/requirements.txt +7 -0
- build/lib/monte_python_insult_robot/sounds.py +74 -0
- build/lib/monte_python_insult_robot/static/index.html +27 -0
- build/lib/monte_python_insult_robot/static/main.js +47 -0
- build/lib/monte_python_insult_robot/static/style.css +25 -0
- monte_python_insult_robot.egg-info/PKG-INFO +52 -0
- monte_python_insult_robot.egg-info/SOURCES.txt +90 -0
- monte_python_insult_robot.egg-info/dependency_links.txt +1 -0
- monte_python_insult_robot.egg-info/entry_points.txt +2 -0
- monte_python_insult_robot.egg-info/requires.txt +7 -0
- monte_python_insult_robot.egg-info/top_level.txt +1 -0
- monte_python_insult_robot/playback.py +41 -24
- pyproject.toml +1 -1
build/lib/monte_python_insult_robot/__init__.py
ADDED
|
File without changes
|
build/lib/monte_python_insult_robot/assets/coconuts.m4a
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:48e5db3f97430adad06e5d9416e866bcd6a2e848edbb09ace1822f64f542e254
|
| 3 |
+
size 330442
|
build/lib/monte_python_insult_robot/assets/coconuts.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:32db8ba781d35ba0b5a47faeddc8959f4c4af4cdc9630b51abd5fade20d64c15
|
| 3 |
+
size 7697052
|
build/lib/monte_python_insult_robot/assets/insults/contempt_hamster.wav
ADDED
|
Binary file (58 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/contempt_wipe.wav
ADDED
|
Binary file (39 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/dismissive_Looney.wav
ADDED
|
Binary file (9.49 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/exasperated_bleed.wav
ADDED
|
Binary file (17.1 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/exasperated_dead.wav
ADDED
|
Binary file (33.7 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/exasperated_repressed.wav
ADDED
|
Binary file (22.2 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/gleeful_bitelegs.wav
ADDED
|
Binary file (18.2 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/gleeful_happy.wav
ADDED
|
Binary file (29.2 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/gleeful_much_rejoicing.mp3
ADDED
|
Binary file (55.6 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/gleeful_privparts.wav
ADDED
|
Binary file (86.2 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/gleeful_spam.mp3
ADDED
|
Binary file (32.6 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/haughty_french.wav
ADDED
|
Binary file (59.7 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/haughty_mp_grail.wav
ADDED
|
Binary file (34.3 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/mock_shock_Newt.wav
ADDED
|
Binary file (99.9 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/mock_shock_runawayrun.wav
ADDED
|
Binary file (32.6 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/pompous_King.wav
ADDED
|
Binary file (29.5 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/pompous_britains.wav
ADDED
|
Binary file (11.9 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/pompous_consult.mp3
ADDED
|
Binary file (13.7 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/pompous_mustbe.wav
ADDED
|
Binary file (66.6 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/rage_Ni.wav
ADDED
|
Binary file (37.2 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/rage_fart.wav
ADDED
|
Binary file (32.6 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/rage_fart_direction.mp3
ADDED
|
Binary file (6.42 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/sneer_mp_taunt.mp3
ADDED
|
Binary file (90.1 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/sneer_taunt.wav
ADDED
|
Binary file (20.5 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/suspicious_bridgeofdeath.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2d7c12fe58db361039f17b2029cbaa28554213a2d33adae747e9145c8dfa7763
|
| 3 |
+
size 102934
|
build/lib/monte_python_insult_robot/assets/insults/suspicious_duck.mp3
ADDED
|
Binary file (33.4 kB). View file
|
|
|
build/lib/monte_python_insult_robot/assets/insults/suspicious_peril.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1cfc8b2935b93c57ee8c61b90c85566779376d4db4858f9b168bbd359d7f23f1
|
| 3 |
+
size 116538
|
build/lib/monte_python_insult_robot/attention.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Attention loop: auto-detects simulation vs real hardware at startup.
|
| 2 |
+
|
| 3 |
+
Simulation (mockup_sim_enabled=True or hardware_id=None):
|
| 4 |
+
- Captures laptop mic via sounddevice at 16 kHz
|
| 5 |
+
- webrtcvad detects speech in 30 ms frames
|
| 6 |
+
- No direction info — robot looks straight ahead
|
| 7 |
+
|
| 8 |
+
Real hardware (ReSpeaker present):
|
| 9 |
+
- Polls /api/state/doa at 5 Hz
|
| 10 |
+
- Turns head toward speaker immediately, body follows 1 second later
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import math
|
| 14 |
+
import threading
|
| 15 |
+
import time
|
| 16 |
+
|
| 17 |
+
import requests
|
| 18 |
+
|
| 19 |
+
from monte_python_insult_robot import audio
|
| 20 |
+
|
| 21 |
+
DAEMON_URL = "http://localhost:8000"
|
| 22 |
+
SPEECH_COOLDOWN = 3.0
|
| 23 |
+
|
| 24 |
+
# --- Simulation mic sensitivity tuning -------------------------------------
|
| 25 |
+
# Raise these to make the robot LESS sensitive to background noise.
|
| 26 |
+
VAD_AGGRESSIVENESS = 3 # webrtcvad 0-3; 3 filters the most non-speech
|
| 27 |
+
RMS_THRESHOLD = 500 # int16 RMS energy floor; frames quieter are ignored
|
| 28 |
+
MIN_VOICED_FRAMES = 8 # consecutive 30 ms voiced frames (~240 ms) to trigger
|
| 29 |
+
|
| 30 |
+
# --- Real-hardware DOA sensitivity tuning ----------------------------------
|
| 31 |
+
DOA_POLL_HZ = 5 # how often to poll /api/state/doa
|
| 32 |
+
MIN_VOICED_POLLS = 3 # consecutive speech_detected polls (~0.6 s) to trigger
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def is_simulation() -> bool:
|
| 36 |
+
try:
|
| 37 |
+
resp = requests.get(f"{DAEMON_URL}/api/daemon/status", timeout=2)
|
| 38 |
+
data = resp.json()
|
| 39 |
+
return data.get("mockup_sim_enabled", False) or data.get("hardware_id") is None
|
| 40 |
+
except Exception:
|
| 41 |
+
return True # safe default
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class AttentionLoop:
|
| 45 |
+
def __init__(self, reachy, on_speech_detected):
|
| 46 |
+
self._reachy = reachy
|
| 47 |
+
self._on_speech_detected = on_speech_detected
|
| 48 |
+
self._running = False
|
| 49 |
+
self._thread = None
|
| 50 |
+
self._last_trigger = 0.0
|
| 51 |
+
self._sim = is_simulation()
|
| 52 |
+
audio._on_playback_done = lambda: setattr(self, "_last_trigger", time.time())
|
| 53 |
+
|
| 54 |
+
if self._sim:
|
| 55 |
+
print("Attention: simulation detected — using laptop mic")
|
| 56 |
+
else:
|
| 57 |
+
print("Attention: real hardware detected — using ReSpeaker DOA")
|
| 58 |
+
|
| 59 |
+
def start(self) -> None:
|
| 60 |
+
self._running = True
|
| 61 |
+
target = self._loop_sim if self._sim else self._loop_doa
|
| 62 |
+
self._thread = threading.Thread(target=target, daemon=True)
|
| 63 |
+
self._thread.start()
|
| 64 |
+
|
| 65 |
+
def stop(self) -> None:
|
| 66 |
+
self._running = False
|
| 67 |
+
|
| 68 |
+
# ------------------------------------------------------------------
|
| 69 |
+
# Simulation path — laptop mic + webrtcvad
|
| 70 |
+
# ------------------------------------------------------------------
|
| 71 |
+
|
| 72 |
+
def _loop_sim(self) -> None:
|
| 73 |
+
import numpy as np
|
| 74 |
+
import sounddevice as sd
|
| 75 |
+
import webrtcvad
|
| 76 |
+
|
| 77 |
+
SAMPLE_RATE = 16000
|
| 78 |
+
FRAME_SAMPLES = int(SAMPLE_RATE * 30 / 1000) # 30 ms
|
| 79 |
+
vad = webrtcvad.Vad(VAD_AGGRESSIVENESS)
|
| 80 |
+
buffer = np.zeros(0, dtype=np.int16)
|
| 81 |
+
voiced_run = 0 # consecutive frames that passed the energy + VAD gates
|
| 82 |
+
|
| 83 |
+
def callback(indata, frames, time_info, status):
|
| 84 |
+
nonlocal buffer
|
| 85 |
+
buffer = np.append(buffer, (indata[:, 0] * 32767).astype(np.int16))
|
| 86 |
+
|
| 87 |
+
with sd.InputStream(samplerate=SAMPLE_RATE, channels=1,
|
| 88 |
+
dtype="float32", callback=callback):
|
| 89 |
+
while self._running:
|
| 90 |
+
if len(buffer) >= FRAME_SAMPLES:
|
| 91 |
+
frame_i16 = buffer[:FRAME_SAMPLES]
|
| 92 |
+
buffer = buffer[FRAME_SAMPLES:]
|
| 93 |
+
if audio.is_playing:
|
| 94 |
+
voiced_run = 0
|
| 95 |
+
continue
|
| 96 |
+
# Energy gate first: cheap, and keeps quiet background hum
|
| 97 |
+
# from ever reaching the VAD.
|
| 98 |
+
rms = float(np.sqrt(np.mean(frame_i16.astype(np.float32) ** 2)))
|
| 99 |
+
try:
|
| 100 |
+
speech = rms >= RMS_THRESHOLD and vad.is_speech(
|
| 101 |
+
frame_i16.tobytes(), SAMPLE_RATE
|
| 102 |
+
)
|
| 103 |
+
except Exception:
|
| 104 |
+
speech = False
|
| 105 |
+
# Require sustained speech, not a single transient blip.
|
| 106 |
+
voiced_run = voiced_run + 1 if speech else 0
|
| 107 |
+
if voiced_run >= MIN_VOICED_FRAMES:
|
| 108 |
+
voiced_run = 0
|
| 109 |
+
self._trigger(face_fn=self._face_forward)
|
| 110 |
+
else:
|
| 111 |
+
time.sleep(0.005)
|
| 112 |
+
|
| 113 |
+
# ------------------------------------------------------------------
|
| 114 |
+
# Real hardware path — ReSpeaker DOA polling
|
| 115 |
+
# ------------------------------------------------------------------
|
| 116 |
+
|
| 117 |
+
def _loop_doa(self) -> None:
|
| 118 |
+
voiced_polls = 0 # consecutive polls reporting speech_detected
|
| 119 |
+
while self._running:
|
| 120 |
+
try:
|
| 121 |
+
resp = requests.get(f"{DAEMON_URL}/api/state/doa", timeout=1)
|
| 122 |
+
if resp.status_code == 200:
|
| 123 |
+
data = resp.json()
|
| 124 |
+
if data and data.get("speech_detected"):
|
| 125 |
+
# Require sustained detection, not a single noisy poll.
|
| 126 |
+
voiced_polls += 1
|
| 127 |
+
if voiced_polls >= MIN_VOICED_POLLS:
|
| 128 |
+
voiced_polls = 0
|
| 129 |
+
angle = data["angle"]
|
| 130 |
+
self._trigger(face_fn=lambda a=angle: self._face_speaker(a))
|
| 131 |
+
else:
|
| 132 |
+
voiced_polls = 0
|
| 133 |
+
except Exception:
|
| 134 |
+
pass # dropped poll: keep the counter, don't treat as silence
|
| 135 |
+
time.sleep(1.0 / DOA_POLL_HZ)
|
| 136 |
+
|
| 137 |
+
# ------------------------------------------------------------------
|
| 138 |
+
# Shared trigger logic
|
| 139 |
+
# ------------------------------------------------------------------
|
| 140 |
+
|
| 141 |
+
def _trigger(self, face_fn) -> None:
|
| 142 |
+
if audio.is_playing:
|
| 143 |
+
return
|
| 144 |
+
now = time.time()
|
| 145 |
+
if now - self._last_trigger < SPEECH_COOLDOWN:
|
| 146 |
+
return
|
| 147 |
+
self._last_trigger = now
|
| 148 |
+
face_fn()
|
| 149 |
+
threading.Thread(target=self._on_speech_detected, daemon=True).start()
|
| 150 |
+
|
| 151 |
+
def _face_forward(self) -> None:
|
| 152 |
+
"""Simulation: look straight ahead, body follows 1s later."""
|
| 153 |
+
self._reachy.look_at_world(1.0, 0.0, 0.05, duration=0.4)
|
| 154 |
+
threading.Thread(target=lambda: self._rotate_body(0.0), daemon=True).start()
|
| 155 |
+
|
| 156 |
+
def _face_speaker(self, angle_rad: float) -> None:
|
| 157 |
+
"""Real hardware: turn head toward DOA angle, body follows 1s later."""
|
| 158 |
+
adjusted = angle_rad - math.pi / 2
|
| 159 |
+
x = math.cos(adjusted)
|
| 160 |
+
y = -math.sin(adjusted)
|
| 161 |
+
self._reachy.look_at_world(x, y, 0.05, duration=0.4)
|
| 162 |
+
body_yaw = max(-2.79, min(2.79, -adjusted))
|
| 163 |
+
threading.Thread(target=lambda: self._rotate_body(body_yaw), daemon=True).start()
|
| 164 |
+
|
| 165 |
+
def _rotate_body(self, yaw: float) -> None:
|
| 166 |
+
time.sleep(1.0)
|
| 167 |
+
self._reachy.goto_target(body_yaw=yaw, duration=0.8)
|
build/lib/monte_python_insult_robot/audio.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Insult audio player: picks a random WAV from assets/insults/ and plays it."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import random
|
| 5 |
+
|
| 6 |
+
import threading
|
| 7 |
+
|
| 8 |
+
import monte_python_insult_robot.expressions as expressions
|
| 9 |
+
from monte_python_insult_robot.playback import play_file
|
| 10 |
+
|
| 11 |
+
INSULTS_DIR = os.path.join(os.path.dirname(__file__), "assets", "insults")
|
| 12 |
+
VOLUME = 0.5
|
| 13 |
+
AUDIO_EXTS = (".wav", ".mp3")
|
| 14 |
+
|
| 15 |
+
# Set by attention.py to suppress VAD triggers during playback
|
| 16 |
+
is_playing = False
|
| 17 |
+
_on_playback_done = None # optional callback set by AttentionLoop
|
| 18 |
+
_speak_lock = threading.Lock()
|
| 19 |
+
_last_played = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _tag_from_filename(filename: str) -> str:
|
| 23 |
+
"""Parse expression tag from '{tag}_{name}.ext' convention. Falls back to 'contempt'."""
|
| 24 |
+
stem = os.path.splitext(filename)[0]
|
| 25 |
+
tag = stem.split("_")[0]
|
| 26 |
+
return tag
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def speak(reachy) -> None:
|
| 30 |
+
"""Pick a random insult, loop matching expression for its duration, and play the audio."""
|
| 31 |
+
global is_playing, _last_played
|
| 32 |
+
if not _speak_lock.acquire(blocking=False):
|
| 33 |
+
return # already playing, drop this call
|
| 34 |
+
try:
|
| 35 |
+
files = [f for f in os.listdir(INSULTS_DIR) if f.lower().endswith(AUDIO_EXTS)]
|
| 36 |
+
if not files:
|
| 37 |
+
print(f"No audio files found in {INSULTS_DIR}")
|
| 38 |
+
return
|
| 39 |
+
candidates = [f for f in files if f != _last_played] or files
|
| 40 |
+
filename = random.choice(candidates)
|
| 41 |
+
_last_played = filename
|
| 42 |
+
path = os.path.join(INSULTS_DIR, filename)
|
| 43 |
+
tag = _tag_from_filename(filename)
|
| 44 |
+
print(f"Playing: {filename} (expression: {tag})")
|
| 45 |
+
stop_event = threading.Event()
|
| 46 |
+
expr_thread = threading.Thread(
|
| 47 |
+
target=expressions.loop_expression, args=(reachy, tag, stop_event), daemon=True
|
| 48 |
+
)
|
| 49 |
+
expr_thread.start()
|
| 50 |
+
is_playing = True
|
| 51 |
+
try:
|
| 52 |
+
play_file(path, volume=VOLUME)
|
| 53 |
+
finally:
|
| 54 |
+
stop_event.set()
|
| 55 |
+
expr_thread.join(timeout=2.0)
|
| 56 |
+
is_playing = False
|
| 57 |
+
if _on_playback_done:
|
| 58 |
+
_on_playback_done()
|
| 59 |
+
finally:
|
| 60 |
+
_speak_lock.release()
|
build/lib/monte_python_insult_robot/expressions.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Physical expressions mapped to Monty Python insult tags.
|
| 2 |
+
|
| 3 |
+
Each expression is a callable that takes a ReachyMini instance and performs
|
| 4 |
+
the appropriate head/antenna movement.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import math
|
| 8 |
+
import numpy as np
|
| 9 |
+
from scipy.spatial.transform import Rotation as R
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _pose(roll_deg=0, pitch_deg=0, yaw_deg=0) -> np.ndarray:
|
| 13 |
+
pose = np.eye(4)
|
| 14 |
+
pose[:3, :3] = R.from_euler("xyz", [roll_deg, pitch_deg, yaw_deg], degrees=True).as_matrix()
|
| 15 |
+
return pose
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# Antenna positions [right_rad, left_rad]
|
| 19 |
+
ANTENNAS_NEUTRAL = [-0.1745, 0.1745]
|
| 20 |
+
ANTENNAS_DROOP = [-0.8, 0.8] # drooping — dismissive
|
| 21 |
+
ANTENNAS_PERK = [ 0.3, -0.3] # perked up — mock shock
|
| 22 |
+
ANTENNAS_RAGE = [-1.2, 1.2] # fully down — rage
|
| 23 |
+
ANTENNAS_HAUGHTY = [ 0.0, 0.0] # straight up — haughty
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def contempt(reachy) -> None:
|
| 27 |
+
"""Slow head tilt to one side, antennas droop slightly."""
|
| 28 |
+
reachy.goto_target(head=_pose(roll_deg=15, pitch_deg=-10), antennas=ANTENNAS_DROOP, duration=0.6)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def dismissive(reachy) -> None:
|
| 32 |
+
"""Head turns away then snaps back."""
|
| 33 |
+
reachy.goto_target(head=_pose(yaw_deg=40), antennas=ANTENNAS_DROOP, duration=0.5)
|
| 34 |
+
reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.4)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def mock_shock(reachy) -> None:
|
| 38 |
+
"""Head rears back, antennas perk up."""
|
| 39 |
+
reachy.goto_target(head=_pose(pitch_deg=20), antennas=ANTENNAS_PERK, duration=0.3)
|
| 40 |
+
reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.5)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def haughty(reachy) -> None:
|
| 44 |
+
"""Head tilts up, antennas straight — looking down the nose."""
|
| 45 |
+
reachy.goto_target(head=_pose(pitch_deg=-20), antennas=ANTENNAS_HAUGHTY, duration=0.7)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def rage(reachy) -> None:
|
| 49 |
+
"""Rapid head shake side to side, antennas slam down."""
|
| 50 |
+
reachy.goto_target(head=_pose(yaw_deg=20), antennas=ANTENNAS_RAGE, duration=0.2)
|
| 51 |
+
reachy.goto_target(head=_pose(yaw_deg=-20), duration=0.2)
|
| 52 |
+
reachy.goto_target(head=_pose(yaw_deg=20), duration=0.2)
|
| 53 |
+
reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.3)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def sneer(reachy) -> None:
|
| 57 |
+
"""Slow roll to one side, antenna droops — withering sneer."""
|
| 58 |
+
reachy.goto_target(head=_pose(roll_deg=-18, yaw_deg=-15), antennas=[-0.5, 0.2], duration=0.5)
|
| 59 |
+
reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.6)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def exasperated(reachy) -> None:
|
| 63 |
+
"""Head drops forward then snaps back up — long-suffering exasperation."""
|
| 64 |
+
reachy.goto_target(head=_pose(pitch_deg=25), antennas=ANTENNAS_DROOP, duration=0.4)
|
| 65 |
+
reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.5)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def gleeful(reachy) -> None:
|
| 69 |
+
"""Quick bouncy nod, antennas perk — delighted malice."""
|
| 70 |
+
reachy.goto_target(head=_pose(pitch_deg=-10), antennas=ANTENNAS_PERK, duration=0.2)
|
| 71 |
+
reachy.goto_target(head=_pose(pitch_deg=10), duration=0.2)
|
| 72 |
+
reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.3)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def suspicious(reachy) -> None:
|
| 76 |
+
"""Head tilts and leans forward — squinting suspicion."""
|
| 77 |
+
reachy.goto_target(head=_pose(roll_deg=10, pitch_deg=8), antennas=[-0.3, 0.3], duration=0.5)
|
| 78 |
+
reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.5)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def pompous(reachy) -> None:
|
| 82 |
+
"""Head sweeps up and holds — insufferable self-importance."""
|
| 83 |
+
reachy.goto_target(head=_pose(pitch_deg=-25, yaw_deg=10), antennas=ANTENNAS_HAUGHTY, duration=0.6)
|
| 84 |
+
reachy.goto_target(head=_pose(pitch_deg=-25, yaw_deg=-10), duration=0.4)
|
| 85 |
+
reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.5)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
EXPRESSIONS = {
|
| 89 |
+
"contempt": contempt,
|
| 90 |
+
"dismissive": dismissive,
|
| 91 |
+
"mock_shock": mock_shock,
|
| 92 |
+
"haughty": haughty,
|
| 93 |
+
"rage": rage,
|
| 94 |
+
"sneer": sneer,
|
| 95 |
+
"exasperated": exasperated,
|
| 96 |
+
"gleeful": gleeful,
|
| 97 |
+
"suspicious": suspicious,
|
| 98 |
+
"pompous": pompous,
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def perform(reachy, tag: str) -> None:
|
| 103 |
+
EXPRESSIONS.get(tag, contempt)(reachy)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def loop_expression(reachy, tag: str, stop_event) -> None:
|
| 107 |
+
"""Repeat the expression until stop_event is set, then return to neutral."""
|
| 108 |
+
fn = EXPRESSIONS.get(tag, contempt)
|
| 109 |
+
while not stop_event.is_set():
|
| 110 |
+
fn(reachy)
|
| 111 |
+
reachy.goto_target(head=_pose(), antennas=ANTENNAS_NEUTRAL, duration=0.4)
|
build/lib/monte_python_insult_robot/insults.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Insult library — expression tags are now encoded in WAV/MP3 filenames.
|
| 2 |
+
|
| 3 |
+
Filename convention: {expression_tag}_{name}.{ext}
|
| 4 |
+
Supported tags: contempt, dismissive, mock_shock, haughty, rage,
|
| 5 |
+
sneer, exasperated, gleeful, suspicious, pompous
|
| 6 |
+
"""
|
build/lib/monte_python_insult_robot/main.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Monte Python Insult Robot — main entry point."""
|
| 2 |
+
|
| 3 |
+
import threading
|
| 4 |
+
|
| 5 |
+
from reachy_mini import ReachyMini, ReachyMiniApp
|
| 6 |
+
|
| 7 |
+
from monte_python_insult_robot.audio import speak
|
| 8 |
+
from monte_python_insult_robot.sounds import ensure_coconut, play_coconut
|
| 9 |
+
from monte_python_insult_robot.attention import AttentionLoop
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class MontePythonInsultRobot(ReachyMiniApp):
|
| 13 |
+
custom_app_url: str | None = None
|
| 14 |
+
request_media_backend: str | None = None
|
| 15 |
+
|
| 16 |
+
def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
|
| 17 |
+
reachy_mini.wake_up()
|
| 18 |
+
ensure_coconut()
|
| 19 |
+
|
| 20 |
+
def deliver_insult():
|
| 21 |
+
play_coconut(reachy_mini)
|
| 22 |
+
speak(reachy_mini)
|
| 23 |
+
|
| 24 |
+
attention = AttentionLoop(
|
| 25 |
+
reachy=reachy_mini,
|
| 26 |
+
on_speech_detected=deliver_insult,
|
| 27 |
+
)
|
| 28 |
+
attention.start()
|
| 29 |
+
print("Monte Python Insult Robot is ready. Waiting for victims...")
|
| 30 |
+
|
| 31 |
+
stop_event.wait()
|
| 32 |
+
|
| 33 |
+
attention.stop()
|
| 34 |
+
reachy_mini.goto_sleep()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
if __name__ == "__main__":
|
| 38 |
+
app = MontePythonInsultRobot()
|
| 39 |
+
try:
|
| 40 |
+
app.wrapped_run()
|
| 41 |
+
except KeyboardInterrupt:
|
| 42 |
+
app.stop()
|
build/lib/monte_python_insult_robot/playback.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared GStreamer audio playback helpers.
|
| 2 |
+
|
| 3 |
+
Centralizes audio playback so audio.py and sounds.py share one code path.
|
| 4 |
+
|
| 5 |
+
Playback is built around ``playbin`` with a proper ``file://`` URI rather than
|
| 6 |
+
string-interpolating the path into a ``parse_launch`` pipeline. That matters
|
| 7 |
+
because the installed app lives under a path with a space
|
| 8 |
+
(``~/Library/Application Support/...``); a raw ``filesrc location=/a b/c.wav``
|
| 9 |
+
makes GStreamer read ``location=/a`` and treat ``b`` as an element, failing with
|
| 10 |
+
``no element "b"``. ``Gst.filename_to_uri`` escapes the path, so any spaces or
|
| 11 |
+
special characters are handled correctly. ``playbin`` also auto-detects the
|
| 12 |
+
container/codec, so wav and mp3 need no branching.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import platform
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _make_audio_sink(Gst):
|
| 19 |
+
"""Return the platform audio sink element, or None to let playbin choose.
|
| 20 |
+
|
| 21 |
+
On macOS we pin the built-in speaker; on Linux (the robot) playbin's
|
| 22 |
+
default ``autoaudiosink`` is correct.
|
| 23 |
+
"""
|
| 24 |
+
if platform.system() != "Darwin":
|
| 25 |
+
return None # playbin defaults to autoaudiosink
|
| 26 |
+
sink = Gst.ElementFactory.make("osxaudiosink", None)
|
| 27 |
+
if sink is not None and sink.find_property("unique-id") is not None:
|
| 28 |
+
try:
|
| 29 |
+
sink.set_property("unique-id", "BuiltInSpeakerDevice")
|
| 30 |
+
except Exception:
|
| 31 |
+
pass # fall back to the default output device
|
| 32 |
+
return sink
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def play_file(path: str, volume: float | None = None) -> None:
|
| 36 |
+
"""Play an audio file synchronously via GStreamer, blocking until EOS/error.
|
| 37 |
+
|
| 38 |
+
Handles wav and mp3 transparently. If *volume* is given (0.0–1.0) it is
|
| 39 |
+
applied; otherwise playback is unattenuated. Paths with spaces or special
|
| 40 |
+
characters are safe — the path is passed as an escaped ``file://`` URI, not
|
| 41 |
+
interpolated into a pipeline string.
|
| 42 |
+
"""
|
| 43 |
+
import gi
|
| 44 |
+
gi.require_version("Gst", "1.0")
|
| 45 |
+
from gi.repository import Gst
|
| 46 |
+
Gst.init(None)
|
| 47 |
+
|
| 48 |
+
playbin = Gst.ElementFactory.make("playbin", "player")
|
| 49 |
+
if playbin is None:
|
| 50 |
+
raise RuntimeError("GStreamer 'playbin' element is unavailable")
|
| 51 |
+
playbin.set_property("uri", Gst.filename_to_uri(path))
|
| 52 |
+
if volume is not None:
|
| 53 |
+
playbin.set_property("volume", float(volume))
|
| 54 |
+
sink = _make_audio_sink(Gst)
|
| 55 |
+
if sink is not None:
|
| 56 |
+
playbin.set_property("audio-sink", sink)
|
| 57 |
+
|
| 58 |
+
playbin.set_state(Gst.State.PLAYING)
|
| 59 |
+
bus = playbin.get_bus()
|
| 60 |
+
while True:
|
| 61 |
+
msg = bus.timed_pop_filtered(
|
| 62 |
+
100 * Gst.MSECOND,
|
| 63 |
+
Gst.MessageType.EOS | Gst.MessageType.ERROR,
|
| 64 |
+
)
|
| 65 |
+
if msg:
|
| 66 |
+
break
|
| 67 |
+
playbin.set_state(Gst.State.NULL)
|
build/lib/monte_python_insult_robot/requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
reachy_mini>=1.8.4
|
| 2 |
+
pydub>=0.25.1
|
| 3 |
+
requests>=2.31.0
|
| 4 |
+
sounddevice>=0.5.0
|
| 5 |
+
webrtcvad-wheels>=2.0.14
|
| 6 |
+
scipy>=1.10.0
|
| 7 |
+
numpy>=1.24.0
|
build/lib/monte_python_insult_robot/sounds.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Coconut clip-clop sound: convert local M4A asset, play a random 2s slice directly via GStreamer."""
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import os
|
| 5 |
+
import random
|
| 6 |
+
from pydub import AudioSegment
|
| 7 |
+
|
| 8 |
+
from monte_python_insult_robot.playback import play_file
|
| 9 |
+
|
| 10 |
+
ASSET_M4A = os.path.join(os.path.dirname(__file__), "assets", "coconuts.m4a")
|
| 11 |
+
ASSET_WAV = os.path.join(os.path.dirname(__file__), "assets", "coconuts.wav")
|
| 12 |
+
CLIP_DURATION_MS = 2000
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def ensure_coconut() -> None:
|
| 16 |
+
"""Convert M4A to WAV if needed."""
|
| 17 |
+
if not os.path.exists(ASSET_WAV):
|
| 18 |
+
print("Converting coconuts.m4a to WAV...")
|
| 19 |
+
AudioSegment.from_file(ASSET_M4A, format="m4a").export(ASSET_WAV, format="wav")
|
| 20 |
+
print(f"Saved to {ASSET_WAV}")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _bob_head(reachy, duration: float) -> None:
|
| 24 |
+
"""Bounce head up/down for the given duration to mimic horse riding."""
|
| 25 |
+
import threading
|
| 26 |
+
import numpy as np
|
| 27 |
+
from scipy.spatial.transform import Rotation as R
|
| 28 |
+
|
| 29 |
+
BOB_INTERVAL = 0.25 # seconds per half-bob (~2 clops/sec)
|
| 30 |
+
BOB_PITCH = 12 # degrees
|
| 31 |
+
|
| 32 |
+
def _pose(pitch_deg):
|
| 33 |
+
pose = np.eye(4)
|
| 34 |
+
pose[:3, :3] = R.from_euler("xyz", [0, pitch_deg, 0], degrees=True).as_matrix()
|
| 35 |
+
return pose
|
| 36 |
+
|
| 37 |
+
stop = threading.Event()
|
| 38 |
+
threading.Timer(duration, stop.set).start()
|
| 39 |
+
|
| 40 |
+
down = True
|
| 41 |
+
while not stop.is_set():
|
| 42 |
+
pitch = BOB_PITCH if down else -BOB_PITCH
|
| 43 |
+
reachy.goto_target(head=_pose(pitch), duration=BOB_INTERVAL)
|
| 44 |
+
down = not down
|
| 45 |
+
|
| 46 |
+
reachy.goto_target(head=_pose(0), duration=0.3)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def play_coconut(reachy) -> None:
|
| 50 |
+
"""Play a random 2s slice of the coconut sound with head bobbing."""
|
| 51 |
+
import tempfile
|
| 52 |
+
import threading
|
| 53 |
+
|
| 54 |
+
audio = AudioSegment.from_wav(ASSET_WAV)
|
| 55 |
+
max_start = len(audio) - CLIP_DURATION_MS
|
| 56 |
+
start_ms = random.randint(0, max_start)
|
| 57 |
+
clip = audio[start_ms : start_ms + CLIP_DURATION_MS]
|
| 58 |
+
|
| 59 |
+
buf = io.BytesIO()
|
| 60 |
+
clip.export(buf, format="wav")
|
| 61 |
+
|
| 62 |
+
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
| 63 |
+
f.write(buf.getvalue())
|
| 64 |
+
tmp_path = f.name
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
duration_s = CLIP_DURATION_MS / 1000
|
| 68 |
+
bob_thread = threading.Thread(target=_bob_head, args=(reachy, duration_s), daemon=True)
|
| 69 |
+
bob_thread.start()
|
| 70 |
+
|
| 71 |
+
play_file(tmp_path)
|
| 72 |
+
bob_thread.join(timeout=1.0)
|
| 73 |
+
finally:
|
| 74 |
+
os.unlink(tmp_path)
|
build/lib/monte_python_insult_robot/static/index.html
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<title>Reachy Mini example app template</title>
|
| 7 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 8 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 9 |
+
</head>
|
| 10 |
+
|
| 11 |
+
<body>
|
| 12 |
+
<h1>Reachy Mini – Control Panel</h1>
|
| 13 |
+
|
| 14 |
+
<div id="controls">
|
| 15 |
+
<label style="display:flex; align-items:center; gap:8px;">
|
| 16 |
+
<input type="checkbox" id="antenna-checkbox" checked>
|
| 17 |
+
Antennas
|
| 18 |
+
</label>
|
| 19 |
+
|
| 20 |
+
<button id="sound-btn">Play Sound</button>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
<div id="status">Antennas status: running</div>
|
| 24 |
+
<script src="/static/main.js"></script>
|
| 25 |
+
</body>
|
| 26 |
+
|
| 27 |
+
</html>
|
build/lib/monte_python_insult_robot/static/main.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
let antennasEnabled = true;
|
| 2 |
+
|
| 3 |
+
async function updateAntennasState(enabled) {
|
| 4 |
+
try {
|
| 5 |
+
const resp = await fetch("/antennas", {
|
| 6 |
+
method: "POST",
|
| 7 |
+
headers: { "Content-Type": "application/json" },
|
| 8 |
+
body: JSON.stringify({ enabled }),
|
| 9 |
+
});
|
| 10 |
+
const data = await resp.json();
|
| 11 |
+
antennasEnabled = data.antennas_enabled;
|
| 12 |
+
updateUI();
|
| 13 |
+
} catch (e) {
|
| 14 |
+
document.getElementById("status").textContent = "Backend error";
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
async function playSound() {
|
| 19 |
+
try {
|
| 20 |
+
await fetch("/play_sound", { method: "POST" });
|
| 21 |
+
} catch (e) {
|
| 22 |
+
console.error("Error triggering sound:", e);
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function updateUI() {
|
| 27 |
+
const checkbox = document.getElementById("antenna-checkbox");
|
| 28 |
+
const status = document.getElementById("status");
|
| 29 |
+
|
| 30 |
+
checkbox.checked = antennasEnabled;
|
| 31 |
+
|
| 32 |
+
if (antennasEnabled) {
|
| 33 |
+
status.textContent = "Antennas status: running";
|
| 34 |
+
} else {
|
| 35 |
+
status.textContent = "Antennas status: stopped";
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
document.getElementById("antenna-checkbox").addEventListener("change", (e) => {
|
| 40 |
+
updateAntennasState(e.target.checked);
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
document.getElementById("sound-btn").addEventListener("click", () => {
|
| 44 |
+
playSound();
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
updateUI();
|
build/lib/monte_python_insult_robot/static/style.css
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
body {
|
| 2 |
+
font-family: sans-serif;
|
| 3 |
+
margin: 24px;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
#sound-btn {
|
| 7 |
+
padding: 10px 20px;
|
| 8 |
+
border: none;
|
| 9 |
+
color: white;
|
| 10 |
+
cursor: pointer;
|
| 11 |
+
font-size: 16px;
|
| 12 |
+
border-radius: 6px;
|
| 13 |
+
background-color: #3498db;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
#status {
|
| 17 |
+
margin-top: 16px;
|
| 18 |
+
font-weight: bold;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
#controls {
|
| 22 |
+
display: flex;
|
| 23 |
+
align-items: center;
|
| 24 |
+
gap: 20px;
|
| 25 |
+
}
|
monte_python_insult_robot.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: monte_python_insult_robot
|
| 3 |
+
Version: 1.0.1
|
| 4 |
+
Summary: French castle guard insult robot — Monty Python style
|
| 5 |
+
Keywords: reachy-mini-app
|
| 6 |
+
Requires-Python: >=3.10
|
| 7 |
+
Description-Content-Type: text/markdown
|
| 8 |
+
Requires-Dist: reachy-mini>=1.8.4
|
| 9 |
+
Requires-Dist: pydub>=0.25.1
|
| 10 |
+
Requires-Dist: requests>=2.31.0
|
| 11 |
+
Requires-Dist: sounddevice>=0.5.0
|
| 12 |
+
Requires-Dist: webrtcvad-wheels>=2.0.14
|
| 13 |
+
Requires-Dist: scipy>=1.10.0
|
| 14 |
+
Requires-Dist: numpy>=1.24.0
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
title: Monte Python Insult Robot
|
| 18 |
+
emoji: 🏰
|
| 19 |
+
colorFrom: yellow
|
| 20 |
+
colorTo: green
|
| 21 |
+
sdk: static
|
| 22 |
+
pinned: false
|
| 23 |
+
short_description: French castle guard insult robot — Monty Python style
|
| 24 |
+
tags:
|
| 25 |
+
- reachy_mini
|
| 26 |
+
- reachy_mini_python_app
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
# Monte Python Insult Robot 🏰
|
| 30 |
+
|
| 31 |
+
A Reachy Mini app that turns the robot into the French castle guard from Monty Python and the Holy Grail.
|
| 32 |
+
|
| 33 |
+
## What it does
|
| 34 |
+
|
| 35 |
+
- Listens for nearby speech using the ReSpeaker mic array (or laptop mic in simulation)
|
| 36 |
+
- Plays coconut clip-clop sounds with head-bobbing horse riding animation
|
| 37 |
+
- Delivers a random pre-recorded insult WAV with a matching physical expression
|
| 38 |
+
- Turns head and body toward the speaker
|
| 39 |
+
|
| 40 |
+
## Insult audio files
|
| 41 |
+
|
| 42 |
+
Place WAV or MP3 files in `monte_python_insult_robot/assets/insults/` using the naming convention:
|
| 43 |
+
|
| 44 |
+
```
|
| 45 |
+
{expression_tag}_{name}.wav
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
Supported expression tags: `contempt`, `dismissive`, `mock_shock`, `haughty`, `rage`, `sneer`, `exasperated`, `gleeful`, `suspicious`, `pompous`
|
| 49 |
+
|
| 50 |
+
## Requirements
|
| 51 |
+
|
| 52 |
+
See `monte_python_insult_robot/requirements.txt`. Key dependencies: `pydub`, `sounddevice`, `webrtcvad-wheels`, `scipy`.
|
monte_python_insult_robot.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
README.md
|
| 2 |
+
pyproject.toml
|
| 3 |
+
./monte_python_insult_robot/__init__.py
|
| 4 |
+
./monte_python_insult_robot/attention.py
|
| 5 |
+
./monte_python_insult_robot/audio.py
|
| 6 |
+
./monte_python_insult_robot/expressions.py
|
| 7 |
+
./monte_python_insult_robot/insults.py
|
| 8 |
+
./monte_python_insult_robot/main.py
|
| 9 |
+
./monte_python_insult_robot/playback.py
|
| 10 |
+
./monte_python_insult_robot/requirements.txt
|
| 11 |
+
./monte_python_insult_robot/sounds.py
|
| 12 |
+
./monte_python_insult_robot/assets/coconuts.m4a
|
| 13 |
+
./monte_python_insult_robot/assets/coconuts.wav
|
| 14 |
+
./monte_python_insult_robot/assets/insults/contempt_hamster.wav
|
| 15 |
+
./monte_python_insult_robot/assets/insults/contempt_wipe.wav
|
| 16 |
+
./monte_python_insult_robot/assets/insults/dismissive_Looney.wav
|
| 17 |
+
./monte_python_insult_robot/assets/insults/exasperated_bleed.wav
|
| 18 |
+
./monte_python_insult_robot/assets/insults/exasperated_dead.wav
|
| 19 |
+
./monte_python_insult_robot/assets/insults/exasperated_repressed.wav
|
| 20 |
+
./monte_python_insult_robot/assets/insults/gleeful_bitelegs.wav
|
| 21 |
+
./monte_python_insult_robot/assets/insults/gleeful_happy.wav
|
| 22 |
+
./monte_python_insult_robot/assets/insults/gleeful_much_rejoicing.mp3
|
| 23 |
+
./monte_python_insult_robot/assets/insults/gleeful_privparts.wav
|
| 24 |
+
./monte_python_insult_robot/assets/insults/gleeful_spam.mp3
|
| 25 |
+
./monte_python_insult_robot/assets/insults/haughty_french.wav
|
| 26 |
+
./monte_python_insult_robot/assets/insults/haughty_mp_grail.wav
|
| 27 |
+
./monte_python_insult_robot/assets/insults/mock_shock_Newt.wav
|
| 28 |
+
./monte_python_insult_robot/assets/insults/mock_shock_runawayrun.wav
|
| 29 |
+
./monte_python_insult_robot/assets/insults/pompous_King.wav
|
| 30 |
+
./monte_python_insult_robot/assets/insults/pompous_britains.wav
|
| 31 |
+
./monte_python_insult_robot/assets/insults/pompous_consult.mp3
|
| 32 |
+
./monte_python_insult_robot/assets/insults/pompous_mustbe.wav
|
| 33 |
+
./monte_python_insult_robot/assets/insults/rage_Ni.wav
|
| 34 |
+
./monte_python_insult_robot/assets/insults/rage_fart.wav
|
| 35 |
+
./monte_python_insult_robot/assets/insults/rage_fart_direction.mp3
|
| 36 |
+
./monte_python_insult_robot/assets/insults/sneer_mp_taunt.mp3
|
| 37 |
+
./monte_python_insult_robot/assets/insults/sneer_taunt.wav
|
| 38 |
+
./monte_python_insult_robot/assets/insults/suspicious_bridgeofdeath.wav
|
| 39 |
+
./monte_python_insult_robot/assets/insults/suspicious_duck.mp3
|
| 40 |
+
./monte_python_insult_robot/assets/insults/suspicious_peril.wav
|
| 41 |
+
./monte_python_insult_robot/static/index.html
|
| 42 |
+
./monte_python_insult_robot/static/main.js
|
| 43 |
+
./monte_python_insult_robot/static/style.css
|
| 44 |
+
monte_python_insult_robot/__init__.py
|
| 45 |
+
monte_python_insult_robot/attention.py
|
| 46 |
+
monte_python_insult_robot/audio.py
|
| 47 |
+
monte_python_insult_robot/expressions.py
|
| 48 |
+
monte_python_insult_robot/insults.py
|
| 49 |
+
monte_python_insult_robot/main.py
|
| 50 |
+
monte_python_insult_robot/playback.py
|
| 51 |
+
monte_python_insult_robot/requirements.txt
|
| 52 |
+
monte_python_insult_robot/sounds.py
|
| 53 |
+
monte_python_insult_robot.egg-info/PKG-INFO
|
| 54 |
+
monte_python_insult_robot.egg-info/SOURCES.txt
|
| 55 |
+
monte_python_insult_robot.egg-info/dependency_links.txt
|
| 56 |
+
monte_python_insult_robot.egg-info/entry_points.txt
|
| 57 |
+
monte_python_insult_robot.egg-info/requires.txt
|
| 58 |
+
monte_python_insult_robot.egg-info/top_level.txt
|
| 59 |
+
monte_python_insult_robot/assets/coconuts.m4a
|
| 60 |
+
monte_python_insult_robot/assets/coconuts.wav
|
| 61 |
+
monte_python_insult_robot/assets/insults/contempt_hamster.wav
|
| 62 |
+
monte_python_insult_robot/assets/insults/contempt_wipe.wav
|
| 63 |
+
monte_python_insult_robot/assets/insults/dismissive_Looney.wav
|
| 64 |
+
monte_python_insult_robot/assets/insults/exasperated_bleed.wav
|
| 65 |
+
monte_python_insult_robot/assets/insults/exasperated_dead.wav
|
| 66 |
+
monte_python_insult_robot/assets/insults/exasperated_repressed.wav
|
| 67 |
+
monte_python_insult_robot/assets/insults/gleeful_bitelegs.wav
|
| 68 |
+
monte_python_insult_robot/assets/insults/gleeful_happy.wav
|
| 69 |
+
monte_python_insult_robot/assets/insults/gleeful_much_rejoicing.mp3
|
| 70 |
+
monte_python_insult_robot/assets/insults/gleeful_privparts.wav
|
| 71 |
+
monte_python_insult_robot/assets/insults/gleeful_spam.mp3
|
| 72 |
+
monte_python_insult_robot/assets/insults/haughty_french.wav
|
| 73 |
+
monte_python_insult_robot/assets/insults/haughty_mp_grail.wav
|
| 74 |
+
monte_python_insult_robot/assets/insults/mock_shock_Newt.wav
|
| 75 |
+
monte_python_insult_robot/assets/insults/mock_shock_runawayrun.wav
|
| 76 |
+
monte_python_insult_robot/assets/insults/pompous_King.wav
|
| 77 |
+
monte_python_insult_robot/assets/insults/pompous_britains.wav
|
| 78 |
+
monte_python_insult_robot/assets/insults/pompous_consult.mp3
|
| 79 |
+
monte_python_insult_robot/assets/insults/pompous_mustbe.wav
|
| 80 |
+
monte_python_insult_robot/assets/insults/rage_Ni.wav
|
| 81 |
+
monte_python_insult_robot/assets/insults/rage_fart.wav
|
| 82 |
+
monte_python_insult_robot/assets/insults/rage_fart_direction.mp3
|
| 83 |
+
monte_python_insult_robot/assets/insults/sneer_mp_taunt.mp3
|
| 84 |
+
monte_python_insult_robot/assets/insults/sneer_taunt.wav
|
| 85 |
+
monte_python_insult_robot/assets/insults/suspicious_bridgeofdeath.wav
|
| 86 |
+
monte_python_insult_robot/assets/insults/suspicious_duck.mp3
|
| 87 |
+
monte_python_insult_robot/assets/insults/suspicious_peril.wav
|
| 88 |
+
monte_python_insult_robot/static/index.html
|
| 89 |
+
monte_python_insult_robot/static/main.js
|
| 90 |
+
monte_python_insult_robot/static/style.css
|
monte_python_insult_robot.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
monte_python_insult_robot.egg-info/entry_points.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[reachy_mini_apps]
|
| 2 |
+
monte_python_insult_robot = monte_python_insult_robot.main:MontePythonInsultRobot
|
monte_python_insult_robot.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
reachy-mini>=1.8.4
|
| 2 |
+
pydub>=0.25.1
|
| 3 |
+
requests>=2.31.0
|
| 4 |
+
sounddevice>=0.5.0
|
| 5 |
+
webrtcvad-wheels>=2.0.14
|
| 6 |
+
scipy>=1.10.0
|
| 7 |
+
numpy>=1.24.0
|
monte_python_insult_robot.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
monte_python_insult_robot
|
monte_python_insult_robot/playback.py
CHANGED
|
@@ -1,45 +1,62 @@
|
|
| 1 |
"""Shared GStreamer audio playback helpers.
|
| 2 |
|
| 3 |
-
Centralizes
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
import platform
|
| 8 |
|
| 9 |
|
| 10 |
-
def
|
| 11 |
-
"""Return
|
| 12 |
-
if platform.system() == "Darwin":
|
| 13 |
-
return "osxaudiosink unique-id=BuiltInSpeakerDevice"
|
| 14 |
-
return "autoaudiosink"
|
| 15 |
-
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def play_file(path: str, volume: float | None = None) -> None:
|
| 25 |
"""Play an audio file synchronously via GStreamer, blocking until EOS/error.
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
| 29 |
"""
|
| 30 |
import gi
|
| 31 |
gi.require_version("Gst", "1.0")
|
| 32 |
from gi.repository import Gst
|
| 33 |
Gst.init(None)
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
while True:
|
| 44 |
msg = bus.timed_pop_filtered(
|
| 45 |
100 * Gst.MSECOND,
|
|
@@ -47,4 +64,4 @@ def play_file(path: str, volume: float | None = None) -> None:
|
|
| 47 |
)
|
| 48 |
if msg:
|
| 49 |
break
|
| 50 |
-
|
|
|
|
| 1 |
"""Shared GStreamer audio playback helpers.
|
| 2 |
|
| 3 |
+
Centralizes audio playback so audio.py and sounds.py share one code path.
|
| 4 |
+
|
| 5 |
+
Playback is built around ``playbin`` with a proper ``file://`` URI rather than
|
| 6 |
+
string-interpolating the path into a ``parse_launch`` pipeline. That matters
|
| 7 |
+
because the installed app lives under a path with a space
|
| 8 |
+
(``~/Library/Application Support/...``); a raw ``filesrc location=/a b/c.wav``
|
| 9 |
+
makes GStreamer read ``location=/a`` and treat ``b`` as an element, failing with
|
| 10 |
+
``no element "b"``. ``Gst.filename_to_uri`` escapes the path, so any spaces or
|
| 11 |
+
special characters are handled correctly. ``playbin`` also auto-detects the
|
| 12 |
+
container/codec, so wav and mp3 need no branching.
|
| 13 |
"""
|
| 14 |
|
| 15 |
import platform
|
| 16 |
|
| 17 |
|
| 18 |
+
def _make_audio_sink(Gst):
|
| 19 |
+
"""Return the platform audio sink element, or None to let playbin choose.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
On macOS we pin the built-in speaker; on Linux (the robot) playbin's
|
| 22 |
+
default ``autoaudiosink`` is correct.
|
| 23 |
+
"""
|
| 24 |
+
if platform.system() != "Darwin":
|
| 25 |
+
return None # playbin defaults to autoaudiosink
|
| 26 |
+
sink = Gst.ElementFactory.make("osxaudiosink", None)
|
| 27 |
+
if sink is not None and sink.find_property("unique-id") is not None:
|
| 28 |
+
try:
|
| 29 |
+
sink.set_property("unique-id", "BuiltInSpeakerDevice")
|
| 30 |
+
except Exception:
|
| 31 |
+
pass # fall back to the default output device
|
| 32 |
+
return sink
|
| 33 |
|
| 34 |
|
| 35 |
def play_file(path: str, volume: float | None = None) -> None:
|
| 36 |
"""Play an audio file synchronously via GStreamer, blocking until EOS/error.
|
| 37 |
|
| 38 |
+
Handles wav and mp3 transparently. If *volume* is given (0.0–1.0) it is
|
| 39 |
+
applied; otherwise playback is unattenuated. Paths with spaces or special
|
| 40 |
+
characters are safe — the path is passed as an escaped ``file://`` URI, not
|
| 41 |
+
interpolated into a pipeline string.
|
| 42 |
"""
|
| 43 |
import gi
|
| 44 |
gi.require_version("Gst", "1.0")
|
| 45 |
from gi.repository import Gst
|
| 46 |
Gst.init(None)
|
| 47 |
|
| 48 |
+
playbin = Gst.ElementFactory.make("playbin", "player")
|
| 49 |
+
if playbin is None:
|
| 50 |
+
raise RuntimeError("GStreamer 'playbin' element is unavailable")
|
| 51 |
+
playbin.set_property("uri", Gst.filename_to_uri(path))
|
| 52 |
+
if volume is not None:
|
| 53 |
+
playbin.set_property("volume", float(volume))
|
| 54 |
+
sink = _make_audio_sink(Gst)
|
| 55 |
+
if sink is not None:
|
| 56 |
+
playbin.set_property("audio-sink", sink)
|
| 57 |
+
|
| 58 |
+
playbin.set_state(Gst.State.PLAYING)
|
| 59 |
+
bus = playbin.get_bus()
|
| 60 |
while True:
|
| 61 |
msg = bus.timed_pop_filtered(
|
| 62 |
100 * Gst.MSECOND,
|
|
|
|
| 64 |
)
|
| 65 |
if msg:
|
| 66 |
break
|
| 67 |
+
playbin.set_state(Gst.State.NULL)
|
pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "monte_python_insult_robot"
|
| 7 |
-
version = "1.0.
|
| 8 |
description = "French castle guard insult robot — Monty Python style"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "monte_python_insult_robot"
|
| 7 |
+
version = "1.0.1"
|
| 8 |
description = "French castle guard insult robot — Monty Python style"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|