88
99import base64
1010import io
11+ import json
1112import queue
1213import struct
1314import 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
3941def _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 )
0 commit comments