Skip to content

Commit 5b1a979

Browse files
authored
Media Devices (#493)
add new MediaDevices class to simplify interactions with local audio devices.
1 parent 270e2cf commit 5b1a979

File tree

7 files changed

+1243
-0
lines changed

7 files changed

+1243
-0
lines changed

README.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,98 @@ except Exception as e:
173173

174174
You may find it useful to adjust the `response_timeout` parameter, which indicates the amount of time you will wait for a response. We recommend keeping this value as low as possible while still satisfying the constraints of your application.
175175

176+
## Using local media devices
177+
178+
The `MediaDevices` class provides a high-level interface for working with local audio input (microphone) and output (speakers) devices. It's built on top of the `sounddevice` library and integrates seamlessly with LiveKit's audio processing features. In order to use `MediaDevices`, you must have the `sounddevice` library installed in your local Python environment, if it's not available, `MediaDevices` will not work.
179+
180+
### Capturing microphone input
181+
182+
```python
183+
from livekit import rtc
184+
185+
# Create a MediaDevices instance
186+
devices = rtc.MediaDevices()
187+
188+
# Open the default microphone with audio processing enabled
189+
mic = devices.open_input(
190+
enable_aec=True, # Acoustic Echo Cancellation
191+
noise_suppression=True, # Noise suppression
192+
high_pass_filter=True, # High-pass filter
193+
auto_gain_control=True # Automatic gain control
194+
)
195+
196+
# Use the audio source to create a track and publish it
197+
track = rtc.LocalAudioTrack.create_audio_track("microphone", mic.source)
198+
await room.local_participant.publish_track(track)
199+
200+
# Clean up when done
201+
await mic.aclose()
202+
```
203+
204+
### Playing audio to speakers
205+
206+
```python
207+
# Open the default output device
208+
player = devices.open_output()
209+
210+
# Add remote audio tracks to the player (typically in a track_subscribed handler)
211+
@room.on("track_subscribed")
212+
def on_track_subscribed(track: rtc.Track, publication, participant):
213+
if track.kind == rtc.TrackKind.KIND_AUDIO:
214+
player.add_track(track)
215+
216+
# Start playback (mixes all added tracks)
217+
await player.start()
218+
219+
# Clean up when done
220+
await player.aclose()
221+
```
222+
223+
### Full duplex audio (microphone + speakers)
224+
225+
For full duplex audio with echo cancellation, open the input device first (with AEC enabled), then open the output device. The output player will automatically feed the APM's reverse stream for effective echo cancellation:
226+
227+
```python
228+
devices = rtc.MediaDevices()
229+
230+
# Open microphone with AEC
231+
mic = devices.open_input(enable_aec=True)
232+
233+
# Open speakers - automatically uses the mic's APM for echo cancellation
234+
player = devices.open_output()
235+
236+
# Publish microphone
237+
track = rtc.LocalAudioTrack.create_audio_track("mic", mic.source)
238+
await room.local_participant.publish_track(track)
239+
240+
# Add remote tracks and start playback
241+
player.add_track(remote_audio_track)
242+
await player.start()
243+
```
244+
245+
### Listing available devices
246+
247+
```python
248+
devices = rtc.MediaDevices()
249+
250+
# List input devices
251+
input_devices = devices.list_input_devices()
252+
for device in input_devices:
253+
print(f"{device['index']}: {device['name']}")
254+
255+
# List output devices
256+
output_devices = devices.list_output_devices()
257+
for device in output_devices:
258+
print(f"{device['index']}: {device['name']}")
259+
260+
# Get default device indices
261+
default_input = devices.default_input_device()
262+
default_output = devices.default_output_device()
263+
```
264+
265+
See [publish_mic.py](examples/local_audio/publish_mic.py) and [full_duplex.py](examples/local_audio/full_duplex.py) for complete examples.
266+
267+
176268
#### Errors
177269

178270
LiveKit is a dynamic realtime environment and calls can fail for various reasons.

examples/local_audio/db_meter.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"""
2+
Audio dB meter utilities for LiveKit Python SDK examples.
3+
4+
This module provides functions to calculate and display audio levels in decibels (dB)
5+
from raw audio samples, useful for monitoring microphone input and room audio levels.
6+
"""
7+
8+
import math
9+
import queue
10+
import time
11+
from typing import List
12+
13+
# dB meter configuration constants
14+
DB_METER_UPDATE_INTERVAL_MS = 50 # Update every 50ms
15+
MIC_METER_WIDTH = 25 # Width of the mic dB meter bar
16+
ROOM_METER_WIDTH = 25 # Width of the room dB meter bar
17+
18+
19+
def calculate_db_level(samples: List[int]) -> float:
20+
"""
21+
Calculate decibel level from audio samples.
22+
23+
Args:
24+
samples: List of 16-bit audio samples
25+
26+
Returns:
27+
dB level as float. Returns -60.0 for silence/empty samples.
28+
"""
29+
if not samples:
30+
return -60.0 # Very quiet
31+
32+
# Calculate RMS (Root Mean Square)
33+
sum_squares = sum(
34+
(sample / 32767.0) ** 2 # Normalize to -1.0 to 1.0 range
35+
for sample in samples
36+
)
37+
38+
rms = math.sqrt(sum_squares / len(samples))
39+
40+
# Convert to dB (20 * log10(rms))
41+
if rms > 0.0:
42+
return 20.0 * math.log10(rms)
43+
else:
44+
return -60.0 # Very quiet
45+
46+
47+
def get_meter_color(db_level: float, position_ratio: float) -> str:
48+
"""
49+
Get ANSI color code based on dB level and position in meter.
50+
51+
Args:
52+
db_level: Current dB level
53+
position_ratio: Position in meter (0.0 to 1.0)
54+
55+
Returns:
56+
ANSI color code string
57+
"""
58+
# Determine color based on both dB level and position in the meter
59+
if db_level > -6.0 and position_ratio > 0.85:
60+
return "\x1b[91m" # Bright red - clipping/very loud
61+
elif db_level > -12.0 and position_ratio > 0.7:
62+
return "\x1b[31m" # Red - loud
63+
elif db_level > -18.0 and position_ratio > 0.5:
64+
return "\x1b[93m" # Bright yellow - medium-loud
65+
elif db_level > -30.0 and position_ratio > 0.3:
66+
return "\x1b[33m" # Yellow - medium
67+
elif position_ratio > 0.1:
68+
return "\x1b[92m" # Bright green - low-medium
69+
else:
70+
return "\x1b[32m" # Green - low
71+
72+
73+
def format_single_meter(db_level: float, meter_width: int, meter_label: str) -> str:
74+
"""
75+
Format a single dB meter with colors.
76+
77+
Args:
78+
db_level: dB level to display
79+
meter_width: Width of the meter bar in characters
80+
meter_label: Label text for the meter
81+
82+
Returns:
83+
Formatted meter string with ANSI colors
84+
"""
85+
# ANSI color codes
86+
COLOR_RESET = "\x1b[0m"
87+
COLOR_DIM = "\x1b[2m"
88+
89+
db_clamped = max(-60.0, min(0.0, db_level))
90+
normalized = (db_clamped + 60.0) / 60.0 # Normalize to 0.0-1.0
91+
filled_width = int(normalized * meter_width)
92+
93+
meter = meter_label
94+
95+
# Add the dB value with appropriate color
96+
if db_level > -6.0:
97+
db_color = "\x1b[91m" # Bright red
98+
elif db_level > -12.0:
99+
db_color = "\x1b[31m" # Red
100+
elif db_level > -24.0:
101+
db_color = "\x1b[33m" # Yellow
102+
else:
103+
db_color = "\x1b[32m" # Green
104+
105+
meter += f"{db_color}{db_level:>7.1f}{COLOR_RESET} "
106+
107+
# Add the visual meter with colors
108+
meter += "["
109+
for i in range(meter_width):
110+
position_ratio = i / meter_width
111+
112+
if i < filled_width:
113+
color = get_meter_color(db_level, position_ratio)
114+
meter += f"{color}{COLOR_RESET}" # Full block for active levels
115+
else:
116+
meter += f"{COLOR_DIM}{COLOR_RESET}" # Light shade for empty
117+
118+
meter += "]"
119+
return meter
120+
121+
122+
def format_dual_meters(mic_db: float, room_db: float) -> str:
123+
"""
124+
Format both dB meters on the same line.
125+
126+
Args:
127+
mic_db: Microphone dB level
128+
room_db: Room audio dB level
129+
130+
Returns:
131+
Formatted dual meter string
132+
"""
133+
mic_meter = format_single_meter(mic_db, MIC_METER_WIDTH, "Mic: ")
134+
room_meter = format_single_meter(room_db, ROOM_METER_WIDTH, " Room: ")
135+
136+
return f"{mic_meter}{room_meter}"
137+
138+
139+
def display_dual_db_meters(
140+
mic_db_receiver, room_db_receiver, room_name: str = "Audio Levels Monitor"
141+
) -> None:
142+
"""
143+
Display dual dB meters continuously until interrupted.
144+
145+
Args:
146+
mic_db_receiver: Queue or receiver for microphone dB levels
147+
room_db_receiver: Queue or receiver for room dB levels
148+
room_name: Name of the room to display as the title
149+
"""
150+
try:
151+
last_update = time.time()
152+
current_mic_db = -60.0
153+
current_room_db = -60.0
154+
155+
print() # Start on a new line
156+
print(f"\x1b[92mRoom [{room_name}]\x1b[0m")
157+
print(
158+
"\x1b[2m────────────────────────────────────────────────────────────────────────────────\x1b[0m"
159+
)
160+
161+
while True:
162+
# Check for new data (non-blocking)
163+
try:
164+
while True: # Drain all available data
165+
mic_db = mic_db_receiver.get_nowait()
166+
current_mic_db = mic_db
167+
except queue.Empty:
168+
pass # No more data available
169+
170+
try:
171+
while True: # Drain all available data
172+
room_db = room_db_receiver.get_nowait()
173+
current_room_db = room_db
174+
except queue.Empty:
175+
pass # No more data available
176+
177+
# Update display at regular intervals
178+
current_time = time.time()
179+
if current_time - last_update >= DB_METER_UPDATE_INTERVAL_MS / 1000.0:
180+
# Clear current line and display meters in place
181+
print(
182+
f"\r\x1b[K{format_dual_meters(current_mic_db, current_room_db)}",
183+
end="",
184+
flush=True,
185+
)
186+
last_update = current_time
187+
188+
# Small sleep to prevent busy waiting
189+
time.sleep(0.01)
190+
191+
except KeyboardInterrupt:
192+
print() # Move to next line after Ctrl+C
193+
194+
195+
def display_single_db_meter(db_receiver, label: str = "Mic Level: ") -> None:
196+
"""
197+
Display a single dB meter continuously until interrupted.
198+
199+
Args:
200+
db_receiver: Queue or receiver for dB levels
201+
label: Label for the meter display
202+
"""
203+
try:
204+
last_update = time.time()
205+
current_db = -60.0
206+
first_display = True
207+
208+
if first_display:
209+
print() # Start on a new line
210+
print(f"\x1b[92m{label}\x1b[0m")
211+
print("\x1b[2m────────────────────────────────────────\x1b[0m")
212+
first_display = False
213+
214+
while True:
215+
# Check for new data (non-blocking)
216+
try:
217+
while True: # Drain all available data
218+
db_level = db_receiver.get_nowait()
219+
current_db = db_level
220+
except queue.Empty:
221+
pass # No more data available
222+
223+
# Update display at regular intervals
224+
current_time = time.time()
225+
if current_time - last_update >= DB_METER_UPDATE_INTERVAL_MS / 1000.0:
226+
# Clear current line and display meter in place
227+
meter = format_single_meter(current_db, 40, label)
228+
print(f"\r\x1b[K{meter}", end="", flush=True)
229+
last_update = current_time
230+
231+
# Small sleep to prevent busy waiting
232+
time.sleep(0.01)
233+
234+
except KeyboardInterrupt:
235+
print() # Move to next line after Ctrl+C
236+
237+
238+
# Example usage and testing functions
239+
def demo_db_meter() -> None:
240+
"""Demo function to test dB meter functionality."""
241+
import random
242+
243+
# Simulate some test data
244+
class MockReceiver:
245+
def __init__(self):
246+
self.data = []
247+
248+
def get_nowait(self):
249+
if not self.data:
250+
# Generate random dB value between -60 and 0
251+
self.data.append(random.uniform(-60, 0))
252+
return self.data.pop(0)
253+
254+
mic_receiver = MockReceiver()
255+
room_receiver = MockReceiver()
256+
257+
print("Starting dB meter demo (Ctrl+C to stop)...")
258+
display_dual_db_meters(mic_receiver, room_receiver)
259+
260+
261+
if __name__ == "__main__":
262+
demo_db_meter()

0 commit comments

Comments
 (0)