Skip to content

Commit da17d5f

Browse files
committed
fix(sim): play Mad Mars sound in browser
1 parent 4c53c4a commit da17d5f

8 files changed

Lines changed: 225 additions & 16 deletions

File tree

ros2_ws/src/mars_bot/mars_control/launch/app.sim.launch.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
# SPDX-License-Identifier: Apache-2.0
22
# Copyright (c) 2026 Innate Inc
3+
import os
4+
5+
from ament_index_python.packages import get_package_share_directory
36
from launch import LaunchDescription
47
from launch_ros.actions import Node
58
from mars_bringup.config_loader import innate_os_root, settings_params
69

710

811
def generate_launch_description():
912
data_directory = str(innate_os_root() / "data")
13+
motor_sound_config = os.path.join(get_package_share_directory("mars_control"), "config", "motor_sound.yaml")
1014

1115
# Default hardware revision for new robots
1216
default_hardware_revision = "R6"
@@ -25,4 +29,14 @@ def generate_launch_description():
2529
],
2630
)
2731

28-
return LaunchDescription([app_node])
32+
# The container has no speaker. Stream the same configured synth as PCM;
33+
# the simulator webapp is the audio device.
34+
motor_sound_node = Node(
35+
package="mars_control",
36+
executable="motor_sound.py",
37+
name="motor_sound",
38+
parameters=[motor_sound_config, *settings_params(), {"motor_sound.browser_audio_topic": "/motor_sound/audio"}],
39+
output="screen",
40+
)
41+
42+
return LaunchDescription([app_node, motor_sound_node])

ros2_ws/src/mars_bot/mars_control/mars_control/motor_sound.py

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@
2020
* throttle demand is the commanded acceleration, which is what makes
2121
accelerating sound different from cruising at the same speed.
2222
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.
2626
"""
2727

28+
import base64
2829
import json
2930
import time
3031

@@ -54,8 +55,8 @@
5455
LIVE_PARAMS = frozenset({"enabled", "volume", "idle_when_stopped", "only_in_mad_mode", "reference_acceleration"})
5556
"""Settable in place. Every other motor_sound.* parameter is baked into the
5657
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
5960
rejected outright rather than pretending to apply."""
6061

6162

@@ -98,8 +99,14 @@ def __init__(self):
9899
self.create_timer(0.1, self._update_drive)
99100

100101
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:
103110
self._synth.trigger_startup()
104111

105112
def _declare_parameters(self):
@@ -144,6 +151,8 @@ def _declare_parameters(self):
144151
("motor_sound.sample_rate", 48000),
145152
("motor_sound.blocksize", 512),
146153
("motor_sound.device", ""),
154+
# Non-empty only in simulation: the browser is its speaker.
155+
("motor_sound.browser_audio_topic", ""),
147156
],
148157
)
149158

@@ -233,19 +242,35 @@ def _audio_callback(self, outdata, frames, _time_info, _status):
233242
escaping here silently aborts the stream, so it swallows everything and
234243
outputs silence instead."""
235244
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)
244246
outdata[:, 0] = mono
245247
outdata[:, 1] = mono
246248
except Exception:
247249
outdata.fill(0.0)
248250

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+
249274
def _on_robot_info(self, msg: String):
250275
try:
251276
mad = is_mad_scale(json.loads(msg.data))

webapp/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
<link rel="modulepreload" href="/js/armReboot.js" />
3030
<link rel="modulepreload" href="/js/pressActivate.js" />
3131
<link rel="modulepreload" href="/js/ttsAudio.js" />
32+
<link rel="modulepreload" href="/js/motorSoundAudio.js" />
33+
<link rel="modulepreload" href="/js/pcmAudioPlayer.js" />
3234
<link rel="modulepreload" href="/js/micAudioState.js" />
3335
<link rel="modulepreload" href="/js/agent/main.js" />
3436
<link rel="modulepreload" href="/js/agent/agentPanel.js" />

webapp/js/motorSoundAudio.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// @ts-check
2+
// The physical robot plays its motor voice through ALSA. Simulation publishes
3+
// the identical synth as PCM because its container has no audio device.
4+
5+
import { ros } from "./rosClient.js";
6+
import { PcmAudioPlayer } from "./pcmAudioPlayer.js";
7+
import { isRobotAudioSpeaker } from "./ttsAudio.js";
8+
9+
const TOPIC = "/motor_sound/audio";
10+
let started = false;
11+
/** @type {PcmAudioPlayer | null} */
12+
let player = null;
13+
14+
export function initMotorSoundAudio() {
15+
if (started) return;
16+
started = true;
17+
18+
// Creating/resuming the context inside the operator's first gesture satisfies
19+
// browser autoplay rules. Until then chunks are deliberately dropped.
20+
const unlock = () => {
21+
if (!player) {
22+
const Context = window.AudioContext || /** @type {any} */ (window).webkitAudioContext;
23+
if (Context) player = new PcmAudioPlayer(new Context());
24+
}
25+
void player?.context.resume();
26+
};
27+
window.addEventListener("pointerdown", unlock, { once: true, capture: true });
28+
window.addEventListener("keydown", unlock, { once: true, capture: true });
29+
document.addEventListener("visibilitychange", () => {
30+
if (document.visibilityState !== "hidden" || !player) return;
31+
player.reset();
32+
void player.context.suspend().catch(() => {});
33+
});
34+
35+
ros.subscribe(TOPIC, (msg) => {
36+
if (!player || document.visibilityState === "hidden" || !isRobotAudioSpeaker() || typeof msg?.data !== "string") {
37+
return;
38+
}
39+
let payload;
40+
try {
41+
payload = JSON.parse(msg.data);
42+
} catch {
43+
// A malformed audio packet should not disrupt the shell.
44+
return;
45+
}
46+
if (player.context.state === "running") {
47+
player.push(payload);
48+
return;
49+
}
50+
void player.context.resume().then(() => {
51+
if (document.visibilityState !== "hidden") player?.push(payload);
52+
}).catch(() => {});
53+
}, undefined, "std_msgs/msg/String");
54+
}

webapp/js/pcmAudioPlayer.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// @ts-check
2+
// Gap-resistant scheduler for the simulator's short mono PCM chunks.
3+
4+
const LEAD_S = 0.12;
5+
const MAX_QUEUED_S = 0.5;
6+
7+
export class PcmAudioPlayer {
8+
/** @param {AudioContext} context */
9+
constructor(context) {
10+
this.context = context;
11+
this.nextAt = 0;
12+
/** @type {Set<AudioBufferSourceNode>} */
13+
this.sources = new Set();
14+
}
15+
16+
/** @param {{ sample_rate?: unknown, pcm?: unknown }} payload */
17+
push(payload) {
18+
const rate = Number(payload?.sample_rate);
19+
if (!Number.isFinite(rate) || rate < 8_000 || rate > 192_000 || typeof payload?.pcm !== "string") return false;
20+
21+
let bytes;
22+
try {
23+
const raw = atob(payload.pcm);
24+
if (!raw.length || raw.length % 2) return false;
25+
bytes = new Uint8Array(raw.length);
26+
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
27+
} catch {
28+
return false;
29+
}
30+
31+
const samples = new Int16Array(bytes.buffer);
32+
const buffer = this.context.createBuffer(1, samples.length, rate);
33+
const channel = buffer.getChannelData(0);
34+
for (let i = 0; i < samples.length; i++) channel[i] = samples[i] / 32768;
35+
36+
const now = this.context.currentTime;
37+
if (this.nextAt > now + MAX_QUEUED_S) return false;
38+
if (this.nextAt < now) this.nextAt = now + LEAD_S;
39+
const source = this.context.createBufferSource();
40+
source.buffer = buffer;
41+
source.connect(this.context.destination);
42+
this.sources.add(source);
43+
source.onended = () => this.sources.delete(source);
44+
source.start(this.nextAt);
45+
this.nextAt += buffer.duration;
46+
return true;
47+
}
48+
49+
/** Stop queued sound immediately; used when a tab is backgrounded. */
50+
reset() {
51+
for (const source of this.sources) {
52+
try {
53+
source.stop();
54+
} catch {
55+
// It may have ended between the Set iteration and stop().
56+
}
57+
}
58+
this.sources.clear();
59+
this.nextAt = 0;
60+
}
61+
}

webapp/js/shell.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { ros } from "./rosClient.js";
88
import { initTtsAudio } from "./ttsAudio.js";
9+
import { initMotorSoundAudio } from "./motorSoundAudio.js";
910
import { getConfig } from "./config.js";
1011
import { sharedAgentState } from "./teleop/agentState.js";
1112
import { createAgentIndicator } from "./agentIndicator.js";
@@ -169,6 +170,7 @@ export function initShell(navigate) {
169170

170171
// Play robot speech (/tts/audio) regardless of which page is open; idempotent.
171172
initTtsAudio();
173+
initMotorSoundAudio();
172174

173175
// A running agent shows a top-center "running" pill, linking back to the Agent
174176
// page to take control. It's persistent (built once); setActive hides it while

webapp/js/ttsAudio.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ navigator.locks?.request("innate-tts-speaker", () => {
2525
return new Promise(() => {}); // hold until this tab closes
2626
});
2727

28+
/** Shared by continuous simulator audio so one tab owns every robot sound. */
29+
export function isRobotAudioSpeaker() {
30+
return speaker;
31+
}
32+
2833
export function initTtsAudio() {
2934
if (started) return;
3035
started = true;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright (c) 2026 Innate Inc
3+
4+
import assert from "node:assert/strict";
5+
import { PcmAudioPlayer } from "../js/pcmAudioPlayer.js";
6+
7+
const starts = [];
8+
const stops = [];
9+
let decoded = null;
10+
const context = {
11+
currentTime: 10,
12+
destination: {},
13+
createBuffer(_channels, length, sampleRate) {
14+
const channel = new Float32Array(length);
15+
decoded = channel;
16+
return { duration: length / sampleRate, getChannelData: () => channel, channel };
17+
},
18+
createBufferSource() {
19+
return {
20+
connect() {},
21+
start: (at) => starts.push(at),
22+
stop: () => stops.push(true),
23+
onended: null,
24+
buffer: null,
25+
};
26+
},
27+
};
28+
29+
const pcm = Buffer.from(new Int16Array([-32768, 0, 32767]).buffer).toString("base64");
30+
const player = new PcmAudioPlayer(/** @type {any} */ (context));
31+
assert.equal(player.push({ sample_rate: 48_000, pcm }), true);
32+
assert.deepEqual(starts, [10.12]);
33+
assert.deepEqual([...decoded], [-1, 0, 32767 / 32768]);
34+
assert.equal(player.nextAt, 10.12 + 3 / 48_000);
35+
assert.equal(player.push({ sample_rate: 0, pcm }), false);
36+
assert.equal(player.push({ sample_rate: 48_000, pcm: "not base64!" }), false);
37+
player.nextAt = context.currentTime + 1;
38+
assert.equal(player.push({ sample_rate: 48_000, pcm }), false);
39+
assert.deepEqual(starts, [10.12]);
40+
player.nextAt = 0;
41+
assert.equal(player.push({ sample_rate: 48_000, pcm }), true);
42+
player.reset();
43+
assert.equal(stops.length, 2);
44+
assert.equal(player.nextAt, 0);
45+
46+
console.log("ok - simulator PCM is decoded, bounded, and stopped when playback resets");

0 commit comments

Comments
 (0)