Skip to content

Commit 7e0df4f

Browse files
committed
add db meter
1 parent 1ba7f9f commit 7e0df4f

File tree

3 files changed

+351
-5
lines changed

3 files changed

+351
-5
lines changed

examples/local_audio/db_meter.py

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

examples/local_audio/full_duplex.py

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import os
22
import asyncio
33
import logging
4+
import threading
5+
import queue
46
from dotenv import load_dotenv, find_dotenv
57

68
from livekit import api, rtc
9+
from db_meter import calculate_db_level, display_dual_db_meters
710

811

912
async def main() -> None:
@@ -22,17 +25,22 @@ async def main() -> None:
2225

2326
devices = rtc.MediaDevices()
2427

25-
# Open microphone with AEC and prepare a player for remote audio feeding AEC reverse stream
26-
mic = devices.open_input(enable_aec=True)
28+
# Open microphone & speaker
29+
mic = devices.open_input()
2730
player = devices.open_output()
2831

2932
# Mixer for all remote audio streams
3033
mixer = rtc.AudioMixer(sample_rate=48000, num_channels=1)
3134

35+
# dB level monitoring
36+
mic_db_queue = queue.Queue()
37+
room_db_queue = queue.Queue()
38+
3239
# Track stream bookkeeping for cleanup
3340
streams_by_pub: dict[str, rtc.AudioStream] = {}
3441
streams_by_participant: dict[str, set[rtc.AudioStream]] = {}
35-
42+
43+
# remove stream from mixer and close it
3644
async def _remove_stream(
3745
stream: rtc.AudioStream, participant_sid: str | None = None, pub_sid: str | None = None
3846
) -> None:
@@ -125,8 +133,56 @@ def on_participant_disconnected(participant: rtc.RemoteParticipant):
125133
await room.local_participant.publish_track(track, pub_opts)
126134
logging.info("published local microphone")
127135

128-
# Start playing mixed remote audio
129-
asyncio.create_task(player.play(mixer))
136+
# Start dB meter display in a separate thread
137+
meter_thread = threading.Thread(
138+
target=display_dual_db_meters,
139+
args=(mic_db_queue, room_db_queue),
140+
daemon=True
141+
)
142+
meter_thread.start()
143+
144+
# Create a monitoring wrapper for the mixer that calculates dB levels
145+
# while passing frames through to the player
146+
async def monitored_mixer():
147+
try:
148+
async for frame in mixer:
149+
# Calculate dB level for room audio
150+
samples = list(frame.data)
151+
db_level = calculate_db_level(samples)
152+
try:
153+
room_db_queue.put_nowait(db_level)
154+
except queue.Full:
155+
pass # Drop if queue is full
156+
# Yield the frame for playback
157+
yield frame
158+
except Exception:
159+
pass
160+
161+
# Start playing mixed remote audio with monitoring
162+
asyncio.create_task(player.play(monitored_mixer()))
163+
164+
# Monitor microphone dB levels
165+
async def monitor_mic_db():
166+
mic_stream = rtc.AudioStream(
167+
track, sample_rate=48000, num_channels=1
168+
)
169+
try:
170+
async for frame_event in mic_stream:
171+
frame = frame_event.frame
172+
# Convert frame data to list of samples
173+
samples = list(frame.data)
174+
db_level = calculate_db_level(samples)
175+
# Update queue with latest value (non-blocking)
176+
try:
177+
mic_db_queue.put_nowait(db_level)
178+
except queue.Full:
179+
pass # Drop if queue is full
180+
except Exception:
181+
pass
182+
finally:
183+
await mic_stream.aclose()
184+
185+
asyncio.create_task(monitor_mic_db())
130186

131187
# Run until Ctrl+C
132188
while True:

0 commit comments

Comments
 (0)