marklopervega commited on
Commit
c28189e
·
verified ·
1 Parent(s): 800e2e0

Fix GStreamer 'no element' crash on paths with spaces (v1.0.1)

Browse files
Files changed (49) hide show
  1. build/lib/monte_python_insult_robot/__init__.py +0 -0
  2. build/lib/monte_python_insult_robot/assets/coconuts.m4a +3 -0
  3. build/lib/monte_python_insult_robot/assets/coconuts.wav +3 -0
  4. build/lib/monte_python_insult_robot/assets/insults/contempt_hamster.wav +0 -0
  5. build/lib/monte_python_insult_robot/assets/insults/contempt_wipe.wav +0 -0
  6. build/lib/monte_python_insult_robot/assets/insults/dismissive_Looney.wav +0 -0
  7. build/lib/monte_python_insult_robot/assets/insults/exasperated_bleed.wav +0 -0
  8. build/lib/monte_python_insult_robot/assets/insults/exasperated_dead.wav +0 -0
  9. build/lib/monte_python_insult_robot/assets/insults/exasperated_repressed.wav +0 -0
  10. build/lib/monte_python_insult_robot/assets/insults/gleeful_bitelegs.wav +0 -0
  11. build/lib/monte_python_insult_robot/assets/insults/gleeful_happy.wav +0 -0
  12. build/lib/monte_python_insult_robot/assets/insults/gleeful_much_rejoicing.mp3 +0 -0
  13. build/lib/monte_python_insult_robot/assets/insults/gleeful_privparts.wav +0 -0
  14. build/lib/monte_python_insult_robot/assets/insults/gleeful_spam.mp3 +0 -0
  15. build/lib/monte_python_insult_robot/assets/insults/haughty_french.wav +0 -0
  16. build/lib/monte_python_insult_robot/assets/insults/haughty_mp_grail.wav +0 -0
  17. build/lib/monte_python_insult_robot/assets/insults/mock_shock_Newt.wav +0 -0
  18. build/lib/monte_python_insult_robot/assets/insults/mock_shock_runawayrun.wav +0 -0
  19. build/lib/monte_python_insult_robot/assets/insults/pompous_King.wav +0 -0
  20. build/lib/monte_python_insult_robot/assets/insults/pompous_britains.wav +0 -0
  21. build/lib/monte_python_insult_robot/assets/insults/pompous_consult.mp3 +0 -0
  22. build/lib/monte_python_insult_robot/assets/insults/pompous_mustbe.wav +0 -0
  23. build/lib/monte_python_insult_robot/assets/insults/rage_Ni.wav +0 -0
  24. build/lib/monte_python_insult_robot/assets/insults/rage_fart.wav +0 -0
  25. build/lib/monte_python_insult_robot/assets/insults/rage_fart_direction.mp3 +0 -0
  26. build/lib/monte_python_insult_robot/assets/insults/sneer_mp_taunt.mp3 +0 -0
  27. build/lib/monte_python_insult_robot/assets/insults/sneer_taunt.wav +0 -0
  28. build/lib/monte_python_insult_robot/assets/insults/suspicious_bridgeofdeath.wav +3 -0
  29. build/lib/monte_python_insult_robot/assets/insults/suspicious_duck.mp3 +0 -0
  30. build/lib/monte_python_insult_robot/assets/insults/suspicious_peril.wav +3 -0
  31. build/lib/monte_python_insult_robot/attention.py +167 -0
  32. build/lib/monte_python_insult_robot/audio.py +60 -0
  33. build/lib/monte_python_insult_robot/expressions.py +111 -0
  34. build/lib/monte_python_insult_robot/insults.py +6 -0
  35. build/lib/monte_python_insult_robot/main.py +42 -0
  36. build/lib/monte_python_insult_robot/playback.py +67 -0
  37. build/lib/monte_python_insult_robot/requirements.txt +7 -0
  38. build/lib/monte_python_insult_robot/sounds.py +74 -0
  39. build/lib/monte_python_insult_robot/static/index.html +27 -0
  40. build/lib/monte_python_insult_robot/static/main.js +47 -0
  41. build/lib/monte_python_insult_robot/static/style.css +25 -0
  42. monte_python_insult_robot.egg-info/PKG-INFO +52 -0
  43. monte_python_insult_robot.egg-info/SOURCES.txt +90 -0
  44. monte_python_insult_robot.egg-info/dependency_links.txt +1 -0
  45. monte_python_insult_robot.egg-info/entry_points.txt +2 -0
  46. monte_python_insult_robot.egg-info/requires.txt +7 -0
  47. monte_python_insult_robot.egg-info/top_level.txt +1 -0
  48. monte_python_insult_robot/playback.py +41 -24
  49. 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 the platform-specific sink selection and the play-until-EOS loop
4
- that were previously duplicated across audio.py, sounds.py, and tts.py.
 
 
 
 
 
 
 
 
5
  """
6
 
7
  import platform
8
 
9
 
10
- def get_audio_sink() -> str:
11
- """Return a GStreamer sink pipeline fragment for the current platform."""
12
- if platform.system() == "Darwin":
13
- return "osxaudiosink unique-id=BuiltInSpeakerDevice"
14
- return "autoaudiosink"
15
-
16
 
17
- def _decoder_for(path: str) -> str:
18
- """Return the GStreamer decode element for the file type."""
19
- if path.lower().endswith(".mp3"):
20
- return "decodebin"
21
- return "wavparse"
 
 
 
 
 
 
 
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
- Auto-selects the decoder from the file extension. If *volume* is given, a
28
- ``volume`` element is inserted (0.0–1.0); otherwise playback is unattenuated.
 
 
29
  """
30
  import gi
31
  gi.require_version("Gst", "1.0")
32
  from gi.repository import Gst
33
  Gst.init(None)
34
 
35
- decoder = _decoder_for(path)
36
- volume_stage = f"volume volume={volume} ! " if volume is not None else ""
37
- pipeline = Gst.parse_launch(
38
- f"filesrc location={path} ! {decoder} ! audioconvert ! audioresample "
39
- f"! {volume_stage}{get_audio_sink()}"
40
- )
41
- pipeline.set_state(Gst.State.PLAYING)
42
- bus = pipeline.get_bus()
 
 
 
 
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
- pipeline.set_state(Gst.State.NULL)
 
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.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"