Audio stops in LiveKit client after microphone cable looseness, but microphone hardware works fine and client doesn't crash

Environment

  • LiveKit Agents SDK version: 「Python Agents v1.5.0」

  • LiveKit Server version: 「v1.0.25」

  • Operating System: NVIDIA Jetson Orin / JetPack 6.1

Problem

When the microphone cable becomes loose or experiences physical instability (e.g., poor contact) during a LiveKit client session, the client stops receiving audio. The microphone hardware itself continues to function normally (system audio meters show signal), but the LiveKit client does not receive any audio frames. The client does not crash or freeze – logs continue to print, the network connection remains active, and other features (like receiving remote audio) still work. Restarting the client fully resolves the issue. The server side shows no errors.

This issue has been observed when using rtc.MediaDevices to capture microphone input in a LiveKit Agent (Python SDK). The problem is silent: no exceptions are raised, and no obvious error logs appear.

Reproduction steps:

  1. Set up a LiveKit Python agent that uses rtc.MediaDevices to open a microphone and publish a local audio track.

  2. Start the agent and join a room. Verify that audio is being sent (e.g., by listening on another client).

  3. While the agent is running, physically wiggle or partially unplug the microphone cable (USB or 3.5mm) to simulate a loose connection. You may need to do this several times to trigger the issue.

  4. Observe that after some point, the agent stops sending audio, even though the system audio settings show microphone input is still active (e.g., volume meter moves).

  5. Notice that the agent’s logs continue to print normally, no exceptions are raised, and the agent remains connected to the room.

  6. Restart the agent – audio works again.

Minimal code example:

python

import asyncio
from livekit import rtc, api

async def main():
    room = rtc.Room()
    await room.connect(URL, token)
    devices = rtc.MediaDevices()
    mic = devices.open_input(enable_aec=True, noise_suppression=True)
    audio_track = rtc.LocalAudioTrack.create_audio_track("mic", mic.source)
    await room.local_participant.publish_track(audio_track)
    await asyncio.Event().wait()  # keep running

What I’ve already checked

  • :white_check_mark: The microphone hardware is working – system audio meters show signal after the cable is reconnected.

  • :white_check_mark: The LiveKit client does not crash, and network connection remains active.

  • :white_check_mark: No exceptions or error logs are printed on either client or server side.

  • :white_check_mark: Restarting the client fully restores audio capture.

The underlying cause might be related to how MediaDevices.open_input() (which uses sounddevice/PortAudio) handles temporary device disruptions. Once the audio callback stops being invoked (or APM silently discards frames), the audio track stops sending data, and there is no built-in recovery mechanism.

Questions

Could you help identify how to make the LiveKit client resilient to temporary microphone connection instability? Specifically:

  1. Is there a way to automatically recover audio capture without restarting the client when the microphone device becomes temporarily unavailable but then works again?

  2. Can the client detect this condition (e.g., “audio source stalled”) and emit a clear error or event, so that the application can restart the audio track or reinitialize the microphone gracefully?

  3. Are there any configuration options or workarounds (e.g., reopening the device on disconnect, or using a different audio backend) that would prevent the audio pipeline from entering a dead state?

Thank you so much for your help!

This is really interesting, I’m surprised this hasn’t come up before. Frustratingly, I have no way to reproduce this because I lack the hardware, but in principle:

No, there is no built-in mechanism for this.

The only way I can think to achieve this would be to set up a watchdog and check for frames on the mic source. It’s a different use case, but this example shows how to access mic frames.

If your watchdog timer detects a few seconds where packets are missing, it could unpublish, create a new track, and publish that.

Hope that helps, it’s definitely a workaround and I suspect there may well be a more elegant solution from others in the community.

@zjh1378805302, The silent part is grounded in the SDK: open_input's sounddevice callback receives PortAudio's status flag but never reads it, and the InputStream is opened with no finished_callback [ livekit/python-sdks media_devices.py ], so a cable glitch raises nothing and frames just stop. No built-in recovery, so the watchdog is the right call.

One refinement on the unpublish/republish: open_input mints its own AudioSource internally, so recovering through it does force a new track and renegotiation. If you own the AudioSource and the sounddevice stream yourself (open_input is just a wrapper over source.capture_frame), you can read the status flag for an immediate stall signal and restart only the input device, keeping the same published track. No renegotiation:

import asyncio, time, sounddevice as sd
from livekit import rtc

source = rtc.AudioSource(48000, 1)
track = rtc.LocalAudioTrack.create_audio_track("mic", source)
await room.local_participant.publish_track(track)   # publish once, keep alive

q: asyncio.Queue = asyncio.Queue(maxsize=50)
loop = asyncio.get_running_loop()
last = time.monotonic()

def cb(indata, n, t, status):
    if status or q.full():        # the device/overflow flags open_input ignores
        return
    f = rtc.AudioFrame(data=indata.tobytes(), sample_rate=48000,
                       num_channels=1, samples_per_channel=n)
    loop.call_soon_threadsafe(q.put_nowait, f)

def open_stream():
    s = sd.InputStream(callback=cb, dtype="int16", channels=1,
                       samplerate=48000, blocksize=4800)
    s.start(); return s

stream = open_stream()

async def pump():
    global last
    while True:
        await source.capture_frame(await q.get()); last = time.monotonic()
asyncio.create_task(pump())

while True:                        # watchdog: reopen the device only, track stays up
    await asyncio.sleep(1)
    if time.monotonic() - last > 2:
        stream.stop(); stream.close(); stream = open_stream(); last = time.monotonic()