Client API Design
This page explains why the client API looks the way it does: the decisions behind it, its threading model and what it guarantees. To use the API, start with the Client API guide; the Javadoc is the reference. Implementation tells how it is built and what is still open.
A high-level layer over the existing JNI API. It owns every native object, gives application code one thread per peer, negotiates on its own, and returns futures instead of nested observers. The low-level API stays as it is.
- Package
dev.onvoid.webrtc.client, in the existingwebrtc-javajar, exported by thewebrtc.javamodule. - Java 11, like the rest of the module: no records, no sealed types.
- Plain Java over the existing classes, with a few native additions such as voice activity detection.
Decisions
The questions that shaped the API, and how they were decided.
| Question | Decision | Consequence |
|---|---|---|
| Where does it live? | A package in the existing jar, not a separate artifact. | Users get it without another dependency. It follows the core's releases. |
| Default perfect negotiation role? | Impolite by default. | Glare between two impolite peers fails with a NegotiationException that names the fix (make one side polite). Every example sets the role explicitly. |
Plain CompletableFuture or an own type? | Plain CompletableFuture. | The API returns a ClientFuture subclass behind the type, whose derived stages are ClientFutures too, so BlockingCallException catches a join()/get() on them as well. The exception message and the event thread names help with the rest. |
Does Peer.close() block? | No, never. | Safe in handlers and on UI threads. closeAsync() returns a future for callers who want to wait. WebRtc.close() is the one call that waits for everything. |
| A camera opened twice? | Shared when the options match, MediaException when they differ. | One capture per device. Most platforms can't open a device with two capabilities anyway. |
| Entry point name? | WebRtc. | try (WebRtc rtc = WebRtc.create()) { ... } |
What hurts on the core API
Each row describes the core API, which stays as it is.
| Problem | Where it shows | Answer |
|---|---|---|
| Observers run on WebRTC's own threads | PeerConnectionObserver is called on the signaling thread. A handler that blocks, or waits for work that needs that thread, stalls every connection of the factory. | Native callbacks only copy and enqueue. Handlers run on a per-peer event thread, where they may block or call close(). |
| Threading rules differ per class | Data channel send results, DTLS and injector observers go through JavaCallbackThread. Peer connection observers don't. Frame sinks run on capture and decode threads. Custom audio has to be pushed from a single thread. | One rule for all control events. Frames get their own documented thread with a drop policy. Custom audio is serialized by the library. |
| Nested callbacks for offer and answer | createOffer → CreateSessionDescriptionObserver → setLocalDescription → SetSessionDescriptionObserver, with the error as a String. A WHEP client needs latches to flatten it. | CompletableFuture everywhere, failing with typed exceptions. In automatic mode the application never calls offer or answer. |
| Signaling correctness is left to the app | Glare, rollback, candidates that arrive before the remote description (they fail silently with the plain addIceCandidate), and onRenegotiationNeeded firing mid-negotiation. | Built-in perfect negotiation with a polite/impolite role, candidate queuing, and a browser-compatible JSON message format. |
| Manual disposal of query results | Every RTCRtpSender, RTCRtpReceiver and RTCRtpTransceiver returned to the app must be disposed, sources are not owned by tracks, and the factory has to outlive its connections. | No dispose() in the API. Everything is AutoCloseable, owned by its creator, and closed in the right order. |
| Receive-only needs a dummy track | A WHEP client creates a VideoDeviceSource and a VideoTrack only to pass them to a RECV_ONLY transceiver. | PeerOptions.receive(VIDEO, AUDIO). |
| Device queries can block the caller | The guides say to query devices on worker threads, never on UI or render threads. | Devices is a snapshot kept current in the background, so reading it is cheap on any thread. |
Before and after
A video call over a WebSocket on the core API, shortened:
PeerConnectionFactory factory = new PeerConnectionFactory();
RTCConfiguration config = new RTCConfiguration();
RTCIceServer iceServer = new RTCIceServer();
iceServer.urls.add("stun:stun.l.google.com:19302");
config.iceServers.add(iceServer);
RTCPeerConnection pc = factory.createPeerConnection(config, new PeerConnectionObserver() {
@Override
public void onIceCandidate(RTCIceCandidate candidate) {
socket.send(toJson(candidate)); // runs on WebRTC's signaling thread
}
@Override
public void onRenegotiationNeeded() {
createOffer(); // glare and rollback are up to you
}
@Override
public void onTrack(RTCRtpTransceiver transceiver) {
RTCRtpReceiver receiver = transceiver.getReceiver();
MediaStreamTrack track = receiver.getTrack();
if (track instanceof VideoTrack) {
((VideoTrack) track).addSink(frame -> view.render(frame));
}
receiver.dispose(); // query results are yours to dispose
transceiver.dispose();
}
});
void createOffer() {
pc.createOffer(new RTCOfferOptions(), new CreateSessionDescriptionObserver() {
@Override
public void onSuccess(RTCSessionDescription offer) {
pc.setLocalDescription(offer, new SetSessionDescriptionObserver() {
@Override
public void onSuccess() { socket.send(toJson(offer)); }
@Override
public void onFailure(String error) { log.error(error); }
});
}
@Override
public void onFailure(String error) { log.error(error); }
});
}
// Not shown: answering, queuing early candidates, JSON parsing, creating and
// disposing sources, tracks and senders, closing pc before disposing factory.With the client API, this is the whole thing:
try (WebRtc rtc = WebRtc.create()) {
Peer peer = rtc.createPeer(PeerOptions.builder()
.iceServer("stun:stun.l.google.com:19302")
.polite(joinedSecond)
.signaling(message -> socket.send(message.toJson()))
.build());
socket.onText(text -> peer.signal(SignalMessage.fromJson(text)));
peer.addTrack(rtc.media().microphone());
peer.addTrack(rtc.media().camera());
peer.onTrack(track -> {
if (track.isVideo()) {
track.asVideo().onFrame(frame -> view.render(frame));
}
});
peer.onStateChange(state -> log.info("Call is {}", state));
peer.whenConnected().join(); // fine here: this is not an event thread
hangUp.await();
} // closes the peer, the camera, the microphone and the factory, in that orderThreading model
The model borrows from Netty's event loop: each peer has one event thread for application code, and the library never runs application code on a thread WebRTC needs. A second serial executor per peer, control, is the only place that calls the native connection. WebRTC's callbacks post to it, and it hands on to the event executor what the application should hear.
Invariant the implementation must keep: nothing the library runs on a WebRTC thread ever waits for a Java thread. Native observers copy their arguments, post them, and return, and neither executor of a peer waits for the other. That rules out the deadlock cycle no matter what a handler does.
| Code | Runs on | May it block? |
|---|---|---|
| Calls into the API | the caller's thread, any thread | No, apart from the exceptions below the table. |
Event handlers, future callbacks, Signaling.send | the peer's event thread, or the configured eventExecutor | Yes, but it only delays that peer's events. It must not join() a future of this API: a direct join()/get() throws BlockingCallException. |
LocalTrack.onEnded, LocalAudio.onVoiceActivity, Devices.onChange, futures of local tracks and devices | the event thread of the runtime's local media, or the configured eventExecutor | Yes, but it delays the other events of local media. The rule against join() holds here too. |
Frame handlers (onFrame, onAudio) | a thread of each subscription, from the frame pool | Better not. A slow video handler gets the newest frame next, and the frames in between are dropped and released. A slow audio handler loses the oldest audio beyond its buffer. |
| Encoded transforms | a thread of each sender and receiver | Better not. A slow transform delays its own frames. A frame it throws on is dropped, and the first such failure reaches onError. |
| Native calls | internal control threads | Not application code. Each operation has a timeout (default 30 s), so a stuck native call fails with a TimeoutException instead of hanging. The peer then brings signaling back to stable, and gives up if that fails too. |
| WHIP and WHEP requests | java.net.http.HttpClient, never the control pool | Not application code. A slow server holds up no peer. |
FileTransfers handlers, file reads and writes | a thread of each FileTransfers, never the peer's event thread | Yes, but it delays the other transfers of that channel. |
Calls into the API don't block, with these exceptions:
createPeerwaits for the connection, so that an invalid configuration fails there.- The
LocalMediamethods that make a track,LocalTrack.setEnabledandLocalVideo.setContentHintwait briefly for a WebRTC thread, since the factory and the tracks are proxies. camera()andscreen()also open their capture on the caller's thread.- The first
devices(), and everyscreens()andwindows(), ask the system there. WebRtc.close()waits up tocloseTimeout, and ajoin()the caller writes waits as written. | WebRTC callbacks | signaling, network and capture threads | Not application code. The library copies and enqueues only. |
Guarantees
- Every method is thread-safe. It can be called from any thread, including from inside a handler of the same peer.
- One peer, one thread at a time. A peer's handlers and future callbacks never run concurrently, and they run in event order. Different peers may run in parallel unless the configured executor is serial, such as the Swing EDT.
- Order is kept with any executor. A pool passed as
eventExecutoris wrapped per peer, so ordering holds whatever executor is given. - No lost events from registration races.
onStateChangeis called with the current state first.onTrackandonChannelreplay what already arrived. A data channel keeps the messages that arrive before its firstonMessagehandler, up tochannelReceiveLimit. A handler registered from within another handler takes effect at once, so it also sees the events already queued behind that one. - Operations run in order. Each peer runs its operations one at a time, like the W3C operations chain.
addTrackfollowed bycreateChannelalways negotiates both, in that order. - Close is final and quiet. After
close()the only event isonStateChange(CLOSED). Pending futures fail withPeerClosedException, and later calls fail the same way instead of crashing in native code. - Handler exceptions are contained. An exception thrown by a handler goes to the runtime's
errorHandler. It never stops delivery and never crosses into native code. - No future stays open forever. A task that completes a future has a fallback for when its executor rejects it, and a closing runtime fails every future still open.
Ownership and lifecycle
There is no dispose() in this API. You close what you created, and closing an owner closes what it owns. Senders, receivers and transceivers are queried and disposed inside the library and are never handed out.
| Object | Created by | Closed by | Closing it also |
|---|---|---|---|
WebRtc | the application | the application, usually try-with-resources | closes all peers, then all local tracks and the device watcher, then disposes the factory and stops its threads |
Peer | rtc.createPeer | the application, or WebRtc | closes its data channels and ends its remote tracks. Local tracks stay open for other peers. |
LocalVideo, LocalAudio | rtc.media() | the application, or WebRtc | detaches the track from every peer it was sent on (no renegotiation), stops capture, releases the source |
Sender | peer.addTrack | sender.remove(), or the peer | renegotiates in automatic mode. The track stays open. |
DataChannel | peer.createChannel, or the remote side | the application, or the peer | completes pending sends with PeerClosedException |
RemoteTrack | the peer | the peer, when the remote side removes it or the peer closes | calls onEnded and ends its frame subscriptions |
Whip.Session, Whep.Session | publish, play | the application | deletes the resource on the server and closes the peer. Tracks stay open. |
Subscription | every on… method | the application, or the owner of the event | removes the handler |
A source passed to LocalMedia.videoTrack or audioTrack stays the caller's. It must outlive the track, and closeAsync() of the track says when it may be closed.
A runtime close that cannot close everything in time leaves the factory undisposed and fails closeAsync(). It never frees what WebRTC's threads may still use.
API surface
Signatures only, the Javadoc has the rest. Value types reuse the existing ones where they are plain data (RTCSessionDescription, RTCIceCandidate, VideoFrame, AudioOptions, VideoDevice, AudioDevice, DesktopSource).
WebRtc, the runtime
The entry point. It owns the PeerConnectionFactory, the audio device module, the library's threads, and everything created through it.
public final class WebRtc implements AutoCloseable {
public static WebRtc create(); // microphone and speakers, built-in codecs
public static Builder builder();
public Peer createPeer(PeerOptions options);
public LocalMedia media(); // cameras, microphones, screens, custom sources
public List<Peer> peers(); // snapshot of the open peers
public AudioMode audioMode();
public LoadStats loadStats(); // how much work waits and runs, cheap enough to read every second
public CompletableFuture<CallCheckResult> checkCall(CallCheck check); // camera, microphone and network before a call
public CompletableFuture<Void> closeAsync();
@Override
public void close(); // peers, then tracks, then the factory; waits up to closeTimeout
public static final class Builder {
public Builder audio(AudioMode mode); // DEVICES by default
public Builder audioProcessing(AudioProcessingConfig config);
public Builder hardwareVideoCodecs(boolean enabled); // GPU encoders and decoders where present
public Builder fieldTrials(Map<String, String> fieldTrials);
public Builder eventExecutor(Executor executor); // e.g. SwingUtilities::invokeLater, Platform::runLater
public Builder eventThreads(int limit); // unlimited by default
public Builder frameThreads(int limit); // unlimited by default
public Builder audioBuffer(Duration buffer); // default 5 s, what onAudio keeps for a slow handler
public Builder errorHandler(Consumer<Throwable> handler); // handler exceptions, failures nobody observed
public Builder operationTimeout(Duration timeout); // default 30 s
public Builder closeTimeout(Duration timeout); // default 10 s
public Builder configureFactory(Consumer<PeerConnectionFactory.Builder> customizer);
public WebRtc build();
}
}
public enum AudioMode {
DEVICES, // send the microphone and audio you push, play to the speakers
CUSTOM, // send audio you push, never open the microphone, play to the speakers
HEADLESS // no audio hardware: send audio you push, read remote audio through onAudio
}AudioModedecides when the runtime is built whether it may open the microphone and where it plays remote audio. Every mode sends audio pushed throughCustomAudio. Each sender gets the audio of its own track, so aDEVICESruntime sends the microphone and pushed audio side by side, also on one peer.media().microphone()on aCUSTOMorHEADLESSruntime throwsConfigurationExceptionsaying why, rather than failing when the track is sent.HEADLESSusesHeadlessAudioDeviceModule, so servers and bots run without audio hardware.- Library threads are daemon threads. On the runtime's own event pool a thread takes the name of the peer it works for (
webrtc-client-events-alice), so thread dumps show which peer is busy. - By default each peer and each subscription with work waiting gets a thread.
eventThreadsandframeThreadscap them for applications with many peers: peers and subscriptions then take turns, so a slow handler delays the others but never starves them. Events wait and are never dropped. Media that waits is bounded by its subscription. The limits don't apply to aneventExecutorof the application, and control threads are never limited, since their tasks wait for one another.
public final class LoadStats {
public Work control(); // tasks of the library that call into WebRTC
public Work events(); // events of all peers
public Work video(); // frames for onFrame handlers
public Work audio(); // chunks for onAudio handlers
public int controlThreads();
public int eventThreads();
public int frameThreads();
public static final class Work {
public int queued();
public Duration oldestWaiting();
public int active();
public long dropped(); // since the runtime started
}
}PeerOptions
public final class PeerOptions {
public static Builder builder();
public static final class Builder {
public Builder name(String name); // thread names, logs
public Builder iceServer(String... urls); // "stun:host:3478", "turn:host" without credentials
public Builder iceServer(String url, String username, String password);
public Builder relayOnly(boolean relayOnly); // TURN candidates only
public Builder signaling(Signaling signaling); // required with Negotiation.AUTOMATIC
public Builder polite(boolean polite); // perfect negotiation role, default false
public Builder recovery(RecoveryPolicy policy); // ICE recovery, off by default
public Builder negotiation(Negotiation negotiation); // AUTOMATIC by default, or MANUAL
public Builder receive(MediaKind... kinds); // receive-only audio and video
public Builder gatheringTimeout(Duration timeout); // the longest Gathering.COMPLETE waits, default 3 s
public Builder channelSendLimit(long bytes); // backlog of a data channel, default 16 MiB
public Builder channelReceiveLimit(long bytes); // unhandled messages of a data channel, default 16 MiB
public Builder sendTransform(RTCEncodedFrameTransformer transform); // every sender, from its first frame
public Builder receiveTransform(RTCEncodedFrameTransformer transform); // every receiver, from its first frame
public Builder e2ee(KeyProvider keys); // SFrame encryption of both, instead of the transforms
public Builder configure(Consumer<RTCConfiguration> customizer); // anything else RTCConfiguration has
public PeerOptions build();
}
}
public enum Negotiation { AUTOMATIC, MANUAL }
public enum MediaKind { AUDIO, VIDEO }Peer, one connection
A connection to one remote peer. Methods that change it return at once and run on the peer's operation queue, in order.
public interface Peer extends AutoCloseable {
String name();
PeerState state(); // the last state, cached on the Java side
// Signaling input: hand over what the remote side sent, in the order it was sent.
void signal(SignalMessage message);
// Sending. In AUTOMATIC mode each change renegotiates by itself.
Sender addTrack(LocalTrack track, String... streamIds);
List<Sender> senders();
// Receiving. Late subscribers get the tracks that already arrived.
Subscription onTrack(Consumer<RemoteTrack> handler);
List<RemoteTrack> remoteTracks();
// Data channels. Late subscribers get the channels that already opened.
DataChannel createChannel(String label);
DataChannel createChannel(String label, ChannelOptions options);
Subscription onChannel(Consumer<DataChannel> handler);
// State. onStateChange calls the handler with the current state first.
Subscription onStateChange(Consumer<PeerState> handler);
Subscription onError(Consumer<WebRtcException> handler);
CompletableFuture<Peer> whenConnected(); // fails if FAILED or CLOSED comes first
CompletableFuture<Void> whenClosed();
Negotiator negotiator(); // MANUAL mode only
CompletableFuture<PeerStats> stats();
Subscription onStats(Duration interval, Consumer<PeerStats> handler);
Subscription onDiagnostics(Duration interval, Consumer<PeerDiagnostics> handler); // the first diagnosis, then changes
void restartIce();
// Escape hatch: runs on the operation queue with the underlying connection.
<T> CompletableFuture<T> withNative(Function<RTCPeerConnection, T> action);
CompletableFuture<Void> closeAsync();
@Override
void close(); // never blocks, idempotent, fine inside a handler
}
public enum PeerState { NEW, CONNECTING, CONNECTED, DISCONNECTED, FAILED, CLOSED }PeerStatefoldsRTCPeerConnectionStateinto six values.DISCONNECTEDcan recover by itself.FAILEDneedsrestartIce(), aRecoveryPolicy, or a new peer.addTrackreturns theSenderhandle right away. If the native call fails, the error goes toonErrorand the sender's futures fail.addTrackwithout stream ids puts every such track of a peer into one shared stream, so a browser plays its audio and video in sync.close()never blocks, so it is safe in a handler and on the Swing EDT.closeAsync().join()waits, from a thread that is not an event thread.- Objects obtained inside
withNativeare owned by the caller, as in the low-level API, and must not be used after the function returns.
ICE recovery
Recovery is opt-in and stays on the same peer. A policy restarts ICE, and nothing else: it never reconnects signaling, creates a new peer or session, or replaces tracks and channels. Those are the application's to decide, since only it knows its signaling and its users.
public final class RecoveryPolicy {
public static Builder builder();
public static final class Builder {
public Builder grace(Duration value); // after DISCONNECTED, default 2 s; FAILED starts at once
public Builder backoff(Duration initial, Duration maximum); // doubles after each failed attempt
public Builder maxAttempts(int value); // per outage, default 3
public Builder attemptTimeout(Duration value); // default 15 s, credentials and signaling included
public Builder iceServers(Function<Integer, ? extends CompletionStage<List<RTCIceServer>>> provider);
public Builder onEvent(Consumer<RecoveryEvent> handler);
public RecoveryPolicy build();
}
}
public final class RecoveryEvent {
public enum State { WAITING, ATTEMPTING, RETRYING, RECOVERED, EXHAUSTED }
public State state();
public int attempt(); // in this outage, from 1; 0 for a recovery during the grace
public Throwable cause(); // the last failure, on RETRYING and EXHAUSTED
}- Only
CONNECTEDcounts as recovered, not a signaling exchange that went through. It resets the budget, also afterEXHAUSTED, which leaves the peer open and tries no more until then. - Attempts never overlap: repeated failures during one start no second restart, and an HTTP restart still out finishes before the next begins.
iceServersfetches fresh TURN credentials before an attempt. Its list replaces all ICE servers. It and the events run on the peer's event executor.whenConnected()keeps meaning the first connection. A later one is aRECOVEREDevent.- Automatic peers take a policy through
PeerOptions.WhipandWheptake one for their sessions, which then restart by PATCH on the same session.
Signaling and SignalMessage
The library has no transport of its own. The application gives it a function that sends, and it hands over messages that browsers understand.
@FunctionalInterface
public interface Signaling {
// Called on the peer's event thread, in the order the remote side must get them.
void send(SignalMessage message);
// Called once when the peer is created. A transport that receives messages
// can push them into inbound instead of calling peer.signal(...). The peer
// closes the subscription when it closes, which must let go of inbound.
default Subscription attach(Consumer<SignalMessage> inbound) {
return () -> { };
}
// Two linked ends, for tests and same-process demos.
static SignalingPair loopback();
}
public final class SignalMessage {
public enum Type { OFFER, ANSWER, CANDIDATE, END_OF_CANDIDATES }
public static SignalMessage of(RTCSessionDescription description);
public static SignalMessage of(RTCIceCandidate candidate);
public static SignalMessage endOfCandidates();
public static SignalMessage fromJson(String json);
public Type type();
public RTCSessionDescription description(); // OFFER and ANSWER
public RTCIceCandidate candidate(); // CANDIDATE
public String toJson();
}toJson() writes the shapes the browser API uses (RTCSessionDescriptionInit, RTCIceCandidateInit), so a JavaScript peer can pass them straight to setRemoteDescription and addIceCandidate:
{"type":"offer","sdp":"v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n..."}
{"candidate":"candidate:842163049 1 udp 1677729535 203.0.113.7 49203 typ srflx raddr 0.0.0.0 rport 0 generation 0","sdpMid":"0","sdpMLineIndex":0}
{"candidate":""}- The last line is the end-of-candidates form.
fromJsonalso takes the browser's variants, withsdpMidandsdpMLineIndexset or with a nullcandidate. All map toEND_OF_CANDIDATES. - Routing (rooms, sender and receiver ids) stays in the application's own envelope. The JSON is the payload inside it.
- Candidates that arrive before the remote description are held and added once it is set, and the operations behind them go on meanwhile.
- The JSON codec is a small built-in one for these fixed shapes, so the module gains no dependency. It is strict and takes at most a MiB per message.
Negotiator, manual mode
For WHIP, WHEP, SFUs and anything else that dictates its own offer and answer exchange. Each step both creates and applies the description, so nothing can change in between.
public interface Negotiator {
CompletableFuture<RTCSessionDescription> createOffer(); // creates and applies the local offer
CompletableFuture<RTCSessionDescription> createOffer(Gathering gathering); // COMPLETE waits for all candidates
CompletableFuture<RTCSessionDescription> answer(RTCSessionDescription offer);
CompletableFuture<RTCSessionDescription> answer(RTCSessionDescription offer, Gathering gathering);
CompletableFuture<Void> applyAnswer(RTCSessionDescription answer);
CompletableFuture<Void> addCandidate(RTCIceCandidate candidate); // held until the remote description is set
}
public enum Gathering { TRICKLE, COMPLETE }In manual mode a Signaling, if one is set, still receives the local candidates for trickle ICE.
Gathering.COMPLETE waits at most gatheringTimeout, since an unreachable STUN or TURN server holds up the end of gathering far longer. A candidate the addCandidate future reported as held and WebRTC later rejects goes to onError.
LocalMedia, Devices, local tracks
public interface LocalMedia {
Devices devices();
LocalCamera camera(); // default camera, default capability
LocalCamera camera(CameraOptions options);
LocalAudio microphone(); // AudioMode.DEVICES only
LocalAudio microphone(AudioOptions options); // echo cancellation, noise suppression, gain control
LocalVideo screen(ScreenOptions options); // a screen or a window from devices()
CustomVideo customVideo(String label);
CustomAudio customAudio(String label); // any AudioMode
LocalVideo videoTrack(String label, VideoTrackSource source); // over a source of the core API
LocalAudio audioTrack(String label, AudioTrackSource source); // such as a MediaFileSource's
}
public interface Devices {
List<VideoDevice> cameras(); // a snapshot, kept current in the background
List<AudioDevice> microphones();
List<AudioDevice> speakers();
List<DesktopSource> screens();
List<DesktopSource> windows();
CompletableFuture<Void> useMicrophone(AudioDevice device);
CompletableFuture<Void> useSpeaker(AudioDevice device);
Subscription onChange(Consumer<Devices> handler);
}
public interface LocalTrack extends AutoCloseable {
String id();
MediaKind kind();
boolean isEnabled();
void setEnabled(boolean enabled); // false sends black frames or silence, capture stays open
Subscription onEnded(Runnable handler); // the track closed, see Open gaps
boolean isEnded();
CompletableFuture<Void> closeAsync(); // completes once the native track is released
@Override
void close(); // stops capture, detaches from every peer, never blocks
}
public interface LocalVideo extends LocalTrack {
Subscription onFrame(Consumer<VideoFrame> handler); // local preview
VideoContentHint contentHint();
void setContentHint(VideoContentHint hint); // motion, detail or text, also while sending
}
public interface LocalCamera extends LocalVideo {
VideoDevice device();
CompletableFuture<Void> switchCamera(VideoDevice device); // peers keep sending, no renegotiation
}
public interface LocalAudio extends LocalTrack {
Subscription onVoiceActivity(Consumer<Boolean> handler); // true when speech begins, false when it ends
Subscription onVoiceActivity(VoiceActivityConfig config, Consumer<Boolean> handler);
}
public interface CustomVideo extends LocalVideo {
void push(VideoFrame frame); // captured now
void push(VideoFrame frame, long timestampMicros); // keeps your source's own timing
}
public interface CustomAudio extends LocalAudio {
void push(byte[] pcm, PcmFormat format); // any length, any thread
void queueLimit(Duration max, AudioOverflow overflow); // 10 s, REJECT by default
long queuedMillis();
void clear();
}
public enum AudioOverflow {
REJECT, // refuse the push with an AudioQueueFullException
DROP_OLDEST // take the push and drop the oldest queued audio
}CameraOptions hd = CameraOptions.builder()
.device(rtc.media().devices().cameras().get(0))
.resolution(1280, 720)
.frameRate(30)
.build(); // the camera's nearest supported capability is used
ScreenOptions screen = ScreenOptions.screen(rtc.media().devices().screens().get(0))
.frameRate(15)
.maxSize(1920, 1080) // scales down to fit, keeping the aspect ratio
.build(); // ScreenOptions.window(...) for a window- One
LocalTrackcan be sent on any number of peers. The library keeps one source and one sender per peer. - Opening a camera that is already open shares its capture when the options match, and fails with
MediaExceptionwhen they differ. The capture stops when the last track using it is closed. CustomAudio.pushtakes any amount of PCM from any thread. The library cuts it into the 10 ms chunks WebRTC wants and feeds them from one internal thread, which is the single-thread rule ofCustomAudioSource.PcmFormat.ofrefuses what WebRTC cannot send: it takes 1, 2, 4, 6 or 8 channels, and rates that are multiples of 100 within the limits ofCustomAudioSource.LocalCameraexists because only a camera can switch. A WebRTC track cannot change its source, so a switch puts a new native track over the new camera behind the sameLocalCamera, on every sender. It opens the new camera first. When that fails, as with two sensors of one camera that cannot stream at once, it closes the current one before opening the new one, and reopens the current one if the new one fails too.- The device lists are snapshots, read once by the first
devices()call, on the caller's thread. Where the system reports no device changes, as a Linux machine without PulseAudio, they are read on each call instead.screens()andwindows()always ask the system. A query that fails, also for an unavailable audio backend, throwsMediaException, so that an empty list means there are no such devices. onVoiceActivityanalyzes the audio in native code, and only the changes reach Java.
Sender, RemoteTrack
public interface Sender {
LocalTrack track();
CompletableFuture<Void> replaceTrack(LocalTrack track); // no renegotiation, null sends nothing
CompletableFuture<Void> configure(Encoding encoding); // max bitrate, max frame rate, scale down, active
CompletableFuture<Void> transform(RTCEncodedFrameTransformer transform); // null sends frames unchanged
CompletableFuture<Void> generateKeyFrame();
CompletableFuture<Void> remove(); // renegotiates in AUTOMATIC mode
CompletableFuture<Void> sendTones(String tones); // DTMF, audio senders only
CompletableFuture<Void> sendTones(String tones, Duration duration, Duration gap);
Subscription onTone(Consumer<String> handler);
}
public interface RemoteTrack {
String id();
MediaKind kind();
List<String> streamIds();
boolean isVideo();
boolean isAudio();
RemoteVideo asVideo(); // IllegalStateException for audio
RemoteAudio asAudio();
CompletableFuture<Void> transform(RTCEncodedFrameTransformer transform);
boolean isMuted();
Subscription onMute(Consumer<Boolean> handler);
Subscription onEnded(Runnable handler);
boolean isEnded();
}
public interface RemoteVideo extends RemoteTrack {
Subscription onFrame(Consumer<VideoFrame> handler); // FrameDelivery.LATEST
Subscription onFrame(FrameDelivery delivery, Consumer<VideoFrame> handler); // LATEST, or queue(n)
CompletableFuture<Void> requestKeyFrame();
}
public interface RemoteAudio extends RemoteTrack {
Subscription onAudio(Consumer<AudioChunk> handler); // 10 ms of PCM each, in order
Subscription onAudio(Duration buffer, Consumer<AudioChunk> handler); // a buffer of its own
void setVolume(double volume); // playout gain, 0.0 to 10.0
}Encoding.builder().maxBitrate(1_500_000).maxFrameRate(30).scaleDownBy(1.0).build();- A frame passed to
onFrameis valid while the handler runs, as today.frame.retain()keeps it longer. The library releases the frames it drops. - Each frame subscription has its own serial executor over the frame pool, so a slow renderer never holds up WebRTC's decoder or the peer's events.
- DTMF is part of
Senderrather than an object of its own. ThesendTonesfuture completes once the tones have been sent, and fails a few seconds after their planned end if WebRTC stopped midway. - Transforms see every encoded frame, after the encoder or before the decoder, as end-to-end encryption needs.
PeerOptionssets them before the first frame. A first transform set on a video sender that already sends restarts its encoder, which costs a key frame. PeerOptions.e2ee(KeyProvider)takes the place of both transforms with SFrame encryption: an encryptor per sender and a decryptor per remote track, which handle key changes, replay and key frame requests. The peer then negotiates only the video codecs that can be encrypted, VP8, VP9 and AV1. Frames that fail go to the key provider's listener, not toonError.
DataChannel
public interface DataChannel extends AutoCloseable {
String label();
ChannelState state(); // CONNECTING, OPEN, CLOSING, CLOSED
CompletableFuture<DataChannel> whenOpen();
CompletableFuture<Void> send(String text); // queued until the channel opens
CompletableFuture<Void> send(byte[] data);
CompletableFuture<Void> send(ByteBuffer data); // copied before the call returns
long bufferedAmount();
Subscription onMessage(Consumer<Message> handler); // early messages are kept for the first handler
Subscription onStateChange(Consumer<ChannelState> handler);
@Override
void close();
}
public final class Message {
public boolean isText();
public String text(); // UTF-8
public byte[] bytes(); // a copy you own
public int size();
public String preview(int maxBytes); // for logs: escaped text, or hex
public String preview(int maxBytes, UnaryOperator<String> mask);
}ChannelOptions.builder().ordered(false).maxRetransmits(0).protocol("json").negotiatedId(3).build();sendcompletes when the message reaches the transport. Above a high-water mark of 1 MiB the library holds sends in Java, so a loop ofsend(chunk).join()slows down by itself instead of filling native memory. A send that would take the backlog beyondchannelSendLimitfails at once withChannelFullException.- WebRTC cannot be told to stop receiving, so a channel whose handlers fall behind by more than
channelReceiveLimit, counted before messages are queued, closes, andonErrorhears why. That beats filling memory or dropping messages unnoticed. - The buffer that
RTCDataChannelObserver.onMessagepasses is copied intoMessage. Nothing a handler gets refers to native memory. Message.toString()gives the type and the length only, so logging a message never leaks what it carries.previewshows content when asked for, and can mask it first.
FileTransfers
Files over a data channel, in the package dev.onvoid.webrtc.client.transfer. A send future of a channel says only that the data reached the transport. Transfers need more: confirmation by the receiver, bounded memory on both sides, and a way to go on after the connection broke.
public final class FileTransfers implements AutoCloseable {
public static FileTransfers over(DataChannel channel); // takes the channel over
public static FileTransfers over(DataChannel channel, TransferOptions options);
public OutgoingTransfer send(Path file) throws IOException;
public OutgoingTransfer send(Path file, String name) throws IOException;
public OutgoingTransfer resume(OutgoingTransfer previous) throws IOException; // over a new channel
public Subscription onOffer(Consumer<TransferOffer> handler);
public CompletableFuture<Void> whenClosed();
@Override
public void close();
}
public final class TransferOffer {
public String id();
public String name();
public long size();
public String safeFileName(); // the name, made safe to use as a file name
public IncomingTransfer accept(Path target);
public void reject(String reason);
}
public interface Transfer {
String id();
String name();
long size();
Path file();
TransferProgress progress();
Subscription onProgress(Consumer<TransferProgress> handler);
CompletableFuture<Void> whenDone(); // the receiver has the whole file, checked
void cancel();
}
public interface OutgoingTransfer extends Transfer { }
public interface IncomingTransfer extends Transfer {
Path partialFile(); // where the file grows until it is complete
}
public final class TransferOptions {
public static TransferOptions defaults();
public static Builder builder();
public static final class Builder {
public Builder chunkSize(int bytes);
public Builder window(int bytes); // the most the sender has out unconfirmed
public Builder resumable(boolean resumable);
public TransferOptions build();
}
}FileTransferstakes the channel over on both sides: nothing else may be sent on it.- The receiver confirms what it wrote, and the sender never has more than the receiver's window out unconfirmed, so a slow disk slows the sender instead of filling memory.
- A transfer is done once the receiver found the SHA-256 of the whole file to match and moved the file into place.
- Files go one at a time in each direction, in the order they were sent, and both sides may send at once.
- A channel that closes fails transfers with a resumable
TransferException. The sender resumes over a new channel, and the receiver goes on where it stopped once it accepts with the same target.
PeerStats
The numbers people look for first, computed from RTCStatsReport. Rates are taken between two samples, so onStats fills them from its second call on.
public final class PeerStats {
public Instant timestamp();
public TransportStats transport(); // the one in use, null while there is none
public List<TransportStats> transports();
public String candidateType(); // host, srflx, prflx, relay
public Duration roundTripTime();
public long bytesSent();
public long bytesReceived();
public long outgoingBitrate(); // bits per second since the previous sample
public long incomingBitrate();
public double packetLoss(); // inbound so far, 0.0 to 1.0
public double recentPacketLoss(); // since the previous sample, -1 without one
public List<VideoStats> outboundVideo();// trackId, width, height, framesPerSecond, codec, qualityLimitation
public List<VideoStats> inboundVideo();
public List<AudioStats> outboundAudio();// codec, jitter, concealment, audio level, sample counts
public List<AudioStats> inboundAudio();
public PeerDiagnostics diagnostics();
public String diagnosticReport(); // JSON for support, from an allowlist
public RTCStatsReport report(); // everything else, sensitive parts included
}
public final class PeerDiagnostics {
public enum State { OBSERVING, HEALTHY, DEGRADED }
public Instant timestamp();
public State state();
public Set<QualityCondition> conditions();
}
public enum QualityCondition {
VIDEO_CPU_LIMITED, HIGH_PACKET_LOSS, TURN_IN_USE, AUDIO_PACKETS_WITHOUT_PCM;
public String reason();
public boolean isProblem(); // false for TURN_IN_USE, which is only informational
}- A diagnosis names conditions with their reason instead of a combined score, which would hide why a call is bad.
HEALTHYmeans no problem the library knows was confirmed, not that all works. - A problem is confirmed, and cleared, only over three measurements spanning at least three seconds. Packet loss enters at 5% and clears at 2%, so a value near one threshold does not flap. A gap of over five seconds or a clock that went back starts the history again (
OBSERVING). stats(),onStatsandonDiagnosticsshare one history per peer.onDiagnosticshands out the first diagnosis, then only changes to its state or conditions.diagnosticReport()can go into a support ticket as it is. It leaves out raw stats, ids, addresses, ports, URLs, credentials, SDP, certificates and codec parameters.
CallCheck, before a call
A self-test for "am I ready for the call?": the camera delivers frames, the microphone audio that is neither silent nor clipped, and a connection to a test peer comes up.
public final class CallCheck {
public static Builder builder();
public static final class Builder {
public Builder video(LocalVideo track); // frames arrive, not all black
public Builder audio(LocalAudio track); // audio arrives, not silent, not clipped
public Builder network(PeerOptions options); // a connection to a test peer comes up
public Builder duration(Duration duration); // how long to measure, default 3 s
public Builder timeout(Duration timeout); // how long to wait for the first sample, default 10 s
public CallCheck build();
}
}
public final class CallCheckResult {
public enum Problem { NO_VIDEO, DARK_VIDEO, NO_AUDIO, SILENT_AUDIO, CLIPPED_AUDIO, NO_CONNECTION;
public String reason();
}
public boolean isReady(); // no problem found, not a guarantee
public Set<Problem> problems();
public Video video(); // null for a part not checked
public Audio audio();
public Network network();
public static final class Video {
public int frames();
public double framesPerSecond();
public int width();
public int height();
public double brightness(); // average luma of the brightest frame, 0 to 1
}
public static final class Audio {
public Duration received();
public double peakLevel(); // linear, 0 to 1
public double rmsLevel();
public double clipping(); // share of samples at full scale
}
public static final class Network {
public boolean isConnected();
public Duration connectTime();
public Duration roundTripTime();
public String candidateType();
public boolean isRelayed();
public Throwable failure(); // TimeoutException, or why the peer failed
}
}- The parts run at the same time, so a check takes as long as its slowest part. Each waits on the runtime's timer for its first sample, then measures for the duration, which tests fire by hand with a
ManualTimer. - Problems with reasons, as for
PeerDiagnostics, rather than a score. - Reachability is told by a connection to a real test peer, never derived from the configuration.
relayOnly(true)makes a connection prove the TURN servers. - WebRTC records a microphone only while a send stream runs, and the client API does not start the recording itself. So the check sends a microphone to a pair of peers of its own, whose sender drops every encoded frame, so that the user does not hear themselves. The audio reaches the check through a sink on the track, after processing.
Whip and Whep
Sessions with a media server, over a peer with Negotiation.MANUAL. Their requests follow RFC 9725 (WHIP) and draft-ietf-wish-whep-05 (WHEP), and the future completes once the peer is connected.
public final class Whip {
public static Builder to(URI endpoint); // https, or http on this machine
public static final class Builder {
public Builder track(LocalTrack track);
public Builder bearerToken(String token);
public Builder basicAuth(String user, String password);
public Builder videoCodecs(String... names);
public Builder audioCodecs(String... names);
public Builder alwaysOfferVideo();
public Builder simulcast();
public Builder simulcast(Encoding... layers);
public Builder iceServer(String... urls); // in place of those the endpoint names
public Builder turnServer(String url, String username, String password);
public Builder gathering(Gathering gathering);
public Builder timeout(Duration timeout);
public Builder sendTransform(RTCEncodedFrameTransformer transform);
public Builder e2ee(KeyProvider keys); // the server forwards what it cannot read
public Builder recovery(RecoveryPolicy policy);
public CompletableFuture<Session> publish(WebRtc rtc);
}
}
public final class Whep {
public static Builder from(URI endpoint); // https, or http on this machine
public static final class Builder {
public Builder bearerToken(String token);
public Builder basicAuth(String user, String password);
public Builder videoCodecs(String... names);
public Builder audioCodecs(String... names);
public Builder iceServer(String... urls);
public Builder receive(MediaKind... kinds); // video and audio by default
public Builder turnServer(String url, String username, String password);
public Builder gathering(Gathering gathering);
public Builder timeout(Duration timeout);
public Builder receiveTransform(RTCEncodedFrameTransformer transform);
public Builder e2ee(KeyProvider keys);
public Builder recovery(RecoveryPolicy policy);
public CompletableFuture<Boolean> detect(WebRtc rtc); // does the endpoint serve WHEP?
public CompletableFuture<Session> play(WebRtc rtc);
}
}
// Whip.Session and Whep.Session both have:
public Peer peer();
public URI resource(); // the session URL from Location
public Map<String, URI> extensions();
public CompletableFuture<Void> restartIce();
public CompletableFuture<Void> closeAsync();
public void close(); // ends the session on the server, closes the peer- The offer goes out at once, without candidates, and candidates follow by PATCH. The peer takes the ICE servers the endpoint names unless the builder named its own.
- The session restarts ICE by itself when the connection breaks, or by
session.restartIce(). ARecoveryPolicyreplaces the built-in rule, and still restarts by PATCH on the same session. - Requests go through
java.net.http.HttpClientand never run on the control pool. Each has a deadline for all of it, and the session's timeout or a cancel aborts what is out. - Failures with a status are
HttpStatusExceptions, with the problem details the server sent.
Subscription and errors
public interface Subscription extends AutoCloseable {
@Override
void close(); // idempotent, no checked exception
}
public class WebRtcException extends RuntimeException { }
public class NegotiationException extends WebRtcException { } // description rejected, glare between two impolite peers
public class PeerClosedException extends WebRtcException { } // operation pending at, or issued after, close
public class MediaException extends WebRtcException { } // device missing, busy, access denied, or opened with other options
public class ConfigurationException extends WebRtcException { } // e.g. microphone() on an AudioMode.CUSTOM runtime
public class BlockingCallException extends WebRtcException { } // join() or get() on an event thread
public class ChannelFullException extends WebRtcException { } // a send beyond channelSendLimit
public class AudioQueueFullException extends WebRtcException { } // a push beyond queueLimit, with AudioOverflow.REJECT
public class HttpStatusException extends WebRtcException { } // a WHIP or WHEP endpoint answered with an error
public class TransferException extends WebRtcException { } // a file transfer failed; isResumable() says if it can go on
public class TransferCancelledException extends TransferException { }Scenarios
Desktop app with Swing
With the EDT as the event executor, every handler can touch Swing components directly. VideoView comes from the webrtc-java-swing module, and webrtc-java-javafx has one for JavaFX.
WebRtc rtc = WebRtc.builder()
.eventExecutor(SwingUtilities::invokeLater)
.build();
Peer peer = rtc.createPeer(options);
peer.onStateChange(state -> statusLabel.setText(state.toString())); // on the EDT
peer.onTrack(track -> {
if (track.isVideo()) {
track.asVideo().onFrame(remoteView); // VideoView: converts off the EDT, paints on it
}
});
hangUpButton.addActionListener(e -> peer.close()); // never blocks the EDTHeadless bot: speak and listen
WebRtc rtc = WebRtc.builder().audio(AudioMode.HEADLESS).build();
CustomAudio voice = rtc.media().customAudio("tts");
Peer peer = rtc.createPeer(options);
peer.addTrack(voice);
peer.onTrack(track -> track.asAudio().onAudio(chunk -> recognizer.feed(chunk.samples())));
// From any thread, in any size. The library sends it in 10 ms chunks.
voice.push(textToSpeech.render("Hello"), PcmFormat.of(48_000, 1));Data channel only
DataChannel chat = peer.createChannel("chat");
chat.onMessage(message -> System.out.println("peer: " + message.text()));
chat.send("hello"); // sent as soon as the channel opens
peer.onChannel(channel -> { // channels the other side opens
if (channel.label().equals("files")) {
channel.onMessage(message -> sink.write(message.bytes()));
}
});WHEP playback
Whep.Session session = Whep.from(URI.create("https://example.com/live/whep"))
.play(rtc)
.get(15, TimeUnit.SECONDS);
session.peer().onTrack(track -> log.info("Receiving {}", track.kind()));
...
session.close();A server of your own, by hand
For an exchange that is neither WHIP nor WHEP, Negotiator gives the same steps without latches or dummy tracks.
Peer peer = rtc.createPeer(PeerOptions.builder()
.negotiation(Negotiation.MANUAL)
.receive(MediaKind.VIDEO, MediaKind.AUDIO)
.build());
peer.onTrack(track -> log.info("Receiving {}", track.kind()));
peer.negotiator().createOffer(Gathering.COMPLETE)
.thenApplyAsync(offer -> http.postSdp(WHEP_URL, offer.sdp), ioPool) // blocking HTTP off the event thread
.thenCompose(sdp -> peer.negotiator().applyAnswer(new RTCSessionDescription(RTCSdpType.ANSWER, sdp)))
.thenCompose(done -> peer.whenConnected())
.join(); // the main thread may waitTwo peers in one test
try (WebRtc rtc = WebRtc.builder().audio(AudioMode.HEADLESS).build()) {
SignalingPair link = Signaling.loopback();
Peer caller = rtc.createPeer(PeerOptions.builder().signaling(link.first()).build());
Peer callee = rtc.createPeer(PeerOptions.builder().signaling(link.second()).polite(true).build());
CompletableFuture<String> received = new CompletableFuture<>();
callee.onChannel(channel -> channel.onMessage(message -> received.complete(message.text())));
caller.createChannel("test").send("ping");
assertEquals("ping", received.get(10, TimeUnit.SECONDS));
}How it maps onto the core API
Anything the client API doesn't cover stays reachable through withNative and configureFactory.
| New | Built on |
|---|---|
WebRtc | PeerConnectionFactory.Builder, AudioDeviceModule or HeadlessAudioDeviceModule, HardwareVideoEncoderFactory and HardwareVideoDecoderFactory |
Peer | RTCPeerConnection, an internal PeerConnectionObserver that only enqueues |
automatic negotiation, Negotiator | setLocalDescription(observer) (implicit offer or answer), setRemoteDescription, rollback, addIceCandidate(candidate, AddIceCandidateObserver) |
Sender | RTCRtpSender and RTCRtpTransceiver, disposed inside the library |
RemoteTrack | RTCRtpReceiver, VideoTrack.addSink, AudioTrack.addSink, AudioTrack.setVolume |
LocalVideo, LocalAudio | VideoDeviceSource, VideoDesktopSource, CustomVideoSource, AudioTrackSource, CustomAudioSource |
Devices | MediaDevices, refreshed on a background thread when DeviceChangeListener fires |
DataChannel | RTCDataChannel, RTCDataChannelObserver, sendAsync with RTCDataChannelSendObserver |
PeerStats | getStats(RTCStatsCollectorCallback), RTCStatsReport |
Whip, Whep | a peer with Negotiation.MANUAL, java.net.http.HttpClient |
transform on senders and tracks | RTCEncodedFrameTransformer |
e2ee | the SFrame encryptors and decryptors of dev.onvoid.webrtc.e2ee |
RecoveryPolicy | restartIce() on the same peer, or a PATCH of the WHIP or WHEP session |
PeerDiagnostics, AudioStats | samples of RTCStatsReport |
FileTransfers | a DataChannel, with a protocol of its own on top |
Implementation
The package lives in webrtc/src/main/java/dev/onvoid/webrtc/client/, with tests in webrtc/src/test/java/dev/onvoid/webrtc/client/. module-info.java exports it and dev.onvoid.webrtc.client.transfer, and exports dev.onvoid.webrtc.client.internal to the media module only: EncodedFrames hands MediaRecorder the encoded frames of senders and remote tracks, taken on the peer's control executor. VideoView lives in webrtc-java-swing (package dev.onvoid.webrtc.client.swing), so that the core module does not need java.desktop, and in webrtc-java-javafx (dev.onvoid.webrtc.client.javafx), where JavaFX is an optional dependency the application brings.
How the threading model is built:
- A runtime runs on a control pool, a frame pool, a timer, an audio pacer and the event executor, which is its own pool or one the application passes.
- Each peer has two
SerialExecutors over those pools. The control executor owns theRTCPeerConnectionand all negotiation state; every WebRTC callback only posts to it. The event executor runs application code and owns what the handlers have been told so far. PeerImplholds the two together and leaves the rest to classes of their own:OperationChain,PerfectNegotiation,ManualNegotiation,NegotiationSteps,RemoteTracks,DataChannels,StatsSampler,SignalingLinkandIceRecovery. All of them run on the peer's control executor, and so doesDiagnosticsReader, which keeps the history of a peer's diagnosis.- Operations that the standard puts on the operations chain go through
OperationChain.enqueue, which starts one only after the previous one finished and fails it afteroperationTimeout. - Native observer completions are posted back to the control executor before anything is chained on them, so no internal code ever runs on a WebRTC thread.
- Public futures are
ClientFutures completed on the event executor.join()/get()on an unfinished one from any event thread throwsBlockingCallException. Derived stages refuse too, throughnewIncompleteFuture. - Frame and audio subscriptions each get a
SerialExecutorover the frame pool and aDispatcherwith the drop policies described above. CustomAudiois cut into 10 ms chunks and pushed from the runtime's audio pacer, every 10 ms.- An impolite peer that ignored an offer reports the two-impolite-peers collision after 5 seconds without an answer.
Open gaps
- A lost device does not end its track. The first design had
LocalTrack.onEndedfire when a device is unplugged or a captured window closes. That was not built, and the Javadoc and the guide say so. The code ends a local track only when it closes: by the application, by the runtime, or when a camera switch fails and gets no camera back. After a loss,Devices.onChangefires for a device, and the application has to close the track itself. Nothing in the client API reports a captured window that closes.
