@@ -43,6 +43,64 @@ def path(self) -> str:
4343AudioSource = AsyncIterator [rtc .AudioFrame ] | str | BuiltinAudioClip
4444
4545
46+ def _frame_gain (
47+ t : int ,
48+ n : int ,
49+ stop_t : int | None ,
50+ fade_in : float ,
51+ fade_out : float ,
52+ sample_rate : int ,
53+ volume : float ,
54+ ) -> np .ndarray | None :
55+ """Combined gain to apply to ``n`` samples starting at sample ``t``.
56+
57+ Returns ``None`` when no modification is needed (volume == 1 and no fade
58+ is active for this frame); the caller passes the frame through untouched.
59+ Otherwise returns an ``np.ndarray`` of length ``n`` with the volume scalar
60+ and the equal-power fade envelope baked in.
61+
62+ Equal-power (``sin(phase * pi/2)``) keeps a gentle slope at the silent end
63+ of each ramp where a linear ramp would knee audibly.
64+ """
65+ fade_in_n = int (fade_in * sample_rate ) if fade_in > 0 else 0
66+ fade_out_n = int (fade_out * sample_rate ) if fade_out > 0 else 0
67+ needs_fade_in = fade_in_n > 0 and t < fade_in_n
68+ needs_fade_out = fade_out_n > 0 and stop_t is not None
69+ if not needs_fade_in and not needs_fade_out and volume == 1.0 :
70+ return None
71+
72+ gain = np .full (n , volume , dtype = np .float32 )
73+ if needs_fade_in :
74+ idx = t + np .arange (n )
75+ phase = np .clip (idx / fade_in_n , 0.0 , 1.0 )
76+ gain *= np .sin (phase * (np .pi / 2 )).astype (np .float32 )
77+ if stop_t is not None and fade_out_n > 0 :
78+ idx = t + np .arange (n )
79+ phase = np .clip ((idx - stop_t ) / fade_out_n , 0.0 , 1.0 )
80+ gain *= np .cos (phase * (np .pi / 2 )).astype (np .float32 )
81+ return gain
82+
83+
84+ def _apply_gain (frame : rtc .AudioFrame , gain : np .ndarray | None ) -> rtc .AudioFrame :
85+ """Return ``frame`` with ``gain`` applied, or the frame unchanged when
86+ ``gain`` is ``None`` (single source of truth for the no-op fast path).
87+ """
88+ if gain is None :
89+ return frame
90+
91+ data = np .frombuffer (frame .data , dtype = np .int16 ).astype (np .float32 )
92+ if frame .num_channels > 1 :
93+ gain = np .repeat (gain , frame .num_channels )
94+ data *= gain
95+ np .clip (data , - 32768 , 32767 , out = data )
96+ return rtc .AudioFrame (
97+ data = data .astype (np .int16 ).tobytes (),
98+ sample_rate = frame .sample_rate ,
99+ num_channels = frame .num_channels ,
100+ samples_per_channel = frame .samples_per_channel ,
101+ )
102+
103+
46104class AudioConfig (NamedTuple ):
47105 """
48106 Definition for the audio to be played in the background
@@ -51,11 +109,18 @@ class AudioConfig(NamedTuple):
51109 volume: The volume of the audio (0.0-1.0)
52110 probability: The probability of the audio being played, when multiple
53111 AudioConfigs are provided (0.0-1.0)
112+ fade_in: Duration in seconds to ramp the volume from 0 up to ``volume``
113+ when playback starts. ``0`` (default) starts at full volume.
114+ fade_out: Duration in seconds to ramp the volume back down to 0 when
115+ ``PlayHandle.stop()`` is called. ``0`` (default) cuts immediately,
116+ preserving the previous behaviour.
54117 """
55118
56119 source : AudioSource
57120 volume : float = 1.0
58121 probability : float = 1.0
122+ fade_in : float = 0.0
123+ fade_out : float = 0.0
59124
60125
61126# The queue size is set to 400ms, which determines how much audio Rust will buffer.
@@ -147,21 +212,19 @@ def _select_sound_from_list(self, sounds: list[AudioConfig]) -> AudioConfig | No
147212
148213 def _normalize_sound_source (
149214 self , source : AudioSource | AudioConfig | list [AudioConfig ] | None
150- ) -> tuple [ AudioSource , float ] | None :
215+ ) -> AudioConfig | None :
151216 if source is None :
152217 return None
153218
154- if isinstance (source , BuiltinAudioClip ):
155- return self ._normalize_builtin_audio (source ), 1.0
156- elif isinstance (source , list ):
157- selected = self ._select_sound_from_list (source )
158- if selected is None :
219+ if isinstance (source , list ):
220+ source = self ._select_sound_from_list (source )
221+ if source is None :
159222 return None
160- return selected .source , selected .volume
161- elif isinstance (source , AudioConfig ):
162- return self ._normalize_builtin_audio (source .source ), source .volume
163223
164- return source , 1.0
224+ if isinstance (source , AudioConfig ):
225+ # `_replace` returns a new NamedTuple; the caller's instance is untouched.
226+ return source ._replace (source = self ._normalize_builtin_audio (source .source ))
227+ return AudioConfig (self ._normalize_builtin_audio (source ))
165228
166229 def _normalize_builtin_audio (self , source : AudioSource ) -> AsyncIterator [rtc .AudioFrame ] | str :
167230 if isinstance (source , BuiltinAudioClip ):
@@ -200,21 +263,19 @@ def play(
200263 if not self ._mixer_atask :
201264 raise RuntimeError ("BackgroundAudio is not started" )
202265
203- normalized = self ._normalize_sound_source (audio )
204- if normalized is None :
266+ cfg = self ._normalize_sound_source (audio )
267+ if cfg is None :
205268 play_handle = PlayHandle ()
206269 play_handle ._mark_playout_done ()
207270 return play_handle
208271
209- sound_source , volume = normalized
210-
211- if loop and isinstance (sound_source , AsyncIterator ):
272+ if loop and isinstance (cfg .source , AsyncIterator ):
212273 raise ValueError (
213274 "Looping sound via AsyncIterator is not supported. Use a string file path or your own 'infinite' AsyncIterator with loop=False" # noqa: E501
214275 )
215276
216- play_handle = PlayHandle ()
217- task = asyncio .create_task (self ._play_task (play_handle , sound_source , volume , loop ))
277+ play_handle = PlayHandle (fade_out = cfg . fade_out )
278+ task = asyncio .create_task (self ._play_task (play_handle , cfg , loop ))
218279 task .add_done_callback (lambda _ : self ._play_tasks .remove (task ))
219280 task .add_done_callback (lambda _ : play_handle ._mark_playout_done ())
220281 self ._play_tasks .append (task )
@@ -267,14 +328,10 @@ async def start(
267328 self ._agent_session .on ("agent_state_changed" , self ._agent_state_changed )
268329
269330 if self ._ambient_sound :
270- normalized = self ._normalize_sound_source (self ._ambient_sound )
271- if normalized :
272- sound_source , volume = normalized
273- selected_sound = AudioConfig (sound_source , volume )
274- if isinstance (sound_source , str ):
275- self ._ambient_handle = self .play (selected_sound , loop = True )
276- else :
277- self ._ambient_handle = self .play (selected_sound )
331+ cfg = self ._normalize_sound_source (self ._ambient_sound )
332+ if cfg is not None :
333+ loop_ambient = isinstance (cfg .source , str )
334+ self ._ambient_handle = self .play (cfg , loop = loop_ambient )
278335
279336 async def aclose (self ) -> None :
280337 """
@@ -325,38 +382,33 @@ def _agent_state_changed(self, ev: AgentStateChangedEvent) -> None:
325382 self ._thinking_handle .stop ()
326383
327384 @log_exceptions (logger = logger )
328- async def _play_task (
329- self , play_handle : PlayHandle , sound : AudioSource , volume : float , loop : bool
330- ) -> None :
385+ async def _play_task (self , play_handle : PlayHandle , cfg : AudioConfig , loop : bool ) -> None :
386+ sound , volume , fade_in , fade_out = cfg . source , cfg . volume , cfg . fade_in , cfg . fade_out
387+
331388 if isinstance (sound , BuiltinAudioClip ):
332389 sound = sound .path ()
333-
334390 if isinstance (sound , str ):
335- if loop :
336- sound = _loop_audio_frames (sound )
337- else :
338- sound = audio_frames_from_file (sound )
391+ sound = _loop_audio_frames (sound ) if loop else audio_frames_from_file (sound )
339392
340393 stopped = False
341394
342395 async def _gen_wrapper () -> AsyncGenerator [rtc .AudioFrame , None ]:
396+ t = 0 # cumulative samples (per channel) emitted so far
397+ stop_t : int | None = None # sample index when stop was requested
343398 try :
344399 async for frame in sound :
345400 if stopped :
346401 break
402+ if stop_t is None and fade_out > 0 and play_handle ._stop_fut .done ():
403+ stop_t = t
347404
348- if volume != 1.0 :
349- data = np .frombuffer (frame .data , dtype = np .int16 ).astype (np .float32 )
350- data *= 10 ** (np .log10 (volume ))
351- np .clip (data , - 32768 , 32767 , out = data )
352- yield rtc .AudioFrame (
353- data = data .astype (np .int16 ).tobytes (),
354- sample_rate = frame .sample_rate ,
355- num_channels = frame .num_channels ,
356- samples_per_channel = frame .samples_per_channel ,
357- )
358- else :
359- yield frame
405+ n = frame .samples_per_channel
406+ gain = _frame_gain (t , n , stop_t , fade_in , fade_out , frame .sample_rate , volume )
407+ yield _apply_gain (frame , gain )
408+
409+ t += n
410+ if stop_t is not None and (t - stop_t ) >= int (fade_out * frame .sample_rate ):
411+ break
360412 finally :
361413 # use try/finally because the mixer's asyncio.wait_for can cancel
362414 # __anext__, which finalizes the generator and skips code after
@@ -394,9 +446,10 @@ async def _publish_track(self) -> None:
394446
395447
396448class PlayHandle :
397- def __init__ (self ) -> None :
449+ def __init__ (self , fade_out : float = 0.0 ) -> None :
398450 self ._done_fut = asyncio .Future [None ]()
399451 self ._stop_fut = asyncio .Future [None ]()
452+ self ._fade_out = fade_out
400453
401454 def done (self ) -> bool :
402455 """
@@ -407,13 +460,23 @@ def done(self) -> bool:
407460 def stop (self ) -> None :
408461 """
409462 Stops the sound from playing.
463+
464+ If the source was started with a ``fade_out`` duration, this
465+ triggers the fade-out and the handle stays "not done" until the
466+ generator has finished tailing out. With ``fade_out=0`` (the
467+ default), playback is cut immediately as before.
410468 """
411469 if self .done ():
412470 return
413471
414472 with contextlib .suppress (asyncio .InvalidStateError ):
415473 self ._stop_fut .set_result (None )
416- self ._mark_playout_done () # TODO(theomonnom): move this to _play_task
474+ # Mark done immediately only when there's no fade-out to
475+ # honour; otherwise the play task's wait_for_playout would
476+ # return before the generator has tailed out, and the
477+ # finally block would aclose() the generator mid-fade.
478+ if self ._fade_out <= 0 :
479+ self ._mark_playout_done ()
417480
418481 async def wait_for_playout (self ) -> None :
419482 """
0 commit comments