diff --git a/design/client-api.md b/design/client-api.md index 1665aa10..19130c64 100644 --- a/design/client-api.md +++ b/design/client-api.md @@ -815,4 +815,7 @@ Next: `Whep.from(...).play(rtc)` return sessions over `HttpURLConnection` (Java 8), and `VideoView` is in `dev.onvoid.webrtc.client.swing`; - porting the existing examples (phases 2 and 3); `MediaFilePlayerExample` is ported, with a - Record button, and `LocalTrack.closeAsync()` came with it. + Record button, and `LocalTrack.closeAsync()` came with it. `PeerConnectionExample`, + `DesktopVideoExample` and `MediaFileExample` are ported too, each over a loopback signaling. + `CodecListExample`, `EncryptedRecordingExample` and `VoiceActivityExample` stay on the core API, + which they demonstrate: the factory's codecs, encoded frame transforms and voice activity. diff --git a/docs/guide/examples.md b/docs/guide/examples.md index 37777876..2dacb935 100644 --- a/docs/guide/examples.md +++ b/docs/guide/examples.md @@ -24,16 +24,21 @@ mvn exec:java -D"exec.mainClass=dev.onvoid.webrtc.examples.ClientApiExample" -D" ## PeerConnection -The [`PeerConnectionExample`](https://github.com/devopvoid/webrtc-java/blob/master/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/PeerConnectionExample.java) demonstrates how to set up a peer connection with audio and video tracks to be able to send and receive media. +The [`PeerConnectionExample`](https://github.com/devopvoid/webrtc-java/blob/master/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/PeerConnectionExample.java) sends the camera and the microphone from one peer to another in the same process, with the [Client API](/guide/client-api), and logs the video frames and audio that arrive. **Key features demonstrated:** -- Creating a `PeerConnectionFactory` -- Creating audio and video tracks -- Setting up a peer connection -- Adding tracks to the peer connection for sending media -- Implementing callbacks to receive incoming audio and video frames +- Creating a `WebRtc` runtime that captures from and plays to the audio devices +- Listing the cameras and microphones +- Opening the camera and the microphone as local tracks +- Connecting two peers through a loopback signaling, in place of a server +- Receiving the remote tracks, their frames and their audio +- Muting a received audio track, while its audio still reaches a handler -This example provides a foundation for building WebRTC applications that need to handle audio and video communication. +This example provides a foundation for building WebRTC applications that need to handle audio and video communication. Press Enter to end the call: + +```bash +mvn exec:java -D"exec.mainClass=dev.onvoid.webrtc.examples.PeerConnectionExample" +``` ## WHEP @@ -64,17 +69,20 @@ This example is useful for understanding what codecs are available on the curren ## Desktop Video -The [`DesktopVideoExample`](https://github.com/devopvoid/webrtc-java/blob/master/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/DesktopVideoExample.java) demonstrates how to set up a peer connection with a desktop video source for screen or window capture. +The [`DesktopVideoExample`](https://github.com/devopvoid/webrtc-java/blob/master/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/DesktopVideoExample.java) shares a screen or a window from one peer to another in the same process, with the [Client API](/guide/client-api). **Key features demonstrated:** -- Creating a `PeerConnectionFactory` -- Getting available desktop sources (screens and windows) -- Creating a `VideoDesktopSource` for capturing screen or window content -- Configuring the `VideoDesktopSource` properties -- Creating a video track with the desktop source -- Setting up a peer connection +- Listing the screens and windows that can be captured +- Capturing one of them as a local video track, with a frame rate and a largest size +- Connecting two peers through a loopback signaling, in place of a server +- Reading the resolution and frame rate the sender encodes -This example is particularly useful for applications that need to implement screen sharing or remote desktop functionality. +This example is particularly useful for applications that need to implement screen sharing or remote desktop functionality. It shares the first screen, or the first window with `window`. Under Wayland, the system asks which one to share instead. + +```bash +mvn exec:java -D"exec.mainClass=dev.onvoid.webrtc.examples.DesktopVideoExample" +mvn exec:java -D"exec.mainClass=dev.onvoid.webrtc.examples.DesktopVideoExample" -D"exec.args=window" +``` ## Media File @@ -83,9 +91,10 @@ The [`MediaFileExample`](https://github.com/devopvoid/webrtc-java/blob/master/we **Key features demonstrated:** - Opening a media file, or a live RTSP stream, with a `MediaFileSource` - Reading what the source contains from its `MediaInfo` -- Creating audio and video tracks from the media sources it feeds -- Adding those tracks to a peer connection +- Sending the media sources it feeds as [Client API](/guide/client-api) tracks, with `videoTrack` and `audioTrack` +- Receiving them on a second peer, connected through a loopback signaling - Following playback through a `MediaPlayerListener` +- Closing the tracks before the source, which owns what they were made from This example is useful for applications that stream pre-recorded media, relay an IP camera into WebRTC, or need a dependable stand-in for a camera in testing. diff --git a/docs/guide/media/media-files.md b/docs/guide/media/media-files.md index c26946cf..68cc4d5c 100644 --- a/docs/guide/media/media-files.md +++ b/docs/guide/media/media-files.md @@ -288,7 +288,7 @@ Some of the formats FFmpeg decodes, such as H.264, H.265/HEVC and AAC, may be co ## Complete Example -See `MediaFileExample` in the `webrtc-examples` module, which opens a file, reports what it contains, creates tracks, adds them to a peer connection and follows playback to the end. +See `MediaFileExample` in the `webrtc-examples` module, which opens a file, reports what it contains, sends its tracks from one [Client API](/guide/client-api) peer to another and follows playback to the end. ```shell mvn -pl webrtc-examples compile diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/DesktopVideoExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/DesktopVideoExample.java index 0fad48c0..01e580a2 100644 --- a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/DesktopVideoExample.java +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/DesktopVideoExample.java @@ -16,255 +16,138 @@ package dev.onvoid.webrtc.examples; -import java.util.ArrayList; +import java.time.Duration; import java.util.List; -import java.util.logging.Level; -import java.util.logging.Logger; - -import dev.onvoid.webrtc.PeerConnectionFactory; -import dev.onvoid.webrtc.PeerConnectionObserver; -import dev.onvoid.webrtc.RTCConfiguration; -import dev.onvoid.webrtc.RTCDataChannel; -import dev.onvoid.webrtc.RTCIceCandidate; -import dev.onvoid.webrtc.RTCIceConnectionState; -import dev.onvoid.webrtc.RTCIceGatheringState; -import dev.onvoid.webrtc.RTCIceServer; -import dev.onvoid.webrtc.RTCPeerConnection; -import dev.onvoid.webrtc.RTCPeerConnectionState; -import dev.onvoid.webrtc.RTCRtpReceiver; -import dev.onvoid.webrtc.RTCRtpSender; -import dev.onvoid.webrtc.RTCRtpTransceiver; -import dev.onvoid.webrtc.RTCSignalingState; -import dev.onvoid.webrtc.media.MediaStream; -import dev.onvoid.webrtc.media.MediaStreamTrack; -import dev.onvoid.webrtc.media.video.VideoDesktopSource; -import dev.onvoid.webrtc.media.video.VideoTrack; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import dev.onvoid.webrtc.client.AudioMode; +import dev.onvoid.webrtc.client.Devices; +import dev.onvoid.webrtc.client.LocalVideo; +import dev.onvoid.webrtc.client.Peer; +import dev.onvoid.webrtc.client.PeerOptions; +import dev.onvoid.webrtc.client.ScreenOptions; +import dev.onvoid.webrtc.client.Signaling; +import dev.onvoid.webrtc.client.SignalingPair; +import dev.onvoid.webrtc.client.VideoStats; +import dev.onvoid.webrtc.client.WebRtc; import dev.onvoid.webrtc.media.video.desktop.DesktopSource; -import dev.onvoid.webrtc.media.video.desktop.ScreenCapturer; -import dev.onvoid.webrtc.media.video.desktop.WindowCapturer; /** - * Example demonstrating how to set up a peer connection with a desktop video source. + * Example demonstrating how to share a screen or a window over a peer connection, with the + * client API. *

* This example shows how to: *

*

- * Note: This example focuses only on setting up the local peer connection with - * a desktop video source for bidirectional media transfer. In a real application, - * you would need to establish a connection with a remote peer through a signaling - * channel (e.g., WebSocket). + * Both peers run in this process, so the call connects without a server. In a real + * application each peer is in another process, and its {@link Signaling} carries the messages + * over a WebSocket or the like. + *

+ * Under Wayland, the system asks which screen or window to share once the capture starts, and + * each list holds a single source without a title that stands for that choice. + *

+ * Run it to share the first screen, or with {@code window} to share the first window, and press + * Enter to stop: + *

+ * mvn exec:java -D"exec.mainClass=dev.onvoid.webrtc.examples.DesktopVideoExample"
+ * mvn exec:java -D"exec.mainClass=dev.onvoid.webrtc.examples.DesktopVideoExample" -D"exec.args=window"
+ * 
* * @author Alex Andres */ public class DesktopVideoExample { - public static void main(String[] args) { - // Create a PeerConnectionFactory, which is the main entry point for WebRTC. - PeerConnectionFactory factory = new PeerConnectionFactory(); - - try { - LocalPeer localPeer = new LocalPeer(factory); - - // Keep the application running to observe state changes. - System.out.println("Press Enter to exit..."); - System.in.read(); - - // Clean up. - localPeer.dispose(); - } - catch (Exception e) { - Logger.getLogger(DesktopVideoExample.class.getName()) - .log(Level.SEVERE, "Error in DesktopVideoExample", e); - } - finally { - // Dispose the factory when done. - factory.dispose(); - } - } - - /** - * Represents a peer connection with audio and desktop video tracks. - */ - private static class LocalPeer implements PeerConnectionObserver { - - private final RTCPeerConnection peerConnection; - private final VideoDesktopSource videoSource; - private final RTCRtpSender videoSender; - - - public LocalPeer(PeerConnectionFactory factory) { - // Create a basic configuration for the peer connection. - RTCConfiguration config = new RTCConfiguration(); - - // Add a STUN server to help with NAT traversal. - RTCIceServer iceServer = new RTCIceServer(); - iceServer.urls.add("stun:stun.l.google.com:19302"); - config.iceServers.add(iceServer); - - // Create the peer connection. - peerConnection = factory.createPeerConnection(config, this); - - // Get available desktop sources. - System.out.println("Getting available desktop sources..."); - - // Get available screens. - ScreenCapturer screenCapturer = new ScreenCapturer(); - List screens = screenCapturer.getDesktopSources(); - System.out.println("\nAvailable screens:"); - for (DesktopSource screen : screens) { - System.out.printf(" Screen: %s (ID: %d)%n", screen.title, screen.id); - } - - // Get available windows. - WindowCapturer windowCapturer = new WindowCapturer(); - List windows = windowCapturer.getDesktopSources(); - System.out.println("\nAvailable windows:"); - for (DesktopSource window : windows) { - System.out.printf(" Window: %s (ID: %d)%n", window.title, window.id); - } - - // Clean up the capturers as we only needed them to get the sources. - screenCapturer.dispose(); - windowCapturer.dispose(); - - // Create a desktop video source. - videoSource = new VideoDesktopSource(); - - // Configure the desktop video source. - // Set frame rate (e.g., 30 fps). - videoSource.setFrameRate(30); - - // Set maximum frame size (e.g., 1920x1080). - videoSource.setMaxFrameSize(1920, 1080); - - // Select a source to capture. - // For this example; we'll use the first available screen if there is one. - if (!screens.isEmpty()) { - DesktopSource selectedScreen = screens.get(0); - System.out.printf("%nSelected screen for capture: %s (ID: %d)%n", - selectedScreen.title, selectedScreen.id); - videoSource.setSourceId(selectedScreen.id, false); - } - // Otherwise, use the first available window if there is one. - else if (!windows.isEmpty()) { - DesktopSource selectedWindow = windows.get(0); - System.out.printf("%nSelected window for capture: %s (ID: %d)%n", - selectedWindow.title, selectedWindow.id); - videoSource.setSourceId(selectedWindow.id, true); - } - // If no sources are available, fall back to a default (primary screen). - else { - System.out.println("\nNo desktop sources found. Using default (primary screen)."); - videoSource.setSourceId(0, false); - } + public static void main(String[] args) throws Exception { + boolean shareWindow = args.length > 0 && args[0].equals("window"); - // Start capturing. - videoSource.start(); + // Only video is sent, so the runtime needs no audio devices. + try (WebRtc rtc = WebRtc.builder().audio(AudioMode.HEADLESS).build()) { + // Screens and windows are read from the system on every call. + Devices devices = rtc.media().devices(); + List screens = devices.screens(); + List windows = devices.windows(); - // Create a video track with the desktop source. - VideoTrack videoTrack = factory.createVideoTrack("video0", videoSource); + System.out.println("Available screens:"); + screens.forEach(screen -> System.out.println(" " + describe(screen))); - // Add the tracks to the peer connection. - List streamIds = new ArrayList<>(); - streamIds.add("stream1"); - videoSender = peerConnection.addTrack(videoTrack, streamIds); + System.out.println("Available windows:"); + windows.forEach(window -> System.out.println(" " + describe(window))); - System.out.println("LocalPeer: Created with a desktop video track"); - } + List sources = shareWindow ? windows : screens; - /** - * Closes the peer connection and releases resources. - */ - public void dispose() { - if (videoSource != null) { - // Stop capturing before disposing. - videoSource.stop(); - videoSource.dispose(); - } - // RTCRtpSender is not owned by the peer connection; the - // application must dispose it once it is no longer needed. - if (videoSender != null) { - videoSender.dispose(); + if (sources.isEmpty()) { + System.out.println("There is no " + (shareWindow ? "window" : "screen") + " to share"); + return; } - if (peerConnection != null) { - peerConnection.close(); - } - } - - // PeerConnectionObserver implementation. - - @Override - public void onIceCandidate(RTCIceCandidate candidate) { - System.out.println("LocalPeer: New ICE candidate: " + candidate.sdp); - // In a real application, you would send this candidate to the remote peer - // through your signaling channel. - } - - @Override - public void onConnectionChange(RTCPeerConnectionState state) { - System.out.println("LocalPeer: Connection state changed to: " + state); - } - - @Override - public void onIceConnectionChange(RTCIceConnectionState state) { - System.out.println("LocalPeer: ICE connection state changed to: " + state); - } - - @Override - public void onIceGatheringChange(RTCIceGatheringState state) { - System.out.println("LocalPeer: ICE gathering state changed to: " + state); - } - - @Override - public void onSignalingChange(RTCSignalingState state) { - System.out.println("LocalPeer: Signaling state changed to: " + state); - } - - @Override - public void onDataChannel(RTCDataChannel dataChannel) { - System.out.println("LocalPeer: Data channel created: " + dataChannel.getLabel()); - } - - @Override - public void onRenegotiationNeeded() { - System.out.println("LocalPeer: Renegotiation needed"); - // In a real application, you would create an offer and set it as the local description. - } - @Override - public void onAddTrack(RTCRtpReceiver receiver, MediaStream[] mediaStreams) { - System.out.println("LocalPeer: Track added: " + receiver.getTrack().getKind()); - - // The receiver is a query result the application owns; dispose it - // once its track has been retrieved. - receiver.dispose(); - } - - @Override - public void onRemoveTrack(RTCRtpReceiver receiver) { - System.out.println("LocalPeer: Track removed: " + receiver.getTrack().getKind()); - - receiver.dispose(); + DesktopSource source = sources.get(0); + ScreenOptions.Builder options = shareWindow + ? ScreenOptions.window(source) + : ScreenOptions.screen(source); + + // Frames larger than 1920x1080 are scaled down, keeping their aspect ratio. + LocalVideo screen = rtc.media().screen(options.frameRate(30).maxSize(1920, 1080).build()); + + System.out.println("Sharing " + describe(source)); + + // The two ends of a loopback signaling hand each message to the other peer. + SignalingPair signaling = Signaling.loopback(); + + Peer sharer = rtc.createPeer(PeerOptions.builder() + .name("sharer") + .signaling(signaling.first()) + .build()); + Peer viewer = rtc.createPeer(PeerOptions.builder() + .name("viewer") + .polite(true) + .signaling(signaling.second()) + .build()); + + viewer.onTrack(track -> { + AtomicInteger frames = new AtomicInteger(); + + track.asVideo().onFrame(frame -> { + if (frames.incrementAndGet() % 30 == 1) { + System.out.printf("Viewer got frame %d, %dx%d%n", frames.get(), + frame.buffer.getWidth(), frame.buffer.getHeight()); + } + }); + }); + + sharer.addTrack(screen); + + // The main thread may wait; a handler must not. + sharer.whenConnected().get(15, TimeUnit.SECONDS); + + sharer.onStats(Duration.ofSeconds(5), stats -> { + VideoStats video = stats.outboundVideo().isEmpty() ? null : stats.outboundVideo().get(0); + + // Nothing is encoded before the first frame was captured. + if (video != null && video.width() > 0) { + System.out.printf("Sharer encodes %s %dx%d at %.0f fps, %d kbit/s%n", video.codec(), + video.width(), video.height(), video.framesPerSecond(), + Math.max(0, stats.outgoingBitrate()) / 1000); + } + }); + + System.out.println("Press Enter to stop sharing..."); + System.in.read(); } + // Closing the runtime closed the peers and stopped the capture. - @Override - public void onTrack(RTCRtpTransceiver transceiver) { - RTCRtpReceiver receiver = transceiver.getReceiver(); - MediaStreamTrack track = receiver.getTrack(); + System.out.println("Closed"); + } - System.out.println("LocalPeer: Transceiver track added: " + track.getKind()); + private static String describe(DesktopSource source) { + String title = source.title == null || source.title.isEmpty() ? "(chosen by the system)" : source.title; - // The receiver and transceiver are query results the application - // owns; dispose them once the track has been retrieved. - receiver.dispose(); - transceiver.dispose(); - } + return title + " (ID: " + source.id + ")"; } -} \ No newline at end of file +} diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFileExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFileExample.java index ccc6a71d..b92a8216 100644 --- a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFileExample.java +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFileExample.java @@ -16,50 +16,48 @@ package dev.onvoid.webrtc.examples; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import java.util.logging.Level; -import java.util.logging.Logger; - -import dev.onvoid.webrtc.PeerConnectionFactory; -import dev.onvoid.webrtc.PeerConnectionObserver; -import dev.onvoid.webrtc.RTCConfiguration; -import dev.onvoid.webrtc.RTCIceCandidate; -import dev.onvoid.webrtc.RTCIceConnectionState; -import dev.onvoid.webrtc.RTCPeerConnection; -import dev.onvoid.webrtc.RTCRtpSender; -import dev.onvoid.webrtc.media.audio.AudioDeviceModule; -import dev.onvoid.webrtc.media.audio.AudioLayer; -import dev.onvoid.webrtc.media.audio.AudioTrack; +import java.util.concurrent.atomic.AtomicLong; + +import dev.onvoid.webrtc.client.AudioMode; +import dev.onvoid.webrtc.client.LocalTrack; +import dev.onvoid.webrtc.client.Peer; +import dev.onvoid.webrtc.client.PeerOptions; +import dev.onvoid.webrtc.client.Signaling; +import dev.onvoid.webrtc.client.SignalingPair; +import dev.onvoid.webrtc.client.WebRtc; import dev.onvoid.webrtc.media.player.MediaFileSource; import dev.onvoid.webrtc.media.player.MediaInfo; import dev.onvoid.webrtc.media.player.MediaPlayerListener; import dev.onvoid.webrtc.media.player.MediaPlayerState; -import dev.onvoid.webrtc.media.video.VideoTrack; - -import java.util.List; /** * Example demonstrating how to send a media file over a peer connection, in - * place of a camera and a microphone. + * place of a camera and a microphone, with the client API. *

* This example shows how to: *

    *
  • Open a media file, or a live RTSP stream, with a MediaFileSource
  • *
  • Read what the source contains from its MediaInfo
  • - *
  • Create audio and video tracks from the sources it feeds
  • - *
  • Add those tracks to a peer connection
  • + *
  • Send the sources it feeds as client API tracks, with {@code videoTrack} and + * {@code audioTrack}
  • + *
  • Receive them on a second peer, connected through a loopback signaling
  • *
  • Follow playback through a MediaPlayerListener
  • + *
  • Close the tracks before the source, which owns what they were made from
  • *
*

* Decoding happens entirely in native code. Frames are paced in real time and * carry the presentation times of the file, so what a receiver gets keeps the * timing of the media rather than the timing of a Java thread. *

- * Note: this example only sets up the local side. A real application would - * reach a remote peer through a signaling channel, as - * {@link PeerConnectionExample} shows. + * Both peers run in this process. A real application reaches its remote peer + * through a {@link Signaling} that carries the messages over a WebSocket or + * the like. {@link MediaFilePlayerExample} also shows the received video and + * plays the received audio. *

* Run it with the media file or stream URL to send: *

@@ -73,7 +71,7 @@
  */
 public class MediaFileExample {
 
-    public static void main(String[] args) {
+    public static void main(String[] args) throws Exception {
         if (args.length < 1) {
             System.out.println("Usage: MediaFileExample ");
             System.out.println("  for example: MediaFileExample movie.mp4");
@@ -81,66 +79,104 @@ public static void main(String[] args) {
             return;
         }
 
-        // A factory that sends pushed audio must not also be capturing from a
-        // microphone, so this one is given a dummy audio layer. An application
-        // that needs both needs a second factory.
-        AudioDeviceModule audioModule = new AudioDeviceModule(AudioLayer.kDummyAudio);
-        PeerConnectionFactory factory = new PeerConnectionFactory(audioModule);
+        // The file's audio is pushed, so the runtime must not capture from a
+        // microphone, and a headless one needs no audio devices at all. The
+        // runtime goes first: it loads the native library the source needs.
+        try (WebRtc rtc = WebRtc.builder().audio(AudioMode.HEADLESS).build()) {
+            // Opening reads the container and picks the streams to play. A
+            // source with nothing playable in it fails here.
+            try (MediaFileSource source = new MediaFileSource(args[0])) {
+                printInfo(source.getInfo());
+
+                SignalingPair signaling = Signaling.loopback();
+
+                Peer sender = rtc.createPeer(PeerOptions.builder()
+                        .name("sender")
+                        .signaling(signaling.first())
+                        .build());
+                Peer receiver = rtc.createPeer(PeerOptions.builder()
+                        .name("receiver")
+                        .polite(true)
+                        .signaling(signaling.second())
+                        .build());
+
+                AtomicInteger videoFrames = new AtomicInteger();
+                AtomicLong audioMillis = new AtomicLong();
+
+                receiver.onTrack(track -> {
+                    System.out.println("Receiving " + track.kind());
+
+                    if (track.isVideo()) {
+                        track.asVideo().onFrame(frame -> videoFrames.incrementAndGet());
+                    }
+                    else {
+                        track.asAudio().onAudio(chunk ->
+                                audioMillis.addAndGet(chunk.frames() * 1000L / chunk.sampleRate()));
+                    }
+                });
+
+                // A source with no video has no video source, and likewise
+                // for audio, so each track is only made if there is something
+                // to feed it. The sources stay the file source's.
+                List tracks = new ArrayList<>();
+
+                if (source.getVideoSource() != null) {
+                    tracks.add(rtc.media().videoTrack("video", source.getVideoSource()));
+                }
+                if (source.getAudioSource() != null) {
+                    tracks.add(rtc.media().audioTrack("audio", source.getAudioSource()));
+                }
 
-        // Opening reads the container and picks the streams to play. A source
-        // with nothing playable in it fails here.
-        try (MediaFileSource source = new MediaFileSource(args[0])) {
-            printInfo(source.getInfo());
+                // Both tracks go into one stream, so a receiver plays them in sync.
+                tracks.forEach(sender::addTrack);
 
-            LocalPeer localPeer = new LocalPeer(factory, source);
+                // The main thread may wait; a handler must not.
+                sender.whenConnected().get(15, TimeUnit.SECONDS);
 
-            CountDownLatch finished = new CountDownLatch(1);
+                CountDownLatch finished = new CountDownLatch(1);
 
-            source.setListener(new MediaPlayerListener() {
+                source.setListener(new MediaPlayerListener() {
 
-                @Override
-                public void onStateChanged(MediaPlayerState state) {
-                    System.out.println("Player state: " + state);
-                }
+                    @Override
+                    public void onStateChanged(MediaPlayerState state) {
+                        System.out.println("Player state: " + state);
+                    }
 
-                @Override
-                public void onEndOfStream() {
-                    System.out.println("Reached the end of the source.");
-                    finished.countDown();
-                }
+                    @Override
+                    public void onEndOfStream() {
+                        System.out.println("Reached the end of the source.");
+                        finished.countDown();
+                    }
 
-                @Override
-                public void onError(String message) {
-                    System.out.println("Playback failed: " + message);
-                    finished.countDown();
-                }
-            });
+                    @Override
+                    public void onError(String message) {
+                        System.out.println("Playback failed: " + message);
+                        finished.countDown();
+                    }
+                });
 
-            // Playing the source over and over is one line, and is what a
-            // stand-in for a camera usually wants:
-            // source.setLooping(true);
+                // Playing the source over and over is one line, and is what a
+                // stand-in for a camera usually wants:
+                // source.setLooping(true);
 
-            source.play();
+                source.play();
 
-            // Report where playback has got to while it runs.
-            while (!finished.await(1, TimeUnit.SECONDS)) {
-                System.out.printf("  at %.1f s, %d video frames and %d audio chunks sent%n",
-                        source.getPositionUs() / 1_000_000.0,
-                        localPeer.videoFrames.get(), localPeer.audioChunks.get());
-            }
+                // Report where playback has got to while it runs.
+                while (!finished.await(1, TimeUnit.SECONDS)) {
+                    System.out.printf("  at %.1f s, %d video frames and %.1f s of audio received%n",
+                            source.getPositionUs() / 1_000_000.0, videoFrames.get(),
+                            audioMillis.get() / 1000.0);
+                }
 
-            // The tracks go before the source, which disposes of the media
-            // sources they were made from.
-            localPeer.dispose();
-        }
-        catch (Exception e) {
-            Logger.getLogger(MediaFileExample.class.getName())
-                    .log(Level.SEVERE, "Error in MediaFileExample", e);
-        }
-        finally {
-            factory.dispose();
-            audioModule.dispose();
+                // The tracks go before the source, which disposes of the media
+                // sources they were made from. Waiting for them is what makes
+                // closing the source safe.
+                for (LocalTrack track : tracks) {
+                    track.closeAsync().get(5, TimeUnit.SECONDS);
+                }
+            }
         }
+        // Closing the runtime closed the peers.
     }
 
     private static void printInfo(MediaInfo info) {
@@ -168,79 +204,4 @@ private static void printInfo(MediaInfo info) {
             System.out.println("  audio: none");
         }
     }
-
-    /**
-     * A peer connection carrying the tracks a media file feeds.
-     */
-    private static class LocalPeer implements PeerConnectionObserver {
-
-        final AtomicInteger videoFrames = new AtomicInteger();
-        final AtomicInteger audioChunks = new AtomicInteger();
-
-        private final RTCPeerConnection peerConnection;
-        private final VideoTrack videoTrack;
-        private final AudioTrack audioTrack;
-        private final RTCRtpSender videoSender;
-        private final RTCRtpSender audioSender;
-
-
-        LocalPeer(PeerConnectionFactory factory, MediaFileSource source) {
-            peerConnection = factory.createPeerConnection(new RTCConfiguration(), this);
-
-            // A source with no video has no video source, and likewise for
-            // audio, so each track is only made if there is something to feed
-            // it.
-            if (source.getVideoSource() != null) {
-                videoTrack = factory.createVideoTrack("video", source.getVideoSource());
-
-                // Watching the track shows what the peer connection is being
-                // given. A real application would not need this.
-                videoTrack.addSink(frame -> videoFrames.incrementAndGet());
-
-                videoSender = peerConnection.addTrack(videoTrack, List.of("stream"));
-            }
-            else {
-                videoTrack = null;
-                videoSender = null;
-            }
-
-            if (source.getAudioSource() != null) {
-                audioTrack = factory.createAudioTrack("audio", source.getAudioSource());
-                audioTrack.addSink((data, bits, rate, channels, frames) ->
-                        audioChunks.incrementAndGet());
-
-                audioSender = peerConnection.addTrack(audioTrack, List.of("stream"));
-            }
-            else {
-                audioTrack = null;
-                audioSender = null;
-            }
-        }
-
-        void dispose() {
-            // An RTCRtpSender is not owned by the peer connection, so each one
-            // handed out by addTrack has to be released here. Doing it before
-            // the connection closes is what lets go of the tracks, and with
-            // them the media sources the file source owns.
-            if (videoSender != null) {
-                videoSender.dispose();
-            }
-            if (audioSender != null) {
-                audioSender.dispose();
-            }
-
-            peerConnection.close();
-        }
-
-        @Override
-        public void onIceCandidate(RTCIceCandidate candidate) {
-            // A real application sends this to the remote peer over its
-            // signaling channel.
-        }
-
-        @Override
-        public void onIceConnectionChange(RTCIceConnectionState state) {
-            System.out.println("ICE connection state: " + state);
-        }
-    }
 }
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/PeerConnectionExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/PeerConnectionExample.java
index ee7679de..e9133f4e 100644
--- a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/PeerConnectionExample.java
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/PeerConnectionExample.java
@@ -16,298 +16,170 @@
 
 package dev.onvoid.webrtc.examples;
 
-import java.util.ArrayList;
-import java.util.List;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-import dev.onvoid.webrtc.PeerConnectionFactory;
-import dev.onvoid.webrtc.PeerConnectionObserver;
-import dev.onvoid.webrtc.RTCConfiguration;
-import dev.onvoid.webrtc.RTCDataChannel;
-import dev.onvoid.webrtc.RTCIceCandidate;
-import dev.onvoid.webrtc.RTCIceConnectionState;
-import dev.onvoid.webrtc.RTCIceGatheringState;
-import dev.onvoid.webrtc.RTCIceServer;
-import dev.onvoid.webrtc.RTCPeerConnection;
-import dev.onvoid.webrtc.RTCPeerConnectionState;
-import dev.onvoid.webrtc.RTCRtpReceiver;
-import dev.onvoid.webrtc.RTCRtpSender;
-import dev.onvoid.webrtc.RTCRtpTransceiver;
-import dev.onvoid.webrtc.RTCSignalingState;
-import dev.onvoid.webrtc.media.MediaStream;
-import dev.onvoid.webrtc.media.MediaStreamTrack;
-import dev.onvoid.webrtc.media.audio.AudioOptions;
-import dev.onvoid.webrtc.media.audio.AudioTrack;
-import dev.onvoid.webrtc.media.audio.AudioTrackSink;
-import dev.onvoid.webrtc.media.audio.AudioTrackSource;
-import dev.onvoid.webrtc.media.video.VideoDeviceSource;
-import dev.onvoid.webrtc.media.video.VideoFrame;
-import dev.onvoid.webrtc.media.video.VideoTrack;
-import dev.onvoid.webrtc.media.video.VideoTrackSink;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+import dev.onvoid.webrtc.client.Devices;
+import dev.onvoid.webrtc.client.LocalAudio;
+import dev.onvoid.webrtc.client.LocalCamera;
+import dev.onvoid.webrtc.client.MediaException;
+import dev.onvoid.webrtc.client.Peer;
+import dev.onvoid.webrtc.client.PeerOptions;
+import dev.onvoid.webrtc.client.RemoteTrack;
+import dev.onvoid.webrtc.client.Signaling;
+import dev.onvoid.webrtc.client.SignalingPair;
+import dev.onvoid.webrtc.client.WebRtc;
 
 /**
- * Example demonstrating how to set up a peer connection with audio and video tracks
- * to be able to send and receive media.
+ * Example demonstrating how to send a camera and a microphone over a peer connection, and how
+ * to receive the video frames and audio on the other side, with the client API.
  * 

* This example shows how to: *

    - *
  • Create a PeerConnectionFactory
  • - *
  • Create audio and video tracks
  • - *
  • Set up a peer connection
  • - *
  • Add tracks to the peer connection for sending media
  • - *
  • Implement callbacks to receive incoming audio and video frames
  • + *
  • Create a {@link WebRtc} runtime that captures from and plays to the audio devices
  • + *
  • List the cameras and microphones
  • + *
  • Open the camera and the microphone as local tracks
  • + *
  • Connect two peers through a loopback signaling, in place of a server
  • + *
  • Send the tracks, and receive them as remote tracks with their frames and audio
  • + *
  • Mute a received audio track, while its audio still reaches a handler
  • *
*

- * Note: This example focuses only on setting up the local peer connection with - * audio and video tracks for bidirectional media transfer. In a real application, - * you would need to establish a connection with a remote peer through a signaling - * channel (e.g., WebSocket). + * Both peers run in this process, so the call connects without a server. In a real + * application each peer is in another process, and its {@link Signaling} carries the messages + * over a WebSocket or the like, as the web example does. Nothing else changes. + *

+ * Run it, and press Enter to end the call: + *

+ * mvn exec:java -D"exec.mainClass=dev.onvoid.webrtc.examples.PeerConnectionExample"
+ * 
* * @author Alex Andres */ public class PeerConnectionExample { - public static void main(String[] args) { - // Create a PeerConnectionFactory, which is the main entry point for WebRTC. - PeerConnectionFactory factory = new PeerConnectionFactory(); - - try { - LocalPeer localPeer = new LocalPeer(factory); - - // Keep the application running to observe state changes. - System.out.println("Press Enter to exit..."); - System.in.read(); - - // Clean up. - localPeer.dispose(); - } - catch (Exception e) { - Logger.getLogger(PeerConnectionExample.class.getName()) - .log(Level.SEVERE, "Error in PeerConnectionExample", e); - } - finally { - // Dispose the factory when done - factory.dispose(); - } - } - - /** - * Represents a peer connection with audio and video tracks. - */ - private static class LocalPeer implements PeerConnectionObserver { - - private final RTCPeerConnection peerConnection; - private final AudioTrackSource audioSource; - private final AudioTrack audioTrack; - private final VideoTrack videoTrack; - private final RTCRtpSender audioSender; - private final RTCRtpSender videoSender; - private final AudioFrameLogger audioFrameLogger = new AudioFrameLogger(); - private final VideoFrameLogger videoFrameLogger = new VideoFrameLogger(); - - - public LocalPeer(PeerConnectionFactory factory) { - // Create a basic configuration for the peer connection. - RTCConfiguration config = new RTCConfiguration(); - - // Add a STUN server to help with NAT traversal. - RTCIceServer iceServer = new RTCIceServer(); - iceServer.urls.add("stun:stun.l.google.com:19302"); - config.iceServers.add(iceServer); - - // Create the peer connection. - peerConnection = factory.createPeerConnection(config, this); - - // Create an audio source with options. - AudioOptions audioOptions = new AudioOptions(); - audioOptions.echoCancellation = true; - audioOptions.autoGainControl = true; - audioOptions.noiseSuppression = true; - - audioSource = factory.createAudioSource(audioOptions); - audioTrack = factory.createAudioTrack("audio0", audioSource); - - VideoDeviceSource videoSource = new VideoDeviceSource(); - videoTrack = factory.createVideoTrack("video0", videoSource); - - // Add the tracks to the peer connection. - List streamIds = new ArrayList<>(); - streamIds.add("stream1"); - audioSender = peerConnection.addTrack(audioTrack, streamIds); - videoSender = peerConnection.addTrack(videoTrack, streamIds); - - System.out.println("LocalPeer: Created with audio and video tracks"); - } - - /** - * Closes the peer connection and releases resources. - */ - public void dispose() { - if (audioTrack != null) { - audioTrack.removeSink(audioFrameLogger); - } - if (videoTrack != null) { - videoTrack.removeSink(videoFrameLogger); + public static void main(String[] args) throws Exception { + // The runtime captures from the microphone and plays to the speakers selected in its + // devices. + try (WebRtc rtc = WebRtc.create()) { + Devices devices = rtc.media().devices(); + + devices.cameras().forEach(camera -> System.out.println("Camera: " + camera.getName())); + devices.microphones().forEach(mic -> System.out.println("Microphone: " + mic.getName())); + + // The two ends of a loopback signaling hand each message to the other peer. + SignalingPair signaling = Signaling.loopback(); + + Peer caller = rtc.createPeer(PeerOptions.builder() + .name("caller") + .iceServer("stun:stun.l.google.com:19302") + .signaling(signaling.first()) + .build()); + Peer callee = rtc.createPeer(PeerOptions.builder() + .name("callee") + .iceServer("stun:stun.l.google.com:19302") + .polite(true) + .signaling(signaling.second()) + .build()); + + caller.onStateChange(state -> System.out.println("Caller is " + state)); + callee.onTrack(PeerConnectionExample::receive); + + boolean sending = false; + + if (devices.cameras().isEmpty()) { + System.out.println("No camera, sending no video"); } - // RTCRtpSender instances are not owned by the peer connection; the - // application must dispose them once they are no longer needed. - if (audioSender != null) { - audioSender.dispose(); + else { + try { + LocalCamera camera = rtc.media().camera(); + caller.addTrack(camera); + sending = true; + + System.out.println("Sending the camera " + camera.device().getName()); + } + catch (MediaException e) { + System.out.println("The camera could not be opened: " + e.getMessage()); + } } - if (videoSender != null) { - videoSender.dispose(); - } - if (peerConnection != null) { - peerConnection.close(); - } - // AudioTrackSource is ref-counted and not owned by the audio - // track; the application must dispose it once no longer needed. - if (audioSource != null) { - audioSource.dispose(); - } - } - - // PeerConnectionObserver implementation. - - @Override - public void onIceCandidate(RTCIceCandidate candidate) { - System.out.println("LocalPeer: New ICE candidate: " + candidate.sdp); - // In a real application, you would send this candidate to the remote peer - // through your signaling channel. - } - - @Override - public void onConnectionChange(RTCPeerConnectionState state) { - System.out.println("LocalPeer: Connection state changed to: " + state); - } - - @Override - public void onIceConnectionChange(RTCIceConnectionState state) { - System.out.println("LocalPeer: ICE connection state changed to: " + state); - } - - @Override - public void onIceGatheringChange(RTCIceGatheringState state) { - System.out.println("LocalPeer: ICE gathering state changed to: " + state); - } - @Override - public void onSignalingChange(RTCSignalingState state) { - System.out.println("LocalPeer: Signaling state changed to: " + state); - } - - @Override - public void onDataChannel(RTCDataChannel dataChannel) { - System.out.println("LocalPeer: Data channel created: " + dataChannel.getLabel()); - } + if (devices.microphones().isEmpty()) { + System.out.println("No microphone, sending no audio"); + } + else { + // Echo cancellation, noise suppression and gain control are on. + LocalAudio microphone = rtc.media().microphone(); + caller.addTrack(microphone); + sending = true; - @Override - public void onRenegotiationNeeded() { - System.out.println("LocalPeer: Renegotiation needed"); - // In a real application, you would create an offer and set it as the local description. - } + System.out.println("Sending the microphone"); + } - @Override - public void onAddTrack(RTCRtpReceiver receiver, MediaStream[] mediaStreams) { - System.out.println("LocalPeer: Track added: " + receiver.getTrack().getKind()); + if (!sending) { + System.out.println("There is nothing to send. The ClientApiExample needs no devices."); + return; + } - // The receiver is a query result the application owns; dispose it - // once its track has been retrieved. - receiver.dispose(); - } + // Adding the tracks negotiated the call. The main thread may wait; a handler must not. + caller.whenConnected().get(15, TimeUnit.SECONDS); - @Override - public void onRemoveTrack(RTCRtpReceiver receiver) { - System.out.println("LocalPeer: Track removed: " + receiver.getTrack().getKind()); + caller.onStats(Duration.ofSeconds(5), stats -> System.out.printf( + "Caller sends %d kbit/s over a %s candidate%n", + Math.max(0, stats.outgoingBitrate()) / 1000, stats.candidateType())); - receiver.dispose(); + System.out.println("Press Enter to end the call..."); + System.in.read(); } + // Closing the runtime closed the peers, the camera and the microphone. - @Override - public void onTrack(RTCRtpTransceiver transceiver) { - RTCRtpReceiver receiver = transceiver.getReceiver(); - MediaStreamTrack track = receiver.getTrack(); - String kind = track.getKind(); - - if (kind.equals(MediaStreamTrack.AUDIO_TRACK_KIND)) { - AudioTrack audioTrack = (AudioTrack) track; - audioTrack.addSink(audioFrameLogger); - } - if (kind.equals(MediaStreamTrack.VIDEO_TRACK_KIND)) { - VideoTrack videoTrack = (VideoTrack) track; - videoTrack.addSink(videoFrameLogger); - } - - System.out.println("LocalPeer: Transceiver track added: " + kind); - - // The receiver and transceiver are query results the application - // owns; dispose them once the track has been retrieved. The track - // itself is unaffected and keeps delivering frames to its sink. - receiver.dispose(); - transceiver.dispose(); - } + System.out.println("Closed"); } - - /** - * A simple implementation of VideoTrackSink that logs information about received frames. + * Logs what the callee receives, once a second. */ - private static class VideoFrameLogger implements VideoTrackSink { + private static void receive(RemoteTrack track) { + String kind = track.kind().name().toLowerCase(); - private static final long LOG_INTERVAL_MS = 1000; // Log every second - private int frameCount = 0; - private long lastLogTime = System.currentTimeMillis(); + System.out.println("Callee receives the " + kind + " track"); + if (track.isVideo()) { + AtomicInteger frames = new AtomicInteger(); + AtomicLong lastLog = new AtomicLong(System.nanoTime()); - @Override - public void onVideoFrame(VideoFrame frame) { - frameCount++; + // The frame is released after the handler returns. A slow handler gets the newest + // frame next, and the frames in between are dropped. + track.asVideo().onFrame(frame -> { + int count = frames.incrementAndGet(); + long now = System.nanoTime(); - long now = System.currentTimeMillis(); - if (now - lastLogTime >= LOG_INTERVAL_MS) { - System.out.printf("Received %d video frames in the last %.1f seconds%n", - frameCount, (now - lastLogTime) / 1000.0); - System.out.printf("Last frame: %dx%d, rotation: %d, timestamp: %dms%n", - frame.buffer.getWidth(), frame.buffer.getHeight(), frame.rotation, - frame.timestampNs / 1000000); + if (now - lastLog.get() >= TimeUnit.SECONDS.toNanos(1)) { + System.out.printf("Received %d video frames, the last one %dx%d%n", count, + frame.buffer.getWidth(), frame.buffer.getHeight()); - frameCount = 0; - lastLogTime = now; - } - - // Release the native resources associated with this frame to prevent memory leaks. - frame.release(); + frames.set(0); + lastLog.set(now); + } + }); } - } + else { + // The callee would play the microphone back to the speakers, and the microphone + // would pick it up again. Muting the track keeps its audio for the handler. + track.asAudio().setVolume(0); + AtomicLong millis = new AtomicLong(); + track.asAudio().onAudio(chunk -> { + long total = millis.addAndGet(chunk.frames() * 1000L / chunk.sampleRate()); - /** - * A simple implementation of AudioTrackSink that logs information about received audio data. - */ - private static class AudioFrameLogger implements AudioTrackSink { - - private static final long LOG_INTERVAL_MS = 1000; // Log every second - private int frameCount = 0; - private long lastLogTime = System.currentTimeMillis(); - - - @Override - public void onData(byte[] data, int bitsPerSample, int sampleRate, int channels, int frames) { - frameCount++; - - long now = System.currentTimeMillis(); - if (now - lastLogTime >= LOG_INTERVAL_MS) { - System.out.printf("Received %d audio frames in the last %.1f seconds%n", - frameCount, (now - lastLogTime) / 1000.0); - System.out.printf("Last audio data: %d bytes, %d bits/sample, %d Hz, %d channels, %d frames%n", - data.length, bitsPerSample, sampleRate, channels, frames); - - frameCount = 0; - lastLogTime = now; - } + if (total % 1000 == 0) { + System.out.printf("Received %d s of audio, %d Hz, %d channel(s)%n", total / 1000, + chunk.sampleRate(), chunk.channels()); + } + }); } + + track.onEnded(() -> System.out.println("The " + kind + " track ended")); } -} \ No newline at end of file +}