Skip to content

Commit 4a34632

Browse files
authored
feat(background_audio): add fade_in / fade_out to AudioConfig (#5832)
1 parent 1445a44 commit 4a34632

1 file changed

Lines changed: 110 additions & 47 deletions

File tree

livekit-agents/livekit/agents/voice/background_audio.py

Lines changed: 110 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,64 @@ def path(self) -> str:
4343
AudioSource = 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+
46104
class 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

396448
class 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

Comments
 (0)