Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
e00dcc8
init media devices
chenosaurus Sep 8, 2025
8f13bbd
add MediaDevices to rtc/__init__.py
chenosaurus Sep 9, 2025
cd9d873
clean up examples
chenosaurus Sep 9, 2025
b58dd7d
fix syntax to create inputstream
chenosaurus Sep 10, 2025
825e9d5
fix audio output thru mixer
chenosaurus Sep 11, 2025
74582ec
remove unused import
chenosaurus Sep 11, 2025
9b2f466
fix linter error
chenosaurus Sep 11, 2025
efb5473
ruff format
chenosaurus Sep 11, 2025
7f1d59e
allow AudioMixer to unwrap AudioFrameEvent
chenosaurus Sep 11, 2025
c8f8c0c
rename dir to match convention
chenosaurus Sep 11, 2025
30ee183
rename methods to be more clear
chenosaurus Sep 11, 2025
89fb1ba
update example
chenosaurus Sep 24, 2025
c48e1eb
update comments
chenosaurus Sep 24, 2025
72f546f
ruff format
chenosaurus Sep 25, 2025
ef56542
clean up input stream creation
chenosaurus Oct 2, 2025
236fad1
add missing dep
chenosaurus Oct 3, 2025
7cc6efb
remove mapping
chenosaurus Oct 3, 2025
1ba7f9f
make apm internal
chenosaurus Oct 3, 2025
7e0df4f
add db meter
chenosaurus Oct 5, 2025
8458783
fix lint issues
chenosaurus Oct 5, 2025
ca27e5f
display room name
chenosaurus Oct 7, 2025
846538f
move audio mixer inside of MediaDevices for ease of playback
chenosaurus Oct 7, 2025
58483ac
remove unused import
chenosaurus Oct 7, 2025
c8ca2eb
adding to readme for MediaDevices usage
chenosaurus Oct 7, 2025
15d104e
format
chenosaurus Oct 10, 2025
4a85e01
Merge branch 'main' into dc/media_devices
chenosaurus Oct 10, 2025
7a3e04b
revert changes to audio mixer as we no longer need it to handle Audio…
chenosaurus Oct 16, 2025
723627c
format
chenosaurus Oct 16, 2025
78468ea
fix comment
chenosaurus Oct 16, 2025
2b32567
fix media devices lint
chenosaurus Oct 16, 2025
1abe8c5
clean up media devices
chenosaurus Oct 16, 2025
8e9dc24
format
chenosaurus Oct 16, 2025
e0e99ae
add example script to list audio devices
chenosaurus Oct 17, 2025
a831e67
media devices should import sounddevice lazily
chenosaurus Oct 17, 2025
29f79f6
format
chenosaurus Oct 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions examples/local_audio/full_duplex.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import os
import asyncio
import logging
from dotenv import load_dotenv, find_dotenv

from livekit import api, rtc


async def main() -> None:
logging.basicConfig(level=logging.INFO)

# Load environment variables from a .env file if present
load_dotenv(find_dotenv())

url = os.getenv("LIVEKIT_URL")
api_key = os.getenv("LIVEKIT_API_KEY")
api_secret = os.getenv("LIVEKIT_API_SECRET")
if not url or not api_key or not api_secret:
raise RuntimeError("LIVEKIT_URL and LIVEKIT_TOKEN must be set in env")

room = rtc.Room()

devices = rtc.MediaDevices()

# Open microphone with AEC and prepare a player for remote audio feeding AEC reverse stream
mic = devices.open_input(enable_aec=True)
player = devices.open_output(apm_for_reverse=mic.apm)

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

# Track stream bookkeeping for cleanup
streams_by_pub: dict[str, rtc.AudioStream] = {}
streams_by_participant: dict[str, set[rtc.AudioStream]] = {}

async def _remove_stream(
stream: rtc.AudioStream, participant_sid: str | None = None, pub_sid: str | None = None
) -> None:
try:
mixer.remove_stream(stream)
except Exception:
pass
try:
await stream.aclose()
except Exception:
pass
if participant_sid and participant_sid in streams_by_participant:
streams_by_participant.get(participant_sid, set()).discard(stream)
if not streams_by_participant.get(participant_sid):
streams_by_participant.pop(participant_sid, None)
if pub_sid is not None:
streams_by_pub.pop(pub_sid, None)

def on_track_subscribed(
track: rtc.Track,
publication: rtc.RemoteTrackPublication,
participant: rtc.RemoteParticipant,
):
if track.kind == rtc.TrackKind.KIND_AUDIO:
stream = rtc.AudioStream(track, sample_rate=48000, num_channels=1)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should abstract further down, and directly allow to add a AudioTrack

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you mean we should add a add_track and remove_track method to the AudioMixer class?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But maybe more like player.add_track

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm yea that would make sense to add to media_devices to avoid users having to deal w/ a mixer to just play tracks back. Lemme add this.

streams_by_pub[publication.sid] = stream
streams_by_participant.setdefault(participant.sid, set()).add(stream)
mixer.add_stream(stream)
logging.info("subscribed to audio from %s", participant.identity)

room.on("track_subscribed", on_track_subscribed)

def on_track_unsubscribed(
track: rtc.Track,
publication: rtc.RemoteTrackPublication,
participant: rtc.RemoteParticipant,
):
stream = streams_by_pub.get(publication.sid)
if stream is not None:
asyncio.create_task(_remove_stream(stream, participant.sid, publication.sid))
logging.info("unsubscribed from audio of %s", participant.identity)

room.on("track_unsubscribed", on_track_unsubscribed)

def on_track_unpublished(
publication: rtc.RemoteTrackPublication, participant: rtc.RemoteParticipant
):
stream = streams_by_pub.get(publication.sid)
if stream is not None:
asyncio.create_task(_remove_stream(stream, participant.sid, publication.sid))
logging.info("track unpublished: %s from %s", publication.sid, participant.identity)

room.on("track_unpublished", on_track_unpublished)

def on_participant_disconnected(participant: rtc.RemoteParticipant):
streams = list(streams_by_participant.pop(participant.sid, set()))
for stream in streams:
# Best-effort discover publication sid
pub_sid = None
for k, v in list(streams_by_pub.items()):
if v is stream:
pub_sid = k
break
asyncio.create_task(_remove_stream(stream, participant.sid, pub_sid))
logging.info("participant disconnected: %s", participant.identity)

room.on("participant_disconnected", on_participant_disconnected)

token = (
api.AccessToken(api_key, api_secret)
.with_identity("local-audio")
.with_name("Local Audio")
.with_grants(
api.VideoGrants(
room_join=True,
room="local-audio",
)
)
.to_jwt()
)

try:
await room.connect(url, token)
logging.info("connected to room %s", room.name)

# Publish microphone
track = rtc.LocalAudioTrack.create_audio_track("mic", mic.source)
pub_opts = rtc.TrackPublishOptions()
pub_opts.source = rtc.TrackSource.SOURCE_MICROPHONE
await room.local_participant.publish_track(track, pub_opts)
logging.info("published local microphone")

# Start playing mixed remote audio
asyncio.create_task(player.play(mixer))

# Run until Ctrl+C
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
pass
finally:
await mic.aclose()
await mixer.aclose()
await player.aclose()
try:
await room.disconnect()
except Exception:
pass


if __name__ == "__main__":
asyncio.run(main())
66 changes: 66 additions & 0 deletions examples/local_audio/publish_mic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import os
import asyncio
import logging
from dotenv import load_dotenv, find_dotenv

from livekit import api, rtc


async def main() -> None:
logging.basicConfig(level=logging.INFO)

# Load environment variables from a .env file if present
load_dotenv(find_dotenv())

url = os.getenv("LIVEKIT_URL")
api_key = os.getenv("LIVEKIT_API_KEY")
api_secret = os.getenv("LIVEKIT_API_SECRET")
if not url or not api_key or not api_secret:
raise RuntimeError(
"LIVEKIT_URL and LIVEKIT_API_KEY and LIVEKIT_API_SECRET must be set in env"
)

room = rtc.Room()

# Create media devices helper and open default microphone with AEC enabled
devices = rtc.MediaDevices()
mic = devices.open_input(enable_aec=True)

token = (
api.AccessToken(api_key, api_secret)
.with_identity("local-audio")
.with_name("Local Audio")
.with_grants(
api.VideoGrants(
room_join=True,
room="local-audio",
)
)
.to_jwt()
)

try:
await room.connect(url, token)
logging.info("connected to room %s", room.name)

track = rtc.LocalAudioTrack.create_audio_track("mic", mic.source)
pub_opts = rtc.TrackPublishOptions()
pub_opts.source = rtc.TrackSource.SOURCE_MICROPHONE
await room.local_participant.publish_track(track, pub_opts)
logging.info("published local microphone")

# Run until Ctrl+C
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
pass
finally:
await mic.aclose()
try:
await room.disconnect()
except Exception:
pass


if __name__ == "__main__":
asyncio.run(main())
11 changes: 11 additions & 0 deletions livekit-rtc/livekit/rtc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@
from .audio_resampler import AudioResampler, AudioResamplerQuality
from .audio_mixer import AudioMixer
from .apm import AudioProcessingModule

try:
from .media_devices import MediaDevices as MediaDevices

_HAS_MEDIA_DEVICES = True
except Exception: # pragma: no cover - optional dependency (sounddevice)
_HAS_MEDIA_DEVICES = False
from .utils import combine_audio_frames
from .rpc import RpcError, RpcInvocationData
from .synchronizer import AVSynchronizer
Expand Down Expand Up @@ -179,3 +186,7 @@
"AudioProcessingModule",
"__version__",
]

# add MediaDevices if available
if _HAS_MEDIA_DEVICES:
__all__.append("MediaDevices")
4 changes: 4 additions & 0 deletions livekit-rtc/livekit/rtc/audio_mixer.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ async def _get_contribution(
except StopAsyncIteration:
exhausted = True
break
# AudioStream may yield either AudioFrame or AudioFrameEvent; unwrap if needed
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The correct typing should be:

self, stream: AsyncIterator[AudioFrame | AudioFrameEvent], buf: np.ndarray

if hasattr(frame, "frame"):
frame = frame.frame # type: ignore[assignment]

new_data = np.frombuffer(frame.data.tobytes(), dtype=np.int16).reshape(
-1, self._num_channels
)
Expand Down
Loading
Loading