Skip to content

Add Linux/Windows support for audio renderer (audio frame capture)#1128

Open
alfredobs97 wants to merge 1 commit into
livekit:mainfrom
alfredobs97:feat/desktop-audio-renderer
Open

Add Linux/Windows support for audio renderer (audio frame capture)#1128
alfredobs97 wants to merge 1 commit into
livekit:mainfrom
alfredobs97:feat/desktop-audio-renderer

Conversation

@alfredobs97

Copy link
Copy Markdown

Summary

AudioFrameCapture.start() currently fails on desktop Linux and Windows with:

MissingPluginException(No implementation found for method startAudioRenderer on channel livekit_client)

The Dart layer already routes desktop platforms to the native implementation (audio_frame_capture_native.dart), but native handlers for startAudioRenderer / stopAudioRenderer exist only on iOS, macOS, and Android. The Linux and Windows plugins handle only startVisualizer / stopVisualizer and fall through to NotImplemented().

This PR adds the missing handlers, bringing Linux and Windows to parity with iOS/macOS/Android for AudioFrameCapture.

Implementation

The low-level AudioTrackSink infrastructure was already in use by the audio visualizer on both platforms, so the change is additive and self-contained — no changes to flutter_webrtc or the Dart layer are needed.

  • shared_cpp/audio_renderer.{h,cpp} (new): platform-agnostic conversion pipeline, a direct port of the Android AudioRenderer / AudioResampler:
    • validate (WebRTC sinks always deliver int16 PCM; anything else is dropped)
    • resample: passthrough / linear interpolation (upsample) / box filter (downsample, anti-aliasing)
    • channel reduction: keep the first N channels (matching Android)
    • encode as little-endian int16 or float32 bytes
  • linux/livekit_plugin.cpp, windows/livekit_plugin.cpp: new AudioRendererSink class, structurally cloned from the existing VisualizerSink (EventChannel + StreamHandlerFunctions, AddSink/RemoveSink, task-runner hop to the platform thread). Streams converted frames to Dart over io.livekit.audio.renderer/channel-<rendererId>. Unlike the visualizer, frames arriving before a subscriber attaches are dropped rather than queued, matching the Android renderer semantics for live audio.
  • Method handlers mirror the Android handleStartAudioRenderer / handleStopAudioRenderer contract: same argument validation and error codes (INVALID_ARGUMENT, No such track), idempotent start for an existing rendererId, and success on stop for unknown ids.
  • CMakeLists: shared_cpp/audio_renderer.cpp added to both platforms' sources.

Testing

  • The shared conversion pipeline was exercised standalone (passthrough, upsample, downsample, float32 encoding, channel reduction, invalid-input drops, format defaults).
  • The event payload matches what audio_frame_capture_native.dart consumes (commonFormat, sampleRate, channels, data as Uint8List, plus frameLength for parity with Android).

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

Open in Devin Review

Comment thread linux/livekit_plugin.cpp
Comment on lines +446 to +452
mutex_.lock();
auto it = renderers_.find(rendererId);
if (it != renderers_.end()) {
it->second->RemoveSink();
renderers_.erase(it);
}
mutex_.unlock();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Potential use-after-free race between RemoveSink and concurrent OnData callback

In stopAudioRenderer (linux/livekit_plugin.cpp:446-452), the mutex is held while RemoveSink() is called and the renderer is erased from the map. However, if OnData is currently executing on the WebRTC audio thread when RemoveSink is called, the AudioRendererSink object could be destroyed (by renderers_.erase) while OnData is still accessing its members (e.g., format_, sink_). Whether this is safe depends on whether WebRTC's RemoveSink blocks until any in-flight OnData call completes. The existing VisualizerSink has the identical pattern (linux/livekit_plugin.cpp:365-369), so this is a pre-existing architectural concern.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch to scrutinize — I verified this against both audio delivery paths used by the desktop binaries, and the pattern is safe: RemoveSink blocks until any in-flight OnData completes, so the sink cannot be destroyed while OnData is executing.

Remote tracks (pc/remote_audio_source.cc, upstream WebRTC and the webrtc-sdk fork that ships the desktop prebuilts): RemoteAudioSource::OnData holds sink_lock_ while iterating sinks and invoking sink->OnData(...), and RemoteAudioSource::RemoveSink acquires the same sink_lock_. RemoveSink therefore cannot return while a delivery is in flight, and no further callbacks occur after it returns.

Local tracks (webrtc-sdk/libwebrtc, src/internal/local_audio_track.h): identical discipline — AddSink, RemoveSink, and OnData all take the same sink_lock_, with sinks_ RTC_GUARDED_BY(sink_lock_).

Corroborating evidence: the wrapper itself relies on this guarantee. AudioTrackImpl::RemoveSink (src/rtc_audio_track_impl.h) destroys the AudioTrackSinkAdapter immediately after rtc_track_->RemoveSink(...) returns. If the guarantee did not hold, every existing consumer — including the VisualizerSink that has been shipping on Linux/Windows — would already be crashing on stop.

Holding the plugin mutex_ while RemoveSink blocks is also fine: OnData never touches the plugin mutex, so there is no lock-order cycle.

As noted, VisualizerSink uses the identical pattern, so this PR intentionally keeps the two classes consistent. There is a separate pre-existing (and unrelated) subtlety shared by both classes — sink_/on_listen_called_ are written on the platform thread and read on the audio thread without synchronization — which would be better addressed as a follow-up cleanup touching both sinks rather than diverging here.

@CLAassistant

CLAassistant commented Jul 8, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Implements the startAudioRenderer / stopAudioRenderer method-channel
handlers on Linux and Windows, bringing desktop platforms to parity
with iOS/macOS/Android for AudioFrameCapture. Previously these calls
fell through to NotImplemented(), surfacing in Dart as
MissingPluginException.

- shared_cpp/audio_renderer.{h,cpp}: platform-agnostic conversion
  pipeline (validate -> resample -> channel reduce -> int16/float32
  encode), a direct port of the Android AudioRenderer/AudioResampler.
- linux/windows livekit_plugin.cpp: AudioRendererSink attaching to
  tracks via libwebrtc AudioTrackSink, streaming converted frames to
  Dart over io.livekit.audio.renderer/channel-<rendererId>.
@alfredobs97 alfredobs97 force-pushed the feat/desktop-audio-renderer branch from e5951be to b3d2e6e Compare July 8, 2026 09:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants