diff --git a/webrtc-examples/pom.xml b/webrtc-examples/pom.xml index 4f459d1a..37329111 100644 --- a/webrtc-examples/pom.xml +++ b/webrtc-examples/pom.xml @@ -16,6 +16,7 @@ true true UTF-8 + 12.0.23 @@ -48,6 +49,13 @@ + + + + src/main/resources + resources + + @@ -56,5 +64,34 @@ webrtc-java ${project.version} + + + org.eclipse.jetty + jetty-server + ${jetty.version} + + + org.eclipse.jetty.websocket + jetty-websocket-jetty-server + ${jetty.version} + + + org.eclipse.jetty + jetty-util + ${jetty.version} + + + + + com.fasterxml.jackson.core + jackson-databind + 2.16.1 + + + + org.slf4j + slf4j-jdk14 + 2.0.17 + \ No newline at end of file diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/CodecListExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/CodecListExample.java index bba5bcb8..87225511 100644 --- a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/CodecListExample.java +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/CodecListExample.java @@ -1,5 +1,5 @@ /* - * Copyright 2025 WebRTC Java Contributors + * Copyright 2025 Alex Andres * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. 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 88fddc6d..ee95938e 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 @@ -1,5 +1,5 @@ /* - * Copyright 2025 WebRTC Java Contributors + * Copyright 2025 Alex Andres * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. 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 27cc7eb2..2c670cdf 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 @@ -1,5 +1,5 @@ /* - * Copyright 2025 WebRTC Java Contributors + * Copyright 2025 Alex Andres * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/WhepExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/WhepExample.java index 3280d194..cde86f14 100644 --- a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/WhepExample.java +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/WhepExample.java @@ -1,6 +1,17 @@ /* - * Example application that demonstrates how to set up a WebRTC peer connection, - * create an offer, and accept a remote answer. + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package dev.onvoid.webrtc.examples; diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/util/MediaFrameLogger.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/util/MediaFrameLogger.java new file mode 100644 index 00000000..ae1f748a --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/util/MediaFrameLogger.java @@ -0,0 +1,113 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.util; + +import dev.onvoid.webrtc.media.audio.AudioTrackSink; +import dev.onvoid.webrtc.media.video.VideoFrame; +import dev.onvoid.webrtc.media.video.VideoTrackSink; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility class for logging audio and video frame information. + * + * @author Alex Andres + */ +public class MediaFrameLogger { + + private static final Logger LOG = LoggerFactory.getLogger(MediaFrameLogger.class); + + + /** + * Creates a new audio track sink that logs information about received audio data. + * + * @return An AudioTrackSink that logs audio frame information. + */ + public static AudioTrackSink createAudioLogger() { + return new AudioFrameLogger(); + } + + /** + * Creates a new video track sink that logs information about received video frames. + * + * @return A VideoTrackSink that logs video frame information. + */ + public static VideoTrackSink createVideoLogger() { + return new VideoFrameLogger(); + } + + + + /** + * 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) { + LOG.info(String.format("Received %d audio frames in the last %.1f seconds", + frameCount, (now - lastLogTime) / 1000.0)); + LOG.info(String.format("Last audio data: %d bytes, %d bits/sample, %d Hz, %d channels, %d frames", + data.length, bitsPerSample, sampleRate, channels, frames)); + + frameCount = 0; + lastLogTime = now; + } + } + } + + + + /** + * A simple implementation of VideoTrackSink that logs information about received frames. + */ + private static class VideoFrameLogger implements VideoTrackSink { + + private static final long LOG_INTERVAL_MS = 1000; // Log every second + private int frameCount = 0; + private long lastLogTime = System.currentTimeMillis(); + + @Override + public void onVideoFrame(VideoFrame frame) { + frameCount++; + + long now = System.currentTimeMillis(); + if (now - lastLogTime >= LOG_INTERVAL_MS) { + LOG.info(String.format("Received %d video frames in the last %.1f seconds", + frameCount, (now - lastLogTime) / 1000.0)); + LOG.info(String.format("Last frame: %dx%d, rotation: %d, timestamp: %dms", + frame.buffer.getWidth(), frame.buffer.getHeight(), frame.rotation, + frame.timestampNs / 1000000)); + + frameCount = 0; + lastLogTime = now; + } + + // Release the native resources associated with this frame to prevent memory leaks. + frame.release(); + } + } +} \ No newline at end of file diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/WebClientExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/WebClientExample.java new file mode 100644 index 00000000..41b9b30e --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/WebClientExample.java @@ -0,0 +1,246 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.web; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.logging.LogManager; + +import dev.onvoid.webrtc.examples.web.model.LeaveMessage; +import dev.onvoid.webrtc.media.MediaStreamTrack; + +import dev.onvoid.webrtc.examples.web.client.SignalingManager; +import dev.onvoid.webrtc.examples.web.connection.PeerConnectionManager; +import dev.onvoid.webrtc.examples.web.media.MediaManager; +import dev.onvoid.webrtc.examples.web.model.JoinMessage; +import dev.onvoid.webrtc.examples.util.MediaFrameLogger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Example demonstrating how to combine WebSocket signaling with WebRTC peer connections. + *

+ * This example shows how to: + *

+ * + * @author Alex Andres + */ +public class WebClientExample { + + static { + setupLogging(); + } + + private static final Logger LOG = LoggerFactory.getLogger(WebClientExample.class); + + /** Manages communication with the signaling server. */ + private final SignalingManager signaling; + + /** Handles WebRTC peer connection setup and management. */ + private final PeerConnectionManager peerConnectionManager; + + /** Manages audio and video tracks and devices. */ + private final MediaManager mediaManager; + + + /** + * Creates a new WebClientExample that combines WebSocket signaling with WebRTC. + * + * @param signalingUri The URI of the signaling server. + * @param subprotocol The WebSocket subprotocol to use. + */ + public WebClientExample(URI signalingUri, String subprotocol) { + signaling = new SignalingManager(signalingUri, subprotocol); + signaling.setUserId("java-client"); + + peerConnectionManager = new PeerConnectionManager(); + + mediaManager = new MediaManager(); + mediaManager.createTracks(peerConnectionManager, true); + + setupCallbacks(); + + LOG.info("WebClientExample created with audio and video tracks"); + } + + /** + * Sets up callbacks between components. + */ + private void setupCallbacks() { + // Set up signaling callbacks. + signaling.setOnOfferReceived(peerConnectionManager::handleOffer); + signaling.setOnAnswerReceived(peerConnectionManager::handleAnswer); + signaling.setOnCandidateReceived(peerConnectionManager::handleCandidate); + signaling.setOnJoinReceived(this::handleJoin); + signaling.setOnLeaveReceived(this::handleLeave); + + // Set up peer connection callbacks. + peerConnectionManager.setOnLocalDescriptionCreated(signaling::sendSessionDescription); + peerConnectionManager.setOnIceCandidateGenerated(signaling::sendIceCandidate); + peerConnectionManager.setOnTrackReceived(this::handleTrackReceived); + } + + /** + * Handles a received media track. + * + * @param track The received media track. + */ + private void handleTrackReceived(MediaStreamTrack track) { + String kind = track.getKind(); + LOG.info("Received track: {}", kind); + + if (kind.equals(MediaStreamTrack.AUDIO_TRACK_KIND)) { + mediaManager.addAudioSink(MediaFrameLogger.createAudioLogger()); + } + else if (kind.equals(MediaStreamTrack.VIDEO_TRACK_KIND)) { + mediaManager.addVideoSink(MediaFrameLogger.createVideoLogger()); + } + } + + /** + * Connects to the signaling server. + * + * @return true if the connection was successful, false otherwise. + */ + public boolean connect() { + return signaling.connect(); + } + + /** + * Disconnects from the signaling server and closes the peer connection. + */ + public void disconnect() { + signaling.disconnect(); + + if (mediaManager != null) { + mediaManager.dispose(); + } + if (peerConnectionManager != null) { + peerConnectionManager.close(); + } + + LOG.info("Disconnected from signaling server and closed peer connection"); + } + + /** + * Joins a room on the signaling server. + *

+ * This method sends a join request to the signaling server for the specified room. + * + * @param roomName the name of the room to join on the signaling server + */ + public void joinRoom(String roomName) { + if (!signaling.isConnected()) { + LOG.warn("Cannot join room: not connected to signaling server"); + return; + } + + signaling.sendJoin(roomName); + } + + /** + * Initiates a call by creating and sending an offer. + */ + public void call() { + if (!signaling.isConnected()) { + LOG.warn("Cannot initiate call: not connected to signaling server"); + return; + } + + peerConnectionManager.setInitiator(true); + peerConnectionManager.createOffer(); + } + + /** + * Handles an incoming join message from the remote peer. + * + * @param message The join message. + */ + private void handleJoin(JoinMessage message) { + LOG.info("Received join message from peer: {}", message.getFrom()); + + // Remote peer wants to join a room, we need to create an offer if we're not the initiator. + if (!peerConnectionManager.isInitiator()) { + peerConnectionManager.createOffer(); + } + } + + /** + * Handles an incoming leave message from the remote peer. + * + * @param message The leave message. + */ + private void handleLeave(LeaveMessage message) { + LOG.info("Peer {} has left the room", message.getFrom()); + + // Handle peer leaving logic, such as closing tracks or updating UI. + } + + /** + * Entry point to demonstrate the WebClientExample. + */ + public static void main(String[] args) { + try { + WebClientExample client = new WebClientExample( + URI.create("wss://localhost:8443/ws"), + "ws-signaling"); + + if (client.connect()) { + LOG.info("Connected to signaling server"); + + // Join a room. + client.joinRoom("default-room"); + + // Initiate the call. + client.call(); + + // Keep the application running to observe state changes. + System.out.println("Press Enter to exit..."); + System.in.read(); + } + else { + LOG.error("Failed to connect to signaling server"); + } + + // Clean up. + client.disconnect(); + } + catch (Exception e) { + LOG.error("Error in WebClientExample", e); + } + } + + /** + * Sets up logging for the application. + */ + public static void setupLogging() { + try { + InputStream configFile = WebClientExample.class.getResourceAsStream("/resources/logging.properties"); + LogManager.getLogManager().readConfiguration(configFile); + } + catch (IOException e) { + System.err.println("Could not load configuration file:"); + System.err.println(e.getMessage()); + } + } +} \ No newline at end of file diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/client/SignalingManager.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/client/SignalingManager.java new file mode 100644 index 00000000..2d6fd4ea --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/client/SignalingManager.java @@ -0,0 +1,415 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.web.client; + +import java.net.URI; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import dev.onvoid.webrtc.RTCIceCandidate; +import dev.onvoid.webrtc.RTCSessionDescription; + +import dev.onvoid.webrtc.examples.web.model.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manages WebSocket signaling for WebRTC connections. + *

+ * This class handles the communication between peers during WebRTC connection establishment. + * It manages sending and receiving: + *

+ *

+ * The manager connects to a signaling server via WebSocket and translates WebRTC-related + * objects to JSON messages that can be transmitted over the signaling channel. + * + * @author Alex Andres + */ +public class SignalingManager { + + private static final Logger LOG = LoggerFactory.getLogger(SignalingManager.class); + + /** The WebSocket client used to communicate with the signaling server. */ + private final WebSocketClient signaling; + + /** JSON mapper for serializing and deserializing signaling messages. */ + private final ObjectMapper jsonMapper; + + /** Callback that is triggered when an offer message is received from a remote peer. */ + private Consumer onOfferReceived; + + /** Callback that is triggered when an answer message is received from a remote peer. */ + private Consumer onAnswerReceived; + + /** Callback that is triggered when an ICE candidate is received from a remote peer. */ + private Consumer onCandidateReceived; + + /** Callback that is triggered when a join message is received. */ + private Consumer onJoinReceived; + + /** Callback that is triggered when a leave message is received. */ + private Consumer onLeaveReceived; + + private String userId = "java-client"; + private String remotePeerId = "web-client"; + + + /** + * Creates a new SignalingManager with the specified URI and subprotocol. + * + * @param signalingUri The URI of the signaling server. + * @param subprotocol The WebSocket subprotocol to use. + */ + public SignalingManager(URI signalingUri, String subprotocol) { + this.signaling = new WebSocketClient(signalingUri, subprotocol); + this.jsonMapper = new ObjectMapper(); + + setupSignaling(); + + LOG.info("SignalingManager created"); + } + + /** + * Connects to the signaling server. + * + * @return true if the connection was successful, false otherwise. + */ + public boolean connect() { + try { + CountDownLatch connectLatch = new CountDownLatch(1); + + signaling.connect() + .thenAccept(ws -> { + LOG.info("Connected to signaling server"); + connectLatch.countDown(); + }) + .exceptionally(e -> { + LOG.error("Failed to connect to signaling server", e); + connectLatch.countDown(); + return null; + }); + + // Wait for connection. + connectLatch.await(5, TimeUnit.SECONDS); + + return signaling.isConnected(); + } + catch (Exception e) { + LOG.error("Error connecting to signaling server", e); + return false; + } + } + + /** + * Disconnects from the signaling server. + */ + public void disconnect() { + if (isConnected()) { + signaling.disconnect(1000, "Client disconnecting"); + + LOG.info("Disconnected from signaling server"); + } + } + + /** + * Sends a join message to the signaling server. + * + * @param roomId The ID of the room to join. + */ + public void sendJoin(String roomId) { + try { + JoinMessage joinMessage = new JoinMessage(userId, roomId, "Java Client"); + + // Convert to JSON and send. + String joinMessageJson = jsonMapper.writeValueAsString(joinMessage); + signaling.sendMessage(joinMessageJson); + + LOG.info("Sent join message for room: {}", roomId); + } + catch (Exception e) { + LOG.error("Error creating JSON join message", e); + } + } + + /** + * Sends a session description (offer or answer) to the remote peer. + * + * @param sdp The session description to send. + */ + public void sendSessionDescription(RTCSessionDescription sdp) { + String type = sdp.sdpType.toString().toLowerCase(); + + try { + SessionDescriptionMessage message = new SessionDescriptionMessage( + type, + userId, + remotePeerId, + sdp.sdp + ); + + String messageJson = jsonMapper.writeValueAsString(message); + signaling.sendMessage(messageJson); + + LOG.info("Sent {} to remote peer", type); + } + catch (Exception e) { + LOG.error("Error creating JSON message", e); + } + } + + /** + * Sends an ICE candidate to the remote peer. + * + * @param candidate The ICE candidate to send. + */ + public void sendIceCandidate(RTCIceCandidate candidate) { + try { + IceCandidateMessage message = new IceCandidateMessage( + userId, + remotePeerId, + candidate.sdp, + candidate.sdpMid, + candidate.sdpMLineIndex + ); + + String candidateJson = jsonMapper.writeValueAsString(message); + signaling.sendMessage(candidateJson); + + LOG.info("Sent ICE candidate to remote peer"); + } + catch (Exception e) { + LOG.error("Error creating JSON message", e); + } + } + + /** + * Sets up the WebSocket signaling to handle incoming messages. + */ + private void setupSignaling() { + signaling.addMessageListener(message -> { + try { + // Parse the message to extract the type. + String type = jsonMapper.readTree(message).path("type").asText(); + MessageType messageType = MessageType.fromString(type); + + switch (messageType) { + case OFFER -> handleOfferMessage(message); + case ANSWER -> handleAnswerMessage(message); + case ICE_CANDIDATE -> handleIceCandidateMessage(message); + case JOIN -> handleJoinMessage(message); + case LEAVE -> handleLeaveMessage(message); + case HEARTBEAT_ACK -> LOG.info("Received: {}", type); + default -> LOG.info("Received message with unknown type: {}", type); + } + } + catch (Exception e) { + LOG.error("Error parsing message type", e); + } + }); + + signaling.addCloseListener(() -> { + LOG.info("Signaling connection closed"); + }); + + signaling.addErrorListener(error -> { + LOG.error("Signaling error", error); + }); + } + + /** + * Sets a callback to be invoked when an offer is received. + * + * @param callback The callback to invoke. + */ + public void setOnOfferReceived(Consumer callback) { + this.onOfferReceived = callback; + } + + /** + * Sets a callback to be invoked when an answer is received. + * + * @param callback The callback to invoke. + */ + public void setOnAnswerReceived(Consumer callback) { + this.onAnswerReceived = callback; + } + + /** + * Sets a callback to be invoked when an ICE candidate is received. + * + * @param callback The callback to invoke. + */ + public void setOnCandidateReceived(Consumer callback) { + this.onCandidateReceived = callback; + } + + /** + * Sets a callback to be invoked when a join message is received. + * + * @param callback The callback to invoke. + */ + public void setOnJoinReceived(Consumer callback) { + this.onJoinReceived = callback; + } + + /** + * Sets a callback to be invoked when a leave message is received. + * + * @param callback The callback to invoke. + */ + public void setOnLeaveReceived(Consumer callback) { + this.onLeaveReceived = callback; + } + + /** + * Sets the user ID for this client. + * + * @param userId The user ID to set. + */ + public void setUserId(String userId) { + this.userId = userId; + } + + /** + * Sets the remote peer ID. + * + * @param remotePeerId The remote peer ID to set. + */ + public void setRemotePeerId(String remotePeerId) { + this.remotePeerId = remotePeerId; + } + + /** + * Gets the user ID for this client. + * + * @return The user ID. + */ + public String getUserId() { + return userId; + } + + /** + * Gets the remote peer ID. + * + * @return The remote peer ID. + */ + public String getRemotePeerId() { + return remotePeerId; + } + + /** + * Checks if the signaling connection is established. + * + * @return true if connected, false otherwise. + */ + public boolean isConnected() { + return signaling != null && signaling.isConnected(); + } + + /** + * Handles an incoming offer message. + * + * @param message The JSON message containing an offer. + */ + private void handleOfferMessage(String message) { + if (onOfferReceived != null) { + try { + SessionDescriptionMessage sdpMessage = jsonMapper.readValue(message, SessionDescriptionMessage.class); + onOfferReceived.accept(sdpMessage); + } + catch (Exception e) { + LOG.error("Error parsing offer message", e); + } + } + } + + /** + * Handles an incoming answer message. + * + * @param message The JSON message containing an answer. + */ + private void handleAnswerMessage(String message) { + if (onAnswerReceived != null) { + try { + SessionDescriptionMessage sdpMessage = jsonMapper.readValue(message, SessionDescriptionMessage.class); + onAnswerReceived.accept(sdpMessage); + } + catch (Exception e) { + LOG.error("Error parsing answer message", e); + } + } + } + + /** + * Handles an incoming ICE candidate message. + * + * @param message The JSON message containing an ICE candidate. + */ + private void handleIceCandidateMessage(String message) { + if (onCandidateReceived != null) { + try { + IceCandidateMessage iceMessage = jsonMapper.readValue(message, IceCandidateMessage.class); + onCandidateReceived.accept(iceMessage); + } + catch (Exception e) { + LOG.error("Error parsing ICE candidate message", e); + } + } + } + + /** + * Handles an incoming join message. + * + * @param message The JSON message containing join information. + */ + private void handleJoinMessage(String message) { + if (onJoinReceived != null) { + try { + JoinMessage joinMessage = jsonMapper.readValue(message, JoinMessage.class); + onJoinReceived.accept(joinMessage); + } + catch (Exception e) { + LOG.error("Error parsing join message", e); + } + } + } + + /** + * Handles an incoming leave message. + * + * @param message The JSON message indicating a peer has left. + */ + private void handleLeaveMessage(String message) { + if (onLeaveReceived != null) { + try { + LeaveMessage leaveMessage = jsonMapper.readValue(message, LeaveMessage.class); + onLeaveReceived.accept(leaveMessage); + } + catch (Exception e) { + LOG.error("Error parsing join message", e); + } + } + } +} \ No newline at end of file diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/client/WebSocketClient.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/client/WebSocketClient.java new file mode 100644 index 00000000..e2a87628 --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/client/WebSocketClient.java @@ -0,0 +1,453 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.web.client; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import dev.onvoid.webrtc.examples.web.model.SignalingMessage; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A WebSocket client implementation using JDK internal WebSockets (java.net.http.WebSocket). + * This client supports secure WebSocket connections (WSS), message sending/receiving, + * and automatic heartbeat to keep the connection alive. + * + * @author Alex Andres + */ +public class WebSocketClient { + + private static final Logger LOG = LoggerFactory.getLogger(WebSocketClient.class); + + /** The interval between heartbeat messages in seconds to keep the WebSocket connection alive. */ + private static final long HEARTBEAT_INTERVAL_SECONDS = 15; + + /** JSON serializer/deserializer for processing WebSocket messages. */ + private final ObjectMapper jsonMapper = new ObjectMapper(); + + private final URI serverUri; + private final List subprotocols; + private final List> messageListeners; + private final List closeListeners; + private final List> errorListeners; + + private WebSocket webSocket; + private ScheduledExecutorService heartbeatExecutor; + private boolean connected = false; + + + /** + * Creates a new WebSocket client. + * + * @param serverUri The URI of the WebSocket server (e.g., "wss://localhost:8443/ws"). + * @param subprotocols The WebSocket subprotocols to use (e.g., "ws-signaling"). + */ + public WebSocketClient(URI serverUri, List subprotocols) { + this.serverUri = serverUri; + this.subprotocols = subprotocols; + this.messageListeners = new CopyOnWriteArrayList<>(); + this.closeListeners = new CopyOnWriteArrayList<>(); + this.errorListeners = new CopyOnWriteArrayList<>(); + } + + /** + * Creates a new WebSocket client with a single subprotocol. + * + * @param serverUri The URI of the WebSocket server. + * @param subprotocol The WebSocket subprotocol to use. + */ + public WebSocketClient(URI serverUri, String subprotocol) { + this(serverUri, List.of(subprotocol)); + } + + /** + * Connects to the WebSocket server. + * + * @return A CompletableFuture that completes when the connection is established. + */ + public CompletableFuture connect() { + if (connected) { + return CompletableFuture.completedFuture(webSocket); + } + + WebSocket.Listener listener = new WebSocketListener(); + + // Create an HTTP client that accepts all SSL certificates (useful for self-signed certs in development). + HttpClient client = HttpClient.newBuilder() + .sslContext(TrustAllCertificates.createSslContext()) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + // Build the WebSocket with our listener and subprotocols. + WebSocket.Builder wsBuilder = client.newWebSocketBuilder() + .connectTimeout(Duration.ofSeconds(10)); + + // Add subprotocols if provided. + if (subprotocols != null && !subprotocols.isEmpty()) { + for (String protocol : subprotocols) { + wsBuilder.subprotocols(protocol); + } + } + + LOG.info("Connecting to WebSocket server: {}", serverUri); + + return wsBuilder.buildAsync(serverUri, listener) + .thenApply(ws -> { + this.webSocket = ws; + this.connected = true; + + // Start heartbeat to keep connection alive. + startHeartbeat(); + + LOG.info("Connected to WebSocket server: {}", serverUri); + return ws; + }) + .exceptionally(ex -> { + LOG.error("Failed to connect to WebSocket server: {}", ex.getMessage()); + notifyErrorListeners(ex); + return null; + }); + } + + /** + * Sends a text message to the WebSocket server. + * + * @param message The message to send. + * + * @return A CompletableFuture that completes when the message is sent. + */ + public CompletableFuture sendMessage(String message) { + if (!connected || webSocket == null) { + return CompletableFuture.failedFuture( + new IllegalStateException("Not connected to WebSocket server")); + } + + LOG.debug("Sending message: {}", message); + + return webSocket.sendText(message, true); + } + + /** + * Closes the WebSocket connection. + * + * @param statusCode The WebSocket close status code. + * @param reason The reason for closing. + * + * @return A CompletableFuture that completes when the connection is closed. + */ + public CompletableFuture disconnect(int statusCode, String reason) { + if (!connected || webSocket == null) { + return CompletableFuture.completedFuture(null); + } + + stopHeartbeat(); + + LOG.info("Disconnecting from WebSocket server: {}", serverUri); + + return webSocket.sendClose(statusCode, reason) + .thenApply(ws -> { + this.connected = false; + this.webSocket = null; + return ws; + }); + } + + /** + * Adds a listener for incoming text messages. + * + * @param listener The message listener. + */ + public void addMessageListener(Consumer listener) { + messageListeners.add(listener); + } + + /** + * Removes a message listener. + * + * @param listener The message listener to remove. + */ + public void removeMessageListener(Consumer listener) { + messageListeners.remove(listener); + } + + /** + * Adds a listener for connection close events. + * + * @param listener The close listener. + */ + public void addCloseListener(Runnable listener) { + closeListeners.add(listener); + } + + /** + * Removes a close listener. + * + * @param listener The close listener to remove. + */ + public void removeCloseListener(Runnable listener) { + closeListeners.remove(listener); + } + + /** + * Adds a listener for connection errors. + * + * @param listener The error listener. + */ + public void addErrorListener(Consumer listener) { + errorListeners.add(listener); + } + + /** + * Removes an error listener. + * + * @param listener The error listener to remove. + */ + public void removeErrorListener(Consumer listener) { + errorListeners.remove(listener); + } + + /** + * Checks if the client is connected to the WebSocket server. + * + * @return true if connected, false otherwise. + */ + public boolean isConnected() { + return connected; + } + + /** + * Starts the heartbeat to keep the WebSocket connection alive. + */ + private void startHeartbeat() { + // Ensure any existing heartbeat is stopped. + stopHeartbeat(); + + heartbeatExecutor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "websocket-heartbeat"); + t.setDaemon(true); + return t; + }); + + heartbeatExecutor.scheduleAtFixedRate(() -> { + if (connected && webSocket != null) { + LOG.debug("Sending heartbeat"); + + sendHeartbeat(); + } + }, HEARTBEAT_INTERVAL_SECONDS, HEARTBEAT_INTERVAL_SECONDS, TimeUnit.SECONDS); + } + + /** + * Sends a heartbeat message to the WebSocket server to keep the connection alive. + */ + private void sendHeartbeat() { + SignalingMessage heartbeat = new SignalingMessage("heartbeat"); + String heartbeatJson; + + try { + heartbeatJson = jsonMapper.writeValueAsString(heartbeat); + } + catch (JsonProcessingException e) { + LOG.error("Failed to send heartbeat", e); + return; + } + + sendMessage(heartbeatJson) + .exceptionally(ex -> { + LOG.error("Failed to send heartbeat: {}", ex.getMessage()); + return null; + }); + } + + /** + * Stops the heartbeat. + */ + private void stopHeartbeat() { + if (heartbeatExecutor != null && !heartbeatExecutor.isShutdown()) { + heartbeatExecutor.shutdownNow(); + heartbeatExecutor = null; + } + } + + /** + * Notifies all registered message listeners with the received message. + * Any exceptions thrown by listeners are caught and logged to prevent + * them from affecting other listeners or the WebSocket connection. + * + * @param message The message received from the WebSocket server. + */ + private void notifyMessageListeners(String message) { + for (Consumer listener : messageListeners) { + try { + listener.accept(message); + } + catch (Exception e) { + LOG.error("Error in message listener", e); + } + } + } + + /** + * Notifies all registered close listeners that the WebSocket connection has closed. + * Any exceptions thrown by listeners are caught and logged to prevent + * them from affecting other listeners. + */ + private void notifyCloseListeners() { + for (Runnable listener : closeListeners) { + try { + listener.run(); + } + catch (Exception e) { + LOG.error("Error in close listener", e); + } + } + } + + /** + * Notifies all registered error listeners about a WebSocket error. + * Any exceptions thrown by listeners are caught and logged to prevent + * them from affecting other listeners. + * + * @param error The error that occurred during WebSocket communication. + */ + private void notifyErrorListeners(Throwable error) { + for (Consumer listener : errorListeners) { + try { + listener.accept(error); + } + catch (Exception e) { + LOG.error("Error in error listener", e); + } + } + } + + + + /** + * WebSocket listener implementation. + */ + private class WebSocketListener implements WebSocket.Listener { + + private final StringBuilder messageBuilder = new StringBuilder(); + + + @Override + public void onOpen(WebSocket webSocket) { + LOG.debug("WebSocket connection opened"); + WebSocket.Listener.super.onOpen(webSocket); + } + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + messageBuilder.append(data); + + if (last) { + String message = messageBuilder.toString(); + messageBuilder.setLength(0); + + LOG.debug("Received message: {}", message); + notifyMessageListeners(message); + } + + return WebSocket.Listener.super.onText(webSocket, data, last); + } + + @Override + public CompletionStage onBinary(WebSocket webSocket, ByteBuffer data, boolean last) { + LOG.debug("Received binary data, size: {}", data.remaining()); + return WebSocket.Listener.super.onBinary(webSocket, data, last); + } + + @Override + public CompletionStage onPing(WebSocket webSocket, ByteBuffer message) { + LOG.debug("Received ping"); + return WebSocket.Listener.super.onPing(webSocket, message); + } + + @Override + public CompletionStage onPong(WebSocket webSocket, ByteBuffer message) { + LOG.debug("Received pong"); + return WebSocket.Listener.super.onPong(webSocket, message); + } + + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + LOG.info("WebSocket connection closed: {} - {}", statusCode, reason); + connected = false; + WebSocketClient.this.webSocket = null; + stopHeartbeat(); + notifyCloseListeners(); + return WebSocket.Listener.super.onClose(webSocket, statusCode, reason); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + LOG.error("WebSocket error", error); + notifyErrorListeners(error); + WebSocket.Listener.super.onError(webSocket, error); + } + } + + + + /** + * Utility class to create an SSL context that trusts all certificates. + * This is useful for development with self-signed certificates. + */ + private static class TrustAllCertificates { + + public static javax.net.ssl.SSLContext createSslContext() { + try { + javax.net.ssl.SSLContext sslContext = javax.net.ssl.SSLContext.getInstance("TLS"); + sslContext.init(null, new javax.net.ssl.TrustManager[] { + new javax.net.ssl.X509TrustManager() { + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return new java.security.cert.X509Certificate[0]; + } + + public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) { + } + + public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) { + } + } + }, new java.security.SecureRandom()); + + return sslContext; + } + catch (Exception e) { + throw new RuntimeException("Failed to create SSL context", e); + } + } + } +} diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/connection/PeerConnectionManager.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/connection/PeerConnectionManager.java new file mode 100644 index 00000000..fbece89c --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/connection/PeerConnectionManager.java @@ -0,0 +1,429 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.web.connection; + +import java.util.List; +import java.util.function.Consumer; + +import dev.onvoid.webrtc.CreateSessionDescriptionObserver; +import dev.onvoid.webrtc.PeerConnectionFactory; +import dev.onvoid.webrtc.PeerConnectionObserver; +import dev.onvoid.webrtc.RTCAnswerOptions; +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.RTCOfferOptions; +import dev.onvoid.webrtc.RTCPeerConnection; +import dev.onvoid.webrtc.RTCPeerConnectionState; +import dev.onvoid.webrtc.RTCRtpReceiver; +import dev.onvoid.webrtc.RTCRtpTransceiver; +import dev.onvoid.webrtc.RTCSdpType; +import dev.onvoid.webrtc.RTCSessionDescription; +import dev.onvoid.webrtc.RTCSignalingState; +import dev.onvoid.webrtc.SetSessionDescriptionObserver; +import dev.onvoid.webrtc.examples.web.model.IceCandidateMessage; +import dev.onvoid.webrtc.examples.web.model.SessionDescriptionMessage; +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.AudioTrackSource; +import dev.onvoid.webrtc.media.audio.CustomAudioSource; +import dev.onvoid.webrtc.media.video.VideoTrack; +import dev.onvoid.webrtc.media.video.VideoTrackSource; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manages WebRTC peer connections, including creation, configuration, + * and session description handling. Also handles signaling messages related to + * peer connections such as offers, answers, and ICE candidates. + * + * @author Alex Andres + */ +public class PeerConnectionManager implements PeerConnectionSignalingHandler { + + private static final Logger LOG = LoggerFactory.getLogger(PeerConnectionManager.class); + + private final PeerConnectionFactory factory; + private final RTCPeerConnection peerConnection; + + private Consumer onLocalDescriptionCreated; + private Consumer onIceCandidateGenerated; + private Consumer onTrackReceived; + + private boolean isInitiator = false; + + + /** + * Creates a new PeerConnectionManager. + */ + public PeerConnectionManager() { + factory = new PeerConnectionFactory(); + + // Create peer connection with default configuration. + RTCIceServer iceServer = new RTCIceServer(); + iceServer.urls.add("stun:stun.l.google.com:19302"); + + RTCConfiguration config = new RTCConfiguration(); + config.iceServers.add(iceServer); + + peerConnection = factory.createPeerConnection(config, new PeerConnectionObserverImpl()); + + LOG.info("PeerConnectionManager created"); + } + + /** + * Adds a media track to the peer connection. + * + * @param track The media track to add. + * @param streamIds The stream IDs to associate with the track. + */ + public void addTrack(MediaStreamTrack track, List streamIds) { + peerConnection.addTrack(track, streamIds); + + LOG.info("Added track: {}", track.getKind()); + } + + /** + * Creates an audio track with the specified options and label. + * + * @param options Configuration options for the audio track source. + * @param label A unique identifier for the audio track. + * + * @return A new AudioTrack instance configured with the provided options. + */ + public AudioTrack createAudioTrack(AudioOptions options, String label) { + AudioTrackSource audioSource = factory.createAudioSource(options); + + return factory.createAudioTrack(label, audioSource); + } + + /** + * Creates an audio track with a custom audio source that can push audio frames. + * + * @param source The custom audio source providing the audio frames. + * @param label A unique identifier for the audio track. + * + * @return A new AudioTrack instance connected to the provided custom source. + */ + public AudioTrack createAudioTrack(CustomAudioSource source, String label) { + return factory.createAudioTrack(label, source); + } + + /** + * Creates a video track with the specified source and label. + * + * @param source The video track source providing the video frames. + * @param label A unique identifier for the video track. + * + * @return A new VideoTrack instance connected to the provided source. + */ + public VideoTrack createVideoTrack(VideoTrackSource source, String label) { + return factory.createVideoTrack(label, source); + } + + /** + * Closes the peer connection. + */ + public void close() { + if (peerConnection != null) { + peerConnection.close(); + } + + if (factory != null) { + factory.dispose(); + } + + LOG.info("Peer connection closed"); + } + + /** + * Creates an offer to initiate a connection. + */ + public void createOffer() { + RTCOfferOptions options = new RTCOfferOptions(); + + peerConnection.createOffer(options, new CreateSessionDescriptionObserver() { + @Override + public void onSuccess(RTCSessionDescription sdp) { + setLocalDescription(sdp); + } + + @Override + public void onFailure(String error) { + LOG.error("Failed to create offer: {}", error); + } + }); + } + + /** + * Creates an answer in response to an offer. + */ + public void createAnswer() { + RTCAnswerOptions options = new RTCAnswerOptions(); + peerConnection.createAnswer(options, new CreateSessionDescriptionObserver() { + @Override + public void onSuccess(RTCSessionDescription answer) { + setLocalDescription(answer); + } + + @Override + public void onFailure(String error) { + LOG.error("Failed to create answer: {}", error); + } + }); + } + + /** + * Sets the local session description. + * + * @param sdp The session description to set. + */ + private void setLocalDescription(RTCSessionDescription sdp) { + peerConnection.setLocalDescription(sdp, new SetSessionDescriptionObserver() { + @Override + public void onSuccess() { + LOG.info("Local description set successfully"); + + if (onLocalDescriptionCreated != null) { + onLocalDescriptionCreated.accept(sdp); + } + } + + @Override + public void onFailure(String error) { + LOG.error("Failed to set local session description: {}", error); + } + }); + } + + /** + * Sets the remote session description. + * + * @param sdp The session description to set. + * @param isOffer True if the description is an offer, false otherwise. + */ + public void setRemoteDescription(RTCSessionDescription sdp, boolean isOffer) { + peerConnection.setRemoteDescription(sdp, new SetSessionDescriptionObserver() { + @Override + public void onSuccess() { + LOG.info("Remote description set successfully"); + + if (isOffer) { + createAnswer(); + } + } + + @Override + public void onFailure(String error) { + LOG.error("Failed to set remote description: {}", error); + } + }); + } + + /** + * Adds an ICE candidate to the peer connection. + * + * @param candidate The ICE candidate to add. + */ + public void addIceCandidate(RTCIceCandidate candidate) { + peerConnection.addIceCandidate(candidate); + LOG.info("Added ICE candidate: {}", candidate.sdp); + } + + /** + * Sets a callback to be invoked when a local session description is created. + * + * @param callback The callback to invoke. + */ + public void setOnLocalDescriptionCreated(Consumer callback) { + this.onLocalDescriptionCreated = callback; + } + + /** + * Sets a callback to be invoked when an ICE candidate is generated. + * + * @param callback The callback to invoke. + */ + public void setOnIceCandidateGenerated(Consumer callback) { + this.onIceCandidateGenerated = callback; + } + + /** + * Sets a callback to be invoked when a media track is received. + * + * @param callback The callback to invoke. + */ + public void setOnTrackReceived(Consumer callback) { + this.onTrackReceived = callback; + } + + /** + * Sets whether this peer is the initiator of the connection. + * + * @param isInitiator true if this peer is the initiator, false otherwise. + */ + public void setInitiator(boolean isInitiator) { + this.isInitiator = isInitiator; + } + + /** + * Gets whether this peer is the initiator of the connection. + * + * @return true if this peer is the initiator, false otherwise. + */ + public boolean isInitiator() { + return isInitiator; + } + + @Override + public void handleOffer(SessionDescriptionMessage message) { + LOG.info("Received offer"); + + try { + String sdpString = message.getSdp(); + String fromPeer = message.getFrom(); + + LOG.info("Parsed offer from: {}", fromPeer); + + // Create remote session description. + RTCSessionDescription sdp = new RTCSessionDescription(RTCSdpType.OFFER, sdpString); + + // Set remote description and create answer + setRemoteDescription(sdp, true); + } + catch (Exception e) { + LOG.error("Error parsing offer JSON", e); + } + } + + @Override + public void handleAnswer(SessionDescriptionMessage message) { + LOG.info("Received answer"); + + try { + String sdpString = message.getSdp(); + + LOG.info("Successfully parsed answer JSON"); + + // Create remote session description. + RTCSessionDescription sdp = new RTCSessionDescription(RTCSdpType.ANSWER, sdpString); + + // Set remote description + setRemoteDescription(sdp, false); + } + catch (Exception e) { + LOG.error("Error parsing answer JSON", e); + } + } + + @Override + public void handleCandidate(IceCandidateMessage message) { + LOG.info("Received ICE candidate"); + + try { + IceCandidateMessage.IceCandidateData data = message.getData(); + String sdpMid = data.getSdpMid(); + int sdpMLineIndex = data.getSdpMLineIndex(); + String candidate = data.getCandidate(); + + LOG.info("Successfully parsed ICE candidate JSON"); + + RTCIceCandidate iceCandidate = new RTCIceCandidate(sdpMid, sdpMLineIndex, candidate); + addIceCandidate(iceCandidate); + } + catch (Exception e) { + LOG.error("Error parsing ICE candidate JSON", e); + } + } + + + + /** + * Implementation of PeerConnectionObserver to handle WebRTC events. + */ + private class PeerConnectionObserverImpl implements PeerConnectionObserver { + + @Override + public void onIceCandidate(RTCIceCandidate candidate) { + LOG.info("New ICE candidate: {}", candidate.sdp); + + if (onIceCandidateGenerated != null) { + onIceCandidateGenerated.accept(candidate); + } + } + + @Override + public void onConnectionChange(RTCPeerConnectionState state) { + LOG.info("Connection state changed to: {}", state); + } + + @Override + public void onIceConnectionChange(RTCIceConnectionState state) { + LOG.info("ICE connection state changed to: {}", state); + } + + @Override + public void onIceGatheringChange(RTCIceGatheringState state) { + LOG.info("ICE gathering state changed to: {}", state); + } + + @Override + public void onSignalingChange(RTCSignalingState state) { + LOG.info("Signaling state changed to: {}", state); + } + + @Override + public void onDataChannel(RTCDataChannel dataChannel) { + LOG.info("Data channel created: {}", dataChannel.getLabel()); + } + + @Override + public void onRenegotiationNeeded() { + LOG.info("Renegotiation needed"); + createOffer(); + } + + @Override + public void onAddTrack(RTCRtpReceiver receiver, MediaStream[] mediaStreams) { + LOG.info("Track added: {}", receiver.getTrack().getKind()); + } + + @Override + public void onRemoveTrack(RTCRtpReceiver receiver) { + LOG.info("Track removed: {}", receiver.getTrack().getKind()); + } + + @Override + public void onTrack(RTCRtpTransceiver transceiver) { + MediaStreamTrack track = transceiver.getReceiver().getTrack(); + String kind = track.getKind(); + + LOG.info("{} track added to transceiver", kind); + + if (onTrackReceived != null) { + onTrackReceived.accept(track); + } + } + } +} \ No newline at end of file diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/connection/PeerConnectionSignalingHandler.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/connection/PeerConnectionSignalingHandler.java new file mode 100644 index 00000000..fda95cb3 --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/connection/PeerConnectionSignalingHandler.java @@ -0,0 +1,51 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.web.connection; + +import dev.onvoid.webrtc.examples.web.model.IceCandidateMessage; +import dev.onvoid.webrtc.examples.web.model.SessionDescriptionMessage; + +/** + * This interface defines methods for processing signaling messages + * directly related to WebRTC peer connections. + * + * @author Alex Andres + */ +public interface PeerConnectionSignalingHandler { + + /** + * Handles an incoming offer from a remote peer. + * + * @param message The session description message containing the offer. + */ + void handleOffer(SessionDescriptionMessage message); + + /** + * Handles an incoming answer from a remote peer. + * + * @param message The session description message containing the answer. + */ + void handleAnswer(SessionDescriptionMessage message); + + /** + * Handles an incoming ICE candidate from a remote peer. + * + * @param message The message containing the ICE candidate. + */ + void handleCandidate(IceCandidateMessage message); + +} \ No newline at end of file diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/media/AudioGenerator.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/media/AudioGenerator.java new file mode 100644 index 00000000..7e2ba87f --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/media/AudioGenerator.java @@ -0,0 +1,174 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.web.media; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import dev.onvoid.webrtc.media.audio.CustomAudioSource; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A generator that produces synthetic audio samples as a continuous sine wave. + *

+ * This class creates a 440 Hz (A4 note) sine wave and delivers audio frames + * at regular 10 ms intervals. The generated audio is stereo with 16-bit samples + * at a 48 kHz sample rate. The audio generator runs on a dedicated scheduled + * thread that can be started and stopped on demand. + *

+ *

+ * The sine wave phase is maintained between audio frames to ensure a continuous + * waveform without clicks or pops that would occur from phase discontinuities. + *

+ * + * @author Alex Andres + */ +public class AudioGenerator { + + private static final Logger LOG = LoggerFactory.getLogger(AudioGenerator.class); + + /** The custom audio source that receives generated audio frames. */ + private final CustomAudioSource customAudioSource; + + /** Flag indicating whether the audio generator is running. */ + private final AtomicBoolean generatorRunning = new AtomicBoolean(false); + + /** Executor service for scheduling audio sample generation. */ + private ScheduledExecutorService executorService; + + /** Future for the scheduled audio sample generation task. */ + private ScheduledFuture generatorFuture; + + /** Default audio parameters */ + private static final int DEFAULT_BITS_PER_SAMPLE = 16; + private static final int DEFAULT_SAMPLE_RATE = 48000; + private static final int DEFAULT_CHANNELS = 2; + private static final int DEFAULT_FRAME_COUNT = 480; // 10ms at 48kHz + + /** + * Sine wave phase tracking for audio generation. + * Maintained between frames to ensure a continuous sine wave without clicks or pops. + */ + private double sinePhase = 0.0; + + + public AudioGenerator(CustomAudioSource audioSource) { + customAudioSource = audioSource; + } + + /** + * Starts the audio sample generator thread that pushes audio frames + * to the custom audio source every 10 ms. + */ + public void start() { + if (generatorRunning.get()) { + LOG.info("Audio generator is already running"); + return; + } + + executorService = Executors.newSingleThreadScheduledExecutor(); + + generatorRunning.set(true); + + generatorFuture = executorService.scheduleAtFixedRate(() -> { + if (!generatorRunning.get()) { + return; + } + + try { + // Create a buffer with audio data (silence in this case). + int bytesPerSample = DEFAULT_BITS_PER_SAMPLE / 8; + byte[] audioData = new byte[DEFAULT_FRAME_COUNT * DEFAULT_CHANNELS * bytesPerSample]; + + // Generate a pleasant sine wave at 440 Hz (A4 note). + double amplitude = 0.2; // 30% of maximum to avoid being too loud + double frequency = 440.0; // A4 note (440 Hz) + double radiansPerSample = 2.0 * Math.PI * frequency / DEFAULT_SAMPLE_RATE; + + for (int i = 0; i < DEFAULT_FRAME_COUNT; i++) { + short sample = (short) (amplitude * Short.MAX_VALUE * Math.sin(sinePhase)); + sinePhase += radiansPerSample; + + // Keep the phase between 0 and 2Ï€. + if (sinePhase > 2.0 * Math.PI) { + sinePhase -= 2.0 * Math.PI; + } + + // Write the sample to both channels (stereo). + for (int channel = 0; channel < DEFAULT_CHANNELS; channel++) { + int index = (i * DEFAULT_CHANNELS + channel) * bytesPerSample; + // Write as little-endian (LSB first). + audioData[index] = (byte) (sample & 0xFF); + audioData[index + 1] = (byte) ((sample >> 8) & 0xFF); + } + } + + // Push the audio data to the custom audio source. + customAudioSource.pushAudio( + audioData, + DEFAULT_BITS_PER_SAMPLE, + DEFAULT_SAMPLE_RATE, + DEFAULT_CHANNELS, + DEFAULT_FRAME_COUNT + ); + } + catch (Exception e) { + LOG.error("Error in audio generator thread", e); + } + }, 0, 10, TimeUnit.MILLISECONDS); + + LOG.info("Audio generator started"); + } + + /** + * Stops the audio sample generator thread. + */ + public void stop() { + if (!generatorRunning.get()) { + return; + } + + generatorRunning.set(false); + + if (generatorFuture != null) { + generatorFuture.cancel(false); + generatorFuture = null; + } + + if (executorService != null) { + executorService.shutdown(); + try { + if (!executorService.awaitTermination(100, TimeUnit.MILLISECONDS)) { + executorService.shutdownNow(); + } + } + catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + executorService = null; + } + + LOG.info("Audio generator stopped"); + } + +} diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/media/MediaManager.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/media/MediaManager.java new file mode 100644 index 00000000..b448feda --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/media/MediaManager.java @@ -0,0 +1,214 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.web.media; + +import java.util.List; + +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.CustomAudioSource; +import dev.onvoid.webrtc.media.video.VideoDeviceSource; +import dev.onvoid.webrtc.media.video.VideoTrack; +import dev.onvoid.webrtc.media.video.VideoTrackSink; + +import dev.onvoid.webrtc.examples.web.connection.PeerConnectionManager; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manages media tracks for WebRTC connections, including audio and video. + * + * @author Alex Andres + */ +public class MediaManager { + + private static final Logger LOG = LoggerFactory.getLogger(MediaManager.class); + + /** The audio track managed by this class. */ + private AudioTrack audioTrack; + + /** The video track managed by this class. */ + private VideoTrack videoTrack; + + /** The custom audio source for generating audio frames. */ + private CustomAudioSource customAudioSource; + + /** The audio generator responsible for creating and pushing audio frames to the custom audio source. */ + private AudioGenerator audioGenerator; + + + /** + * Creates a new MediaManager. + */ + public MediaManager() { + LOG.info("MediaManager created"); + } + + /** + * Creates and initializes audio and video tracks. + * + * @param peerConnectionManager The peer connection manager to use for creating tracks. + */ + public void createTracks(PeerConnectionManager peerConnectionManager) { + createAudioTrack(peerConnectionManager); + createVideoTrack(peerConnectionManager); + + // Add tracks to the peer connection. + List streamIds = List.of("stream0"); + + peerConnectionManager.addTrack(getAudioTrack(), streamIds); + peerConnectionManager.addTrack(getVideoTrack(), streamIds); + } + + /** + * Creates and initializes tracks with a custom audio source and standard video track. + * + * @param peerConnectionManager The peer connection manager to use for creating tracks. + * @param useCustomAudio Whether to use a custom audio source that can push frames. + */ + public void createTracks(PeerConnectionManager peerConnectionManager, boolean useCustomAudio) { + if (useCustomAudio) { + createCustomAudioTrack(peerConnectionManager); + } + else { + createAudioTrack(peerConnectionManager); + } + + createVideoTrack(peerConnectionManager); + + // Add tracks to the peer connection. + List streamIds = List.of("stream0"); + + peerConnectionManager.addTrack(getAudioTrack(), streamIds); + peerConnectionManager.addTrack(getVideoTrack(), streamIds); + } + + /** + * Creates an audio track with default options. + */ + private void createAudioTrack(PeerConnectionManager peerConnectionManager) { + AudioOptions audioOptions = new AudioOptions(); + audioOptions.echoCancellation = true; + audioOptions.autoGainControl = true; + audioOptions.noiseSuppression = true; + + audioTrack = peerConnectionManager.createAudioTrack(audioOptions, "audio0"); + + LOG.info("Audio track created"); + } + + /** + * Creates an audio track with a custom audio source that can push audio frames. + * Also starts the audio generator thread that pushes audio frames every 10ms. + * + * @param peerConnectionManager The peer connection manager to use for creating the track. + */ + public void createCustomAudioTrack(PeerConnectionManager peerConnectionManager) { + customAudioSource = new CustomAudioSource(); + + audioTrack = peerConnectionManager.createAudioTrack(customAudioSource, "audio0"); + + // Start the audio generator. + audioGenerator = new AudioGenerator(customAudioSource); + audioGenerator.start(); + + LOG.info("Custom audio track created with audio generator"); + } + + /** + * Creates a video track using the default video device. + */ + private void createVideoTrack(PeerConnectionManager peerConnectionManager) { + VideoDeviceSource videoSource = new VideoDeviceSource(); + videoSource.start(); + + videoTrack = peerConnectionManager.createVideoTrack(videoSource, "video0"); + + LOG.info("Video track created"); + } + + /** + * Gets the audio track. + * + * @return The audio track. + */ + public AudioTrack getAudioTrack() { + return audioTrack; + } + + /** + * Gets the video track. + * + * @return The video track. + */ + public VideoTrack getVideoTrack() { + return videoTrack; + } + + /** + * Adds a sink to the audio track. + * + * @param sink The sink to add. + */ + public void addAudioSink(AudioTrackSink sink) { + if (audioTrack != null) { + audioTrack.addSink(sink); + + LOG.info("Added sink to audio track"); + } + } + + /** + * Adds a sink to the video track. + * + * @param sink The sink to add. + */ + public void addVideoSink(VideoTrackSink sink) { + if (videoTrack != null) { + videoTrack.addSink(sink); + + LOG.info("Added sink to video track"); + } + } + + /** + * Disposes of all media resources. + */ + public void dispose() { + // Stop the audio generator if it's running. + if (audioGenerator != null) { + audioGenerator.stop(); + audioGenerator = null; + } + + if (audioTrack != null) { + audioTrack.dispose(); + audioTrack = null; + } + + if (videoTrack != null) { + videoTrack.dispose(); + videoTrack = null; + } + + customAudioSource = null; + + LOG.info("Media resources disposed"); + } +} \ No newline at end of file diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/IceCandidateMessage.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/IceCandidateMessage.java new file mode 100644 index 00000000..d53f366a --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/IceCandidateMessage.java @@ -0,0 +1,170 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.web.model; + +/** + * Represents an ICE candidate message used in WebRTC signaling. + *

+ * This class is used to exchange ICE candidates between peers during + * the connection establishment process. + *

+ * + * @author Alex Andres + */ +public class IceCandidateMessage extends SignalingMessage { + + private IceCandidateData data; + + + /** + * Default constructor required for JSON deserialization. + */ + public IceCandidateMessage() { + super(MessageType.ICE_CANDIDATE.getValue()); + } + + /** + * Creates a new IceCandidateMessage with the specified sender and recipient. + * + * @param from the sender ID. + * @param to the recipient ID. + */ + public IceCandidateMessage(String from, String to) { + super(MessageType.ICE_CANDIDATE.getValue(), from, to); + + data = new IceCandidateData(); + } + + /** + * Creates a new IceCandidateMessage with the specified sender, recipient, and candidate information. + * + * @param from the sender ID. + * @param to the recipient ID. + * @param candidate the candidate string. + * @param sdpMid the SDP mid. + * @param sdpMLineIndex the SDP mline index. + */ + public IceCandidateMessage(String from, String to, String candidate, String sdpMid, int sdpMLineIndex) { + super(MessageType.ICE_CANDIDATE.getValue(), from, to); + + data = new IceCandidateData(candidate, sdpMid, sdpMLineIndex); + } + + /** + * Gets the ICE candidate data. + * + * @return the ICE candidate data. + */ + public IceCandidateData getData() { + return data; + } + + /** + * Sets the ICE candidate data. + * + * @param data the ICE candidate data. + */ + public void setData(IceCandidateData data) { + this.data = data; + } + + + + /** + * Inner class representing the data payload of an ICE candidate message. + */ + public static class IceCandidateData { + + private String candidate; + private String sdpMid; + private int sdpMLineIndex; + + + /** + * Default constructor required for JSON deserialization. + */ + public IceCandidateData() { + } + + /** + * Creates a new IceCandidateData with the specified candidate information. + * + * @param candidate the candidate string. + * @param sdpMid the SDP mid. + * @param sdpMLineIndex the SDP mline index. + */ + public IceCandidateData(String candidate, String sdpMid, int sdpMLineIndex) { + this.candidate = candidate; + this.sdpMid = sdpMid; + this.sdpMLineIndex = sdpMLineIndex; + } + + /** + * Gets the candidate string. + * + * @return the candidate string. + */ + public String getCandidate() { + return candidate; + } + + /** + * Sets the candidate string. + * + * @param candidate the candidate string. + */ + public void setCandidate(String candidate) { + this.candidate = candidate; + } + + /** + * Gets the SDP mid. + * + * @return the SDP mid. + */ + public String getSdpMid() { + return sdpMid; + } + + /** + * Sets the SDP mid. + * + * @param sdpMid the SDP mid. + */ + public void setSdpMid(String sdpMid) { + this.sdpMid = sdpMid; + } + + /** + * Gets the SDP mline index. + * + * @return the SDP mline index. + */ + public int getSdpMLineIndex() { + return sdpMLineIndex; + } + + /** + * Sets the SDP mline index. + * + * @param sdpMLineIndex the SDP mline index. + */ + public void setSdpMLineIndex(int sdpMLineIndex) { + this.sdpMLineIndex = sdpMLineIndex; + } + } +} \ No newline at end of file diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/JoinMessage.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/JoinMessage.java new file mode 100644 index 00000000..53b22077 --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/JoinMessage.java @@ -0,0 +1,207 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.web.model; + +/** + * Represents a join message used in WebRTC signaling. + * This class is used when a peer wants to join a room or session. + * + * @author Alex Andres + */ +public class JoinMessage extends SignalingMessage { + + private JoinData data; + + + /** + * Default constructor required for JSON deserialization. + */ + public JoinMessage() { + super(MessageType.JOIN.getValue()); + } + + /** + * Creates a new JoinMessage with the specified sender, room, and user information. + * + * @param from the sender ID. + * @param room the room to join. + * @param userName the user's name. + */ + public JoinMessage(String from, String room, String userName) { + super(MessageType.JOIN.getValue(), from, null); + + data = new JoinData(room, new UserInfo(userName, from)); + } + + /** + * Gets the join data. + * + * @return the join data. + */ + public JoinData getData() { + return data; + } + + /** + * Sets the join data. + * + * @param data the join data. + */ + public void setData(JoinData data) { + this.data = data; + } + + + + /** + * Inner class representing the data payload of a join message. + */ + public static class JoinData { + + private String room; + private UserInfo userInfo; + + + /** + * Default constructor required for JSON deserialization. + */ + public JoinData() { + } + + /** + * Creates a new JoinData with the specified room. + * + * @param room the room to join + */ + public JoinData(String room) { + this.room = room; + } + + /** + * Creates a new JoinData with the specified room and user information. + * + * @param room the room to join + * @param userInfo the user information + */ + public JoinData(String room, UserInfo userInfo) { + this.room = room; + this.userInfo = userInfo; + } + + /** + * Gets the room. + * + * @return the room + */ + public String getRoom() { + return room; + } + + /** + * Sets the room. + * + * @param room the room + */ + public void setRoom(String room) { + this.room = room; + } + + /** + * Gets the user information. + * + * @return the user information + */ + public UserInfo getUserInfo() { + return userInfo; + } + + /** + * Sets the user information. + * + * @param userInfo the user information + */ + public void setUserInfo(UserInfo userInfo) { + this.userInfo = userInfo; + } + } + + + + /** + * Inner class representing user information. + */ + public static class UserInfo { + + private String userId; + + private String name; + + + /** + * Default constructor required for JSON deserialization. + */ + public UserInfo() { + } + + /** + * Creates a new UserInfo with the specified name and avatar. + * + * @param name the user's name. + * @param userId the user's ID. + */ + public UserInfo(String name, String userId) { + this.name = name; + this.userId = userId; + } + + /** + * Gets the user's name. + * + * @return the user's name. + */ + public String getName() { + return name; + } + + /** + * Sets the user's name. + * + * @param name the user's name. + */ + public void setName(String name) { + this.name = name; + } + + /** + * Gets the user's ID. + * + * @return the user's ID. + */ + public String getUserId() { + return userId; + } + + /** + * Sets the user's ID. + * + * @param userId the user's ID. + */ + public void setUserId(String userId) { + this.userId = userId; + } + } +} \ No newline at end of file diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/LeaveMessage.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/LeaveMessage.java new file mode 100644 index 00000000..6b5cf1c6 --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/LeaveMessage.java @@ -0,0 +1,34 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.web.model; + +/** + * Represents a message indicating that a user is leaving a room. + * This class is used when a peer leaves a room or session. + * + * @author Alex Andres + */ +public class LeaveMessage extends SignalingMessage { + + /** + * Constructs a new leave message with default values. + */ + public LeaveMessage() { + super(MessageType.LEAVE.getValue()); + } + +} diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/MessageType.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/MessageType.java new file mode 100644 index 00000000..b4b8f2c7 --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/MessageType.java @@ -0,0 +1,87 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.web.model; + +/** + * Represents the types of messages exchanged during WebRTC signaling. + * Each message type corresponds to a specific action or state in the WebRTC connection process. + * + * @author Alex Andres + */ +public enum MessageType { + + /** WebRTC offer message containing session description. */ + OFFER("offer"), + + /** WebRTC answer message containing session description. */ + ANSWER("answer"), + + /** ICE candidate information for establishing peer connections. */ + ICE_CANDIDATE("ice-candidate"), + + /** Message indicating a peer joining the signaling session. */ + JOIN("join"), + + /** Message indicating a peer leaving the signaling session. */ + LEAVE("leave"), + + /** Heartbeat message to maintain connection. */ + HEARTBEAT("heartbeat"), + + /** Acknowledgment response to a heartbeat message. */ + HEARTBEAT_ACK("heartbeat-ack"), + + /** Fallback type for unrecognized messages. */ + UNKNOWN(""); + + /** The string representation of the message type. */ + private final String value; + + + /** + * Constructs a message type with the specified string value. + * + * @param value The string representation of this message type + */ + MessageType(String value) { + this.value = value; + } + + /** + * Returns the string representation of this message type. + * + * @return The string value of this message type + */ + public String getValue() { + return value; + } + + /** + * Finds and returns the message type enum constant that matches the given string. + * + * @param text The string to convert to a message type + * @return The matching message type, or UNKNOWN if no match is found + */ + public static MessageType fromString(String text) { + for (MessageType type : MessageType.values()) { + if (type.value.equals(text)) { + return type; + } + } + return UNKNOWN; + } +} diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/SessionDescriptionMessage.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/SessionDescriptionMessage.java new file mode 100644 index 00000000..08e529af --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/SessionDescriptionMessage.java @@ -0,0 +1,136 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.web.model; + +/** + * Represents a session description message used in WebRTC signaling. + *

+ * This class is used for both offer and answer messages, which contain + * Session Description Protocol (SDP) information. + *

+ * + * @author Alex Andres + */ +public class SessionDescriptionMessage extends SignalingMessage { + + private SessionDescriptionData data; + + + /** + * Default constructor required for JSON deserialization. + */ + public SessionDescriptionMessage() { + super(); + } + + /** + * Creates a new SessionDescriptionMessage with the specified type, sender, recipient, and SDP. + * + * @param type the message type (either "offer" or "answer"). + * @param from the sender ID. + * @param to the recipient ID. + * @param sdp the SDP string. + */ + public SessionDescriptionMessage(String type, String from, String to, String sdp) { + super(type, from, to); + + data = new SessionDescriptionData(sdp); + } + + /** + * Gets the session description data. + * + * @return the session description data. + */ + public SessionDescriptionData getData() { + return data; + } + + /** + * Sets the session description data. + * + * @param data the session description data. + */ + public void setData(SessionDescriptionData data) { + this.data = data; + } + + /** + * Sets the SDP string. + * + * @param sdp the SDP string. + */ + public void setSdp(String sdp) { + if (data == null) { + data = new SessionDescriptionData(); + } + data.setSdp(sdp); + } + + /** + * Gets the SDP string. + * + * @return the SDP string. + */ + public String getSdp() { + return data != null ? data.getSdp() : null; + } + + + + /** + * Inner class representing the data payload of a session description message. + */ + public static class SessionDescriptionData { + + private String sdp; + + + /** + * Default constructor required for JSON deserialization. + */ + public SessionDescriptionData() { + } + + /** + * Creates a new SessionDescriptionData with the specified SDP. + * + * @param sdp the SDP string. + */ + public SessionDescriptionData(String sdp) { + this.sdp = sdp; + } + + /** + * Gets the SDP string. + * + * @return the SDP string. + */ + public String getSdp() { + return sdp; + } + + /** + * Sets the SDP string. + * + * @param sdp the SDP string. + */ + public void setSdp(String sdp) { + this.sdp = sdp; + } + } +} \ No newline at end of file diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/SignalingMessage.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/SignalingMessage.java new file mode 100644 index 00000000..f09d7fcb --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/SignalingMessage.java @@ -0,0 +1,117 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.web.model; + +/** + * Base class for all signaling messages. + *

+ * This class represents the common structure of all messages exchanged + * during WebRTC signaling. Specific message types extend this class + * and add their own data payload. + *

+ * + * @author Alex Andres + */ +public class SignalingMessage { + + private String type; + private String from; + private String to; + + + /** + * Default constructor required for JSON deserialization. + */ + public SignalingMessage() { + } + + /** + * Creates a new SignalingMessage with the specified type. + * + * @param type the message type. + */ + public SignalingMessage(String type) { + this.type = type; + } + + /** + * Creates a new SignalingMessage with the specified type, sender, and recipient. + * + * @param type the message type. + * @param from the sender ID. + * @param to the recipient ID. + */ + public SignalingMessage(String type, String from, String to) { + this.type = type; + this.from = from; + this.to = to; + } + + /** + * Gets the message type. + * + * @return the message type. + */ + public String getType() { + return type; + } + + /** + * Sets the message type. + * + * @param type the message type. + */ + public void setType(String type) { + this.type = type; + } + + /** + * Gets the sender ID. + * + * @return the sender ID. + */ + public String getFrom() { + return from; + } + + /** + * Sets the sender ID. + * + * @param from the sender ID. + */ + public void setFrom(String from) { + this.from = from; + } + + /** + * Gets the recipient ID. + * + * @return the recipient ID. + */ + public String getTo() { + return to; + } + + /** + * Sets the recipient ID. + * + * @param to the recipient ID. + */ + public void setTo(String to) { + this.to = to; + } +} \ No newline at end of file diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/server/WebServer.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/server/WebServer.java new file mode 100644 index 00000000..98eb42a5 --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/server/WebServer.java @@ -0,0 +1,134 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.web.server; + +import java.io.InputStream; +import java.security.KeyStore; + +import dev.onvoid.webrtc.examples.web.WebClientExample; + +import org.eclipse.jetty.http.pathmap.PathSpec; +import org.eclipse.jetty.server.*; +import org.eclipse.jetty.server.handler.PathMappingsHandler; +import org.eclipse.jetty.server.handler.ResourceHandler; +import org.eclipse.jetty.util.resource.ResourceFactory; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.eclipse.jetty.websocket.server.WebSocketUpgradeHandler; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A web server implementation using Jetty that provides both HTTPS and WebSocket functionality. + *

+ * This server: + *

+ *

+ * + * @author Alex Andres + */ +public class WebServer { + + static { + WebClientExample.setupLogging(); + } + + public static final Logger LOG = LoggerFactory.getLogger(WebServer.class); + + private static final int PORT = 8443; + + + public static void main(String[] args) throws Exception { + Server server = new Server(); + + // Configure HTTPS only. + HttpConfiguration httpsConfig = new HttpConfiguration(); + httpsConfig.addCustomizer(new SecureRequestCustomizer()); + + // Create the HTTP/1.1 ConnectionFactory. + HttpConnectionFactory http = new HttpConnectionFactory(httpsConfig); + + // Create the TLS ConnectionFactory, setting HTTP/1.1 as the wrapped protocol. + SslContextFactory.Server sslContextFactory = createSslContextFactory(); + SslConnectionFactory tls = new SslConnectionFactory(sslContextFactory, http.getProtocol()); + + ServerConnector httpsConnector = new ServerConnector(server, tls, http); + httpsConnector.setPort(PORT); + + // Add only HTTPS connector. + server.addConnector(httpsConnector); + + // Create WebSocket upgrade handler. + WebSocketUpgradeHandler wsHandler = WebSocketUpgradeHandler.from(server, container -> { + container.addMapping("/ws", (upgradeRequest, upgradeResponse, callback) -> { + if (upgradeRequest.getSubProtocols().contains("ws-signaling")) { + upgradeResponse.setAcceptedSubProtocol("ws-signaling"); + return new WebSocketHandler(); + } + return null; + }); + }); + + // Create a static resource handler. + ResourceHandler resourceHandler = new ResourceHandler(); + ResourceFactory resourceFactory = ResourceFactory.of(resourceHandler); + resourceHandler.setBaseResource(resourceFactory.newClassLoaderResource("/resources/web/")); + + // Create a path mappings handler to combine WebSocket and static content. + PathMappingsHandler pathHandler = new PathMappingsHandler(); + pathHandler.addMapping(PathSpec.from("/ws/*"), wsHandler); + pathHandler.addMapping(PathSpec.from("/"), resourceHandler); + + server.setHandler(pathHandler); + server.start(); + + LOG.info("HTTPS: https://localhost:{}", PORT); + LOG.info("WebSocket: wss://localhost:{}/ws", PORT); + LOG.info("Note: Using self-signed certificate - browsers will show security warnings"); + + server.join(); + } + + private static SslContextFactory.Server createSslContextFactory() { + SslContextFactory.Server sslContextFactory = new SslContextFactory.Server(); + + try (InputStream keystoreStream = WebServer.class.getResourceAsStream("/resources/keystore.p12")) { + if (keystoreStream == null) { + throw new IllegalArgumentException("Keystore not found in resources."); + } + + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(keystoreStream, "l0c4lh0s7".toCharArray()); + + sslContextFactory.setKeyStore(keyStore); + sslContextFactory.setKeyStorePassword("l0c4lh0s7"); + } + catch (Exception e) { + LOG.error("Load keystore failed", e); + } + + // Security settings. + sslContextFactory.setIncludeProtocols("TLSv1.3", "TLSv1.2"); + + return sslContextFactory; + } +} diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/server/WebSocketHandler.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/server/WebSocketHandler.java new file mode 100644 index 00000000..d4ece58a --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/server/WebSocketHandler.java @@ -0,0 +1,173 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.examples.web.server; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArraySet; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import dev.onvoid.webrtc.examples.web.model.LeaveMessage; +import dev.onvoid.webrtc.examples.web.model.MessageType; +import dev.onvoid.webrtc.examples.web.model.SignalingMessage; + +import org.eclipse.jetty.websocket.api.Callback; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handles WebSocket connections and messages for the WebRTC signaling server. + *

+ * This class manages all active WebSocket sessions, processes incoming messages, + * and provides methods for broadcasting messages to connected clients. + *

+ * + * @author Alex Andres + */ +@WebSocket +public class WebSocketHandler { + + private static final Logger LOG = LoggerFactory.getLogger(WebSocketHandler.class); + + /** Thread-safe set to store all active sessions. */ + private static final Set sessions = new CopyOnWriteArraySet<>(); + + /** Thread-safe map to store session to user-id mappings. */ + private static final Map sessionToUserId = new ConcurrentHashMap<>(); + + /** JSON serializer/deserializer for processing WebSocket messages. */ + private final ObjectMapper jsonMapper = new ObjectMapper(); + + + @OnWebSocketOpen + public void onOpen(Session session) { + sessions.add(session); + + LOG.info("New connection: {} (Total connections: {})", session.getRemoteSocketAddress(), sessions.size()); + } + + @OnWebSocketMessage + public void onMessage(Session session, String message) { + LOG.info("Message from {}: {}", session.getRemoteSocketAddress(), message); + + try { + JsonNode messageNode = jsonMapper.readTree(message); + String type = messageNode.path("type").asText(); + + // Check if this is a heartbeat message. + if (MessageType.HEARTBEAT.getValue().equals(type)) { + // Send heartbeat acknowledgment. + SignalingMessage heartbeatAck = new SignalingMessage("heartbeat-ack"); + String heartbeatAckJson = jsonMapper.writeValueAsString(heartbeatAck); + + sendMessage(session, heartbeatAckJson); + return; + } + + // Check if this is a join message and extract the user-id. + if (MessageType.JOIN.getValue().equals(type)) { + String userId = messageNode.path("from").asText(); + + if (userId != null && !userId.isEmpty()) { + // Store the user-id for this session. + sessionToUserId.put(session, userId); + + LOG.info("Registered user-id '{}' for session {}", userId, session.getRemoteSocketAddress()); + } + } + } + catch (JsonProcessingException e) { + LOG.error("Error parsing message type", e); + } + + // Broadcast the message to all other connected peers (excluding sender). + broadcastToOthers(session, message); + } + + @OnWebSocketClose + public void onClose(Session session, int statusCode, String reason) { + sessions.remove(session); + + LOG.info("Connection closed: {} ({}) - Status: {}", session.getRemoteSocketAddress(), reason, statusCode); + LOG.info("Remaining connections: {}", sessions.size()); + + // Notify other clients about disconnection. + try { + // Get the user-id for this session or use the socket address as a fallback. + String userId = sessionToUserId.getOrDefault(session, session.getRemoteSocketAddress().toString()); + + // Create a leave message. + LeaveMessage leaveMessage = new LeaveMessage(); + leaveMessage.setFrom(userId); + + String leaveMessageJson = jsonMapper.writeValueAsString(leaveMessage); + broadcastToAll(leaveMessageJson); + + LOG.info("Sent participant left message for user-id: {}", userId); + + // Clean up the session-to-userId mapping. + sessionToUserId.remove(session); + } + catch (JsonProcessingException e) { + LOG.error("Error creating participant left message", e); + } + } + + @OnWebSocketError + public void onError(Session session, Throwable error) { + LOG.error("WebSocket error for {}", session.getRemoteSocketAddress(), error); + } + + /** + * Broadcasts a message to all connected peers except the sender. + * + * @param sender the session of the sender. + * @param message the message to broadcast. + */ + private void broadcastToOthers(Session sender, String message) { + sessions.stream() + .filter(Session::isOpen) + .filter(session -> !session.equals(sender)) + .forEach(session -> sendMessage(session, message)); + } + + /** + * Broadcasts a message to all connected peers. + * + * @param message the message to broadcast. + */ + private void broadcastToAll(String message) { + sessions.stream() + .filter(Session::isOpen) + .forEach(session -> sendMessage(session, message)); + } + + private void sendMessage(Session session, String message) { + session.sendText(message, Callback.from(() -> {}, throwable -> { + LOG.error("Error sending message to session: {}", throwable.getMessage()); + // Remove broken sessions. + sessions.remove(session); + })); + } +} diff --git a/webrtc-examples/src/main/java/module-info.java b/webrtc-examples/src/main/java/module-info.java index e051b4cd..b330984a 100644 --- a/webrtc-examples/src/main/java/module-info.java +++ b/webrtc-examples/src/main/java/module-info.java @@ -1,7 +1,14 @@ module webrtc.java.examples { + requires com.fasterxml.jackson.databind; requires java.logging; requires java.net.http; + requires org.eclipse.jetty.server; + requires org.eclipse.jetty.websocket.server; requires webrtc.java; + exports dev.onvoid.webrtc.examples.web.client; + exports dev.onvoid.webrtc.examples.web.server; + exports dev.onvoid.webrtc.examples.web.model; + } \ No newline at end of file diff --git a/webrtc-examples/src/main/resources/keystore.p12 b/webrtc-examples/src/main/resources/keystore.p12 new file mode 100644 index 00000000..b655bbac Binary files /dev/null and b/webrtc-examples/src/main/resources/keystore.p12 differ diff --git a/webrtc-examples/src/main/resources/logging.properties b/webrtc-examples/src/main/resources/logging.properties new file mode 100644 index 00000000..47bfe9da --- /dev/null +++ b/webrtc-examples/src/main/resources/logging.properties @@ -0,0 +1,11 @@ +.level = INFO + +# Configure console handler. +handlers = java.util.logging.ConsoleHandler + +# Set the console handler level and formatter. +java.util.logging.ConsoleHandler.level = ALL +java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter + +# Custom format pattern. +java.util.logging.SimpleFormatter.format = [%4$s] %5$s%n diff --git a/webrtc-examples/src/main/resources/web/PeerConnectionManager.js b/webrtc-examples/src/main/resources/web/PeerConnectionManager.js new file mode 100644 index 00000000..716013b7 --- /dev/null +++ b/webrtc-examples/src/main/resources/web/PeerConnectionManager.js @@ -0,0 +1,212 @@ +/** + * Manages WebRTC peer connections for real-time communication. + * + * This class encapsulates the WebRTC API functionality including + * - Connection initialization + * - Local media stream management + * - SDP offer/answer creation and handling + * - ICE candidate processing + * - Connection state monitoring + * + * @class + * @property {Object} configuration - The RTCPeerConnection configuration + * @property {function} onTrackCallback - Callback for track events + * @property {function} onIceCandidateCallback - Callback for ICE candidate events + * @property {function} onIceConnectionStateChangeCallback - Callback for ICE connection state changes + * @property {function} onConnectionStateChangeCallback - Callback for connection state changes + * @property {RTCPeerConnection|null} peerConnection - The WebRTC peer connection object + * @property {MediaStream|null} localStream - The local media stream + */ +class PeerConnectionManager { + + /** + * Creates a new PeerConnectionManager instance. + * + * @param {Array|Object} iceServers - ICE server configuration for the RTCPeerConnection + * @param {function} onTrack - Callback function that handles track events + * @param {function} onIceCandidate - Callback function for ICE candidate events + * @param {function} onIceConnectionStateChange - Callback function for ICE connection state changes + * @param {function} onConnectionStateChange - Callback function for connection state changes + */ + constructor(iceServers, onTrack, onIceCandidate, onIceConnectionStateChange, onConnectionStateChange) { + this.configuration = { iceServers }; + this.onTrackCallback = onTrack; + this.onIceCandidateCallback = onIceCandidate; + this.onIceConnectionStateChangeCallback = onIceConnectionStateChange; + this.onConnectionStateChangeCallback = onConnectionStateChange; + this.peerConnection = null; + this.localStream = null; + } + + // Helper method to get the current user ID + getUserId() { + // In a real application, this would be a unique identifier for the user + return "web-client"; + } + + // Helper method to get the remote peer ID + getRemotePeerId() { + // In a real application, this would be the ID of the peer we're communicating with + // For simplicity, we're using a fixed value here + return "java-client"; + } + + initialize() { + this.peerConnection = new RTCPeerConnection(this.configuration); + + this.peerConnection.ontrack = (event) => { + if (this.onTrackCallback) { + this.onTrackCallback(event); + } + }; + + this.peerConnection.onicecandidate = (event) => { + if (event.candidate && this.onIceCandidateCallback) { + const candidate = { + type: "ice-candidate", + from: this.getUserId(), + to: this.getRemotePeerId(), + data: { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex + } + }; + this.onIceCandidateCallback(candidate); + } + }; + + this.peerConnection.oniceconnectionstatechange = () => { + if (this.onIceConnectionStateChangeCallback) { + this.onIceConnectionStateChangeCallback(this.peerConnection.iceConnectionState); + } + }; + + this.peerConnection.onconnectionstatechange = () => { + if (this.onConnectionStateChangeCallback) { + this.onConnectionStateChangeCallback(this.peerConnection.connectionState); + } + }; + } + + async addLocalMedia() { + try { + this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }); + this.localStream.getTracks().forEach(track => { + this.peerConnection.addTrack(track, this.localStream); + }); + return true; + } + catch (error) { + console.error("Error accessing media devices:", error); + return false; + } + } + + async createOffer() { + try { + const offer = await this.peerConnection.createOffer(); + await this.peerConnection.setLocalDescription(offer); + return { + type: "offer", + from: this.getUserId(), + to: this.getRemotePeerId(), + data: { + sdp: this.peerConnection.localDescription.sdp + } + }; + } + catch (error) { + console.error("Error creating offer:", error); + throw error; + } + } + + async handleOffer(message) { + try { + // Extract SDP from the data object in the message format + const sdp = message.data.sdp.replace(/\\n/g, '\n'); + const sessionDescription = new RTCSessionDescription({ + type: "offer", + sdp: sdp + }); + await this.peerConnection.setRemoteDescription(sessionDescription); + return true; + } + catch (error) { + console.error("Error setting remote description for offer:", error); + throw error; + } + } + + async createAnswer() { + try { + const answer = await this.peerConnection.createAnswer(); + await this.peerConnection.setLocalDescription(answer); + + console.log("Local answer:", answer); + console.log("Local description:", this.peerConnection.localDescription); + + return { + type: "answer", + from: this.getUserId(), + to: this.getRemotePeerId(), + data: { + sdp: this.peerConnection.localDescription.sdp + } + }; + } + catch (error) { + console.error("Error creating answer:", error); + throw error; + } + } + + async handleAnswer(message) { + try { + // Extract SDP from the data object in the new message format + const sdp = message.data.sdp.replace(/\\n/g, '\n'); + const sessionDescription = new RTCSessionDescription({ + type: "answer", + sdp: sdp + }); + await this.peerConnection.setRemoteDescription(sessionDescription); + } + catch (error) { + console.error("Error setting remote description:", error); + throw error; + } + } + + async addIceCandidate(message) { + try { + // Extract candidate information from the data object in the new message format + const candidate = new RTCIceCandidate({ + sdpMid: message.data.sdpMid, + sdpMLineIndex: message.data.sdpMLineIndex, + candidate: message.data.candidate + }); + await this.peerConnection.addIceCandidate(candidate); + + console.log('ICE candidate added successfully'); + } + catch (error) { + console.error('Error adding ICE candidate:', error); + throw error; + } + } + + close() { + if (this.localStream) { + this.localStream.getTracks().forEach(track => track.stop()); + this.localStream = null; + } + + if (this.peerConnection) { + this.peerConnection.close(); + this.peerConnection = null; + } + } +} + +export { PeerConnectionManager }; \ No newline at end of file diff --git a/webrtc-examples/src/main/resources/web/SignalingChannel.js b/webrtc-examples/src/main/resources/web/SignalingChannel.js new file mode 100644 index 00000000..cdcb128a --- /dev/null +++ b/webrtc-examples/src/main/resources/web/SignalingChannel.js @@ -0,0 +1,142 @@ +/** + * SignalingChannel handles WebSocket communication for WebRTC signaling. + * It includes a heartbeat mechanism to keep the connection alive and detect disconnections early. + */ +class SignalingChannel { + + /** + * Creates a new SignalingChannel instance. + * + * @param {string} serverUrl - The WebSocket server URL. + * @param {function} onMessage - Callback for received messages. + * @param {function} onOpen - Callback for connection open. + * @param {function} onClose - Callback for connection close. + * @param {function} onError - Callback for connection errors. + */ + constructor(serverUrl, onOpen, onClose, onMessage, onError) { + this.serverUrl = serverUrl; + this.onMessageCallback = onMessage; + this.onOpenCallback = onOpen; + this.onCloseCallback = onClose; + this.onErrorCallback = onError; + this.socket = null; + this.heartbeatInterval = 10000; // 10 seconds + this.heartbeatTimer = null; + } + + /** + * Establishes a WebSocket connection to the signaling server. + * Automatically starts the heartbeat mechanism when connected. + */ + connect() { + this.socket = new WebSocket(this.serverUrl, "ws-signaling"); + + this.socket.onopen = () => { + this.startHeartbeat(); + + if (this.onOpenCallback) { + this.onOpenCallback(); + } + }; + + this.socket.onmessage = (event) => { + console.log("Received message:", event.data); + const message = JSON.parse(event.data); + + // Handle heartbeat response. + if (message.type === "heartbeat-ack") { + // Heartbeat acknowledged. + return; + } + + if (this.onMessageCallback) { + this.onMessageCallback(message); + } + }; + + this.socket.onclose = () => { + this.stopHeartbeat(); + + if (this.onCloseCallback) { + this.onCloseCallback(); + } + }; + + this.socket.onerror = (error) => { + this.stopHeartbeat(); + + if (this.onErrorCallback) { + this.onErrorCallback(error); + } + }; + } + + /** + * Starts the heartbeat mechanism to keep the WebSocket connection alive. + * Sends a heartbeat message every this.heartbeatInterval millisecond. + */ + startHeartbeat() { + // Clear any existing timer. + this.stopHeartbeat(); + + this.heartbeatTimer = setInterval(() => { + if (this.isConnected()) { + this.send({ + type: "heartbeat", + from: this.getUserId() + }); + } + else { + this.stopHeartbeat(); + } + }, this.heartbeatInterval); + } + + // Helper method to get the current user ID + getUserId() { + // In a real application, this would be a unique identifier for the user + return "web-client"; + } + + /** + * Stops the heartbeat mechanism. + * Called automatically when the connection is closed or on error. + */ + stopHeartbeat() { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + send(message) { + if (this.isConnected()) { + this.socket.send(JSON.stringify(message)); + } + else { + console.error("WebSocket is not open. Cannot send message:", message); + } + } + + /** + * Closes the WebSocket connection and stops the heartbeat mechanism. + */ + close() { + this.stopHeartbeat(); + + if (this.isConnected()) { + this.socket.close(); + } + } + + /** + * Checks if the WebSocket connection is currently open. + * + * @returns {boolean} True if the connection is open. + */ + isConnected() { + return this.socket && this.socket.readyState === WebSocket.OPEN; + } +} + +export { SignalingChannel }; \ No newline at end of file diff --git a/webrtc-examples/src/main/resources/web/WebRTCClient.js b/webrtc-examples/src/main/resources/web/WebRTCClient.js new file mode 100644 index 00000000..4bc5a683 --- /dev/null +++ b/webrtc-examples/src/main/resources/web/WebRTCClient.js @@ -0,0 +1,332 @@ +import { SignalingChannel } from './SignalingChannel.js'; +import { PeerConnectionManager } from './PeerConnectionManager.js'; + +class WebRTCClient { + + constructor() { + // Configuration. + this.serverUrl = "wss://localhost:8443/ws"; + this.iceServers = [ + { urls: "stun:stun.l.google.com:19302" } + ]; + + // DOM elements. + this.remoteVideo = document.getElementById("remoteVideo"); + this.videoContainer = document.getElementById("videoContainer"); + this.stopButton = document.getElementById("stopButton"); + this.statusElement = document.getElementById("status"); + this.participantsList = document.getElementById("participantsList"); + + // Initialize components. + this.signalingChannel = null; + this.peerConnectionManager = null; + this.participants = new Map(); + + // Set up event listeners. + this.stopButton.addEventListener("click", () => this.disconnect()); + + // Set the initial status. + this.setStatus("Click \"Start Connection\" to begin"); + + this.connect(); + } + + setStatus(status) { + this.statusElement.textContent = status; + + console.log(status); + } + + connect() { + this.setStatus("Connecting to the signaling server..."); + + // Initialize signaling channel. + this.signalingChannel = new SignalingChannel( + this.serverUrl, + () => this.handleSignalingOpen(), + () => this.handleSignalingClose(), + (message) => this.handleSignalingMessage(message), + (error) => this.handleSignalingError(error) + ); + + // Connect to the signaling server. + this.signalingChannel.connect(); + } + + disconnect() { + this.setStatus("Closing connection"); + + // Close peer connection + if (this.peerConnectionManager) { + this.peerConnectionManager.close(); + this.peerConnectionManager = null; + } + + // Close signaling channel + if (this.signalingChannel) { + this.signalingChannel.close(); + this.signalingChannel = null; + } + + // Clear remote video + if (this.remoteVideo.srcObject) { + this.remoteVideo.srcObject.getTracks().forEach(track => track.stop()); + this.remoteVideo.srcObject = null; + } + + // Hide video container + this.videoContainer.style.display = 'none'; + + // Clear participants list + this.participants.clear(); + this.updateParticipantsList(); + + // Update UI + this.stopButton.disabled = true; + this.setStatus("Connection closed"); + } + + joinRoom(roomName) { + // First, send a join message to the room. + const joinMessage = { + type: "join", + from: this.peerConnectionManager.getUserId(), + data: { + room: roomName, + userInfo: { + name: "Web Client" + } + } + }; + this.signalingChannel.send(joinMessage); + this.setStatus('Joining room: default-room'); + } + + handleSignalingOpen() { + this.setStatus("Connected to the signaling server"); + + // Initialize peer connection manager. + this.peerConnectionManager = new PeerConnectionManager( + this.iceServers, + (event) => this.handleTrack(event), + (candidate) => this.handleLocalCandidate(candidate), + (state) => this.handleIceConnectionStateChange(state), + (state) => this.handleConnectionStateChange(state) + ); + + // Add ourselves to the participant list. + const myUserId = this.peerConnectionManager.getUserId(); + this.participants.set(myUserId, { name: "Me (Local)" }); + this.updateParticipantsList(); + + this.joinRoom("default-room"); + + // Update UI. + this.stopButton.disabled = false; + } + + handleSignalingMessage(message) { + switch(message.type) { + case 'answer': + this.handleAnswer(message); + break; + case 'ice-candidate': + this.handleRemoteCandidate(message); + break; + case 'offer': + this.handleOffer(message); + break; + case 'join': + this.handleUserJoined(message); + break; + case 'leave': + this.handleUserLeft(message); + break; + default: + console.log('Unknown message type:', message.type); + } + } + + handleUserJoined(message) { + const userId = message.data.userInfo.userId; + this.setStatus('User joined: ' + userId); + + // Add user to participants map if not already present. + if (!this.participants.has(userId)) { + const userInfo = message.data.userInfo || { userId: userId }; + this.participants.set(userId, userInfo); + + // Update the participant list in the UI. + this.updateParticipantsList(); + } + } + + handleUserLeft(message) { + const userId = message.from; + this.setStatus('User left: ' + userId); + + // Remove user from participants map if present + if (this.participants.has(userId)) { + this.participants.delete(userId); + + // Update the participant list in the UI + this.updateParticipantsList(); + } + } + + async handleOffer(message) { + this.setStatus('Received offer from ' + message.from); + console.log('Received offer:', message); + + // Initialize WebRTC. + this.startWebRTC(); + + try { + // Set the offer to the peer connection. + await this.peerConnectionManager.handleOffer(message); + this.setStatus('Remote offer set successfully, creating answer'); + + // Create and send answer + const answer = await this.peerConnectionManager.createAnswer(); + this.setStatus('Sending answer to ' + message.from); + this.signalingChannel.send(answer); + } + catch (error) { + this.setStatus('Failed to process offer: ' + error); + console.error('Error handling offer:', error); + } + } + + updateParticipantsList() { + // Clear the current list + this.participantsList.innerHTML = ''; + + // Add each participant to the list + this.participants.forEach((userInfo, userId) => { + const listItem = document.createElement('li'); + listItem.textContent = userInfo.name || userId; + listItem.setAttribute('data-user-id', userId); + this.participantsList.appendChild(listItem); + }); + } + + handleSignalingClose() { + this.setStatus('Disconnected from signaling server'); + this.disconnect(); + } + + handleSignalingError(error) { + this.setStatus('Error connecting to signaling server'); + console.error('WebSocket error:', error); + } + + async startWebRTC() { + this.peerConnectionManager.initialize(); + + // Add local media if available + // const mediaAdded = await this.peerConnectionManager.addLocalMedia(); + // if (!mediaAdded) { + // this.setStatus('Failed to access camera/microphone. Creating offer without local media.'); + // } + + // Create and send offer + // try { + // const offer = await this.peerConnectionManager.createOffer(); + // this.setStatus('Sending offer to Java server'); + // this.signalingChannel.send(offer); + // } + // catch (error) { + // this.setStatus('Failed to create offer: ' + error); + // } + } + + handleTrack(event) { + this.setStatus('Received remote track'); + console.log('Track event:', event); + + if (event.streams && event.streams[0]) { + // If remoteVideo already has a srcObject, we need to add the new track to it + // instead of replacing the entire stream. + if (this.remoteVideo.srcObject) { + // Check if this track already exists in the current stream. + const existingTrack = this.remoteVideo.srcObject.getTracks().find( + track => track.id === event.track.id + ); + + // Only add the track if it doesn't already exist. + if (!existingTrack) { + // Add the new track to the existing stream. + this.remoteVideo.srcObject.addTrack(event.track); + this.setStatus(`Added ${event.track.kind} track to existing stream`); + } + } + else { + // First track, set the stream directly. + this.remoteVideo.srcObject = event.streams[0]; + this.setStatus(`Set initial ${event.track.kind} stream`); + } + + // Show the video container when we receive a track. + this.videoContainer.style.display = 'block'; + + // Ensure the video plays. + this.remoteVideo.play().catch(error => { + console.error('Error playing video:', error); + this.setStatus('Error playing video: ' + error.message); + }); + } + else { + console.warn('Received track event without streams'); + this.setStatus('Received track without stream data'); + } + } + + handleLocalCandidate(candidate) { + this.signalingChannel.send(candidate); + } + + handleIceConnectionStateChange(state) { + this.setStatus('ICE connection state: ' + state); + + // Hide video if connection is disconnected, failed, or closed + if (['disconnected', 'failed', 'closed'].includes(state)) { + this.videoContainer.style.display = 'none'; + } + } + + handleConnectionStateChange(state) { + this.setStatus('Connection state: ' + state); + + // Show video only when connected + if (state === 'connected') { + this.videoContainer.style.display = 'block'; + } else if (['disconnected', 'failed', 'closed'].includes(state)) { + this.videoContainer.style.display = 'none'; + } + } + + async handleAnswer(message) { + this.setStatus('Received answer from ' + message.from + ', setting remote description'); + + try { + await this.peerConnectionManager.handleAnswer(message); + this.setStatus('Remote description set successfully'); + } + catch (error) { + this.setStatus('Failed to set remote description: ' + error); + } + } + + async handleRemoteCandidate(message) { + try { + this.setStatus('Received ICE candidate from ' + message.from); + + await this.peerConnectionManager.addIceCandidate(message); + } + catch (error) { + console.error("Error handling remote candidate:", error); + } + } +} + +export { WebRTCClient }; \ No newline at end of file diff --git a/webrtc-examples/src/main/resources/web/index.html b/webrtc-examples/src/main/resources/web/index.html new file mode 100644 index 00000000..7ba5e44f --- /dev/null +++ b/webrtc-examples/src/main/resources/web/index.html @@ -0,0 +1,158 @@ + + + + + + WebRTC Java - Browser Demo + + + + + + + +
+ +
+

Participants

+
    + +
+
+ + +
+ +
+ +
+ + +
+ +
+
+
+ + + + + diff --git a/webrtc-examples/src/main/resources/web/main.js b/webrtc-examples/src/main/resources/web/main.js new file mode 100644 index 00000000..3a456f7c --- /dev/null +++ b/webrtc-examples/src/main/resources/web/main.js @@ -0,0 +1,6 @@ +import { WebRTCClient } from './WebRTCClient.js'; + +// Initialize the WebRTC client when the page loads +document.addEventListener("DOMContentLoaded", () => { + new WebRTCClient(); +}); \ No newline at end of file diff --git a/webrtc-jni/src/main/cpp/include/JNI_CustomAudioSource.h b/webrtc-jni/src/main/cpp/include/JNI_CustomAudioSource.h new file mode 100644 index 00000000..c9a2fd5b --- /dev/null +++ b/webrtc-jni/src/main/cpp/include/JNI_CustomAudioSource.h @@ -0,0 +1,29 @@ +/* DO NOT EDIT THIS FILE - it is machine generated */ +#include +/* Header for class dev_onvoid_webrtc_media_audio_CustomAudioSource */ + +#ifndef _Included_dev_onvoid_webrtc_media_audio_CustomAudioSource +#define _Included_dev_onvoid_webrtc_media_audio_CustomAudioSource +#ifdef __cplusplus +extern "C" { +#endif + /* + * Class: dev_onvoid_webrtc_media_audio_CustomAudioSource + * Method: initialize + * Signature: ()V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_audio_CustomAudioSource_initialize + (JNIEnv *, jobject); + + /* + * Class: dev_onvoid_webrtc_media_audio_CustomAudioSource + * Method: pushAudio + * Signature: ([BIIII)V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_audio_CustomAudioSource_pushAudio + (JNIEnv *, jobject, jbyteArray, jint, jint, jint, jint); + +#ifdef __cplusplus +} +#endif +#endif \ No newline at end of file diff --git a/webrtc-jni/src/main/cpp/include/media/SyncClock.h b/webrtc-jni/src/main/cpp/include/media/SyncClock.h new file mode 100644 index 00000000..283a9338 --- /dev/null +++ b/webrtc-jni/src/main/cpp/include/media/SyncClock.h @@ -0,0 +1,46 @@ +/* + * Copyright 2019 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef JNI_WEBRTC_MEDIA_SYNC_CLOCK_H_ +#define JNI_WEBRTC_MEDIA_SYNC_CLOCK_H_ + +#include "system_wrappers/include/clock.h" +#include "system_wrappers/include/ntp_time.h" + +#include + +namespace jni +{ + // Synchronized Clock for A/V timing + class SyncClock + { + public: + SyncClock(); + + // Get current timestamp in microseconds + int64_t GetTimestampUs() const; + + // Get current timestamp in milliseconds + int64_t GetTimestampMs() const; + + // Get NTP timestamp for RTP synchronization + webrtc::NtpTime GetNtpTime() const; + + private: + std::chrono::steady_clock::time_point start_time_; + }; +} +#endif \ No newline at end of file diff --git a/webrtc-jni/src/main/cpp/include/media/audio/CustomAudioSource.h b/webrtc-jni/src/main/cpp/include/media/audio/CustomAudioSource.h new file mode 100644 index 00000000..56679726 --- /dev/null +++ b/webrtc-jni/src/main/cpp/include/media/audio/CustomAudioSource.h @@ -0,0 +1,61 @@ +/* + * Copyright 2019 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef JNI_WEBRTC_API_AUDIO_CUSTOM_AUDIO_SOURCE_H_ +#define JNI_WEBRTC_API_AUDIO_CUSTOM_AUDIO_SOURCE_H_ + +#include "api/media_stream_interface.h" +#include "rtc_base/ref_counted_object.h" + +#include "media/SyncClock.h" + +#include +#include +#include + +namespace jni +{ + class CustomAudioSource : public webrtc::AudioSourceInterface + { + public: + explicit CustomAudioSource(std::shared_ptr clock); + + // AudioSourceInterface implementation + void RegisterObserver(webrtc::ObserverInterface * observer) override; + void UnregisterObserver(webrtc::ObserverInterface * observer) override; + void AddSink(webrtc::AudioTrackSinkInterface * sink) override; + void RemoveSink(webrtc::AudioTrackSinkInterface * sink) override; + SourceState state() const override; + bool remote() const override; + + // Push audio data with synchronization + void PushAudioData(const void * audio_data, int bits_per_sample, + int sample_rate, size_t number_of_channels, + size_t number_of_frames); + + // Set audio capture delay for synchronization adjustment + void SetAudioCaptureDelay(int64_t delay_us); + + private: + std::vector sinks_; + std::shared_ptr clock_; + //webrtc::CriticalSection crit_; + std::atomic total_samples_captured_; + int64_t audio_capture_delay_us_; + }; +} + +#endif \ No newline at end of file diff --git a/webrtc-jni/src/main/cpp/include/media/video/CustomVideoSource.h b/webrtc-jni/src/main/cpp/include/media/video/CustomVideoSource.h new file mode 100644 index 00000000..e6d923e7 --- /dev/null +++ b/webrtc-jni/src/main/cpp/include/media/video/CustomVideoSource.h @@ -0,0 +1,49 @@ +/* + * Copyright 2019 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef JNI_WEBRTC_MEDIA_VIDEO_CUSTOM_VIDEO_SOURCE_H_ +#define JNI_WEBRTC_MEDIA_VIDEO_CUSTOM_VIDEO_SOURCE_H_ + +#include "api/video/video_frame.h" +#include "api/video/video_source_interface.h" +#include "media/base/adapted_video_track_source.h" +#include "rtc_base/ref_counted_object.h" + +#include "media/SyncClock.h" + +#include + +namespace jni +{ + class CustomVideoSource : public webrtc::AdaptedVideoTrackSource + { + public: + explicit CustomVideoSource(std::shared_ptr clock); + + // AdaptedVideoTrackSource implementation. + virtual bool is_screencast() const override; + virtual std::optional needs_denoising() const override; + SourceState state() const override; + bool remote() const override; + + void PushFrame(const webrtc::VideoFrame & frame); + + private: + std::shared_ptr clock_; + uint16_t frame_id_; + }; +} +#endif \ No newline at end of file diff --git a/webrtc-jni/src/main/cpp/src/JNI_CustomAudioSource.cpp b/webrtc-jni/src/main/cpp/src/JNI_CustomAudioSource.cpp new file mode 100644 index 00000000..a0dbca45 --- /dev/null +++ b/webrtc-jni/src/main/cpp/src/JNI_CustomAudioSource.cpp @@ -0,0 +1,45 @@ +/* + * Copyright 2019 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "JNI_CustomAudioSource.h" +#include "JavaUtils.h" + +#include "media/audio/CustomAudioSource.h" + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_audio_CustomAudioSource_initialize +(JNIEnv * env, jobject caller) +{ + std::shared_ptr sync_clock = std::make_shared(); + webrtc::scoped_refptr source = webrtc::make_ref_counted(sync_clock); + + SetHandle(env, caller, source.release()); +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_audio_CustomAudioSource_pushAudio +(JNIEnv * env, jobject caller, jbyteArray audioData, jint bits_per_sample, jint sampleRate, jint channels, jint frameCount) +{ + jni::CustomAudioSource * source = GetHandle(env, caller); + CHECK_HANDLE(source); + + jbyte * data = env->GetByteArrayElements(audioData, nullptr); + jsize length = env->GetArrayLength(audioData); + + if (data != nullptr) { + source->PushAudioData(data, bits_per_sample, sampleRate, channels, frameCount); + + env->ReleaseByteArrayElements(audioData, data, JNI_ABORT); + } +} \ No newline at end of file diff --git a/webrtc-jni/src/main/cpp/src/media/SyncClock.cpp b/webrtc-jni/src/main/cpp/src/media/SyncClock.cpp new file mode 100644 index 00000000..ecc1a915 --- /dev/null +++ b/webrtc-jni/src/main/cpp/src/media/SyncClock.cpp @@ -0,0 +1,43 @@ +/* + * Copyright 2019 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "media/SyncClock.h" + +namespace jni +{ + SyncClock::SyncClock() : + start_time_(std::chrono::steady_clock::now()) + { + } + + int64_t SyncClock::GetTimestampUs() const + { + auto now = std::chrono::steady_clock::now(); + auto duration = now - start_time_; + return std::chrono::duration_cast(duration).count(); + } + + int64_t SyncClock::GetTimestampMs() const + { + return GetTimestampUs() / 1000; + } + + webrtc::NtpTime SyncClock::GetNtpTime() const + { + int64_t ntp_time_ms = webrtc::Clock::GetRealTimeClock()->CurrentNtpInMilliseconds(); + return webrtc::NtpTime(ntp_time_ms); + } +} \ No newline at end of file diff --git a/webrtc-jni/src/main/cpp/src/media/audio/CustomAudioSource.cpp b/webrtc-jni/src/main/cpp/src/media/audio/CustomAudioSource.cpp new file mode 100644 index 00000000..0b90cbd6 --- /dev/null +++ b/webrtc-jni/src/main/cpp/src/media/audio/CustomAudioSource.cpp @@ -0,0 +1,82 @@ +#include "media/audio/CustomAudioSource.h" + +#include +#include + +namespace jni +{ + CustomAudioSource::CustomAudioSource(std::shared_ptr clock) : + clock_(clock), + total_samples_captured_(0), + audio_capture_delay_us_(0) + { + } + + void CustomAudioSource::RegisterObserver(webrtc::ObserverInterface * observer) + { + // Not implemented - not needed for custom sources + } + + void CustomAudioSource::UnregisterObserver(webrtc::ObserverInterface * observer) + { + // Not implemented - not needed for custom sources + } + + void CustomAudioSource::AddSink(webrtc::AudioTrackSinkInterface * sink) + { + //webrtc::CritScope lock(&crit_); + + sinks_.push_back(sink); + } + + void CustomAudioSource::RemoveSink(webrtc::AudioTrackSinkInterface * sink) + { + //webrtc::CritScope lock(&crit_); + + sinks_.erase(std::remove(sinks_.begin(), sinks_.end(), sink), sinks_.end()); + } + + webrtc::AudioSourceInterface::SourceState CustomAudioSource::state() const + { + return kLive; + } + + bool CustomAudioSource::remote() const + { + return false; + } + + void CustomAudioSource::PushAudioData(const void * audio_data, int bits_per_sample, + int sample_rate, size_t number_of_channels, + size_t number_of_frames) + { + //webrtc::CritScope lock(&crit_); + + // Calculate absolute capture time + int64_t timestamp_us = clock_->GetTimestampUs(); + + // Apply delay if audio capture has inherent latency + timestamp_us -= audio_capture_delay_us_; + + // Calculate NTP time for this audio frame + int64_t ntp_time_ms = clock_->GetNtpTime().ToMs(); + + // Create absolute capture time + absl::optional absolute_capture_time_ms = timestamp_us / 1000; + + // Send to all sinks with timing information + for (auto * sink : sinks_) { + sink->OnData(audio_data, bits_per_sample, sample_rate, + number_of_channels, number_of_frames, + absolute_capture_time_ms); + } + + // Update total samples for tracking + total_samples_captured_ += number_of_frames; + } + + void CustomAudioSource::SetAudioCaptureDelay(int64_t delay_us) + { + audio_capture_delay_us_ = delay_us; + } +} \ No newline at end of file diff --git a/webrtc-jni/src/main/cpp/src/media/video/CustomVideoSource.cpp b/webrtc-jni/src/main/cpp/src/media/video/CustomVideoSource.cpp new file mode 100644 index 00000000..db1f979a --- /dev/null +++ b/webrtc-jni/src/main/cpp/src/media/video/CustomVideoSource.cpp @@ -0,0 +1,68 @@ +/* + * Copyright 2019 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "media/video/CustomVideoSource.h" + +namespace jni +{ + CustomVideoSource::CustomVideoSource(std::shared_ptr clock) : + clock_(clock), + frame_id_(0) + { + } + + bool CustomVideoSource::is_screencast() const + { + return false; + } + + std::optional CustomVideoSource::needs_denoising() const + { + return false; + } + + webrtc::MediaSourceInterface::SourceState CustomVideoSource::state() const + { + return kLive; + } + + bool CustomVideoSource::remote() const + { + return false; + } + + void CustomVideoSource::PushFrame(const webrtc::VideoFrame& frame) + { + // Create frame with proper timestamp + webrtc::VideoFrame timestamped_frame = frame; + + // Use synchronized clock for timestamp + int64_t timestamp_us = clock_->GetTimestampUs(); + timestamped_frame.set_timestamp_us(timestamp_us); + + // Set RTP timestamp (90kHz clock) + uint32_t rtp_timestamp = static_cast((timestamp_us * 90) / 1000); + timestamped_frame.set_rtp_timestamp(rtp_timestamp); + + // Set NTP time for synchronization + timestamped_frame.set_ntp_time_ms(clock_->GetNtpTime().ToMs()); + + // Increment frame ID + timestamped_frame.set_id(frame_id_++); + + OnFrame(timestamped_frame); + } +} \ No newline at end of file diff --git a/webrtc-jni/src/main/cpp/src/media/video/VideoTrackDesktopSource.cpp b/webrtc-jni/src/main/cpp/src/media/video/VideoTrackDesktopSource.cpp index 6127c846..07c850de 100644 --- a/webrtc-jni/src/main/cpp/src/media/video/VideoTrackDesktopSource.cpp +++ b/webrtc-jni/src/main/cpp/src/media/video/VideoTrackDesktopSource.cpp @@ -98,19 +98,23 @@ namespace jni FireOnChanged(); } - bool VideoTrackDesktopSource::is_screencast() const { + bool VideoTrackDesktopSource::is_screencast() const + { return true; } - std::optional VideoTrackDesktopSource::needs_denoising() const { + std::optional VideoTrackDesktopSource::needs_denoising() const + { return false; } - webrtc::MediaSourceInterface::SourceState VideoTrackDesktopSource::state() const { + webrtc::MediaSourceInterface::SourceState VideoTrackDesktopSource::state() const + { return sourceState; } - bool VideoTrackDesktopSource::remote() const { + bool VideoTrackDesktopSource::remote() const + { return false; } diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/CustomAudioSource.java b/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/CustomAudioSource.java new file mode 100644 index 00000000..0461aa3d --- /dev/null +++ b/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/CustomAudioSource.java @@ -0,0 +1,53 @@ +/* + * Copyright 2019 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.media.audio; + +/** + * Custom implementation of an audio source for WebRTC that allows pushing audio data + * from external sources directly to the WebRTC audio pipeline. + * + * @author Alex Andres + */ +public class CustomAudioSource extends AudioTrackSource { + + /** + * Constructs a new CustomAudioSource instance. + */ + public CustomAudioSource() { + super(); + + initialize(); + } + + /** + * Pushes audio data to be processed by this audio source. + * + * @param audioData The raw audio data bytes to process. + * @param bits_per_sample The number of bits per sample (e.g., 8, 16, 32). + * @param sampleRate The sample rate of the audio in Hz (e.g., 44100, 48000). + * @param channels The number of audio channels (1 for mono, 2 for stereo). + * @param frameCount The number of frames in the provided audio data. + */ + public native void pushAudio(byte[] audioData, int bits_per_sample, + int sampleRate, int channels, int frameCount); + + /** + * Initializes the native resources required by this audio source. + */ + private native void initialize(); + +} diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/CustomAudioSourceTest.java b/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/CustomAudioSourceTest.java new file mode 100644 index 00000000..a9e5db5e --- /dev/null +++ b/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/CustomAudioSourceTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2025 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.media.audio; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.onvoid.webrtc.TestBase; +import dev.onvoid.webrtc.media.MediaSource; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +class CustomAudioSourceTest extends TestBase { + + private CustomAudioSource customAudioSource; + + + @BeforeEach + void init() { + customAudioSource = new CustomAudioSource(); + } + + @AfterEach + void dispose() { + + } + + @Test + void stateAfterCreation() { + assertEquals(MediaSource.State.LIVE, customAudioSource.getState()); + } + + @Test + void addNullSink() { + assertThrows(NullPointerException.class, () -> { + AudioTrack audioTrack = factory.createAudioTrack("audioTrack", customAudioSource); + audioTrack.addSink(null); + audioTrack.dispose(); + }); + } + + @Test + void removeNullSink() { + assertThrows(NullPointerException.class, () -> { + AudioTrack audioTrack = factory.createAudioTrack("audioTrack", customAudioSource); + audioTrack.removeSink(null); + audioTrack.dispose(); + }); + } + + @Test + void addRemoveSink() { + AudioTrack audioTrack = factory.createAudioTrack("audioTrack", customAudioSource); + AudioTrackSink sink = (data, bitsPerSample, sampleRate, channels, frames) -> { }; + + audioTrack.addSink(sink); + audioTrack.removeSink(sink); + audioTrack.dispose(); + } + + @Test + void pushAudioData() { + // 16-bit, 48kHz, stereo, 10ms + testAudioFormat(16, 48000, 2, 480); + } + + @Test + void pushAudioWithDifferentFormats() { + testAudioFormat(8, 8000, 1, 80); // 8-bit, 8kHz, mono, 10ms + testAudioFormat(16, 16000, 1, 160); // 16-bit, 16kHz, mono, 10ms + testAudioFormat(16, 44100, 2, 441); // 16-bit, 44.1kHz, stereo, 10ms + testAudioFormat(16, 48000, 2, 480); // 16-bit, 48kHz, stereo, 10ms + } + + private void testAudioFormat(int bitsPerSample, int sampleRate, int channels, int frameCount) { + AudioTrack audioTrack = factory.createAudioTrack("audioTrack", customAudioSource); + + final AtomicBoolean dataReceived = new AtomicBoolean(false); + final AtomicInteger receivedBitsPerSample = new AtomicInteger(0); + final AtomicInteger receivedSampleRate = new AtomicInteger(0); + final AtomicInteger receivedChannels = new AtomicInteger(0); + final AtomicInteger receivedFrames = new AtomicInteger(0); + + AudioTrackSink testSink = (data, bits, rate, chans, frames) -> { + dataReceived.set(true); + receivedBitsPerSample.set(bits); + receivedSampleRate.set(rate); + receivedChannels.set(chans); + receivedFrames.set(frames); + }; + + audioTrack.addSink(testSink); + + // Create a buffer with test audio data (silence in this case). + int bytesPerSample = bitsPerSample / 8; + byte[] audioData = new byte[frameCount * channels * bytesPerSample]; + + customAudioSource.pushAudio(audioData, bitsPerSample, sampleRate, channels, frameCount); + + // Verify that our sink received the data with correct parameters. + assertTrue(dataReceived.get(), "Audio data was not received by the sink"); + assertEquals(bitsPerSample, receivedBitsPerSample.get(), "Bits per sample doesn't match"); + assertEquals(sampleRate, receivedSampleRate.get(), "Sample rate doesn't match"); + assertEquals(channels, receivedChannels.get(), "Channel count doesn't match"); + assertEquals(frameCount, receivedFrames.get(), "Frame count doesn't match"); + + // Clean up. + audioTrack.removeSink(testSink); + audioTrack.dispose(); + } +} \ No newline at end of file