Skip to content

Commit 416b58b

Browse files
explicitly clean up the platformAudio
1 parent acdcda4 commit 416b58b

4 files changed

Lines changed: 165 additions & 44 deletions

File tree

examples/local_audio/full_duplex.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,20 @@ def list_audio_devices() -> None:
6565
print(f"Failed to initialize PlatformAudio: {e}")
6666
return
6767

68-
print("\nRecording devices (microphones):")
69-
for device in platform_audio.recording_devices():
70-
print(f" [{device.index}] {device.name}")
71-
print(f" ID: {device.id}")
68+
try:
69+
print("\nRecording devices (microphones):")
70+
for device in platform_audio.recording_devices():
71+
print(f" [{device.index}] {device.name}")
72+
print(f" ID: {device.id}")
7273

73-
print("\nPlayout devices (speakers):")
74-
for device in platform_audio.playout_devices():
75-
print(f" [{device.index}] {device.name}")
76-
print(f" ID: {device.id}")
74+
print("\nPlayout devices (speakers):")
75+
for device in platform_audio.playout_devices():
76+
print(f" [{device.index}] {device.name}")
77+
print(f" ID: {device.id}")
7778

78-
print()
79+
print()
80+
finally:
81+
platform_audio.close()
7982

8083

8184
async def main(args: argparse.Namespace) -> None:
@@ -123,6 +126,7 @@ async def main(args: argparse.Namespace) -> None:
123126
logging.warning(f"Failed to select speaker: {e}")
124127

125128
room = rtc.Room()
129+
source = None
126130

127131
# dB level monitoring (mic only)
128132
mic_db_queue: queue.Queue[float] = queue.Queue()
@@ -236,6 +240,10 @@ async def monitor_mic_db():
236240
await room.disconnect()
237241
except Exception:
238242
pass
243+
# Clean up PlatformAudio resources
244+
if source is not None:
245+
source.close()
246+
platform_audio.close()
239247

240248

241249
if __name__ == "__main__":

examples/local_audio/publish_mic.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,15 @@ def list_audio_devices() -> None:
5454
print(f"Failed to initialize PlatformAudio: {e}")
5555
return
5656

57-
print("\nRecording devices (microphones):")
58-
for device in platform_audio.recording_devices():
59-
print(f" [{device.index}] {device.name}")
60-
print(f" ID: {device.id}")
57+
try:
58+
print("\nRecording devices (microphones):")
59+
for device in platform_audio.recording_devices():
60+
print(f" [{device.index}] {device.name}")
61+
print(f" ID: {device.id}")
6162

62-
print()
63+
print()
64+
finally:
65+
platform_audio.close()
6366

6467

6568
async def main(args: argparse.Namespace) -> None:
@@ -95,6 +98,7 @@ async def main(args: argparse.Namespace) -> None:
9598
logging.warning(f"Failed to select microphone: {e}")
9699

97100
room = rtc.Room()
101+
source = None
98102

99103
# dB level monitoring
100104
mic_db_queue: queue.Queue[float] = queue.Queue()
@@ -170,6 +174,10 @@ async def monitor_mic_db():
170174
await room.disconnect()
171175
except Exception:
172176
pass
177+
# Clean up PlatformAudio resources
178+
if source is not None:
179+
source.close()
180+
platform_audio.close()
173181

174182

175183
if __name__ == "__main__":

livekit-rtc/livekit/rtc/platform_audio.py

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ class PlatformAudioSource:
146146
147147
Note: This class is created via PlatformAudio.create_audio_source() and should
148148
not be instantiated directly.
149+
150+
Resource Management:
151+
Call `close()` when done to immediately release native resources. If not
152+
called, resources are released when the object is garbage collected, but
153+
GC timing is non-deterministic and may cause issues on some platforms
154+
(especially Windows) where audio device handles must be released promptly.
149155
"""
150156

151157
def __init__(self, ffi_handle: FfiHandle, info: proto_audio_frame.AudioSourceInfo):
@@ -157,6 +163,27 @@ def _handle(self) -> int:
157163
"""Internal FFI handle for use with LocalAudioTrack.create_audio_track()."""
158164
return self._ffi_handle.handle
159165

166+
def close(self) -> None:
167+
"""Release the native audio source resources.
168+
169+
Call this method when you are done using the audio source to immediately
170+
release the underlying native handle. This is especially important on
171+
Windows where audio device handles must be released promptly to avoid
172+
interfering with other audio operations.
173+
174+
If `close()` is not called, resources will be released when the object
175+
is garbage collected. However, Python's garbage collection timing is
176+
non-deterministic, which can lead to:
177+
- Audio device contention if creating new audio sources before old ones
178+
are collected
179+
- Test failures when running multiple audio tests in sequence
180+
- Resource leaks in long-running applications that frequently create
181+
and discard audio sources
182+
183+
It is safe to call `close()` multiple times; subsequent calls are no-ops.
184+
"""
185+
self._ffi_handle.dispose()
186+
160187

161188
class PlatformAudio:
162189
"""Platform audio device management via WebRTC's Audio Device Module (ADM).
@@ -185,12 +212,18 @@ class PlatformAudio:
185212
source = platform_audio.create_audio_source()
186213
track = rtc.LocalAudioTrack.create_audio_track("mic", source)
187214
await room.local_participant.publish_track(track)
215+
216+
# When done, close resources
217+
source.close()
218+
platform_audio.close()
188219
```
189220
190-
Note:
191-
The PlatformAudio instance must be kept alive while audio is needed.
192-
When all PlatformAudio instances are garbage collected, the ADM is
193-
automatically disabled.
221+
Resource Management:
222+
Call `close()` when done to immediately release native ADM resources.
223+
If not called, resources are released via garbage collection, but GC
224+
timing is non-deterministic. On Windows especially, failing to promptly
225+
release ADM resources can cause audio device contention and interfere
226+
with subsequent audio operations (including synthetic AudioSource usage).
194227
"""
195228

196229
def __init__(self) -> None:
@@ -353,3 +386,41 @@ def create_audio_source(
353386
FfiHandle(source_info.handle.id),
354387
source_info,
355388
)
389+
390+
def close(self) -> None:
391+
"""Release the native Audio Device Module resources.
392+
393+
Call this method when you are done using PlatformAudio to immediately
394+
release the underlying ADM handle and associated native resources.
395+
396+
Why call close() explicitly?
397+
Python's garbage collection is non-deterministic. If you rely on GC
398+
to clean up PlatformAudio, the ADM may remain active longer than
399+
expected. This can cause problems on some platforms (especially
400+
Windows) where:
401+
402+
- Audio device handles are held longer than necessary, preventing
403+
other applications or audio subsystems from accessing the devices
404+
- Subsequent audio operations (including synthetic AudioSource) may
405+
fail or behave unexpectedly due to ADM still being active
406+
- Tests running in sequence may interfere with each other if the
407+
previous test's ADM resources haven't been collected yet
408+
409+
What happens if close() is not called?
410+
Resources will eventually be released when the PlatformAudio object
411+
is garbage collected. This works fine in simple applications where
412+
the object goes out of scope and is collected promptly. However, in
413+
these scenarios you should call close() explicitly:
414+
415+
- Running multiple tests that use audio
416+
- Switching between PlatformAudio and synthetic AudioSource
417+
- Long-running applications that create/destroy audio resources
418+
- Applications where prompt device release is important
419+
420+
It is safe to call `close()` multiple times; subsequent calls are no-ops.
421+
422+
Note:
423+
Always close PlatformAudioSource instances before closing the parent
424+
PlatformAudio instance.
425+
"""
426+
self._ffi_handle.dispose()

tests/rtc/test_platform_audio.py

Lines changed: 60 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@
1616
_platform_audio_error: Optional[str] = None
1717

1818

19+
def _dispose_track(track: rtc.Track) -> None:
20+
"""Release track handles created only for local PlatformAudio tests.
21+
22+
Note: Track doesn't have a public close() method yet, so we use the internal API.
23+
"""
24+
track._ffi_handle.dispose()
25+
26+
1927
def _check_platform_audio_available() -> tuple[bool, Optional[str]]:
2028
"""Check if PlatformAudio can be initialized on this system."""
2129
global _platform_audio_available, _platform_audio_error
@@ -27,8 +35,7 @@ def _check_platform_audio_available() -> tuple[bool, Optional[str]]:
2735
pa = rtc.PlatformAudio()
2836
_platform_audio_available = True
2937
_platform_audio_error = None
30-
# Keep reference to avoid cleanup issues
31-
del pa
38+
pa.close()
3239
except rtc.PlatformAudioError as e:
3340
_platform_audio_available = False
3441
_platform_audio_error = str(e)
@@ -60,8 +67,10 @@ def platform_audio():
6067
pytest.skip(f"PlatformAudio not available: {error}")
6168

6269
pa = rtc.PlatformAudio()
63-
yield pa
64-
# Cleanup handled by garbage collection
70+
try:
71+
yield pa
72+
finally:
73+
pa.close()
6574

6675

6776
class TestPlatformAudioCreation:
@@ -79,9 +88,12 @@ def test_platform_audio_multiple_instances(self, platform_audio):
7988
pytest.skip(f"PlatformAudio not available: {error}")
8089

8190
pa2 = rtc.PlatformAudio()
82-
assert pa2 is not None
83-
# Both should work
84-
assert platform_audio is not None
91+
try:
92+
assert pa2 is not None
93+
# Both should work
94+
assert platform_audio is not None
95+
finally:
96+
pa2.close()
8597

8698

8799
class TestDeviceEnumeration:
@@ -197,8 +209,11 @@ class TestAudioSourceCreation:
197209
def test_create_audio_source_default_options(self, platform_audio):
198210
"""Test creating an audio source with default options."""
199211
source = platform_audio.create_audio_source()
200-
assert source is not None
201-
assert isinstance(source, rtc.PlatformAudioSource)
212+
try:
213+
assert source is not None
214+
assert isinstance(source, rtc.PlatformAudioSource)
215+
finally:
216+
source.close()
202217

203218
def test_create_audio_source_custom_options(self, platform_audio):
204219
"""Test creating an audio source with custom options."""
@@ -209,8 +224,11 @@ def test_create_audio_source_custom_options(self, platform_audio):
209224
prefer_hardware=False,
210225
)
211226
source = platform_audio.create_audio_source(options)
212-
assert source is not None
213-
assert isinstance(source, rtc.PlatformAudioSource)
227+
try:
228+
assert source is not None
229+
assert isinstance(source, rtc.PlatformAudioSource)
230+
finally:
231+
source.close()
214232

215233
def test_create_audio_source_all_processing_disabled(self, platform_audio):
216234
"""Test creating an audio source with all processing disabled."""
@@ -220,24 +238,35 @@ def test_create_audio_source_all_processing_disabled(self, platform_audio):
220238
auto_gain_control=False,
221239
)
222240
source = platform_audio.create_audio_source(options)
223-
assert source is not None
241+
try:
242+
assert source is not None
243+
finally:
244+
source.close()
224245

225246
def test_audio_source_has_handle(self, platform_audio):
226247
"""Test that created audio source has a valid internal handle."""
227248
source = platform_audio.create_audio_source()
228-
# The _handle property is used internally for track creation
229-
assert hasattr(source, "_handle")
230-
assert source._handle > 0
249+
try:
250+
# The _handle property is used internally for track creation
251+
assert hasattr(source, "_handle")
252+
assert source._handle > 0
253+
finally:
254+
source.close()
231255

232256
def test_create_multiple_audio_sources(self, platform_audio):
233257
"""Test creating multiple audio sources from the same PlatformAudio."""
234258
source1 = platform_audio.create_audio_source()
235-
source2 = platform_audio.create_audio_source()
236-
237-
assert source1 is not None
238-
assert source2 is not None
239-
# Each source should have a unique handle
240-
assert source1._handle != source2._handle
259+
source2 = None
260+
try:
261+
source2 = platform_audio.create_audio_source()
262+
assert source1 is not None
263+
assert source2 is not None
264+
# Each source should have a unique handle
265+
assert source1._handle != source2._handle
266+
finally:
267+
source1.close()
268+
if source2 is not None:
269+
source2.close()
241270

242271

243272
class TestPlatformAudioOptions:
@@ -291,8 +320,13 @@ class TestIntegrationWithTrack:
291320
def test_create_track_from_platform_audio_source(self, platform_audio):
292321
"""Test creating a LocalAudioTrack from PlatformAudioSource."""
293322
source = platform_audio.create_audio_source()
294-
track = rtc.LocalAudioTrack.create_audio_track("test-mic", source)
295-
296-
assert track is not None
297-
assert track.name == "test-mic"
298-
assert track.kind == rtc.TrackKind.KIND_AUDIO
323+
track = None
324+
try:
325+
track = rtc.LocalAudioTrack.create_audio_track("test-mic", source)
326+
assert track is not None
327+
assert track.name == "test-mic"
328+
assert track.kind == rtc.TrackKind.KIND_AUDIO
329+
finally:
330+
if track is not None:
331+
_dispose_track(track)
332+
source.close()

0 commit comments

Comments
 (0)