|
20 | 20 | * throttle demand is the commanded acceleration, which is what makes |
21 | 21 | accelerating sound different from cruising at the same speed. |
22 | 22 |
|
23 | | -Audio goes to the ALSA ``default`` device, which ``config/alsa/asound.conf`` |
24 | | -defines as dmix + softvol. dmix is what lets this run alongside TTS instead of |
25 | | -fighting it for the device. |
| 23 | +On hardware, audio goes to the ALSA ``default`` device, which |
| 24 | +``config/alsa/asound.conf`` defines as dmix + softvol. In simulation the same |
| 25 | +synth is streamed as PCM to the browser because the container has no speaker. |
26 | 26 | """ |
27 | 27 |
|
| 28 | +import base64 |
28 | 29 | import json |
29 | 30 | import time |
30 | 31 |
|
|
54 | 55 | LIVE_PARAMS = frozenset({"enabled", "volume", "idle_when_stopped", "only_in_mad_mode", "reference_acceleration"}) |
55 | 56 | """Settable in place. Every other motor_sound.* parameter is baked into the |
56 | 57 | synth at construction, so applying one live means rebuilding it.""" |
57 | | -STREAM_PARAMS = frozenset({"sample_rate", "blocksize", "device"}) |
58 | | -"""Baked into the audio stream, which opens once at boot. A live set is |
| 58 | +STREAM_PARAMS = frozenset({"sample_rate", "blocksize", "device", "browser_audio_topic"}) |
| 59 | +"""Baked into the audio output, which opens once at boot. A live set is |
59 | 60 | rejected outright rather than pretending to apply.""" |
60 | 61 |
|
61 | 62 |
|
@@ -98,8 +99,14 @@ def __init__(self): |
98 | 99 | self.create_timer(0.1, self._update_drive) |
99 | 100 |
|
100 | 101 | self._update_drive() |
101 | | - self._stream = self._open_stream(params) |
102 | | - if self._stream is not None and not self._only_in_mad_mode: |
| 102 | + browser_topic = str(params["browser_audio_topic"].value) |
| 103 | + self._browser_audio = self.create_publisher(String, browser_topic, 2) if browser_topic else None |
| 104 | + self._stream = None if self._browser_audio is not None else self._open_stream(params) |
| 105 | + if self._browser_audio is not None: |
| 106 | + self._browser_frames = max(int(params["blocksize"].value), int(params["sample_rate"].value) // 10) |
| 107 | + self.create_timer(self._browser_frames / int(params["sample_rate"].value), self._publish_browser_audio) |
| 108 | + self.get_logger().info(f"motor sound streaming to {browser_topic}") |
| 109 | + if (self._stream is not None or self._browser_audio is not None) and not self._only_in_mad_mode: |
103 | 110 | self._synth.trigger_startup() |
104 | 111 |
|
105 | 112 | def _declare_parameters(self): |
@@ -144,6 +151,8 @@ def _declare_parameters(self): |
144 | 151 | ("motor_sound.sample_rate", 48000), |
145 | 152 | ("motor_sound.blocksize", 512), |
146 | 153 | ("motor_sound.device", ""), |
| 154 | + # Non-empty only in simulation: the browser is its speaker. |
| 155 | + ("motor_sound.browser_audio_topic", ""), |
147 | 156 | ], |
148 | 157 | ) |
149 | 158 |
|
@@ -233,19 +242,35 @@ def _audio_callback(self, outdata, frames, _time_info, _status): |
233 | 242 | escaping here silently aborts the stream, so it swallows everything and |
234 | 243 | outputs silence instead.""" |
235 | 244 | try: |
236 | | - synth = self._synth |
237 | | - mono = synth.render(frames) |
238 | | - retiring = self._retiring |
239 | | - # The identity check covers the instant between _swap_synth parking |
240 | | - # the old synth and rebinding _synth: never fade a voice under itself. |
241 | | - if retiring is not None and retiring is not synth: |
242 | | - self._retiring = None |
243 | | - mono = mono + retiring.render(frames) * np.linspace(1.0, 0.0, frames) |
| 245 | + mono = self._render(frames) |
244 | 246 | outdata[:, 0] = mono |
245 | 247 | outdata[:, 1] = mono |
246 | 248 | except Exception: |
247 | 249 | outdata.fill(0.0) |
248 | 250 |
|
| 251 | + def _render(self, frames: int) -> np.ndarray: |
| 252 | + synth = self._synth |
| 253 | + mono = synth.render(frames) |
| 254 | + retiring = self._retiring |
| 255 | + # The identity check covers the instant between _swap_synth parking |
| 256 | + # the old synth and rebinding _synth: never fade a voice under itself. |
| 257 | + if retiring is not None and retiring is not synth: |
| 258 | + self._retiring = None |
| 259 | + mono = mono + retiring.render(frames) * np.linspace(1.0, 0.0, frames) |
| 260 | + return mono |
| 261 | + |
| 262 | + def _publish_browser_audio(self): |
| 263 | + """Send the same synth to the simulator UI as signed 16-bit mono PCM.""" |
| 264 | + mono = self._render(self._browser_frames) |
| 265 | + if not np.any(mono): |
| 266 | + return |
| 267 | + pcm = (np.clip(mono, -1.0, 1.0) * 32767.0).astype("<i2", copy=False) |
| 268 | + payload = { |
| 269 | + "sample_rate": self._synth.sample_rate, |
| 270 | + "pcm": base64.b64encode(pcm.tobytes()).decode("ascii"), |
| 271 | + } |
| 272 | + self._browser_audio.publish(String(data=json.dumps(payload, separators=(",", ":")))) |
| 273 | + |
249 | 274 | def _on_robot_info(self, msg: String): |
250 | 275 | try: |
251 | 276 | mad = is_mad_scale(json.loads(msg.data)) |
|
0 commit comments