Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ add_library(livekit SHARED
src/realtime_audio_processing.cpp
src/audio_source.cpp
src/audio_stream.cpp
src/fresh_audio_stream.cpp
src/data_track_frame.cpp
src/data_stream.cpp
src/data_track_error.cpp
Expand Down
81 changes: 81 additions & 0 deletions docs/fresh-audio-stream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Fresh native audio ingress

`FreshAudioStream` is a track-bound input for native playout. It returns owned
48 kHz stereo PCM in 10 ms frames. It does not subscribe a publication, select an
output device, mix participants, or change Room state. Those remain application
owners. Stop all streams before disconnecting their Room or shutting down the SDK.

```text
WebRTC decoded sink (timestamp before conversion)
-> NativeAudioSink format conversion
-> four preallocated PCM frames
-> synchronous private native read
-> FreshAudioFrame with steady-clock decoded_at
```

Construct, read, inspect stats, and destroy a stream on one owner thread. Reads
never wait for a frame. The queue admits at most four 960-sample values; overflow
evicts the oldest. Producer and consumer each make one atomic attempt to access
the queue. Contention drops the incoming frame or returns `empty` to the reader.
The queue does not allocate, log, notify a worker, serialize protobuf, or invoke
an application callback. It bypasses the legacy Rust and C++ `AudioStream` queues.

Keep `FreshAudioFrame::decoded_at` unchanged through subsequent application
queues. It is in `std::chrono::steady_clock`'s domain. Conversion time, native
queue residence, and native-to-C++ bridge time contribute to age. Translation
uses the start of the native read and may conservatively overestimate age by
the duration of that call; it never intentionally refreshes the timestamp at
delivery. `age_us` is a snapshot at return, not a replacement timestamp.

The default maximum age is 60 ms. A caller may request 1–1,000,000 microseconds;
larger buffers or age limits should not be used to hide a slow playout consumer.
Stale frames are discarded before delivery. `sequence`, `discontinuity`, and
the received/drop/stale/invalid/queued counters make gaps and capacity visible.
An empty read leaves its output value unchanged: callers must inspect the result
and must not replay their previous frame.

The existing native sink performs rate/channel conversion and may initialize
its resampler when the input format changes. That work precedes the fixed queue
and is included in frame age; this API does not claim that all WebRTC internals
are allocation-free. Invalid converted frame lengths are counted and rejected.
There is no silent fallback to a different queue or an unbounded path.

The stream retains its typed track. Destruction first retires ingress, then
removes the native sink before releasing that track. The application still
needs a publication/source generation fence: retaining a track does not grant
permission to play PCM after a participant's track is removed or replaced.

With manual subscriptions, seed the application's publication registry after
connect using `Room::remotePublications(maximum)`. Initial publications do not
emit `onTrackPublished`. This bounded snapshot copies owning handles under the
Room lock, avoiding concurrent iteration over a participant's publication map.
It reports truncation and caps capacity at 4096 entries. Take snapshots on a
control thread, and fence their application against intervening Room events;
an owning handle alone does not make a removed publication current again.

Verification lives in the Rust `fresh_audio_stream` tests and C++
`AudioSourceTest.FreshSink*` tests. Full remote Room, output, latency, and resource
proofs are the consuming application's Media Lab responsibility.

The deterministic Rust callback test injects 250 ms of native processing age
for 100 consecutive frames and verifies that none becomes fresh at the queue
boundary. Separate application probes delay the reader by 250 ms and exercise
renderer wake stalls. A 30-minute, two-participant application run measured
p95 scheduled age at most 50 ms and maximum 56.911 ms, with bounded queues and
no underruns; these are development measurements on one Windows machine.

## Publisher replacement regression

The remote-audio routing fixture uncovered a publisher regression with mixed
microphone, screen audio and screen video: after a track replacement worked,
removing a different track could also remove that replacement at the receiver.
Disabling transceiver reuse removed the symptom, but would discard the existing
resource bound. Waiting for removal offer/answer settlement did not fix it.

The correction keeps libwebrtc's sender MSID in `SetLocalDescription` and applies
the current track identity only to the offer emitted to the SFU. All codec,
MID, SSRC and transport fields remain the same. The fixture passes 17 phases,
including immediate screen replacement, microphone replacement, adjacent source
continuity, demand fencing, persistent volume and receiver Room replacement.
A native Rust regression test checks local/wire identities over replacement and
a subsequent unrelated negotiation while retaining one RTP transceiver.
83 changes: 83 additions & 0 deletions include/livekit/fresh_audio_stream.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright 2026 LiveKit
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

#pragma once

#include <array>
#include <chrono>
#include <cstdint>
#include <memory>
#include <thread>

#include "livekit/track.h"
#include "livekit/visibility.h"

namespace livekit {

struct FreshAudioFrame {
std::array<std::int16_t, 960> samples{};
std::uint64_t sequence = 0;
/// Elapsed monotonic time since decoded native sink ingress, before queues.
std::uint64_t age_us = 0;
/// Conservative timestamp in the consuming C++ process's steady-clock domain.
/// Preserve this value through application queues instead of re-stamping PCM.
std::chrono::steady_clock::time_point decoded_at{};
bool discontinuity = false;
};

struct FreshAudioStreamStats {
std::uint64_t received = 0;
std::uint64_t dropped = 0;
std::uint64_t stale = 0;
std::uint64_t invalid = 0;
std::uint64_t queued = 0;
};

enum class FreshAudioReadResult { frame, empty, invalid_input, wrong_thread };

/// @brief Track-bound 48 kHz stereo/10 ms decoded ingress for native playout.
/// Four preallocated frames; overload drops oldest, contention never waits.
/// No per-frame FFI event, callback thread, or legacy AudioStream queue.
/// Construct/read/destroy on one owner, before Room/SDK teardown. Copy the
/// returned decoded_at through the application's own queues unchanged.
/// NativeAudioSink format conversion is included in the age. It may initialize
/// its resampler on a format change; this API bounds the post-conversion queue.
class LIVEKIT_API FreshAudioStream final {
public:
explicit FreshAudioStream(std::shared_ptr<Track> track);
~FreshAudioStream();
FreshAudioStream(const FreshAudioStream&) = delete;
FreshAudioStream& operator=(const FreshAudioStream&) = delete;
FreshAudioStream(FreshAudioStream&&) = delete;
FreshAudioStream& operator=(FreshAudioStream&&) = delete;

/// Read once without waiting. Maximum age must be 1..1,000,000 microseconds.
/// On empty/error, output is unchanged. Stats are refreshed by valid reads.
[[nodiscard]] FreshAudioReadResult tryRead(FreshAudioFrame& output, std::uint64_t maximum_age_us = 60'000) noexcept;
[[nodiscard]] FreshAudioStreamStats stats() const noexcept { return stats_; }

private:
struct Deleter {
void operator()(void* value) const noexcept;
};
std::shared_ptr<Track> track_;
std::unique_ptr<void, Deleter> state_;
const std::thread::id owner_ = std::this_thread::get_id();
FreshAudioStreamStats stats_;
std::uint64_t bridge_stale_ = 0;
std::uint64_t delivered_sequence_ = 0;
};
} // namespace livekit
15 changes: 15 additions & 0 deletions include/livekit/room.h
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ class E2EEManager;
class LocalParticipant;
class RemoteParticipant;

struct RemotePublicationSnapshot {
struct Entry {
std::shared_ptr<RemoteParticipant> participant;
std::shared_ptr<RemoteTrackPublication> publication;
};
std::vector<Entry> entries;
bool truncated = false;
};

/// Represents a single ICE server configuration.
struct IceServer {
/// TURN/STUN server URL (e.g. "stun:stun.l.google.com:19302").
Expand Down Expand Up @@ -237,6 +246,12 @@ class LIVEKIT_API Room {
/// corresponding participant disconnects or the room is torn down.
std::vector<std::weak_ptr<RemoteParticipant>> remoteParticipants() const;

/// Copies publication handles under the Room lock, including publications
/// present before connect (which do not emit onTrackPublished). Use on a
/// control thread. Capacity is capped at 4096; truncation is explicit.
/// Handles own their lifetimes, but later Room events can supersede them.
[[nodiscard]] RemotePublicationSnapshot remotePublications(std::size_t maximum) const;

/// Returns the current connection state of the room.
ConnectionState connectionState() const;

Expand Down
Loading
Loading