Skip to content

Commit 950cd0b

Browse files
committed
feat(sim): fade world audio with orbit-camera distance
The viewer dispatches a perspective snapshot (active view, listener, robot and prop positions) after every primary render. In the third-person orbit view, motor sound and speech fade with camera distance; robot-camera views stay at full loudness. /tts/audio in sim now always carries a JSON clip with the simulated body it comes from, so a household resident's line plays from their prop.
1 parent 0a1b004 commit 950cd0b

13 files changed

Lines changed: 165 additions & 40 deletions

File tree

ros2_ws/src/brain/brain_client/brain_client/nodes/brain_client_node.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,6 @@ def _on_environment_speech(self, payload: dict) -> None:
426426
except (KeyError, ValueError) as exc:
427427
self.get_logger().warning(f"Ignoring invalid environment speech request: {exc}")
428428
return
429-
430429
shown = threading.Event()
431430

432431
def show() -> None:
@@ -449,6 +448,7 @@ def deliver(_success: bool) -> None:
449448
on_start=show,
450449
on_done=deliver,
451450
protected=True, # another character's line: agent flushes must not cancel it
451+
audio_source=payload.get("source"),
452452
)
453453
if not queued:
454454
deliver(False)

ros2_ws/src/brain/brain_client/brain_client/transport/tts.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import base64
1010
import io
11+
import json
1112
import queue
1213
import struct
1314
import subprocess
@@ -34,6 +35,7 @@ class _Utterance:
3435
on_done: Callable[[bool], None] | None
3536
reply_id: str | None # sentences of one streamed reply share an id
3637
protected: bool # never flushed (environment speech: not our backlog)
38+
audio_source: str | None = "robot" # which simulated body speaks; None means nowhere in particular
3739

3840

3941
def _survives_flush(item: _Utterance, playing_reply_id: str | None) -> bool:
@@ -160,6 +162,7 @@ def speak_text(
160162
text: str,
161163
voice_config: dict[str, Any] | None = None,
162164
on_start: Callable[[], None] | None = None,
165+
audio_source: str | None = "robot",
163166
) -> bool:
164167
"""
165168
Convert text to speech and play it.
@@ -168,6 +171,7 @@ def speak_text(
168171
text: Text to speak
169172
voice_config: Optional voice configuration override
170173
on_start: Called once the first audio reaches the speaker
174+
audio_source: Simulated body the sound comes from (sim only; the webapp fades it by camera distance)
171175
172176
Returns:
173177
True if speech was successfully generated and played, False otherwise
@@ -201,7 +205,7 @@ def speak_text(
201205
}
202206

203207
if self._simulator_mode and self.tts_audio_pub is not None:
204-
success = self._synthesize_to_topic(text, voice, t_start, on_start)
208+
success = self._synthesize_to_topic(text, voice, t_start, on_start, audio_source)
205209
else:
206210
success = self._synthesize_to_aplay(text, voice, t_start, on_start)
207211
except Exception as e:
@@ -348,8 +352,9 @@ def _synthesize_to_topic(
348352
voice: dict[str, Any],
349353
t_start: float,
350354
on_start: Callable[[], None] | None = None,
355+
audio_source: str | None = "robot",
351356
) -> bool:
352-
"""Synthesize the full clip and publish it (base64 WAV) on /tts/audio.
357+
"""Synthesize the full clip and publish it (JSON: base64 WAV + source) on /tts/audio.
353358
354359
The sim container has no audio device, so the webapp is the speaker. We
355360
collect the whole clip (utterances are short) and publish it once.
@@ -370,7 +375,7 @@ def _synthesize_to_topic(
370375
return False
371376

372377
wav = _finalize_wav(bytes(buf))
373-
self._publish_audio(wav)
378+
self._publish_audio(wav, audio_source)
374379
if on_start is not None:
375380
on_start()
376381
# Publishing the full clip is the beginning of playback, not the end.
@@ -386,14 +391,13 @@ def _synthesize_to_topic(
386391
)
387392
return True
388393

389-
def _publish_audio(self, wav: bytes) -> None:
390-
"""Publish one already-finalized clip on /tts/audio as base64 WAV."""
394+
def _publish_audio(self, wav: bytes, audio_source: str | None) -> None:
391395
if self.tts_audio_pub is None or not wav:
392396
return
393397
from std_msgs.msg import String
394398

395-
payload = base64.b64encode(wav).decode("ascii")
396-
self.tts_audio_pub.publish(String(data=payload))
399+
payload = {"audio": base64.b64encode(wav).decode("ascii"), "source": audio_source}
400+
self.tts_audio_pub.publish(String(data=json.dumps(payload, separators=(",", ":"))))
397401

398402
def speak_text_async(
399403
self,
@@ -404,6 +408,7 @@ def speak_text_async(
404408
on_done: Callable[[bool], None] | None = None,
405409
reply_id: str | None = None,
406410
protected: bool = False,
411+
audio_source: str | None = "robot",
407412
) -> bool:
408413
"""
409414
Queue text to be spoken. Utterances play in order, one at a time;
@@ -440,7 +445,9 @@ def speak_text_async(
440445
self._speech_queue.extend(kept)
441446
queued = len(self._speech_queue) < self._speech_queue_maxlen
442447
if queued:
443-
self._speech_queue.append(_Utterance(text, voice_config, on_start, on_done, reply_id, protected))
448+
self._speech_queue.append(
449+
_Utterance(text, voice_config, on_start, on_done, reply_id, protected, audio_source)
450+
)
444451
self._speech_cv.notify()
445452
if not queued:
446453
self.logger.warning(f"🔇 Speech queue full, dropping: '{text[:60]}'")
@@ -516,12 +523,12 @@ def _speech_loop(self):
516523
# reply's flush spares siblings of speech nobody has heard, and they
517524
# play ahead of the newer answer.
518525
take_floor = self._floor_taken_on_start(item.reply_id, self._once(item.on_start))
519-
success = self.speak_text(item.text, item.voice_config, take_floor)
526+
success = self.speak_text(item.text, item.voice_config, take_floor, item.audio_source)
520527
if not success:
521528
self._set_playing_reply(None)
522529
self.logger.info("🔄 Retrying TTS after 1 second...")
523530
time.sleep(1)
524-
success = self.speak_text(item.text, item.voice_config, take_floor)
531+
success = self.speak_text(item.text, item.voice_config, take_floor, item.audio_source)
525532
if not success:
526533
self._set_playing_reply(None)
527534
self._drop_queued_reply(item.reply_id)

ros2_ws/src/brain/brain_client/test/test_tts.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def test_failed_reply_no_longer_holds_the_floor(monkeypatch):
4646
handler._speech_cv = threading.Condition()
4747
handler._playing_reply_id = None
4848
handler.logger = SimpleNamespace(info=lambda _message: None, error=lambda _message: None)
49-
handler.speak_text = lambda _text, _voice, _on_start=None: False
49+
handler.speak_text = lambda _text, _voice, _on_start=None, _audio_source="robot": False
5050
monkeypatch.setattr("brain_client.transport.tts.time.sleep", lambda _seconds: None)
5151

5252
handler._speech_loop()
@@ -83,7 +83,7 @@ def enqueue_new_reply():
8383
assert handler.speak_text_async("new reply", replace_pending=True, reply_id="new-reply")
8484
handler._speech_queue.append(None)
8585

86-
def speak(text, _voice, on_start=None):
86+
def speak(text, _voice, on_start=None, _audio_source="robot"):
8787
spoken.append(text)
8888
if text == "failed first":
8989
attempt = spoken.count(text)

ros2_ws/src/mars_bot/mars_sim_driver/mars_sim_driver/challenges.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ class EnvironmentReply:
304304
speaker: str
305305
text: str
306306
voice_id: str
307+
source: str | None = None
307308

308309

309310
@dataclass
@@ -688,6 +689,8 @@ def tick(
688689
"text": reply.text,
689690
"voice_id": reply.voice_id,
690691
}
692+
if reply.source:
693+
payload["source"] = reply.source
691694
elif isinstance(reply, str):
692695
payload = {"sender": "user", "text": reply, "timestamp": time.time()}
693696
else:

sim/challenges/40_household_orders/runtime.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,19 +179,23 @@ def update(self, state: WorldState, events: list[dict]) -> RuntimeResult:
179179
resident.name,
180180
f"I'm {resident.name} and I want {resident.order} Please repeat the complete order back to me.",
181181
resident.voice_id,
182+
resident.prop,
182183
)
183184
)
184185
continue
185186
if self._matches(resident, event["text"]):
186187
self._confirmed.add(resident.id)
187188
result.events.append({"type": "resident_order_confirmed", "resident": resident.id})
188-
result.replies.append(EnvironmentReply(resident.name, "That's correct. Thank you.", resident.voice_id))
189+
result.replies.append(
190+
EnvironmentReply(resident.name, "That's correct. Thank you.", resident.voice_id, resident.prop)
191+
)
189192
else:
190193
result.replies.append(
191194
EnvironmentReply(
192195
resident.name,
193196
f"Not quite. I want {resident.order} Please repeat the complete order back to me.",
194197
resident.voice_id,
198+
resident.prop,
195199
)
196200
)
197201
return result

sim/viewer/src/props.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,15 @@ export class PropLibrary {
276276
return [...this.roots.values()].filter((r) => r.visible);
277277
}
278278

279+
/** Live world positions keyed by the authoritative prop names. */
280+
get audioSourcePositions(): Record<string, [number, number, number]> {
281+
const positions: Record<string, [number, number, number]> = {};
282+
for (const [name, root] of this.roots) {
283+
if (root.visible) positions[name] = [root.position.x, root.position.y, root.position.z];
284+
}
285+
return positions;
286+
}
287+
279288
private buildPlacementPreview(name: string): PlacementPreview | undefined {
280289
const info = this.info.get(name);
281290
if (!info) return undefined;

sim/viewer/src/scene.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ const TOP_FALLBACK_HEIGHT_M = 12;
140140
// Robot-mounted camera views: frames, axis conventions, FOV and near plane
141141
// match the driver's cameras (mars_sim_driver.core's CAMERAS).
142142
export type CameraView = "orbit" | "main" | "arm";
143+
export interface SimAudioPerspective {
144+
view: CameraView;
145+
listener: [number, number, number];
146+
sources: Record<string, [number, number, number]>;
147+
}
143148
// Track mars_sim_driver/constants.py: per-camera FOVs matching what the
144149
// driver renders (the head and wrist are different physical lenses), so the
145150
// operator's preview frames what the robot consumes. main is the head's real
@@ -831,6 +836,21 @@ export class SimScene {
831836
this.applyControlsEnabled();
832837
}
833838

839+
/** Snapshot from the exact scene state used by the primary render. */
840+
audioPerspective(): SimAudioPerspective {
841+
const activeCamera =
842+
(this.activeView !== "orbit" ? this.robotCameras.get(this.activeView) : undefined) ?? this.camera;
843+
const listener = activeCamera.getWorldPosition(new THREE.Vector3());
844+
return {
845+
view: this.activeView,
846+
listener: [listener.x, listener.y, listener.z],
847+
sources: {
848+
robot: [this.robotRoot.position.x, this.robotRoot.position.y, this.robotRoot.position.z],
849+
...this.props.audioSourcePositions,
850+
},
851+
};
852+
}
853+
834854
/** While a placement drag owns the pointer the orbit controls stay off,
835855
* even in the orbit view -- otherwise aiming a prop also spins the camera.
836856
* Applied immediately: a flag that is only consulted by setView() would not

sim/viewer/src/simStage.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const MIN_FRAME_MS = 1000 / 62;
3030
// are a contract with webapp/js/agent/challengePanel.js.
3131
const PANEL_OPEN_EVENT = "innate:panel-open";
3232
const PANEL_ID = "sim-scene-setup";
33+
const AUDIO_PERSPECTIVE_EVENT = "innate:sim-audio-perspective";
3334

3435
const VIEW_FOR: Record<string, CameraView> = { main: "main", arm: "arm", orbit: "orbit" };
3536
const ROTATION_DRAG_PX = 6;
@@ -529,6 +530,9 @@ export function createSimStage(
529530
cancelAnimationFrame(raf);
530531
raf = 0;
531532
};
533+
const clearAudioPerspective = () => {
534+
document.dispatchEvent(new CustomEvent(AUDIO_PERSPECTIVE_EVENT, { detail: null }));
535+
};
532536

533537
const loop = (now: number) => {
534538
raf = requestAnimationFrame(loop);
@@ -555,6 +559,7 @@ export function createSimStage(
555559
// ...then the primary view full-frame on top.
556560
scene.setView(VIEW_FOR[session.primaryCamera] ?? "orbit");
557561
scene.render();
562+
document.dispatchEvent(new CustomEvent(AUDIO_PERSPECTIVE_EVENT, { detail: scene.audioPerspective() }));
558563
frame++;
559564

560565
if (perfEl) {
@@ -631,6 +636,7 @@ export function createSimStage(
631636
detach() {
632637
attached = false;
633638
stopLoop();
639+
clearAudioPerspective();
634640
// The agent page lifts this into its own layout; take it back before
635641
// that page clears its DOM.
636642
wrap.appendChild(debugStack);
@@ -644,6 +650,7 @@ export function createSimStage(
644650
unsubscribe();
645651
unsubscribeProps();
646652
stopLoop();
653+
clearAudioPerspective();
647654
observer.disconnect();
648655
longTaskObserver?.disconnect();
649656
window.removeEventListener("pointerup", finishDrop);

tests/test_challenge_runtime.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ def update(self, _state, events) -> RuntimeResult:
5151
continue
5252
resident = event["text"]
5353
result.events.append({"type": "confirmed", "resident": resident})
54-
result.replies.append(EnvironmentReply(resident.title(), "Confirmed", f"voice-{resident}"))
54+
result.replies.append(
55+
EnvironmentReply(resident.title(), "Confirmed", f"voice-{resident}", f"resident-{resident}")
56+
)
5557
return result
5658

5759

@@ -126,6 +128,7 @@ def test_environment_reply_is_a_speech_request_the_brain_voices(tmp_path):
126128
assert payload["speaker"] == "A"
127129
assert payload["sender"] == "environment_speech"
128130
assert payload["voice_id"] == "voice-a"
131+
assert payload["source"] == "resident-a"
129132

130133

131134
def test_restart_invalidates_queued_and_dequeued_replies(tmp_path):

webapp/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
<link rel="modulepreload" href="/js/pressActivate.js" />
3131
<link rel="modulepreload" href="/js/ttsAudio.js" />
3232
<link rel="modulepreload" href="/js/motorSoundAudio.js" />
33+
<link rel="modulepreload" href="/js/simSpatialAudio.js" />
3334
<link rel="modulepreload" href="/js/micAudioState.js" />
3435
<link rel="modulepreload" href="/js/agent/main.js" />
3536
<link rel="modulepreload" href="/js/agent/agentPanel.js" />

0 commit comments

Comments
 (0)