From eedb45c480f925102ef707b71bcde5b59e4597af Mon Sep 17 00:00:00 2001 From: Alex Andres Date: Sun, 20 Sep 2026 01:49:09 +0200 Subject: [PATCH 1/7] feat: let native extensions feed the custom media sources directly Groundwork for the FFmpeg based media module (#77), which decodes a file natively and must get the frames into WebRTC. Carrying every frame and every 10 ms chunk through Java would mean a copy per frame, Java in the loop of a 100 Hz audio path, and no way to ever pass through encoded media. An extension library now calls into this library through a small C function table instead. NativeApi.tableAddress() hands out the address of a struct webrtc_java_api, NativeApi.handleOf() the address of the native object behind a CustomVideoSource or CustomAudioSource, and the extension pushes straight into that source. The table's video push wraps the caller's planes rather than copying them and releases them through a callback, so a decoder that already produces I420 reaches the encoder without a copy. Neither native library links against the other, which is what makes this work: NativeLoader extracts to a temporary file with a generated name, so there is nothing to link to. NativeLoader also learns to load a library together with the shared libraries it needs, under their real file names, so that a dynamic linker finds them as the same modules. Both sources can now be given the time a frame or chunk was captured, and the Java API exposes that as pushFrame(VideoFrame, long) and pushAudio(..., long), with SyncClock.currentTimeUs() as the clock they are read in. This is what makes a media file play back at its own frame rate: the encoder derives the outgoing RTP timestamp from the frame's NTP capture time, so a caller that supplies presentation times keeps the sent timeline free of the jitter of the pushing thread, and keeps audio and video lined up. Pushing without a timestamp behaves exactly as before. Refs #77 --- docs/guide/audio/custom-audio-source.md | 14 ++ docs/guide/video/custom-video-source.md | 26 +++ .../main/cpp/include/JNI_CustomAudioSource.h | 8 + .../main/cpp/include/JNI_CustomVideoSource.h | 8 + .../src/main/cpp/include/JNI_NativeApi.h | 44 ++++ .../src/main/cpp/include/JNI_SyncClock.h | 8 + .../src/main/cpp/include/api/ExtensionApi.h | 33 +++ .../include/media/audio/CustomAudioSource.h | 21 +- .../include/media/video/CustomVideoSource.h | 18 ++ .../src/main/cpp/include/webrtc_java_api.h | 195 ++++++++++++++++++ .../main/cpp/src/JNI_CustomAudioSource.cpp | 18 +- .../main/cpp/src/JNI_CustomVideoSource.cpp | 16 +- webrtc-jni/src/main/cpp/src/JNI_NativeApi.cpp | 31 +++ webrtc-jni/src/main/cpp/src/JNI_SyncClock.cpp | 12 +- .../src/main/cpp/src/api/ExtensionApi.cpp | 169 +++++++++++++++ .../cpp/src/media/audio/CustomAudioSource.cpp | 32 ++- .../cpp/src/media/video/CustomVideoSource.cpp | 28 ++- webrtc/pom.xml | 5 + .../dev/onvoid/webrtc/internal/NativeApi.java | 91 ++++++++ .../onvoid/webrtc/internal/NativeLoader.java | 79 +++++++ .../dev/onvoid/webrtc/media/SyncClock.java | 31 +++ .../webrtc/media/audio/CustomAudioSource.java | 75 ++++++- .../webrtc/media/video/CustomVideoSource.java | 40 +++- webrtc/src/main/java/module-info.java | 5 + .../webrtc/internal/NativeApiTests.java | 100 +++++++++ .../media/audio/CustomAudioSourceTest.java | 20 ++ .../media/video/CustomVideoSourceTest.java | 64 ++++++ 27 files changed, 1174 insertions(+), 17 deletions(-) create mode 100644 webrtc-jni/src/main/cpp/include/JNI_NativeApi.h create mode 100644 webrtc-jni/src/main/cpp/include/api/ExtensionApi.h create mode 100644 webrtc-jni/src/main/cpp/include/webrtc_java_api.h create mode 100644 webrtc-jni/src/main/cpp/src/JNI_NativeApi.cpp create mode 100644 webrtc-jni/src/main/cpp/src/api/ExtensionApi.cpp create mode 100644 webrtc/src/main/java/dev/onvoid/webrtc/internal/NativeApi.java create mode 100644 webrtc/src/test/java/dev/onvoid/webrtc/internal/NativeApiTests.java diff --git a/docs/guide/audio/custom-audio-source.md b/docs/guide/audio/custom-audio-source.md index bdc5d0ab..df26ff84 100644 --- a/docs/guide/audio/custom-audio-source.md +++ b/docs/guide/audio/custom-audio-source.md @@ -92,6 +92,20 @@ The call checks its arguments and throws `IllegalArgumentException` rather than The audio is handed to the track's senders on the thread that calls `pushAudio`, so call it from a single thread. A scheduled executor with one thread, as shown below, is the simplest way to do that. ::: +### Keeping the Source's Own Timing + +A chunk pushed with the call above is treated as captured at that moment. A source with timing of its own, such as a media file played alongside video, should say when the audio was captured instead: + +```java +long baseUs = SyncClock.currentTimeUs(); // once, when playback starts +long presentationUs = chunkIndex * 10_000L; // 10 ms per chunk + +audioSource.pushAudio(pcm, 16, sampleRate, channels, frameCount, + baseUs + presentationUs); +``` + +Use the same base for the audio and the video of one source, and the two stay in sync on the receiving side. See [Custom Video Source](/guide/video/custom-video-source) for the video half. Chunks must still be pushed in real time; the timestamp describes the audio, it does not schedule it. + ## Audio Format Considerations When pushing audio data, you need to consider the following parameters: diff --git a/docs/guide/video/custom-video-source.md b/docs/guide/video/custom-video-source.md index 913958ac..9753ac13 100644 --- a/docs/guide/video/custom-video-source.md +++ b/docs/guide/video/custom-video-source.md @@ -142,6 +142,32 @@ public class VideoStreamer { } ``` +### Keeping the Source's Own Timing + +`pushFrame(VideoFrame)` treats a frame as captured at the moment of the call, so the timing that reaches the other peer is the timing of your pushing thread. For a live source such as a camera that is exactly right. For a source that has timing of its own, such as a video file, it is not: every hiccup of the executor above ends up in the stream, and audio pushed alongside drifts out of sync with the video. + +Pass the capture time instead: + +```java +// The clock capture timestamps are interpreted in. +long baseUs = SyncClock.currentTimeUs(); + +// For each frame, its own presentation time within the source. +long presentationUs = frameIndex * 1_000_000L / frameRate; + +videoSource.pushFrame(frame, baseUs + presentationUs); +``` + +Map your source's timeline onto the clock once, when playback starts, and add each frame's presentation time to that base. The frame rate that reaches the encoder then follows your timestamps rather than the jitter of the thread. + +::: warning +Frames must still be pushed in real time. A frame is encoded and sent when it arrives, so a timestamp in the future does not delay it; it only describes when the frame was meant to be shown. + +Timestamps must also advance by at least one millisecond from frame to frame. WebRTC drops a frame whose capture time does not move forward. +::: + +Push audio with timestamps from the same clock, as described in [Custom Audio Source](/guide/audio/custom-audio-source), and the two stay in sync on the receiving side. + ## Integration with Video Tracks ### Adding Sinks to Monitor Video diff --git a/webrtc-jni/src/main/cpp/include/JNI_CustomAudioSource.h b/webrtc-jni/src/main/cpp/include/JNI_CustomAudioSource.h index d68c37a4..1b430a03 100644 --- a/webrtc-jni/src/main/cpp/include/JNI_CustomAudioSource.h +++ b/webrtc-jni/src/main/cpp/include/JNI_CustomAudioSource.h @@ -39,6 +39,14 @@ extern "C" { JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_audio_CustomAudioSource_pushAudioInternal (JNIEnv *, jobject, jbyteArray, jint, jint, jint, jint); + /* + * Class: dev_onvoid_webrtc_media_audio_CustomAudioSource + * Method: pushAudioTimestamped + * Signature: ([BIIIIJ)V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_audio_CustomAudioSource_pushAudioTimestamped + (JNIEnv *, jobject, jbyteArray, jint, jint, jint, jint, jlong); + #ifdef __cplusplus } #endif diff --git a/webrtc-jni/src/main/cpp/include/JNI_CustomVideoSource.h b/webrtc-jni/src/main/cpp/include/JNI_CustomVideoSource.h index 0b6de8a2..588982fb 100644 --- a/webrtc-jni/src/main/cpp/include/JNI_CustomVideoSource.h +++ b/webrtc-jni/src/main/cpp/include/JNI_CustomVideoSource.h @@ -39,6 +39,14 @@ extern "C" { JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_video_CustomVideoSource_pushFrame (JNIEnv *, jobject, jobject); + /* + * Class: dev_onvoid_webrtc_media_video_CustomVideoSource + * Method: pushFrameTimestamped + * Signature: (Ldev/onvoid/webrtc/media/video/VideoFrame;J)V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_video_CustomVideoSource_pushFrameTimestamped + (JNIEnv *, jobject, jobject, jlong); + #ifdef __cplusplus } #endif diff --git a/webrtc-jni/src/main/cpp/include/JNI_NativeApi.h b/webrtc-jni/src/main/cpp/include/JNI_NativeApi.h new file mode 100644 index 00000000..320b8851 --- /dev/null +++ b/webrtc-jni/src/main/cpp/include/JNI_NativeApi.h @@ -0,0 +1,44 @@ +/* + * Copyright 2026 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 +/* Header for class dev_onvoid_webrtc_internal_NativeApi */ + +#ifndef _Included_dev_onvoid_webrtc_internal_NativeApi +#define _Included_dev_onvoid_webrtc_internal_NativeApi +#ifdef __cplusplus +extern "C" { +#endif + /* + * Class: dev_onvoid_webrtc_internal_NativeApi + * Method: tableAddress + * Signature: ()J + */ + JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_internal_NativeApi_tableAddress + (JNIEnv *, jclass); + + /* + * Class: dev_onvoid_webrtc_internal_NativeApi + * Method: version + * Signature: ()I + */ + JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_internal_NativeApi_version + (JNIEnv *, jclass); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/webrtc-jni/src/main/cpp/include/JNI_SyncClock.h b/webrtc-jni/src/main/cpp/include/JNI_SyncClock.h index 04a62b4c..328ff3ed 100644 --- a/webrtc-jni/src/main/cpp/include/JNI_SyncClock.h +++ b/webrtc-jni/src/main/cpp/include/JNI_SyncClock.h @@ -47,6 +47,14 @@ extern "C" { JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_SyncClock_initialize (JNIEnv *, jobject); + /* + * Class: dev_onvoid_webrtc_media_SyncClock + * Method: currentTimeUs + * Signature: ()J + */ + JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_SyncClock_currentTimeUs + (JNIEnv *, jclass); + #ifdef __cplusplus } #endif diff --git a/webrtc-jni/src/main/cpp/include/api/ExtensionApi.h b/webrtc-jni/src/main/cpp/include/api/ExtensionApi.h new file mode 100644 index 00000000..f0843f22 --- /dev/null +++ b/webrtc-jni/src/main/cpp/include/api/ExtensionApi.h @@ -0,0 +1,33 @@ +/* + * Copyright 2026 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_EXTENSION_API_H_ +#define JNI_WEBRTC_API_EXTENSION_API_H_ + +#include "webrtc_java_api.h" + +namespace jni +{ + // Returns the function table native extension libraries use to feed media + // into the custom media sources of this library. See webrtc_java_api.h for + // the interface itself and for how an extension gets hold of this address. + // + // The table is a singleton with static storage duration, so the returned + // pointer stays valid for the lifetime of the process. + const webrtc_java_api * GetExtensionApi(); +} + +#endif diff --git a/webrtc-jni/src/main/cpp/include/media/audio/CustomAudioSource.h b/webrtc-jni/src/main/cpp/include/media/audio/CustomAudioSource.h index 3a81c5de..89f7557f 100644 --- a/webrtc-jni/src/main/cpp/include/media/audio/CustomAudioSource.h +++ b/webrtc-jni/src/main/cpp/include/media/audio/CustomAudioSource.h @@ -42,15 +42,34 @@ namespace jni SourceState state() const override; bool remote() const override; - // Push audio data with synchronization + // Push audio data with synchronization. The chunk is captured now, + // so it is stamped with this source's clock less the configured + // capture delay. This is what the Java pushAudio() calls. void PushAudioData(const void * audio_data, int bits_per_sample, int sample_rate, size_t number_of_channels, size_t number_of_frames); + // Push audio data captured at the given time, in microseconds on + // the same clock as webrtc::TimeMicros(). The capture delay is not + // applied: a caller that knows when the audio was captured has + // already accounted for it. + void PushAudioData(const void * audio_data, int bits_per_sample, + int sample_rate, size_t number_of_channels, + size_t number_of_frames, int64_t timestamp_us); + // Set audio capture delay for synchronization adjustment void SetAudioCaptureDelay(int64_t delay_us); private: + // Hands one chunk to every sink. Both push paths hold mutex_ for + // the whole delivery, as they always have, so a sink cannot be + // removed while it is being called. + void DeliverAudioDataLocked(const void * audio_data, int bits_per_sample, + int sample_rate, size_t number_of_channels, + size_t number_of_frames, + int64_t absolute_capture_time_ms) + RTC_EXCLUSIVE_LOCKS_REQUIRED(mutex_); + // Guards sinks_ and audio_capture_delay_us_ against concurrent // AddSink()/RemoveSink() (called by WebRTC's own signaling/worker // thread as tracks attach/detach) racing with PushAudioData() and diff --git a/webrtc-jni/src/main/cpp/include/media/video/CustomVideoSource.h b/webrtc-jni/src/main/cpp/include/media/video/CustomVideoSource.h index 7b8c1055..7b7037fe 100644 --- a/webrtc-jni/src/main/cpp/include/media/video/CustomVideoSource.h +++ b/webrtc-jni/src/main/cpp/include/media/video/CustomVideoSource.h @@ -40,9 +40,27 @@ namespace jni SourceState state() const override; bool remote() const override; + // Delivers a frame captured now, stamping it with this source's + // clock. This is what the Java pushFrame() calls, so a caller that + // has no capture time of its own gets the timing of the moment it + // pushes. void PushFrame(const webrtc::VideoFrame & frame); + // Delivers a frame captured at the given time, in microseconds on + // the same clock as webrtc::TimeMicros(). + // + // A caller that knows when a frame was meant to be shown, such as + // one playing a media file, should use this: the encoder derives + // the outgoing RTP timestamp from the frame's NTP capture time + // (VideoStreamEncoder::OnFrame), so passing the presentation time + // keeps the sent timeline free of the jitter of the pushing + // thread, and keeps video lined up with audio pushed alongside it. + void PushFrame(const webrtc::VideoFrame & frame, int64_t timestamp_us); + private: + void DeliverFrame(const webrtc::VideoFrame & frame, int64_t timestamp_us, + int64_t ntp_time_ms); + std::shared_ptr clock_; std::atomic frame_id_; }; diff --git a/webrtc-jni/src/main/cpp/include/webrtc_java_api.h b/webrtc-jni/src/main/cpp/include/webrtc_java_api.h new file mode 100644 index 00000000..ddb7ece4 --- /dev/null +++ b/webrtc-jni/src/main/cpp/include/webrtc_java_api.h @@ -0,0 +1,195 @@ +/* + * Copyright 2026 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 WEBRTC_JAVA_API_H_ +#define WEBRTC_JAVA_API_H_ + +/* + * The C interface a native extension library uses to feed media into + * webrtc-java's custom media sources, without linking against this library or + * against WebRTC, and without routing the media through Java. + * + * Deliberately plain C: no C++ types, no JNI types, no WebRTC types. An + * extension compiled against this header keeps working against any build of + * webrtc-java that reports the same interface version, whatever compiler or + * standard library either side was built with. + * + * How an extension obtains the table: + * + * 1. Java side: dev.onvoid.webrtc.internal.NativeApi.tableAddress() returns + * the address of a "struct webrtc_java_api" owned by this library, and + * NativeApi.handleOf(source) returns the native handle of a + * CustomVideoSource or CustomAudioSource. + * 2. The extension receives both as jlong values and casts them back. + * + * There is no link-time dependency between the two native libraries, so the + * extension does not care where this library was loaded from or under which + * file name, which matters because NativeLoader extracts it to a temporary + * file with a generated name. + * + * Versioning: the "version" field is the first member of the table and never + * moves. An extension must refuse to run when it reads a version it does not + * know. Within one version, members are only ever appended, never reordered + * or removed, and "size" tells an extension how much of the table the loaded + * library actually provides. + * + * Threading: every function may be called from any thread, including threads + * that are not attached to the JVM. The push functions deliver to WebRTC + * synchronously on the calling thread, so the caller must not hold locks that + * a WebRTC callback could need, and must pace its calls in real time: a frame + * is encoded and sent when it arrives, not when its timestamp says. + */ + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** The interface version described by this header. */ +#define WEBRTC_JAVA_API_VERSION 1u + +/** Success, returned by every function that reports a status. */ +#define WEBRTC_JAVA_OK 0 +/** A handle was null, or a required pointer inside a struct was null. */ +#define WEBRTC_JAVA_ERR_HANDLE (-1) +/** A field of the pushed frame or chunk is out of range. */ +#define WEBRTC_JAVA_ERR_FORMAT (-2) + +/** + * Called when WebRTC has released its last reference to the pixel data of a + * pushed video frame, and the extension may reuse or free it. It runs on + * whichever thread drops that reference, which is usually an encoder thread + * and not the thread that pushed the frame, and it may run after the source + * itself was disposed. It must not block. + */ +typedef void (*wj_release_fn)(void * opaque); + +/** + * One decoded video frame in I420 (planar YUV 4:2:0, 8 bit). + * + * The planes are not copied. They must stay valid and unmodified until + * "release" is called. Passing a null "release" means the caller guarantees + * the planes outlive every use of the frame, which is rarely true; prefer + * handing over a reference and dropping it in the callback. + */ +struct wj_i420_frame { + /** Frame width in pixels, greater than zero. */ + int width; + /** Frame height in pixels, greater than zero. */ + int height; + + /** Luma plane, "height" rows of "stride_y" bytes. */ + const uint8_t * y; + /** Blue-difference chroma plane, (height + 1) / 2 rows of "stride_u". */ + const uint8_t * u; + /** Red-difference chroma plane, (height + 1) / 2 rows of "stride_v". */ + const uint8_t * v; + + /** Luma row stride in bytes, at least "width". */ + int stride_y; + /** Chroma row stride in bytes, at least (width + 1) / 2. */ + int stride_u; + /** Chroma row stride in bytes, at least (width + 1) / 2. */ + int stride_v; + + /** Clockwise rotation to apply on render: 0, 90, 180 or 270. */ + int rotation; + + /** + * Capture time in the clock of now_us(). Frames must be pushed in + * increasing timestamp order. A value of zero means "stamp it with the + * source's own clock", which is what the Java pushFrame() does. + */ + int64_t timestamp_us; + + /** Called once WebRTC is done with the planes; may be null. */ + wj_release_fn release; + /** Passed to "release" unchanged. */ + void * opaque; +}; + +/** + * One chunk of decoded audio: interleaved signed 16-bit PCM in host byte + * order, exactly 10 ms long, which is the only chunk length WebRTC accepts. + * + * The samples are copied before the call returns, so the caller may reuse the + * buffer immediately. + */ +struct wj_audio_chunk { + /** "frames" * "channels" samples, interleaved. */ + const int16_t * samples; + /** Sample rate in Hz, at most 48000. */ + int sample_rate; + /** Channel count, 1 or 2. */ + int channels; + /** Frames per channel, which must equal sample_rate / 100. */ + int frames; + + /** + * Capture time in the clock of now_us(), or zero to stamp the chunk with + * the source's own clock, which is what the Java pushAudio() does. + */ + int64_t timestamp_us; +}; + +/** + * The function table this library exposes to native extensions. It is a + * singleton with static storage duration, so its address stays valid for the + * lifetime of the process and needs no release. + */ +struct webrtc_java_api { + /** WEBRTC_JAVA_API_VERSION of the library providing this table. */ + uint32_t version; + /** sizeof(struct webrtc_java_api) as the library compiled it. */ + uint32_t size; + + /** + * The monotonic clock WebRTC itself uses, in microseconds. Timestamps in + * pushed frames and chunks are interpreted in this clock, so an extension + * that wants audio and video to line up on the receiver maps its own + * presentation times onto it once and keeps that mapping. + */ + int64_t (*now_us)(void); + + /** + * Delivers one video frame to a CustomVideoSource. + * + * @param source The handle from NativeApi.handleOf(CustomVideoSource). + * @param frame The frame to deliver; borrowed for the call only. + * + * @return WEBRTC_JAVA_OK, or a negative error code, in which case + * "release" is still called before returning. + */ + int (*video_source_push)(void * source, const struct wj_i420_frame * frame); + + /** + * Delivers one 10 ms audio chunk to a CustomAudioSource. + * + * @param source The handle from NativeApi.handleOf(CustomAudioSource). + * @param chunk The chunk to deliver; copied during the call. + * + * @return WEBRTC_JAVA_OK or a negative error code. + */ + int (*audio_source_push)(void * source, const struct wj_audio_chunk * chunk); +}; + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/webrtc-jni/src/main/cpp/src/JNI_CustomAudioSource.cpp b/webrtc-jni/src/main/cpp/src/JNI_CustomAudioSource.cpp index b9af2797..99ce8e1f 100644 --- a/webrtc-jni/src/main/cpp/src/JNI_CustomAudioSource.cpp +++ b/webrtc-jni/src/main/cpp/src/JNI_CustomAudioSource.cpp @@ -73,4 +73,20 @@ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_audio_CustomAudioSource_push env->ReleaseByteArrayElements(audioData, data, JNI_ABORT); } -} \ No newline at end of file +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_audio_CustomAudioSource_pushAudioTimestamped +(JNIEnv * env, jobject caller, jbyteArray audioData, jint bits_per_sample, jint sampleRate, jint channels, jint frameCount, jlong timestampUs) +{ + jni::CustomAudioSource * source = GetHandle(env, caller); + CHECK_HANDLE(source); + + // The caller validated the format, the frame count and the array length. + jbyte * data = env->GetByteArrayElements(audioData, nullptr); + + if (data != nullptr) { + source->PushAudioData(data, bits_per_sample, sampleRate, channels, frameCount, timestampUs); + + env->ReleaseByteArrayElements(audioData, data, JNI_ABORT); + } +} diff --git a/webrtc-jni/src/main/cpp/src/JNI_CustomVideoSource.cpp b/webrtc-jni/src/main/cpp/src/JNI_CustomVideoSource.cpp index 78f87c2a..a00e7f2f 100644 --- a/webrtc-jni/src/main/cpp/src/JNI_CustomVideoSource.cpp +++ b/webrtc-jni/src/main/cpp/src/JNI_CustomVideoSource.cpp @@ -72,4 +72,18 @@ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_video_CustomVideoSource_push source->PushFrame(nativeFrame); } -} \ No newline at end of file +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_video_CustomVideoSource_pushFrameTimestamped +(JNIEnv * env, jobject caller, jobject javaFrame, jlong timestampUs) +{ + jni::CustomVideoSource * source = GetHandle(env, caller); + CHECK_HANDLE(source); + + if (javaFrame != nullptr) { + auto frame = jni::JavaLocalRef(env, javaFrame); + webrtc::VideoFrame nativeFrame = jni::VideoFrame::toNative(env, frame); + + source->PushFrame(nativeFrame, timestampUs); + } +} diff --git a/webrtc-jni/src/main/cpp/src/JNI_NativeApi.cpp b/webrtc-jni/src/main/cpp/src/JNI_NativeApi.cpp new file mode 100644 index 00000000..6f0ad9e4 --- /dev/null +++ b/webrtc-jni/src/main/cpp/src/JNI_NativeApi.cpp @@ -0,0 +1,31 @@ +/* + * Copyright 2026 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_NativeApi.h" + +#include "api/ExtensionApi.h" + +JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_internal_NativeApi_tableAddress +(JNIEnv * env, jclass caller) +{ + return reinterpret_cast(jni::GetExtensionApi()); +} + +JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_internal_NativeApi_version +(JNIEnv * env, jclass caller) +{ + return static_cast(jni::GetExtensionApi()->version); +} diff --git a/webrtc-jni/src/main/cpp/src/JNI_SyncClock.cpp b/webrtc-jni/src/main/cpp/src/JNI_SyncClock.cpp index 284e76e5..faafdc51 100644 --- a/webrtc-jni/src/main/cpp/src/JNI_SyncClock.cpp +++ b/webrtc-jni/src/main/cpp/src/JNI_SyncClock.cpp @@ -18,6 +18,8 @@ #include "JavaUtils.h" #include "media/SyncClock.h" +#include "rtc_base/time_utils.h" + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_SyncClock_initialize (JNIEnv * env, jobject caller) { @@ -63,4 +65,12 @@ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_SyncClock_dispose delete clock; SetHandle(env, caller, nullptr); -} \ No newline at end of file +} + +JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_SyncClock_currentTimeUs +(JNIEnv * env, jclass caller) +{ + // The clock capture timestamps handed to the custom media sources are + // interpreted in, which is the one WebRTC itself runs on. + return webrtc::TimeMicros(); +} diff --git a/webrtc-jni/src/main/cpp/src/api/ExtensionApi.cpp b/webrtc-jni/src/main/cpp/src/api/ExtensionApi.cpp new file mode 100644 index 00000000..a8d0f72e --- /dev/null +++ b/webrtc-jni/src/main/cpp/src/api/ExtensionApi.cpp @@ -0,0 +1,169 @@ +/* + * Copyright 2026 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 "api/ExtensionApi.h" + +#include "media/audio/CustomAudioSource.h" +#include "media/video/CustomVideoSource.h" + +#include "api/video/video_frame.h" +#include "api/video/video_rotation.h" +#include "common_video/include/video_frame_buffer.h" +#include "rtc_base/time_utils.h" + +#include + +namespace jni +{ + namespace + { + int64_t ApiNowUs() + { + return webrtc::TimeMicros(); + } + + bool ToVideoRotation(int degrees, webrtc::VideoRotation & rotation) + { + switch (degrees) { + case 0: + rotation = webrtc::kVideoRotation_0; + return true; + case 90: + rotation = webrtc::kVideoRotation_90; + return true; + case 180: + rotation = webrtc::kVideoRotation_180; + return true; + case 270: + rotation = webrtc::kVideoRotation_270; + return true; + default: + return false; + } + } + + // Reports the frame as consumed without delivering it. The contract in + // webrtc_java_api.h is that the release callback runs whatever the + // outcome, so a rejected frame must not leak the caller's buffer. + int ReleaseAndFail(const wj_i420_frame * frame, int error) + { + if (frame != nullptr && frame->release != nullptr) { + frame->release(frame->opaque); + } + + return error; + } + + int ApiVideoSourcePush(void * source, const wj_i420_frame * frame) + { + if (frame == nullptr) { + return WEBRTC_JAVA_ERR_HANDLE; + } + if (source == nullptr || frame->y == nullptr || frame->u == nullptr || frame->v == nullptr) { + return ReleaseAndFail(frame, WEBRTC_JAVA_ERR_HANDLE); + } + + const int chromaWidth = (frame->width + 1) / 2; + + if (frame->width <= 0 || frame->height <= 0 + || frame->stride_y < frame->width + || frame->stride_u < chromaWidth + || frame->stride_v < chromaWidth) { + return ReleaseAndFail(frame, WEBRTC_JAVA_ERR_FORMAT); + } + + webrtc::VideoRotation rotation; + + if (!ToVideoRotation(frame->rotation, rotation)) { + return ReleaseAndFail(frame, WEBRTC_JAVA_ERR_FORMAT); + } + + // The planes are wrapped, not copied. WebRTC keeps the buffer alive + // for as long as it needs the pixels, which outlives this call, and + // runs the callback when it drops the last reference. + wj_release_fn release = frame->release; + void * opaque = frame->opaque; + + webrtc::scoped_refptr buffer = webrtc::WrapI420Buffer( + frame->width, frame->height, + frame->y, frame->stride_y, + frame->u, frame->stride_u, + frame->v, frame->stride_v, + [release, opaque]() { + if (release != nullptr) { + release(opaque); + } + }); + + webrtc::VideoFrame videoFrame = webrtc::VideoFrame::Builder() + .set_video_frame_buffer(buffer) + .set_rotation(rotation) + .build(); + + CustomVideoSource * videoSource = static_cast(source); + + if (frame->timestamp_us != 0) { + videoSource->PushFrame(videoFrame, frame->timestamp_us); + } + else { + videoSource->PushFrame(videoFrame); + } + + return WEBRTC_JAVA_OK; + } + + int ApiAudioSourcePush(void * source, const wj_audio_chunk * chunk) + { + if (source == nullptr || chunk == nullptr || chunk->samples == nullptr) { + return WEBRTC_JAVA_ERR_HANDLE; + } + if (chunk->sample_rate <= 0 || chunk->sample_rate > 48000 + || chunk->channels < 1 || chunk->channels > 2 + || chunk->frames <= 0 || chunk->frames * 100 != chunk->sample_rate) { + return WEBRTC_JAVA_ERR_FORMAT; + } + + CustomAudioSource * audioSource = static_cast(source); + + if (chunk->timestamp_us != 0) { + audioSource->PushAudioData(chunk->samples, 16, chunk->sample_rate, + static_cast(chunk->channels), + static_cast(chunk->frames), + chunk->timestamp_us); + } + else { + audioSource->PushAudioData(chunk->samples, 16, chunk->sample_rate, + static_cast(chunk->channels), + static_cast(chunk->frames)); + } + + return WEBRTC_JAVA_OK; + } + + const webrtc_java_api kExtensionApi = { + WEBRTC_JAVA_API_VERSION, + static_cast(sizeof(webrtc_java_api)), + &ApiNowUs, + &ApiVideoSourcePush, + &ApiAudioSourcePush + }; + } + + const webrtc_java_api * GetExtensionApi() + { + return &kExtensionApi; + } +} diff --git a/webrtc-jni/src/main/cpp/src/media/audio/CustomAudioSource.cpp b/webrtc-jni/src/main/cpp/src/media/audio/CustomAudioSource.cpp index b0b67aea..7c6f5f18 100644 --- a/webrtc-jni/src/main/cpp/src/media/audio/CustomAudioSource.cpp +++ b/webrtc-jni/src/main/cpp/src/media/audio/CustomAudioSource.cpp @@ -1,5 +1,7 @@ #include "media/audio/CustomAudioSource.h" +#include "rtc_base/time_utils.h" + #include #include @@ -58,17 +60,37 @@ namespace jni // 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(); + DeliverAudioDataLocked(audio_data, bits_per_sample, sample_rate, + number_of_channels, number_of_frames, + timestamp_us / 1000); + } + + void CustomAudioSource::PushAudioData(const void * audio_data, int bits_per_sample, + int sample_rate, size_t number_of_channels, + size_t number_of_frames, int64_t timestamp_us) + { + webrtc::MutexLock lock(&mutex_); + + // The caller's capture time is on the same clock as TimeMicros(), so + // in milliseconds it is already the clock AudioTrackSinkInterface + // requires of an absolute capture timestamp. + DeliverAudioDataLocked(audio_data, bits_per_sample, sample_rate, + number_of_channels, number_of_frames, + timestamp_us / webrtc::kNumMicrosecsPerMillisec); + } - // Create absolute capture time - absl::optional absolute_capture_time_ms = timestamp_us / 1000; + void CustomAudioSource::DeliverAudioDataLocked(const void * audio_data, int bits_per_sample, + int sample_rate, size_t number_of_channels, + size_t number_of_frames, + int64_t absolute_capture_time_ms) + { + absl::optional capture_time_ms = absolute_capture_time_ms; // 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); + capture_time_ms); } // Update total samples for tracking diff --git a/webrtc-jni/src/main/cpp/src/media/video/CustomVideoSource.cpp b/webrtc-jni/src/main/cpp/src/media/video/CustomVideoSource.cpp index db1f979a..20890223 100644 --- a/webrtc-jni/src/main/cpp/src/media/video/CustomVideoSource.cpp +++ b/webrtc-jni/src/main/cpp/src/media/video/CustomVideoSource.cpp @@ -16,6 +16,8 @@ #include "media/video/CustomVideoSource.h" +#include "rtc_base/time_utils.h" + namespace jni { CustomVideoSource::CustomVideoSource(std::shared_ptr clock) : @@ -45,20 +47,38 @@ namespace jni } void CustomVideoSource::PushFrame(const webrtc::VideoFrame& frame) + { + // No capture time from the caller, so the frame is captured now. + DeliverFrame(frame, clock_->GetTimestampUs(), clock_->GetNtpTime().ToMs()); + } + + void CustomVideoSource::PushFrame(const webrtc::VideoFrame& frame, int64_t timestamp_us) + { + // The caller's capture time is on the same clock as TimeMicros(), so + // the frame's age gives its NTP capture time. That is the field the + // encoder turns into the outgoing RTP timestamp, which is why a + // caller's timing reaches the wire at all. + int64_t age_ms = (webrtc::TimeMicros() - timestamp_us) / webrtc::kNumMicrosecsPerMillisec; + + DeliverFrame(frame, timestamp_us, clock_->GetNtpTime().ToMs() - age_ms); + } + + void CustomVideoSource::DeliverFrame(const webrtc::VideoFrame& frame, int64_t timestamp_us, + int64_t ntp_time_ms) { // 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) + // Set RTP timestamp (90kHz clock). The encoder recomputes this from + // the NTP capture time below before sending, so it only matters to a + // sink that reads the frame before it is encoded. 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()); + timestamped_frame.set_ntp_time_ms(ntp_time_ms); // Increment frame ID timestamped_frame.set_id(frame_id_++); diff --git a/webrtc/pom.xml b/webrtc/pom.xml index da3251ed..4c64f822 100644 --- a/webrtc/pom.xml +++ b/webrtc/pom.xml @@ -18,7 +18,11 @@ org.apache.maven.plugins maven-surefire-plugin + + --add-exports webrtc.java/dev.onvoid.webrtc.internal=ALL-UNNAMED --add-opens webrtc.java/dev.onvoid.webrtc=ALL-UNNAMED --add-opens webrtc.java/dev.onvoid.webrtc.logging=ALL-UNNAMED --add-opens webrtc.java/dev.onvoid.webrtc.media=ALL-UNNAMED @@ -66,6 +70,7 @@ -Xcheck:jni + --add-exports webrtc.java/dev.onvoid.webrtc.internal=ALL-UNNAMED --add-opens webrtc.java/dev.onvoid.webrtc=ALL-UNNAMED --add-opens webrtc.java/dev.onvoid.webrtc.logging=ALL-UNNAMED --add-opens webrtc.java/dev.onvoid.webrtc.media=ALL-UNNAMED diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/internal/NativeApi.java b/webrtc/src/main/java/dev/onvoid/webrtc/internal/NativeApi.java new file mode 100644 index 00000000..b6cb7dd7 --- /dev/null +++ b/webrtc/src/main/java/dev/onvoid/webrtc/internal/NativeApi.java @@ -0,0 +1,91 @@ +/* + * Copyright 2026 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.internal; + +import java.util.Objects; + +/** + * The entry point a native extension library uses to reach this library's + * native side directly, without going through Java for every frame. + *

+ * An extension gets two addresses from here and passes both to its own native + * code: {@link #tableAddress()}, which is a {@code struct webrtc_java_api} of + * function pointers declared in {@code webrtc_java_api.h}, and + * {@link #handleOf(NativeObject)}, which is the native object behind a + * {@code CustomVideoSource} or {@code CustomAudioSource}. The extension then + * pushes decoded media straight into that source. + *

+ * Neither native library links against the other, which is what makes this + * work at all: {@link NativeLoader} extracts the library to a temporary file + * with a generated name, so there is nothing an extension could link to. + *

+ * This is not API for applications. It has no use outside a native extension, + * and an address used after the source it belongs to was disposed crashes the + * process. + * + * @author Alex Andres + */ +public final class NativeApi { + + static { + try { + NativeLoader.loadLibrary("webrtc-java"); + } + catch (Exception e) { + throw new RuntimeException("Load library 'webrtc-java' failed", e); + } + } + + + private NativeApi() { + // Static access only. + } + + /** + * Returns the address of this library's {@code struct webrtc_java_api}. + * The table has static storage duration, so the address stays valid for + * the lifetime of the process and needs no release. + * + * @return The address of the function table. + */ + public static native long tableAddress(); + + /** + * Returns the interface version of the function table. An extension built + * against a different version must not use the table. + * + * @return The value of {@code WEBRTC_JAVA_API_VERSION} in this library. + */ + public static native int version(); + + /** + * Returns the address of the native object bound to the given Java object, + * so that a native extension can act on it directly. + * + * @param object The object whose native counterpart is wanted. + * + * @return The native handle, or {@code 0} if the object was disposed. + * + * @throws NullPointerException If the object is {@code null}. + */ + public static long handleOf(NativeObject object) { + Objects.requireNonNull(object, "NativeObject is null"); + + return object.getNativeHandle(); + } + +} diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/internal/NativeLoader.java b/webrtc/src/main/java/dev/onvoid/webrtc/internal/NativeLoader.java index a46dffdc..c7077c6e 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/internal/NativeLoader.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/internal/NativeLoader.java @@ -89,6 +89,85 @@ public static void loadLibrary(final String libName) throws Exception { } } + /** + * Loads the specified native library together with the shared libraries it + * needs, which a native extension module packages next to it. + *

+ * The dependencies are loaded first, in the given order, so that by the + * time the library itself is loaded every symbol it imports is already in + * the process. They are extracted into one temporary directory under the + * exact names given, because a dynamic linker matches an already-loaded + * module by file name: a randomly named copy would be loaded a second + * time, or not found at all. + * + * @param libName The name of the library to load, without any + * platform specific prefix, file extension or path. + * @param dependencies The file names of the libraries to load first, + * exactly as they are named in the JAR, in the order + * they depend on each other. + * + * @throws Exception if one of the libraries could not be loaded. + */ + public static void loadLibrary(final String libName, final String... dependencies) + throws Exception { + if (LOADED_LIB_SET.contains(libName)) { + return; + } + + String libFileName = System.mapLibraryName( + libName + "-" + getOSFamily() + "-" + getOSArch()); + Path tempDir = Files.createTempDirectory(libName); + + tempDir.toFile().deleteOnExit(); + + for (String dependency : dependencies) { + loadFromDirectory(tempDir, dependency); + } + + loadFromDirectory(tempDir, libFileName); + + LOADED_LIB_SET.add(libName); + } + + /** + * Extracts one library from the JAR into the given directory, keeping its + * file name, and loads it. + * + * @param directory The directory to extract into. + * @param fileName The resource name of the library, which is also the + * name it is written under. + * + * @throws Exception if the library is not in the JAR or could not be + * loaded. + */ + private static void loadFromDirectory(Path directory, String fileName) + throws Exception { + Path libPath = directory.resolve(fileName); + + try (InputStream is = NativeLoader.class.getClassLoader() + .getResourceAsStream(fileName)) { + if (is == null) { + throw new UnsatisfiedLinkError( + "Native library '" + fileName + "' is not on the classpath"); + } + + Files.copy(is, libPath, StandardCopyOption.REPLACE_EXISTING); + } + + File libFile = libPath.toFile(); + + libFile.deleteOnExit(); + + try { + System.load(libPath.toAbsolutePath().toString()); + } + catch (Throwable e) { + libFile.delete(); + + throw e; + } + } + private static String getExtension(String fileName) { final int index = getExtensionIndex(fileName); diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/media/SyncClock.java b/webrtc/src/main/java/dev/onvoid/webrtc/media/SyncClock.java index 790c1d47..e7ac0fb0 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/media/SyncClock.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/media/SyncClock.java @@ -16,6 +16,7 @@ package dev.onvoid.webrtc.media; +import dev.onvoid.webrtc.internal.NativeLoader; import dev.onvoid.webrtc.internal.NativeObject; /** @@ -26,6 +27,16 @@ */ public class SyncClock extends NativeObject { + static { + try { + NativeLoader.loadLibrary("webrtc-java"); + } + catch (Exception e) { + throw new RuntimeException("Load library 'webrtc-java' failed", e); + } + } + + /** * Constructs a new SyncClock instance. */ @@ -35,6 +46,26 @@ public SyncClock() { initialize(); } + /** + * Returns the current time of the media clock, in microseconds. + *

+ * This is the clock in which capture timestamps are interpreted, for + * example by {@link dev.onvoid.webrtc.media.video.CustomVideoSource#pushFrame( + * dev.onvoid.webrtc.media.video.VideoFrame, long) pushFrame} and + * {@link dev.onvoid.webrtc.media.audio.CustomAudioSource#pushAudio(byte[], + * int, int, int, int, long) pushAudio}. It is the monotonic clock WebRTC + * itself runs on, which is what makes those timestamps comparable with + * WebRTC's own notion of now. + *

+ * It is unrelated to the timestamps an instance of this class + * reports: {@link #getTimestampUs()} counts from the moment that instance + * was created, while this clock counts from an arbitrary but process-wide + * fixed point. + * + * @return The current time of the media clock in microseconds. + */ + public static native long currentTimeUs(); + /** * Get the current timestamp in microseconds. * 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 index b4ab39df..cefc6f1c 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/CustomAudioSource.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/CustomAudioSource.java @@ -94,6 +94,63 @@ public CustomAudioSource(SyncClock clock) { */ public void pushAudio(byte[] audioData, int bits_per_sample, int sampleRate, int channels, int frameCount) { + validate(audioData, bits_per_sample, sampleRate, channels, frameCount); + + pushAudioInternal(audioData, bits_per_sample, sampleRate, channels, + frameCount); + } + + /** + * Pushes audio data that was captured at the given time. Apart from the + * timestamp this behaves exactly like + * {@link #pushAudio(byte[], int, int, int, int)}. + *

+ * A source that knows its own timing, such as one playing a media file, + * should use this so that the audio lines up with video pushed with + * matching timestamps. Chunks must still be pushed in real time. + * + * @param audioData The raw audio data bytes to process. Must hold at + * least {@code frameCount * channels * 2} bytes. + * @param bits_per_sample The number of bits per sample, which must be 16. + * @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. + * @param timestampUs The capture time of the chunk, on the clock of + * {@link dev.onvoid.webrtc.media.SyncClock#currentTimeUs()}. + * + * @throws NullPointerException If the audio data is {@code null}. + * @throws IllegalArgumentException If the audio format is not 16-bit PCM, + * if a value is not positive, if the chunk + * is larger than WebRTC can take, or if the + * array is too short for the frames it is + * said to hold. + * + * @see dev.onvoid.webrtc.media.SyncClock#currentTimeUs() + */ + public void pushAudio(byte[] audioData, int bits_per_sample, int sampleRate, + int channels, int frameCount, long timestampUs) { + validate(audioData, bits_per_sample, sampleRate, channels, frameCount); + + pushAudioTimestamped(audioData, bits_per_sample, sampleRate, channels, + frameCount, timestampUs); + } + + /** + * Checks that a chunk of audio is something WebRTC can take, so that a bad + * chunk is rejected here instead of reading past the end of the array or + * aborting the process in native code. + * + * @param audioData The raw audio data bytes to process. + * @param bits_per_sample The number of bits per sample, which must be 16. + * @param sampleRate The sample rate of the audio in Hz. + * @param channels The number of audio channels. + * @param frameCount The number of frames in the provided audio data. + * + * @throws NullPointerException If the audio data is {@code null}. + * @throws IllegalArgumentException If any value is out of range. + */ + private static void validate(byte[] audioData, int bits_per_sample, + int sampleRate, int channels, int frameCount) { requireNonNull(audioData, "audioData must not be null"); if (bits_per_sample != BITS_PER_SAMPLE) { @@ -134,9 +191,6 @@ public void pushAudio(byte[] audioData, int bits_per_sample, int sampleRate, "Audio data holds %d bytes, but %d frames of %d channels need %d", audioData.length, frameCount, channels, required)); } - - pushAudioInternal(audioData, bits_per_sample, sampleRate, channels, - frameCount); } /** @@ -159,6 +213,21 @@ private native void pushAudioInternal(byte[] audioData, int bits_per_sample, int sampleRate, int channels, int frameCount); + /** + * Hands the validated audio data to the native source, stamped with the + * given capture time. + * + * @param audioData The raw audio data bytes to process. + * @param bits_per_sample The number of bits per sample. + * @param sampleRate The sample rate of the audio in Hz. + * @param channels The number of audio channels. + * @param frameCount The number of frames in the provided audio data. + * @param timestampUs The capture time of the chunk in microseconds. + */ + private native void pushAudioTimestamped(byte[] audioData, int bits_per_sample, + int sampleRate, int channels, + int frameCount, long timestampUs); + /** * Initializes the native resources required by this audio source. */ diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/media/video/CustomVideoSource.java b/webrtc/src/main/java/dev/onvoid/webrtc/media/video/CustomVideoSource.java index 083115cf..38ac57fc 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/media/video/CustomVideoSource.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/media/video/CustomVideoSource.java @@ -47,12 +47,41 @@ public CustomVideoSource(SyncClock clock) { } /** - * Pushes audio data to be processed by this audio source. + * Pushes a video frame to be processed by this video source. The frame is + * treated as captured at the moment of the call. * * @param frame The video frame to be pushed to the source. */ public native void pushFrame(VideoFrame frame); + /** + * Pushes a video frame that was captured, or is meant to be shown, at the + * given time. + *

+ * The sent frame rate follows the capture timestamps, not the moments the + * frames are pushed, so a source that knows its own timing, such as one + * playing a video file, should use this. The receiver then sees the + * intended timing even when the pushing thread is late, and audio pushed + * with matching timestamps stays in sync with the video. + *

+ * Frames must still be pushed in real time, because a frame is encoded and + * sent when it arrives. Timestamps must increase by at least one + * millisecond from frame to frame; WebRTC drops a frame whose capture time + * does not advance. + * + * @param frame The video frame to be pushed to the source. + * @param timestampUs The capture time of the frame, on the clock of + * {@link dev.onvoid.webrtc.media.SyncClock#currentTimeUs()}. + * + * @see dev.onvoid.webrtc.media.SyncClock#currentTimeUs() + */ + public void pushFrame(VideoFrame frame, long timestampUs) { + // Not a native method itself: two native methods of the same name + // would have to carry JNI's mangled long names, which would rename the + // existing entry point for no gain. + pushFrameTimestamped(frame, timestampUs); + } + /** * Disposes of any native resources held by this video source. * This method should be called when the video source is no longer needed @@ -60,6 +89,15 @@ public CustomVideoSource(SyncClock clock) { */ public native void dispose(); + /** + * Hands the frame to the native source, stamped with the given capture + * time. + * + * @param frame The video frame to be pushed to the source. + * @param timestampUs The capture time of the frame in microseconds. + */ + private native void pushFrameTimestamped(VideoFrame frame, long timestampUs); + /** * Initializes the native resources required by this video source. */ diff --git a/webrtc/src/main/java/module-info.java b/webrtc/src/main/java/module-info.java index 6af21845..999476b6 100644 --- a/webrtc/src/main/java/module-info.java +++ b/webrtc/src/main/java/module-info.java @@ -9,4 +9,9 @@ exports dev.onvoid.webrtc.media.video; exports dev.onvoid.webrtc.media.video.desktop; + // Not API for applications. A native extension module, such as the FFmpeg + // based media module, needs NativeApi to reach the native side of a custom + // media source directly instead of carrying every frame through Java. + exports dev.onvoid.webrtc.internal to webrtc.java.media; + } \ No newline at end of file diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/internal/NativeApiTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/internal/NativeApiTests.java new file mode 100644 index 00000000..e6a81d0e --- /dev/null +++ b/webrtc/src/test/java/dev/onvoid/webrtc/internal/NativeApiTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2026 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.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.onvoid.webrtc.TestBase; +import dev.onvoid.webrtc.media.SyncClock; +import dev.onvoid.webrtc.media.audio.CustomAudioSource; +import dev.onvoid.webrtc.media.video.CustomVideoSource; + +import org.junit.jupiter.api.Test; + +/** + * Covers what a native extension library gets handed: the address of the + * function table it calls, and the addresses of the media sources it feeds. + * + * @author Alex Andres + */ +class NativeApiTests extends TestBase { + + @Test + void tableAddressIsStable() { + long first = NativeApi.tableAddress(); + + assertNotEquals(0L, first); + // The table has static storage duration, so an extension may hold the + // address for the lifetime of the process. + assertEquals(first, NativeApi.tableAddress()); + } + + @Test + void versionMatchesHeader() { + // An extension refuses to run against a version it does not know, so + // this must only ever change together with webrtc_java_api.h. + assertEquals(1, NativeApi.version()); + } + + @Test + void handleOfVideoSource() { + CustomVideoSource source = new CustomVideoSource(); + + assertNotEquals(0L, NativeApi.handleOf(source)); + + source.dispose(); + + // Disposal clears the handle, which is how an extension can tell that + // the source it was given is gone. + assertEquals(0L, NativeApi.handleOf(source)); + } + + @Test + void handleOfAudioSource() { + CustomAudioSource source = new CustomAudioSource(); + + assertNotEquals(0L, NativeApi.handleOf(source)); + + source.dispose(); + + assertEquals(0L, NativeApi.handleOf(source)); + } + + @Test + void handleOfNull() { + assertThrows(NullPointerException.class, () -> NativeApi.handleOf(null)); + } + + @Test + void mediaClockAdvances() throws Exception { + // The clock the table's now_us() reports and in which the push + // timestamps are interpreted. + long first = SyncClock.currentTimeUs(); + + Thread.sleep(20); + + long second = SyncClock.currentTimeUs(); + + assertTrue(second > first, "Media clock did not advance"); + assertTrue(second - first >= 10_000, + "Media clock advanced by " + (second - first) + " us over 20 ms"); + } + +} 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 index 04497e9c..0aca70b6 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/CustomAudioSourceTest.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/CustomAudioSourceTest.java @@ -103,6 +103,26 @@ void pushAudioRejectsChunkLargerThanWebRtcTakes() { customAudioSource.pushAudio(new byte[maxFrames * 2 * 2], 16, 48000, 2, maxFrames); } + @Test + void pushAudioWithTimestamp() { + // The timestamped overload takes the same chunks and rejects the same + // bad arguments; only the capture time it reports differs. + byte[] data = new byte[480 * 2 * 2]; + long timestampUs = SyncClock.currentTimeUs(); + + customAudioSource.pushAudio(data, 16, 48000, 2, 480, timestampUs); + customAudioSource.pushAudio(data, 16, 48000, 2, 480, timestampUs + 10_000); + + assertThrows(NullPointerException.class, + () -> customAudioSource.pushAudio(null, 16, 48000, 2, 480, timestampUs)); + assertThrows(IllegalArgumentException.class, + () -> customAudioSource.pushAudio(data, 8, 48000, 2, 480, timestampUs)); + assertThrows(IllegalArgumentException.class, + () -> customAudioSource.pushAudio(data, 16, 48000, 2, 481, timestampUs)); + assertThrows(IllegalArgumentException.class, + () -> customAudioSource.pushAudio(data, 16, 48000, 0, 480, timestampUs)); + } + @Test void stateAfterCreation() { assertEquals(MediaSource.State.LIVE, customAudioSource.getState()); diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/media/video/CustomVideoSourceTest.java b/webrtc/src/test/java/dev/onvoid/webrtc/media/video/CustomVideoSourceTest.java index 04e36cc3..42a80626 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/media/video/CustomVideoSourceTest.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/media/video/CustomVideoSourceTest.java @@ -18,6 +18,8 @@ import static org.junit.jupiter.api.Assertions.*; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -99,6 +101,68 @@ void pushVideoFrameWithDifferentResolutions() { testVideoFrame(1920, 1080); // Full HD } + @Test + void pushFrameWithTimestamp() { + // A caller that knows its own timing, such as one playing a file, + // supplies the capture time and the frame carries it through unchanged + // instead of being stamped with the moment it was pushed. + VideoTrack videoTrack = factory.createVideoTrack("videoTrack", customVideoSource); + + final List timestamps = new ArrayList<>(); + + VideoTrackSink sink = frame -> timestamps.add(frame.timestampNs); + + videoTrack.addSink(sink); + + long baseUs = SyncClock.currentTimeUs(); + long frameUs = 1_000_000L / 25; + + for (int i = 0; i < 3; i++) { + NativeI420Buffer buffer = NativeI420Buffer.allocate(320, 240); + VideoFrame frame = new VideoFrame(buffer, 0); + + customVideoSource.pushFrame(frame, baseUs + i * frameUs); + + frame.release(); + } + + assertEquals(3, timestamps.size(), "Not every frame reached the sink"); + + for (int i = 0; i < 3; i++) { + assertEquals((baseUs + i * frameUs) * 1000L, timestamps.get(i), + "Frame " + i + " did not keep its capture time"); + } + + videoTrack.removeSink(sink); + videoTrack.dispose(); + } + + @Test + void pushFrameWithoutTimestampUsesSourceClock() { + // Without a capture time the source stamps the frame itself, which is + // the behaviour of pushFrame(VideoFrame) and must stay that way. + VideoTrack videoTrack = factory.createVideoTrack("videoTrack", customVideoSource); + + final List timestamps = new ArrayList<>(); + + VideoTrackSink sink = frame -> timestamps.add(frame.timestampNs); + + videoTrack.addSink(sink); + + NativeI420Buffer buffer = NativeI420Buffer.allocate(320, 240); + VideoFrame frame = new VideoFrame(buffer, 424242L); + + customVideoSource.pushFrame(frame); + frame.release(); + + assertEquals(1, timestamps.size()); + assertNotEquals(424242L, timestamps.get(0), + "Frame kept the timestamp it was constructed with"); + + videoTrack.removeSink(sink); + videoTrack.dispose(); + } + @Test void constructWithSyncClock() { // Create a SyncClock. From 7aab0475d6c13e530010a294360917b1ff57b6b2 Mon Sep 17 00:00:00 2001 From: Alex Andres Date: Sun, 20 Sep 2026 01:51:31 +0200 Subject: [PATCH 2/7] feat: add the webrtc-java-media module skeleton and its FFmpeg build Scaffolding for the media extension (#77): the Maven module, the JPMS module webrtc.java.media, the CMake build of the native library, and the CMake build of FFmpeg itself from the third-party/ffmpeg submodule, which is now tracked in .gitmodules pinned to n7.1.1 and marked shallow. FFmpeg is configured LGPL only, without --enable-gpl and without --enable-nonfree, as shared libraries so an application can replace them, and with everything disabled except the containers, decoders, parsers and the file protocol this module plays. It installs into ffmpeg.install.dir and a later configure reuses it, the way the WebRTC checkout is reused. The native library links against FFmpeg's C ABI and the JVM only. It reaches WebRTC through the function table of webrtc_java_api.h at runtime, so it needs neither WebRTC's headers nor its C++ standard library, and the module builds with the platform's default toolchain. The module stays out of the default reactor behind the with-media-extension profile until it builds on every platform in CI: building FFmpeg needs make, nasm and pkg-config, which the rest of the project does not. What is here so far is a vertical slice rather than the decoder: FFmpeg.version() and FFmpeg.license() prove that the libraries were built, packaged, loaded in dependency order and can be called. MediaInfo is the shape the reader will report. The Java side compiles against the qualified export of dev.onvoid.webrtc.internal; the native side has not been built on any platform yet, so the FFmpeg configure line and the packaging are still unproven. Refs #77 --- .gitmodules | 4 + pom.xml | 13 + webrtc-java-media/.gitignore | 3 + webrtc-java-media/README.md | 51 ++++ webrtc-java-media/pom.xml | 147 ++++++++++ webrtc-java-media/src/main/cpp/CMakeLists.txt | 69 +++++ .../cpp/dependencies/ffmpeg/CMakeLists.txt | 252 ++++++++++++++++++ .../src/main/cpp/include/JNI_FFmpeg.h | 44 +++ .../src/main/cpp/src/JNI_FFmpeg.cpp | 37 +++ .../onvoid/webrtc/media/ffmpeg/FFmpeg.java | 117 ++++++++ .../onvoid/webrtc/media/ffmpeg/MediaInfo.java | 197 ++++++++++++++ .../src/main/java/module-info.java | 11 + webrtc-java-media/third-party/ffmpeg | 1 + 13 files changed, 946 insertions(+) create mode 100644 .gitmodules create mode 100644 webrtc-java-media/.gitignore create mode 100644 webrtc-java-media/README.md create mode 100644 webrtc-java-media/pom.xml create mode 100644 webrtc-java-media/src/main/cpp/CMakeLists.txt create mode 100644 webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt create mode 100644 webrtc-java-media/src/main/cpp/include/JNI_FFmpeg.h create mode 100644 webrtc-java-media/src/main/cpp/src/JNI_FFmpeg.cpp create mode 100644 webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/FFmpeg.java create mode 100644 webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaInfo.java create mode 100644 webrtc-java-media/src/main/java/module-info.java create mode 160000 webrtc-java-media/third-party/ffmpeg diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..e237b00c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "webrtc-java-media/third-party/ffmpeg"] + path = webrtc-java-media/third-party/ffmpeg + url = https://github.com/FFmpeg/FFmpeg.git + shallow = true diff --git a/pom.xml b/pom.xml index f6222127..b8e6770a 100644 --- a/pom.xml +++ b/pom.xml @@ -354,6 +354,19 @@ webrtc.macos.aarch64 + + + with-media-extension + + webrtc-java-media + + diff --git a/webrtc-java-media/.gitignore b/webrtc-java-media/.gitignore new file mode 100644 index 00000000..023936a0 --- /dev/null +++ b/webrtc-java-media/.gitignore @@ -0,0 +1,3 @@ +# FFmpeg out-of-tree build output +/build +/libs diff --git a/webrtc-java-media/README.md b/webrtc-java-media/README.md new file mode 100644 index 00000000..743d3612 --- /dev/null +++ b/webrtc-java-media/README.md @@ -0,0 +1,51 @@ +# webrtc-java-media + +Media extension for [webrtc-java](https://github.com/devopvoid/webrtc-java). It reads media files +and network streams with FFmpeg and feeds them into a peer connection, so an application can send +a video file the way it would send a camera. + +```java +MediaFileSource source = new MediaFileSource(Path.of("movie.mp4")); + +VideoTrack videoTrack = factory.createVideoTrack("video", source.getVideoSource()); +AudioTrack audioTrack = factory.createAudioTrack("audio", source.getAudioSource()); + +peerConnection.addTrack(videoTrack, List.of("stream")); +peerConnection.addTrack(audioTrack, List.of("stream")); + +source.play(); +``` + +## How it fits together + +Decoding happens entirely in native code. The module does not carry frames through Java: it calls +into webrtc-java's native side through the small C interface in `webrtc_java_api.h`, which hands +decoded pictures to a `CustomVideoSource` and 10 ms chunks to a `CustomAudioSource` without a copy +per frame. Frames are paced in real time and carry their own presentation times, so playback keeps +the timing of the file rather than the timing of a thread. + +A factory that sends media from this module sends pushed audio, so it cannot also send audio +captured by its `AudioDeviceModule`. Use a separate factory if you need both. + +## FFmpeg + +This module uses [FFmpeg](https://ffmpeg.org), licensed under the LGPL version 2.1 or later. The +FFmpeg source it is built from is the `third-party/ffmpeg` submodule, pinned to a release tag, and +is configured without `--enable-gpl` and without `--enable-nonfree`. + +FFmpeg is linked dynamically and its libraries ship as separate files inside the platform jar, so +you may replace them with your own build, as the LGPL requires. The wrapper code in this module is +licensed under the Apache License 2.0 like the rest of webrtc-java. + +## Building + +The submodule has to be present: + +```shell +git submodule update --init --depth 1 webrtc-java-media/third-party/ffmpeg +mvn install -Pwith-media-extension +``` + +The first build compiles FFmpeg, which takes a while; later builds reuse the install directory +(`ffmpeg.install.dir`, by default `~/ffmpeg/`). Building FFmpeg needs `make`, `nasm` and +`pkg-config`; on Windows those come from an MSYS2 shell. diff --git a/webrtc-java-media/pom.xml b/webrtc-java-media/pom.xml new file mode 100644 index 00000000..71093639 --- /dev/null +++ b/webrtc-java-media/pom.xml @@ -0,0 +1,147 @@ + + + 4.0.0 + + + dev.onvoid.webrtc + webrtc-java-parent + 0.19.0-SNAPSHOT + + + webrtc-java-media + + webrtc-java-media + + Media extension for webrtc-java. Reads and decodes media files and network streams with + FFmpeg and feeds them into a peer connection. + + + + + 7.1.1 + + ${user.home}/ffmpeg/${platform.classifier} + Release + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + com.googlecode.cmake-maven-project + cmake-maven-plugin + 3.31.5-b1 + + + cmake-generate + + generate + + + src/main/cpp + ${project.build.directory}/${platform.classifier} + + + + + + + + + + + cmake-compile + + compile + + + ${cmake.config} + ${project.build.directory}/${platform.classifier} + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + natives-jar + package + + jar + + + ${platform.classifier} + ${project.build.directory}/natives + + + ${ffmpeg.version} + LGPL-2.1-or-later + + + + + + + + + + + + dev.onvoid.webrtc + webrtc-java + ${project.version} + + + + + + windows-x86_64 + + + windows + amd64 + + + + -Ax64 + ${cmake.build.type} + + + + windows-aarch64 + + -AARM64 + ${cmake.build.type} + + + + + diff --git a/webrtc-java-media/src/main/cpp/CMakeLists.txt b/webrtc-java-media/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..2e83e2ea --- /dev/null +++ b/webrtc-java-media/src/main/cpp/CMakeLists.txt @@ -0,0 +1,69 @@ +cmake_minimum_required(VERSION 3.13) +project(webrtc-java-media CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +if(UNIX AND NOT APPLE) + set(LINUX TRUE) + set(CMAKE_POSITION_INDEPENDENT_CODE ON) +endif() + +if(APPLE) + set(CMAKE_CXX_VISIBILITY_PRESET hidden) +elseif(WIN32) + # Match the runtime the rest of the project's native code uses. + set(CMAKE_CXX_FLAGS_RELEASE "/MT") + set(CMAKE_CXX_FLAGS_DEBUG "/MTd") +endif() + +find_package(JNI REQUIRED) + +add_subdirectory(dependencies/ffmpeg) + +file(GLOB SOURCES "src/*.cpp") + +add_library(${PROJECT_NAME} SHARED ${SOURCES}) + +target_include_directories(${PROJECT_NAME} + PRIVATE + include + # webrtc_java_api.h: the C interface of webrtc-java's native library. + # Only the header is used. There is no link dependency between the two + # libraries; the function table is passed in at runtime. + ${CMAKE_CURRENT_SOURCE_DIR}/../../../../webrtc-jni/src/main/cpp/include + ${JNI_INCLUDE_DIRS} +) + +target_link_libraries(${PROJECT_NAME} PRIVATE ffmpeg) + +if(LINUX) + target_link_libraries(${PROJECT_NAME} PRIVATE dl pthread) +endif() + +# The Java side loads the library as +# webrtc-java-media--, the same scheme NativeLoader uses for the +# core library. +if(DEFINED OUTPUT_NAME_SUFFIX AND NOT OUTPUT_NAME_SUFFIX STREQUAL "") + set_target_properties(${PROJECT_NAME} PROPERTIES + OUTPUT_NAME "${PROJECT_NAME}-${OUTPUT_NAME_SUFFIX}") +endif() + +# +# Everything that has to end up in the platform jar goes into one directory, +# which the jar plugin picks up as the classes directory of the natives jar. +# +set(NATIVES_DIR "${CMAKE_CURRENT_BINARY_DIR}/../natives") + +add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${NATIVES_DIR}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" "${NATIVES_DIR}" + COMMENT "Collecting the native library for the platform jar") + +foreach(RUNTIME_LIB ${FFMPEG_RUNTIME_LIBS}) + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${RUNTIME_LIB}" "${NATIVES_DIR}" + COMMENT "Collecting ${RUNTIME_LIB} for the platform jar") +endforeach() diff --git a/webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt b/webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt new file mode 100644 index 00000000..1a5947ba --- /dev/null +++ b/webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt @@ -0,0 +1,252 @@ +cmake_minimum_required(VERSION 3.13) +project(ffmpeg) + +# +# Builds the FFmpeg libraries this module links against, from the +# third-party/ffmpeg submodule, and exposes them as the imported target +# "ffmpeg". +# +# The build runs at CMake configure time and installs into FFMPEG_INSTALL_DIR, +# the way the WebRTC dependency of webrtc-jni does. A configure that finds the +# libraries already installed there skips the whole thing, so only the first +# build of a platform pays for it. +# +# The configuration is deliberately LGPL only: no --enable-gpl and no +# --enable-nonfree, and the libraries are shared so that they can be replaced, +# which is what the LGPL asks of us. Only the demuxers, decoders, parsers and +# protocols this module actually plays are enabled; everything else is off, to +# keep the shipped libraries small. +# + +set(FFMPEG_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../third-party/ffmpeg") +get_filename_component(FFMPEG_SOURCE_DIR "${FFMPEG_SOURCE_DIR}" ABSOLUTE) + +if(NOT DEFINED FFMPEG_INSTALL_DIR OR FFMPEG_INSTALL_DIR STREQUAL "") + message(FATAL_ERROR "FFMPEG_INSTALL_DIR is not set") +endif() + +file(MAKE_DIRECTORY "${FFMPEG_INSTALL_DIR}") + +# The libraries this module needs, in dependency order. The order matters +# twice: for the link line, and for the order the Java side loads them in. +set(FFMPEG_LIBS avutil swresample swscale avcodec avformat) + +# +# Decides whether FFmpeg has to be built, by looking for one of its headers +# and one of its libraries in the install directory. +# +function(ffmpeg_is_installed RESULT) + if(NOT EXISTS "${FFMPEG_INSTALL_DIR}/include/libavformat/avformat.h") + set(${RESULT} FALSE PARENT_SCOPE) + return() + endif() + + file(GLOB FOUND_LIBS + "${FFMPEG_INSTALL_DIR}/lib/avformat*" + "${FFMPEG_INSTALL_DIR}/lib/libavformat*" + "${FFMPEG_INSTALL_DIR}/bin/avformat*") + + if(FOUND_LIBS) + set(${RESULT} TRUE PARENT_SCOPE) + else() + set(${RESULT} FALSE PARENT_SCOPE) + endif() +endfunction() + +ffmpeg_is_installed(FFMPEG_INSTALLED) + +if(FFMPEG_INSTALLED) + message(STATUS "FFmpeg found in ${FFMPEG_INSTALL_DIR}, skipping build") +else() + if(NOT EXISTS "${FFMPEG_SOURCE_DIR}/configure") + message(FATAL_ERROR + "The FFmpeg submodule is not checked out. Run:\n" + " git submodule update --init --depth 1 webrtc-java-media/third-party/ffmpeg") + endif() + + # FFmpeg's configure is a shell script, so a POSIX shell is needed even to + # build with MSVC. On Windows that means an MSYS2 shell; Git for Windows' + # bash does not carry make, nasm or pkg-config. + find_program(BASH_EXECUTABLE NAMES bash) + + if(NOT BASH_EXECUTABLE) + message(FATAL_ERROR + "No 'bash' found, which FFmpeg's configure needs. On Windows install MSYS2 and " + "put its usr/bin on the PATH.") + endif() + + find_program(MAKE_EXECUTABLE NAMES make gmake mingw32-make) + + if(NOT MAKE_EXECUTABLE) + message(FATAL_ERROR "No 'make' found, which building FFmpeg needs.") + endif() + + set(FFMPEG_CONFIGURE_ARGS + --prefix=${FFMPEG_INSTALL_DIR} + --disable-static + --enable-shared + --enable-pic + --disable-debug + --disable-doc + --disable-programs + --disable-avdevice + --disable-avfilter + --disable-postproc + --disable-network + --disable-everything + --disable-autodetect + # Containers this module reads. + --enable-demuxer=mov + --enable-demuxer=matroska + --enable-demuxer=avi + --enable-demuxer=mpegts + --enable-demuxer=flv + --enable-demuxer=wav + --enable-demuxer=mp3 + --enable-demuxer=ogg + --enable-demuxer=flac + --enable-demuxer=aac + # Video this module decodes. + --enable-decoder=h264 + --enable-decoder=hevc + --enable-decoder=vp8 + --enable-decoder=vp9 + --enable-decoder=mpeg4 + --enable-decoder=mjpeg + # Audio this module decodes. + --enable-decoder=aac + --enable-decoder=mp3 + --enable-decoder=opus + --enable-decoder=vorbis + --enable-decoder=flac + --enable-decoder=pcm_s16le + --enable-decoder=pcm_s16be + --enable-decoder=pcm_u8 + --enable-decoder=pcm_f32le + # Parsers for the decoders above. + --enable-parser=h264 + --enable-parser=hevc + --enable-parser=vp8 + --enable-parser=vp9 + --enable-parser=mpeg4video + --enable-parser=mjpeg + --enable-parser=aac + --enable-parser=mpegaudio + --enable-parser=opus + --enable-parser=vorbis + --enable-parser=flac + # Local files only for now; network protocols follow once the TLS + # backends are wired up per platform. + --enable-protocol=file + --enable-protocol=pipe + ) + + if(WIN32) + # FFmpeg builds against the MSVC runtime through its own msvc + # toolchain support, which keeps the DLLs free of an MSYS2 runtime + # dependency. configure needs Windows-style output, so cl and link + # must be on the PATH of the shell that runs it. + list(APPEND FFMPEG_CONFIGURE_ARGS --toolchain=msvc) + endif() + + if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64|ARM64)$" AND WIN32) + # No assembler for this target yet; correctness first, speed later. + list(APPEND FFMPEG_CONFIGURE_ARGS --disable-asm --arch=arm64) + endif() + + if(APPLE AND CMAKE_OSX_ARCHITECTURES) + list(APPEND FFMPEG_CONFIGURE_ARGS + --extra-cflags=-arch\ ${CMAKE_OSX_ARCHITECTURES} + --extra-ldflags=-arch\ ${CMAKE_OSX_ARCHITECTURES}) + endif() + + # Build out of tree so the submodule working tree stays clean. + set(FFMPEG_BUILD_DIR "${CMAKE_CURRENT_BINARY_DIR}/ffmpeg-build") + file(MAKE_DIRECTORY "${FFMPEG_BUILD_DIR}") + + string(REPLACE ";" " " FFMPEG_CONFIGURE_ARGS_STR "${FFMPEG_CONFIGURE_ARGS}") + + message(STATUS "Configuring FFmpeg ${FFMPEG_VERSION} into ${FFMPEG_INSTALL_DIR}") + message(STATUS " ${FFMPEG_SOURCE_DIR}/configure ${FFMPEG_CONFIGURE_ARGS_STR}") + + execute_process( + COMMAND "${BASH_EXECUTABLE}" -c + "'${FFMPEG_SOURCE_DIR}/configure' ${FFMPEG_CONFIGURE_ARGS_STR}" + WORKING_DIRECTORY "${FFMPEG_BUILD_DIR}" + RESULT_VARIABLE CONFIGURE_RESULT + ) + + if(NOT CONFIGURE_RESULT EQUAL 0) + message(FATAL_ERROR + "FFmpeg configure failed (${CONFIGURE_RESULT}). See " + "${FFMPEG_BUILD_DIR}/ffbuild/config.log for what it could not find.") + endif() + + include(ProcessorCount) + ProcessorCount(BUILD_JOBS) + + if(BUILD_JOBS EQUAL 0) + set(BUILD_JOBS 1) + endif() + + message(STATUS "Building FFmpeg with ${BUILD_JOBS} jobs") + + execute_process( + COMMAND "${MAKE_EXECUTABLE}" -j${BUILD_JOBS} + WORKING_DIRECTORY "${FFMPEG_BUILD_DIR}" + RESULT_VARIABLE BUILD_RESULT + ) + + if(NOT BUILD_RESULT EQUAL 0) + message(FATAL_ERROR "FFmpeg build failed (${BUILD_RESULT})") + endif() + + execute_process( + COMMAND "${MAKE_EXECUTABLE}" install + WORKING_DIRECTORY "${FFMPEG_BUILD_DIR}" + RESULT_VARIABLE INSTALL_RESULT + ) + + if(NOT INSTALL_RESULT EQUAL 0) + message(FATAL_ERROR "FFmpeg install failed (${INSTALL_RESULT})") + endif() + + ffmpeg_is_installed(FFMPEG_INSTALLED) + + if(NOT FFMPEG_INSTALLED) + message(FATAL_ERROR + "FFmpeg reported success but nothing was installed in ${FFMPEG_INSTALL_DIR}") + endif() +endif() + +# +# Expose the installed libraries as one interface target. The import libraries +# are what the link line needs; the runtime libraries are collected separately +# because they have to travel in the platform jar. +# +add_library(ffmpeg INTERFACE) + +target_include_directories(ffmpeg INTERFACE "${FFMPEG_INSTALL_DIR}/include") + +foreach(LIB ${FFMPEG_LIBS}) + find_library(FFMPEG_${LIB}_LIB + NAMES ${LIB} lib${LIB} + PATHS "${FFMPEG_INSTALL_DIR}/lib" + NO_DEFAULT_PATH) + + if(NOT FFMPEG_${LIB}_LIB) + message(FATAL_ERROR "Could not find lib${LIB} in ${FFMPEG_INSTALL_DIR}/lib") + endif() + + target_link_libraries(ffmpeg INTERFACE "${FFMPEG_${LIB}_LIB}") +endforeach() + +# The shared libraries to ship. On Windows the DLLs land in bin/, elsewhere in +# lib/ next to the import libraries. +file(GLOB FFMPEG_RUNTIME_LIBS + "${FFMPEG_INSTALL_DIR}/bin/*.dll" + "${FFMPEG_INSTALL_DIR}/lib/*.so.*" + "${FFMPEG_INSTALL_DIR}/lib/*.dylib") + +set(FFMPEG_RUNTIME_LIBS "${FFMPEG_RUNTIME_LIBS}" CACHE INTERNAL + "FFmpeg shared libraries that ship in the platform jar") diff --git a/webrtc-java-media/src/main/cpp/include/JNI_FFmpeg.h b/webrtc-java-media/src/main/cpp/include/JNI_FFmpeg.h new file mode 100644 index 00000000..84234f0a --- /dev/null +++ b/webrtc-java-media/src/main/cpp/include/JNI_FFmpeg.h @@ -0,0 +1,44 @@ +/* + * Copyright 2026 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 +/* Header for class dev_onvoid_webrtc_media_ffmpeg_FFmpeg */ + +#ifndef _Included_dev_onvoid_webrtc_media_ffmpeg_FFmpeg +#define _Included_dev_onvoid_webrtc_media_ffmpeg_FFmpeg +#ifdef __cplusplus +extern "C" { +#endif + /* + * Class: dev_onvoid_webrtc_media_ffmpeg_FFmpeg + * Method: version + * Signature: ()Ljava/lang/String; + */ + JNIEXPORT jstring JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_FFmpeg_version + (JNIEnv *, jclass); + + /* + * Class: dev_onvoid_webrtc_media_ffmpeg_FFmpeg + * Method: license + * Signature: ()Ljava/lang/String; + */ + JNIEXPORT jstring JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_FFmpeg_license + (JNIEnv *, jclass); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/webrtc-java-media/src/main/cpp/src/JNI_FFmpeg.cpp b/webrtc-java-media/src/main/cpp/src/JNI_FFmpeg.cpp new file mode 100644 index 00000000..dd8c7285 --- /dev/null +++ b/webrtc-java-media/src/main/cpp/src/JNI_FFmpeg.cpp @@ -0,0 +1,37 @@ +/* + * Copyright 2026 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_FFmpeg.h" + +extern "C" { +#include +#include +} + +JNIEXPORT jstring JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_FFmpeg_version +(JNIEnv * env, jclass caller) +{ + // The version of the libraries that were actually loaded, which is not + // necessarily the one this module was built against: the LGPL lets an + // application replace them. + return env->NewStringUTF(av_version_info()); +} + +JNIEXPORT jstring JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_FFmpeg_license +(JNIEnv * env, jclass caller) +{ + return env->NewStringUTF(avformat_license()); +} diff --git a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/FFmpeg.java b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/FFmpeg.java new file mode 100644 index 00000000..1151addb --- /dev/null +++ b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/FFmpeg.java @@ -0,0 +1,117 @@ +/* + * Copyright 2026 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.ffmpeg; + +import dev.onvoid.webrtc.internal.NativeLoader; + +/** + * Loads this module's native library and reports what it was built against. + *

+ * The FFmpeg libraries are loaded first, in the order they depend on each + * other, and under the names they were built with. A dynamic linker matches an + * already loaded module by file name, so by the time the media library itself + * is loaded every symbol it imports is resolved without changing any search + * path. + * + * @author Alex Andres + */ +public final class FFmpeg { + + /** The name of this module's native library, without platform decoration. */ + private static final String LIBRARY = "webrtc-java-media"; + + + static { + try { + NativeLoader.loadLibrary(LIBRARY, dependencies()); + } + catch (Exception e) { + throw new RuntimeException("Load library '" + LIBRARY + "' failed", e); + } + } + + + private FFmpeg() { + // Static access only. + } + + /** + * Returns the FFmpeg version the loaded native library was built against, + * for example {@code 7.1.1}. Useful to confirm which FFmpeg an application + * actually ended up with, since the libraries may be replaced. + * + * @return The FFmpeg version string. + */ + public static native String version(); + + /** + * Returns the license of the loaded FFmpeg libraries, which must be an + * LGPL variant. A build that reports a GPL license is one this project did + * not produce, and redistributing it carries obligations this project does + * not meet. + * + * @return The FFmpeg license string, for example {@code LGPL version 2.1 or later}. + */ + public static native String license(); + + /** + * Makes sure the native library is loaded. Loading happens when this class + * is first used, so this is only needed to bring the failure forward to a + * point where it can be reported. + */ + public static void load() { + // Touching the class runs the static initializer. + } + + /** + * Returns the shared libraries to load before this module's own, in the + * order they depend on each other, named as they are in the platform jar. + * + * @return The file names of the FFmpeg libraries for this platform. + */ + private static String[] dependencies() { + String osName = System.getProperty("os.name").toLowerCase(); + + if (osName.startsWith("windows")) { + return new String[] { + "avutil-59.dll", + "swresample-5.dll", + "swscale-8.dll", + "avcodec-61.dll", + "avformat-61.dll" + }; + } + if (osName.startsWith("mac os")) { + return new String[] { + "libavutil.59.dylib", + "libswresample.5.dylib", + "libswscale.8.dylib", + "libavcodec.61.dylib", + "libavformat.61.dylib" + }; + } + + return new String[] { + "libavutil.so.59", + "libswresample.so.5", + "libswscale.so.8", + "libavcodec.so.61", + "libavformat.so.61" + }; + } + +} diff --git a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaInfo.java b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaInfo.java new file mode 100644 index 00000000..eb5d5ca6 --- /dev/null +++ b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaInfo.java @@ -0,0 +1,197 @@ +/* + * Copyright 2026 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.ffmpeg; + +/** + * What a media source contains: how long it runs, and the format of the video + * and audio streams that will be played from it. + *

+ * A stream that is absent is reported as a zero size, frame rate, sample rate + * or channel count, so {@link #hasVideo()} and {@link #hasAudio()} are the + * questions to ask before reading the rest. + * + * @author Alex Andres + */ +public class MediaInfo { + + /** How long the source runs in microseconds, or 0 if it is not known. */ + private final long durationUs; + + /** The width of the video in pixels, or 0 if there is no video. */ + private final int videoWidth; + + /** The height of the video in pixels, or 0 if there is no video. */ + private final int videoHeight; + + /** The frame rate of the video, or 0 if there is no video. */ + private final double frameRate; + + /** The name of the video codec, or {@code null} if there is no video. */ + private final String videoCodec; + + /** The sample rate of the audio in Hz, or 0 if there is no audio. */ + private final int sampleRate; + + /** The channel count of the audio, or 0 if there is no audio. */ + private final int channels; + + /** The name of the audio codec, or {@code null} if there is no audio. */ + private final String audioCodec; + + + /** + * Creates media information. Called by native code once a source has been + * opened and its streams have been examined. + * + * @param durationUs How long the source runs in microseconds. + * @param videoWidth The width of the video in pixels. + * @param videoHeight The height of the video in pixels. + * @param frameRate The frame rate of the video. + * @param videoCodec The name of the video codec. + * @param sampleRate The sample rate of the audio in Hz. + * @param channels The channel count of the audio. + * @param audioCodec The name of the audio codec. + */ + public MediaInfo(long durationUs, int videoWidth, int videoHeight, + double frameRate, String videoCodec, int sampleRate, int channels, + String audioCodec) { + this.durationUs = durationUs; + this.videoWidth = videoWidth; + this.videoHeight = videoHeight; + this.frameRate = frameRate; + this.videoCodec = videoCodec; + this.sampleRate = sampleRate; + this.channels = channels; + this.audioCodec = audioCodec; + } + + /** + * Returns how long the source runs, in microseconds. Live streams and some + * containers do not say, in which case this is {@code 0}. + * + * @return The duration in microseconds, or {@code 0} if it is not known. + */ + public long getDurationUs() { + return durationUs; + } + + /** + * Returns whether the source has a video stream that can be played. + * + * @return True if there is video. + */ + public boolean hasVideo() { + return videoWidth > 0 && videoHeight > 0; + } + + /** + * Returns the width of the video in pixels. + * + * @return The width, or {@code 0} if there is no video. + */ + public int getVideoWidth() { + return videoWidth; + } + + /** + * Returns the height of the video in pixels. + * + * @return The height, or {@code 0} if there is no video. + */ + public int getVideoHeight() { + return videoHeight; + } + + /** + * Returns the frame rate of the video. For a variable frame rate source + * this is the average the container reports. + * + * @return The frame rate, or {@code 0} if there is no video. + */ + public double getFrameRate() { + return frameRate; + } + + /** + * Returns the name of the video codec, as FFmpeg names it, for example + * {@code h264} or {@code vp9}. + * + * @return The codec name, or {@code null} if there is no video. + */ + public String getVideoCodec() { + return videoCodec; + } + + /** + * Returns whether the source has an audio stream that can be played. + * + * @return True if there is audio. + */ + public boolean hasAudio() { + return sampleRate > 0 && channels > 0; + } + + /** + * Returns the sample rate of the audio in Hz, as it is in the source. The + * audio is resampled before it reaches WebRTC, so this is not necessarily + * the rate that is sent. + * + * @return The sample rate, or {@code 0} if there is no audio. + */ + public int getSampleRate() { + return sampleRate; + } + + /** + * Returns the channel count of the audio, as it is in the source. + * + * @return The channel count, or {@code 0} if there is no audio. + */ + public int getChannels() { + return channels; + } + + /** + * Returns the name of the audio codec, as FFmpeg names it, for example + * {@code aac} or {@code opus}. + * + * @return The codec name, or {@code null} if there is no audio. + */ + public String getAudioCodec() { + return audioCodec; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("MediaInfo["); + + builder.append(durationUs / 1_000_000.0).append("s"); + + if (hasVideo()) { + builder.append(", video ").append(videoWidth).append("x") + .append(videoHeight).append(" @ ").append(frameRate) + .append(" ").append(videoCodec); + } + if (hasAudio()) { + builder.append(", audio ").append(sampleRate).append(" Hz ") + .append(channels).append(" ch ").append(audioCodec); + } + + return builder.append("]").toString(); + } + +} diff --git a/webrtc-java-media/src/main/java/module-info.java b/webrtc-java-media/src/main/java/module-info.java new file mode 100644 index 00000000..e48c075b --- /dev/null +++ b/webrtc-java-media/src/main/java/module-info.java @@ -0,0 +1,11 @@ +/** + * Media extension for webrtc-java: reads media files and network streams with + * FFmpeg and feeds them into a peer connection. + */ +module webrtc.java.media { + + requires webrtc.java; + + exports dev.onvoid.webrtc.media.ffmpeg; + +} diff --git a/webrtc-java-media/third-party/ffmpeg b/webrtc-java-media/third-party/ffmpeg new file mode 160000 index 00000000..db69d06e --- /dev/null +++ b/webrtc-java-media/third-party/ffmpeg @@ -0,0 +1 @@ +Subproject commit db69d06eeeab4f46da15030a80d539efb4503ca8 From c9552985e8276d794bf6d635627e920a45678d00 Mon Sep 17 00:00:00 2001 From: Alex Andres Date: Sun, 20 Sep 2026 14:23:26 +0200 Subject: [PATCH 3/7] build: make the FFmpeg dependency build work on Windows Bringing up windows-x86_64 turned up three things the script had wrong. find_program(bash) picked Git for Windows' bash, which carries neither make nor nasm. MSYS2 is now looked for by path first, with MSYS2_ROOT as an override, and make and nasm are checked for inside it rather than with find_program, since they are only visible from its own shell. FFmpeg's configure probes the compiler, so the shell has to be entered through vcvars, located with vswhere. Inside it the MSVC toolchain directory has to precede /usr/bin: MSYS2 ships a coreutils link that otherwise shadows MSVC's linker, and configure then reports that the C compiler cannot create executables. configure, make and make install are therefore written out as one generated shell script and run once, instead of three execute_process calls, because all three have to share that one shell. An MSVC build of FFmpeg installs the import libraries into bin/ next to the DLLs and leaves only the .def files in lib/, so find_library searches both. FFmpeg n7.1.1 now builds from the submodule in about 140 seconds, and FFmpeg.version() and FFmpeg.license() report n7.1.1 and LGPL version 2.1 or later from the packaged platform jar. --- webrtc-java-media/README.md | 14 +- .../cpp/dependencies/ffmpeg/CMakeLists.txt | 215 ++++++++++++++---- 2 files changed, 183 insertions(+), 46 deletions(-) diff --git a/webrtc-java-media/README.md b/webrtc-java-media/README.md index 743d3612..ef048c1c 100644 --- a/webrtc-java-media/README.md +++ b/webrtc-java-media/README.md @@ -47,5 +47,15 @@ mvn install -Pwith-media-extension ``` The first build compiles FFmpeg, which takes a while; later builds reuse the install directory -(`ffmpeg.install.dir`, by default `~/ffmpeg/`). Building FFmpeg needs `make`, `nasm` and -`pkg-config`; on Windows those come from an MSYS2 shell. +(`ffmpeg.install.dir`, by default `~/ffmpeg/`). + +Building FFmpeg needs `make` and `nasm`. On Windows they come from MSYS2, which the build looks for +in `C:/msys64` unless `MSYS2_ROOT` points somewhere else: + +```shell +winget install MSYS2.MSYS2 +C:\msys64\usr\bin\bash -lc "pacman -S --needed make nasm diffutils pkgconf" +``` + +Maven still runs from an ordinary shell. The build enters MSYS2 and the Visual Studio environment +on its own, because FFmpeg's configure needs a POSIX shell that can also see `cl` and `link`. diff --git a/webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt b/webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt index 1a5947ba..30fe26a8 100644 --- a/webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt +++ b/webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt @@ -56,7 +56,7 @@ endfunction() ffmpeg_is_installed(FFMPEG_INSTALLED) if(FFMPEG_INSTALLED) - message(STATUS "FFmpeg found in ${FFMPEG_INSTALL_DIR}, skipping build") + message(STATUS "FFmpeg ${FFMPEG_VERSION} found in ${FFMPEG_INSTALL_DIR}, skipping build") else() if(NOT EXISTS "${FFMPEG_SOURCE_DIR}/configure") message(FATAL_ERROR @@ -64,25 +64,113 @@ else() " git submodule update --init --depth 1 webrtc-java-media/third-party/ffmpeg") endif() - # FFmpeg's configure is a shell script, so a POSIX shell is needed even to - # build with MSVC. On Windows that means an MSYS2 shell; Git for Windows' - # bash does not carry make, nasm or pkg-config. - find_program(BASH_EXECUTABLE NAMES bash) + # FFmpeg's configure and its makefiles are shell scripts, so a POSIX shell + # is needed even to build with MSVC. On Windows that shell has to be + # MSYS2's: Git for Windows' bash carries neither make nor nasm, and it is + # the one a bare find_program(bash) turns up, so look for MSYS2 by path + # first and only then fall back to the PATH. + if(WIN32) + find_program(BASH_EXECUTABLE + NAMES bash + PATHS "$ENV{MSYS2_ROOT}/usr/bin" + "C:/msys64/usr/bin" + "C:/msys2/usr/bin" + "C:/tools/msys64/usr/bin" + NO_DEFAULT_PATH) + + if(NOT BASH_EXECUTABLE) + message(FATAL_ERROR + "No MSYS2 bash found, which building FFmpeg on Windows needs. Install MSYS2 " + "(winget install MSYS2.MSYS2), then inside it run:\n" + " pacman -S --needed make nasm diffutils pkgconf\n" + "Set MSYS2_ROOT if it is not installed in C:/msys64.") + endif() + + # make and nasm live inside MSYS2 and are only visible from its own + # shell, so check for them there rather than with find_program. + get_filename_component(MSYS2_BIN_DIR "${BASH_EXECUTABLE}" DIRECTORY) + + foreach(REQUIRED_TOOL make nasm) + if(NOT EXISTS "${MSYS2_BIN_DIR}/${REQUIRED_TOOL}.exe") + message(FATAL_ERROR + "MSYS2 has no '${REQUIRED_TOOL}', which building FFmpeg needs. Run inside " + "MSYS2:\n pacman -S --needed make nasm diffutils pkgconf") + endif() + endforeach() + + # configure probes the compiler, so the shell it runs in has to have cl + # and link on its PATH. vcvars is what puts them there, and vswhere + # ships with every Visual Studio since 2017. + find_program(VSWHERE_EXECUTABLE + NAMES vswhere + HINTS "$ENV{ProgramFiles\(x86\)}/Microsoft Visual Studio/Installer" + "C:/Program Files (x86)/Microsoft Visual Studio/Installer") + + if(NOT VSWHERE_EXECUTABLE) + message(FATAL_ERROR + "No 'vswhere' found, so Visual Studio could not be located. Building FFmpeg " + "on Windows needs a Visual Studio with the C++ workload.") + endif() + + execute_process( + COMMAND "${VSWHERE_EXECUTABLE}" -latest -products * -property installationPath + OUTPUT_VARIABLE VS_INSTALL_DIR + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + + if(NOT VS_INSTALL_DIR) + message(FATAL_ERROR "vswhere reported no Visual Studio installation.") + endif() + + file(TO_CMAKE_PATH "${VS_INSTALL_DIR}" VS_INSTALL_DIR) + + # The cross compiler is hosted on x64 in every case this builds for. + set(FFMPEG_MSVC_HOST_ARCH x64) + + if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64|ARM64)$") + set(FFMPEG_MSVC_TARGET_ARCH arm64) + set(FFMPEG_VCVARS "${VS_INSTALL_DIR}/VC/Auxiliary/Build/vcvarsamd64_arm64.bat") + else() + set(FFMPEG_MSVC_TARGET_ARCH x64) + set(FFMPEG_VCVARS "${VS_INSTALL_DIR}/VC/Auxiliary/Build/vcvars64.bat") + endif() + + if(NOT EXISTS "${FFMPEG_VCVARS}") + message(FATAL_ERROR + "No '${FFMPEG_VCVARS}'. Visual Studio is installed without the C++ tools for " + "this target architecture.") + endif() + + file(TO_NATIVE_PATH "${FFMPEG_VCVARS}" FFMPEG_VCVARS) + else() + find_program(BASH_EXECUTABLE NAMES bash) - if(NOT BASH_EXECUTABLE) - message(FATAL_ERROR - "No 'bash' found, which FFmpeg's configure needs. On Windows install MSYS2 and " - "put its usr/bin on the PATH.") - endif() + if(NOT BASH_EXECUTABLE) + message(FATAL_ERROR "No 'bash' found, which FFmpeg's configure needs.") + endif() - find_program(MAKE_EXECUTABLE NAMES make gmake mingw32-make) + find_program(MAKE_EXECUTABLE NAMES make gmake) - if(NOT MAKE_EXECUTABLE) - message(FATAL_ERROR "No 'make' found, which building FFmpeg needs.") + if(NOT MAKE_EXECUTABLE) + message(FATAL_ERROR "No 'make' found, which building FFmpeg needs.") + endif() endif() + # configure and make are run by a POSIX shell, which on Windows means MSYS2 + # and its /c/... form of a path rather than C:/... + macro(to_shell_path PATH_IN RESULT) + set(${RESULT} "${PATH_IN}") + + if(WIN32) + string(REGEX REPLACE "^([A-Za-z]):/" "/\\1/" ${RESULT} "${${RESULT}}") + endif() + endmacro() + + to_shell_path("${FFMPEG_INSTALL_DIR}" FFMPEG_INSTALL_DIR_SH) + to_shell_path("${FFMPEG_SOURCE_DIR}" FFMPEG_SOURCE_DIR_SH) + set(FFMPEG_CONFIGURE_ARGS - --prefix=${FFMPEG_INSTALL_DIR} + --prefix=${FFMPEG_INSTALL_DIR_SH} --disable-static --enable-shared --enable-pic @@ -163,25 +251,10 @@ else() # Build out of tree so the submodule working tree stays clean. set(FFMPEG_BUILD_DIR "${CMAKE_CURRENT_BINARY_DIR}/ffmpeg-build") file(MAKE_DIRECTORY "${FFMPEG_BUILD_DIR}") + to_shell_path("${FFMPEG_BUILD_DIR}" FFMPEG_BUILD_DIR_SH) string(REPLACE ";" " " FFMPEG_CONFIGURE_ARGS_STR "${FFMPEG_CONFIGURE_ARGS}") - message(STATUS "Configuring FFmpeg ${FFMPEG_VERSION} into ${FFMPEG_INSTALL_DIR}") - message(STATUS " ${FFMPEG_SOURCE_DIR}/configure ${FFMPEG_CONFIGURE_ARGS_STR}") - - execute_process( - COMMAND "${BASH_EXECUTABLE}" -c - "'${FFMPEG_SOURCE_DIR}/configure' ${FFMPEG_CONFIGURE_ARGS_STR}" - WORKING_DIRECTORY "${FFMPEG_BUILD_DIR}" - RESULT_VARIABLE CONFIGURE_RESULT - ) - - if(NOT CONFIGURE_RESULT EQUAL 0) - message(FATAL_ERROR - "FFmpeg configure failed (${CONFIGURE_RESULT}). See " - "${FFMPEG_BUILD_DIR}/ffbuild/config.log for what it could not find.") - endif() - include(ProcessorCount) ProcessorCount(BUILD_JOBS) @@ -189,26 +262,77 @@ else() set(BUILD_JOBS 1) endif() - message(STATUS "Building FFmpeg with ${BUILD_JOBS} jobs") + # + # configure, make and make install go into one shell script rather than + # three execute_process calls, because on Windows all three have to run in + # the same MSYS2 shell, and that shell has to be entered through vcvars so + # that FFmpeg's configure can find cl and link. + # + set(FFMPEG_SHELL_PREAMBLE "") - execute_process( - COMMAND "${MAKE_EXECUTABLE}" -j${BUILD_JOBS} - WORKING_DIRECTORY "${FFMPEG_BUILD_DIR}" - RESULT_VARIABLE BUILD_RESULT - ) + if(WIN32) + # MSYS2's own make, reached from inside its shell. + set(FFMPEG_MAKE make) + + # MSYS2 ships a coreutils "link" in /usr/bin that otherwise shadows + # MSVC's linker, and FFmpeg's configure then reports that the C + # compiler cannot create executables. Putting the MSVC toolchain + # first is what keeps that from happening. + set(FFMPEG_SHELL_PREAMBLE +"if [ -n \"$VCToolsInstallDir\" ]; then + export PATH=\"$(cygpath -u \"$VCToolsInstallDir\")/bin/Host${FFMPEG_MSVC_HOST_ARCH}/${FFMPEG_MSVC_TARGET_ARCH}:$PATH\" +fi +") + else() + set(FFMPEG_MAKE "${MAKE_EXECUTABLE}") + endif() - if(NOT BUILD_RESULT EQUAL 0) - message(FATAL_ERROR "FFmpeg build failed (${BUILD_RESULT})") + set(FFMPEG_BUILD_SCRIPT "${CMAKE_CURRENT_BINARY_DIR}/build-ffmpeg.sh") + + file(WRITE "${FFMPEG_BUILD_SCRIPT}" +"# Generated by CMake. Builds FFmpeg ${FFMPEG_VERSION} for webrtc-java-media. +set -e +${FFMPEG_SHELL_PREAMBLE}cd \"${FFMPEG_BUILD_DIR_SH}\" +\"${FFMPEG_SOURCE_DIR_SH}/configure\" ${FFMPEG_CONFIGURE_ARGS_STR} +\"${FFMPEG_MAKE}\" -j${BUILD_JOBS} +\"${FFMPEG_MAKE}\" install +") + + if(WIN32) + to_shell_path("${FFMPEG_BUILD_SCRIPT}" FFMPEG_BUILD_SCRIPT_SH) + + # vcvars is a batch file, so entering it needs cmd, and the shell it + # hands over to has to inherit the PATH it set up. + set(FFMPEG_LAUNCHER "${CMAKE_CURRENT_BINARY_DIR}/build-ffmpeg.cmd") + + file(WRITE "${FFMPEG_LAUNCHER}" +"@echo off +call \"${FFMPEG_VCVARS}\" >nul +if errorlevel 1 exit /b 1 +set MSYS2_PATH_TYPE=inherit +\"${BASH_EXECUTABLE}\" -l \"${FFMPEG_BUILD_SCRIPT_SH}\" +") + + file(TO_NATIVE_PATH "${FFMPEG_LAUNCHER}" FFMPEG_LAUNCHER_NATIVE) + set(FFMPEG_BUILD_COMMAND cmd /c "${FFMPEG_LAUNCHER_NATIVE}") + else() + set(FFMPEG_BUILD_COMMAND "${BASH_EXECUTABLE}" "${FFMPEG_BUILD_SCRIPT}") endif() + message(STATUS "Building FFmpeg ${FFMPEG_VERSION} into ${FFMPEG_INSTALL_DIR} with ${BUILD_JOBS} jobs") + message(STATUS " configure ${FFMPEG_CONFIGURE_ARGS_STR}") + execute_process( - COMMAND "${MAKE_EXECUTABLE}" install + COMMAND ${FFMPEG_BUILD_COMMAND} WORKING_DIRECTORY "${FFMPEG_BUILD_DIR}" - RESULT_VARIABLE INSTALL_RESULT + RESULT_VARIABLE BUILD_RESULT ) - if(NOT INSTALL_RESULT EQUAL 0) - message(FATAL_ERROR "FFmpeg install failed (${INSTALL_RESULT})") + if(NOT BUILD_RESULT EQUAL 0) + message(FATAL_ERROR + "Building FFmpeg failed (${BUILD_RESULT}). The script that ran is " + "${FFMPEG_BUILD_SCRIPT}; see ${FFMPEG_BUILD_DIR}/ffbuild/config.log for what " + "configure could not find.") endif() ffmpeg_is_installed(FFMPEG_INSTALLED) @@ -229,13 +353,16 @@ add_library(ffmpeg INTERFACE) target_include_directories(ffmpeg INTERFACE "${FFMPEG_INSTALL_DIR}/include") foreach(LIB ${FFMPEG_LIBS}) + # An MSVC build of FFmpeg puts the import libraries next to the DLLs in + # bin/ and leaves only the .def files in lib/, so both have to be searched. find_library(FFMPEG_${LIB}_LIB NAMES ${LIB} lib${LIB} - PATHS "${FFMPEG_INSTALL_DIR}/lib" + PATHS "${FFMPEG_INSTALL_DIR}/lib" "${FFMPEG_INSTALL_DIR}/bin" NO_DEFAULT_PATH) if(NOT FFMPEG_${LIB}_LIB) - message(FATAL_ERROR "Could not find lib${LIB} in ${FFMPEG_INSTALL_DIR}/lib") + message(FATAL_ERROR + "Could not find lib${LIB} in ${FFMPEG_INSTALL_DIR}/lib or ${FFMPEG_INSTALL_DIR}/bin") endif() target_link_libraries(ffmpeg INTERFACE "${FFMPEG_${LIB}_LIB}") From 159efd972bc0889cb56abab15873448c800d22bf Mon Sep 17 00:00:00 2001 From: Alex Andres Date: Sun, 20 Sep 2026 14:27:20 +0200 Subject: [PATCH 4/7] feat: read a media source and report what it contains MediaReader opens anything libavformat accepts and selects the video and audio stream that are meant to be played, which gives MediaInfo the native code behind it: duration, video size, frame rate and codec, audio sample rate, channel count and codec. A source holding neither video nor audio fails to open rather than handing back an empty reader, and a failure carries the message FFmpeg gave for it instead of a bare number. This module builds its own natives, so unlike webrtc they are not a jar its tests could resolve. Surefire gets the staging directory the CMake build collects them into on the test class path instead, since NativeLoader looks the libraries up as class path resources and a directory serves them as well as a jar. The jni-check profile mirrors the one in the webrtc module. The test asset is three seconds of 320x240 VP8 and 48 kHz mono Opus in WebM, 61 KB. Six tests cover the video, audio and duration it reports, a missing source, closing twice and using a closed reader; they pass under -Xcheck:jni with no warnings. --- webrtc-java-media/pom.xml | 40 ++++ webrtc-java-media/src/main/cpp/CMakeLists.txt | 2 +- .../src/main/cpp/include/JNI_MediaReader.h | 52 +++++ .../src/main/cpp/include/media/MediaReader.h | 86 ++++++++ .../src/main/cpp/src/JNI_MediaReader.cpp | 166 +++++++++++++++ .../src/main/cpp/src/media/MediaReader.cpp | 189 ++++++++++++++++++ .../webrtc/media/ffmpeg/MediaReader.java | 103 ++++++++++ .../webrtc/media/ffmpeg/MediaReaderTest.java | 112 +++++++++++ .../src/test/resources/media-test.webm | Bin 0 -> 61007 bytes 9 files changed, 749 insertions(+), 1 deletion(-) create mode 100644 webrtc-java-media/src/main/cpp/include/JNI_MediaReader.h create mode 100644 webrtc-java-media/src/main/cpp/include/media/MediaReader.h create mode 100644 webrtc-java-media/src/main/cpp/src/JNI_MediaReader.cpp create mode 100644 webrtc-java-media/src/main/cpp/src/media/MediaReader.cpp create mode 100644 webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaReader.java create mode 100644 webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaReaderTest.java create mode 100644 webrtc-java-media/src/test/resources/media-test.webm diff --git a/webrtc-java-media/pom.xml b/webrtc-java-media/pom.xml index 71093639..cd0c9c3f 100644 --- a/webrtc-java-media/pom.xml +++ b/webrtc-java-media/pom.xml @@ -38,6 +38,23 @@ maven-compiler-plugin + + org.apache.maven.plugins + maven-surefire-plugin + + + + ${project.build.directory}/natives + + + + com.googlecode.cmake-maven-project @@ -122,6 +139,29 @@ + + + jni-check + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + -Xcheck:jni + + + + + + windows-x86_64 diff --git a/webrtc-java-media/src/main/cpp/CMakeLists.txt b/webrtc-java-media/src/main/cpp/CMakeLists.txt index 2e83e2ea..c9a8c294 100644 --- a/webrtc-java-media/src/main/cpp/CMakeLists.txt +++ b/webrtc-java-media/src/main/cpp/CMakeLists.txt @@ -21,7 +21,7 @@ find_package(JNI REQUIRED) add_subdirectory(dependencies/ffmpeg) -file(GLOB SOURCES "src/*.cpp") +file(GLOB SOURCES "src/*.cpp" "src/media/*.cpp") add_library(${PROJECT_NAME} SHARED ${SOURCES}) diff --git a/webrtc-java-media/src/main/cpp/include/JNI_MediaReader.h b/webrtc-java-media/src/main/cpp/include/JNI_MediaReader.h new file mode 100644 index 00000000..77d8badc --- /dev/null +++ b/webrtc-java-media/src/main/cpp/include/JNI_MediaReader.h @@ -0,0 +1,52 @@ +/* + * Copyright 2026 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 +/* Header for class dev_onvoid_webrtc_media_ffmpeg_MediaReader */ + +#ifndef _Included_dev_onvoid_webrtc_media_ffmpeg_MediaReader +#define _Included_dev_onvoid_webrtc_media_ffmpeg_MediaReader +#ifdef __cplusplus +extern "C" { +#endif + /* + * Class: dev_onvoid_webrtc_media_ffmpeg_MediaReader + * Method: open + * Signature: (Ljava/lang/String;)J + */ + JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_open + (JNIEnv *, jclass, jstring); + + /* + * Class: dev_onvoid_webrtc_media_ffmpeg_MediaReader + * Method: info + * Signature: (J)Ldev/onvoid/webrtc/media/ffmpeg/MediaInfo; + */ + JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_info + (JNIEnv *, jclass, jlong); + + /* + * Class: dev_onvoid_webrtc_media_ffmpeg_MediaReader + * Method: dispose + * Signature: (J)V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_dispose + (JNIEnv *, jclass, jlong); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/webrtc-java-media/src/main/cpp/include/media/MediaReader.h b/webrtc-java-media/src/main/cpp/include/media/MediaReader.h new file mode 100644 index 00000000..0bda73ed --- /dev/null +++ b/webrtc-java-media/src/main/cpp/include/media/MediaReader.h @@ -0,0 +1,86 @@ +/* + * Copyright 2026 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 WEBRTC_JAVA_MEDIA_MEDIA_READER_H_ +#define WEBRTC_JAVA_MEDIA_MEDIA_READER_H_ + +#include + +extern "C" { +#include +} + +namespace ffmpeg +{ + // Opens a media source with libavformat and reports what it contains. + // + // A source is anything libavformat accepts: a file path today, and an + // http, rtsp or rtmp URL once those protocols are enabled in the build. + // Opening reads the container and picks the streams that will be played. + // Decoding is built on top of this and does not belong here. + class MediaReader + { + public: + MediaReader() = default; + ~MediaReader(); + + MediaReader(const MediaReader &) = delete; + MediaReader & operator=(const MediaReader &) = delete; + + // Opens the given source and selects the video and audio stream + // that are meant to be played. Returns 0, or the negative AVERROR + // libavformat reported, in which case the reader stays closed. + int Open(const std::string & url); + + // Releases the container. Does nothing on a closed reader, and + // runs from the destructor. + void Close(); + + bool IsOpen() const; + + // How long the source runs in microseconds, or 0 when the + // container does not say, as is the case for a live stream. + int64_t GetDurationUs() const; + + bool HasVideo() const; + int GetVideoWidth() const; + int GetVideoHeight() const; + + // The average frame rate the container reports, or 0 when it does + // not say. A variable frame rate source only has an average. + double GetFrameRate() const; + + // The codec name as FFmpeg spells it, never null: an unknown or + // absent codec reads as "none". It points into libavcodec's own + // static table and outlives this reader. + const char * GetVideoCodecName() const; + + bool HasAudio() const; + int GetSampleRate() const; + int GetChannels() const; + const char * GetAudioCodecName() const; + + private: + const AVStream * VideoStream() const; + const AVStream * AudioStream() const; + + AVFormatContext * format_context_ = nullptr; + int video_stream_index_ = -1; + int audio_stream_index_ = -1; + }; +} + +#endif diff --git a/webrtc-java-media/src/main/cpp/src/JNI_MediaReader.cpp b/webrtc-java-media/src/main/cpp/src/JNI_MediaReader.cpp new file mode 100644 index 00000000..3e952f7a --- /dev/null +++ b/webrtc-java-media/src/main/cpp/src/JNI_MediaReader.cpp @@ -0,0 +1,166 @@ +/* + * Copyright 2026 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_MediaReader.h" +#include "media/MediaReader.h" + +#include + +extern "C" { +#include +} + +namespace +{ + // Turns an AVERROR code into the message FFmpeg has for it, so that a + // failure to open reaches Java saying what libavformat actually objected + // to instead of a bare number. + std::string ErrorMessage(int error) + { + char buffer[AV_ERROR_MAX_STRING_SIZE] = { 0 }; + + if (av_strerror(error, buffer, sizeof(buffer)) < 0) { + return "Unknown FFmpeg error " + std::to_string(error); + } + + return buffer; + } + + void ThrowIOException(JNIEnv * env, const std::string & message) + { + jclass cls = env->FindClass("java/io/IOException"); + + if (cls != nullptr) { + env->ThrowNew(cls, message.c_str()); + env->DeleteLocalRef(cls); + } + } + + ffmpeg::MediaReader * ReaderOf(JNIEnv * env, jlong handle) + { + if (handle == 0) { + jclass cls = env->FindClass("java/lang/IllegalStateException"); + + if (cls != nullptr) { + env->ThrowNew(cls, "MediaReader is closed"); + env->DeleteLocalRef(cls); + } + + return nullptr; + } + + return reinterpret_cast(handle); + } +} + +JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_open +(JNIEnv * env, jclass caller, jstring source) +{ + if (source == nullptr) { + ThrowIOException(env, "Source must not be null"); + + return 0; + } + + const char * chars = env->GetStringUTFChars(source, nullptr); + + if (chars == nullptr) { + // The VM is out of memory and has already thrown. + return 0; + } + + std::string url(chars); + + env->ReleaseStringUTFChars(source, chars); + + auto reader = new ffmpeg::MediaReader(); + int result = reader->Open(url); + + if (result < 0) { + delete reader; + + ThrowIOException(env, "Opening '" + url + "' failed: " + ErrorMessage(result)); + + return 0; + } + + return reinterpret_cast(reader); +} + +JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_info +(JNIEnv * env, jclass caller, jlong handle) +{ + ffmpeg::MediaReader * reader = ReaderOf(env, handle); + + if (reader == nullptr) { + return nullptr; + } + + jclass cls = env->FindClass("dev/onvoid/webrtc/media/ffmpeg/MediaInfo"); + + if (cls == nullptr) { + return nullptr; + } + + jmethodID ctor = env->GetMethodID(cls, "", + "(JIIDLjava/lang/String;IILjava/lang/String;)V"); + + if (ctor == nullptr) { + env->DeleteLocalRef(cls); + + return nullptr; + } + + // A stream that is not there is reported as a null codec name and zero + // everything else, which is the contract MediaInfo documents. + jstring videoCodec = reader->HasVideo() + ? env->NewStringUTF(reader->GetVideoCodecName()) : nullptr; + jstring audioCodec = reader->HasAudio() + ? env->NewStringUTF(reader->GetAudioCodecName()) : nullptr; + + jobject info = env->NewObject(cls, ctor, + static_cast(reader->GetDurationUs()), + static_cast(reader->GetVideoWidth()), + static_cast(reader->GetVideoHeight()), + static_cast(reader->GetFrameRate()), + videoCodec, + static_cast(reader->GetSampleRate()), + static_cast(reader->GetChannels()), + audioCodec); + + if (videoCodec != nullptr) { + env->DeleteLocalRef(videoCodec); + } + if (audioCodec != nullptr) { + env->DeleteLocalRef(audioCodec); + } + + env->DeleteLocalRef(cls); + + return info; +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_dispose +(JNIEnv * env, jclass caller, jlong handle) +{ + // Closing twice is allowed, so a handle that is already zero is simply + // nothing to do rather than an error. + if (handle == 0) { + return; + } + + delete reinterpret_cast(handle); +} diff --git a/webrtc-java-media/src/main/cpp/src/media/MediaReader.cpp b/webrtc-java-media/src/main/cpp/src/media/MediaReader.cpp new file mode 100644 index 00000000..605d9fea --- /dev/null +++ b/webrtc-java-media/src/main/cpp/src/media/MediaReader.cpp @@ -0,0 +1,189 @@ +/* + * Copyright 2026 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/MediaReader.h" + +extern "C" { +#include +#include +} + +namespace ffmpeg +{ + MediaReader::~MediaReader() + { + Close(); + } + + int MediaReader::Open(const std::string & url) + { + Close(); + + int result = avformat_open_input(&format_context_, url.c_str(), nullptr, nullptr); + + if (result < 0) { + // avformat_open_input frees the context and nulls the pointer + // itself when it fails, so there is nothing left to release. + format_context_ = nullptr; + + return result; + } + + result = avformat_find_stream_info(format_context_, nullptr); + + if (result < 0) { + Close(); + + return result; + } + + // A container may hold several streams of a kind, and libavformat is + // the one that knows which of them is meant to be played. A source + // with no stream of a kind reports AVERROR_STREAM_NOT_FOUND, which is + // not a failure: a file may be video only or audio only. + video_stream_index_ = av_find_best_stream(format_context_, + AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); + audio_stream_index_ = av_find_best_stream(format_context_, + AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); + + if (video_stream_index_ < 0) { + video_stream_index_ = -1; + } + if (audio_stream_index_ < 0) { + audio_stream_index_ = -1; + } + + if (video_stream_index_ < 0 && audio_stream_index_ < 0) { + // Nothing here can be played, which is worth reporting as a + // failure rather than handing back an empty reader. + Close(); + + return AVERROR_STREAM_NOT_FOUND; + } + + return 0; + } + + void MediaReader::Close() + { + if (format_context_ != nullptr) { + avformat_close_input(&format_context_); + } + + format_context_ = nullptr; + video_stream_index_ = -1; + audio_stream_index_ = -1; + } + + bool MediaReader::IsOpen() const + { + return format_context_ != nullptr; + } + + int64_t MediaReader::GetDurationUs() const + { + if (format_context_ == nullptr || format_context_->duration == AV_NOPTS_VALUE) { + return 0; + } + + // AVFormatContext::duration is already in AV_TIME_BASE units, which + // are microseconds. + return format_context_->duration; + } + + bool MediaReader::HasVideo() const + { + return VideoStream() != nullptr; + } + + int MediaReader::GetVideoWidth() const + { + const AVStream * stream = VideoStream(); + + return stream != nullptr ? stream->codecpar->width : 0; + } + + int MediaReader::GetVideoHeight() const + { + const AVStream * stream = VideoStream(); + + return stream != nullptr ? stream->codecpar->height : 0; + } + + double MediaReader::GetFrameRate() const + { + const AVStream * stream = VideoStream(); + + if (stream == nullptr || stream->avg_frame_rate.den == 0) { + return 0; + } + + return av_q2d(stream->avg_frame_rate); + } + + const char * MediaReader::GetVideoCodecName() const + { + const AVStream * stream = VideoStream(); + + return avcodec_get_name(stream != nullptr + ? stream->codecpar->codec_id : AV_CODEC_ID_NONE); + } + + bool MediaReader::HasAudio() const + { + return AudioStream() != nullptr; + } + + int MediaReader::GetSampleRate() const + { + const AVStream * stream = AudioStream(); + + return stream != nullptr ? stream->codecpar->sample_rate : 0; + } + + int MediaReader::GetChannels() const + { + const AVStream * stream = AudioStream(); + + return stream != nullptr ? stream->codecpar->ch_layout.nb_channels : 0; + } + + const char * MediaReader::GetAudioCodecName() const + { + const AVStream * stream = AudioStream(); + + return avcodec_get_name(stream != nullptr + ? stream->codecpar->codec_id : AV_CODEC_ID_NONE); + } + + const AVStream * MediaReader::VideoStream() const + { + if (format_context_ == nullptr || video_stream_index_ < 0) { + return nullptr; + } + + return format_context_->streams[video_stream_index_]; + } + + const AVStream * MediaReader::AudioStream() const + { + if (format_context_ == nullptr || audio_stream_index_ < 0) { + return nullptr; + } + + return format_context_->streams[audio_stream_index_]; + } +} diff --git a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaReader.java b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaReader.java new file mode 100644 index 00000000..affb63ab --- /dev/null +++ b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaReader.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026 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.ffmpeg; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * An opened media source, and what it contains. + *

+ * A source is anything FFmpeg can demux: a media file today, and an http, rtsp + * or rtmp URL once those protocols are enabled in the build. Opening reads the + * container and picks the video and audio stream that are meant to be played; + * a source with neither fails to open. + *

+ * A reader holds a native resource and has to be closed. It is not safe to use + * from several threads at once, other than {@link #close()}, which may be + * called more than once and from any thread. + * + * @author Alex Andres + */ +public class MediaReader implements AutoCloseable { + + static { + FFmpeg.load(); + } + + /** The native reader, or 0 once this reader has been closed. */ + private long handle; + + + /** + * Opens the media file at the given path. + * + * @param path The path of the file to open. + * + * @throws IOException if the source cannot be opened, or holds nothing + * that can be played. + */ + public MediaReader(Path path) throws IOException { + this(path.toAbsolutePath().toString()); + } + + /** + * Opens the given media source. + * + * @param source The path or URL of the source to open. + * + * @throws IOException if the source cannot be opened, or holds nothing + * that can be played. + */ + public MediaReader(String source) throws IOException { + handle = open(source); + } + + /** + * Returns what this source contains: how long it runs, and the format of + * its video and audio streams. + * + * @return The media information. + * + * @throws IllegalStateException if this reader has been closed. + */ + public MediaInfo getInfo() { + return info(handle); + } + + /** + * Releases the native reader. Closing a reader that is already closed does + * nothing. + */ + @Override + public synchronized void close() { + long closing = handle; + + // Cleared first, so that a second close finds nothing to release even + // if the first one is still running. + handle = 0; + + dispose(closing); + } + + private static native long open(String source) throws IOException; + + private static native MediaInfo info(long handle); + + private static native void dispose(long handle); + +} diff --git a/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaReaderTest.java b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaReaderTest.java new file mode 100644 index 00000000..b154b073 --- /dev/null +++ b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaReaderTest.java @@ -0,0 +1,112 @@ +/* + * Copyright 2026 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.ffmpeg; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.junit.jupiter.api.Test; + +/** + * Tests reading a media source. The asset is three seconds of VP8 and Opus in + * WebM, which is what a peer connection would be fed with. + * + * @author Alex Andres + */ +class MediaReaderTest { + + /** The committed test asset: 320x240 VP8 at 15 fps, 48 kHz mono Opus. */ + private static final String ASSET = "/media-test.webm"; + + + @Test + void readsVideoInfo() throws Exception { + try (MediaReader reader = new MediaReader(asset())) { + MediaInfo info = reader.getInfo(); + + assertTrue(info.hasVideo()); + assertEquals(320, info.getVideoWidth()); + assertEquals(240, info.getVideoHeight()); + assertEquals(15.0, info.getFrameRate(), 0.01); + assertEquals("vp8", info.getVideoCodec()); + } + } + + @Test + void readsAudioInfo() throws Exception { + try (MediaReader reader = new MediaReader(asset())) { + MediaInfo info = reader.getInfo(); + + assertTrue(info.hasAudio()); + assertEquals(48000, info.getSampleRate()); + assertEquals(1, info.getChannels()); + assertEquals("opus", info.getAudioCodec()); + } + } + + @Test + void readsDuration() throws Exception { + try (MediaReader reader = new MediaReader(asset())) { + // The asset runs 3.008 seconds. The tolerance is there because the + // duration comes from the container, and FFmpeg may be replaced. + assertEquals(3_008_000, reader.getInfo().getDurationUs(), 50_000); + } + } + + @Test + void missingSourceFails() { + IOException e = assertThrows(IOException.class, + () -> new MediaReader(Paths.get("no-such-file.webm"))); + + // The message has to name the source and what FFmpeg objected to, + // otherwise a wrong path is a guessing game. + assertTrue(e.getMessage().contains("no-such-file.webm"), e.getMessage()); + } + + @Test + void closesTwice() throws Exception { + MediaReader reader = new MediaReader(asset()); + + reader.close(); + reader.close(); + } + + @Test + void infoAfterCloseFails() throws Exception { + MediaReader reader = new MediaReader(asset()); + + reader.close(); + + assertThrows(IllegalStateException.class, reader::getInfo); + } + + private static Path asset() throws Exception { + URL url = MediaReaderTest.class.getResource(ASSET); + + assertNotNull(url, "Test asset " + ASSET + " is missing"); + + return Paths.get(url.toURI()); + } + +} diff --git a/webrtc-java-media/src/test/resources/media-test.webm b/webrtc-java-media/src/test/resources/media-test.webm new file mode 100644 index 0000000000000000000000000000000000000000..0ac3f417f20b426e39cb0e1457864b144e80a2f9 GIT binary patch literal 61007 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 Date: Sun, 20 Sep 2026 14:47:47 +0200 Subject: [PATCH 5/7] feat: decode and pace a media source into the custom media sources This is the part of the module that actually sends a file. Nothing goes through Java: one thread demuxes and decodes, a pacer delivers in real time, and both talk to webrtc-java through the C function table rather than over JNI. VideoDecoder hands a frame on untouched when the decoder already gave yuv420p, which is almost all 8-bit H.264, VP8, VP9 and MPEG-4 content, so the decoded picture reaches the encoder without a copy. What the pacer hands over is a reference to that AVFrame, dropped again in the release callback once WebRTC lets go of the pixels. Anything else goes through swscale first. AudioDecoder resamples to the only shape WebRTC takes, interleaved 16-bit PCM at 48 kHz in mono or stereo, and re-cuts it into exact 10 ms chunks, advancing their timestamps in 10 ms steps so the audio is regular whatever the container's packet timing was. MediaPacer makes one mapping from the source's presentation times onto the WebRTC clock and keeps it, so what reaches the receiver carries the timing of the file rather than of a thread. Video and audio are paced independently: they go to different sources, need no ordering between them, and keeping them apart is what stops a full video queue starving audio. The queues are bounded, so decoding runs ahead only as far as they allow and a whole file is never decoded into memory. Looping keeps the mapping and adds the source's duration to every timestamp, which both hides the seam and keeps capture times rising, as the encoder requires. A seek drops what is queued and starts a fresh mapping. MediaFileSource is the short way to all of it, and the one the README already described: it opens a source, makes a custom source for each kind of media the file has, and wires a player to feed them. Nineteen tests cover this where an application would see it, on the sinks of tracks made from those sources: frame and chunk counts, the 320x240 and 48 kHz mono shape they arrive in, that three seconds of media takes three seconds to play, looping, pause, seek, and the reader being spent once a player adopts it. They pass under -Xcheck:jni with no warnings. The test asset gained a keyframe every second, because seeking lands on a keyframe and it had only the one at the start. --- .../src/main/cpp/include/JNI_MediaPlayer.h | 92 ++++ .../src/main/cpp/include/media/AudioDecoder.h | 106 ++++ .../cpp/include/media/JavaPlayerObserver.h | 67 +++ .../src/main/cpp/include/media/MediaPacer.h | 162 ++++++ .../src/main/cpp/include/media/MediaPlayer.h | 161 ++++++ .../cpp/include/media/MediaPlayerObserver.h | 47 ++ .../src/main/cpp/include/media/MediaReader.h | 21 +- .../src/main/cpp/include/media/VideoDecoder.h | 82 +++ .../src/main/cpp/src/JNI_MediaPlayer.cpp | 161 ++++++ .../src/main/cpp/src/media/AudioDecoder.cpp | 252 +++++++++ .../main/cpp/src/media/JavaPlayerObserver.cpp | 176 ++++++ .../src/main/cpp/src/media/MediaPacer.cpp | 365 +++++++++++++ .../src/main/cpp/src/media/MediaPlayer.cpp | 515 ++++++++++++++++++ .../src/main/cpp/src/media/MediaReader.cpp | 57 +- .../src/main/cpp/src/media/VideoDecoder.cpp | 238 ++++++++ .../webrtc/media/ffmpeg/MediaFileSource.java | 234 ++++++++ .../webrtc/media/ffmpeg/MediaPlayer.java | 249 +++++++++ .../media/ffmpeg/MediaPlayerListener.java | 54 ++ .../webrtc/media/ffmpeg/MediaPlayerState.java | 66 +++ .../webrtc/media/ffmpeg/MediaReader.java | 17 + .../media/ffmpeg/MediaFileSourceTest.java | 150 +++++ .../webrtc/media/ffmpeg/MediaPlayerTest.java | 338 ++++++++++++ .../src/test/resources/media-test.webm | Bin 61007 -> 62549 bytes 23 files changed, 3596 insertions(+), 14 deletions(-) create mode 100644 webrtc-java-media/src/main/cpp/include/JNI_MediaPlayer.h create mode 100644 webrtc-java-media/src/main/cpp/include/media/AudioDecoder.h create mode 100644 webrtc-java-media/src/main/cpp/include/media/JavaPlayerObserver.h create mode 100644 webrtc-java-media/src/main/cpp/include/media/MediaPacer.h create mode 100644 webrtc-java-media/src/main/cpp/include/media/MediaPlayer.h create mode 100644 webrtc-java-media/src/main/cpp/include/media/MediaPlayerObserver.h create mode 100644 webrtc-java-media/src/main/cpp/include/media/VideoDecoder.h create mode 100644 webrtc-java-media/src/main/cpp/src/JNI_MediaPlayer.cpp create mode 100644 webrtc-java-media/src/main/cpp/src/media/AudioDecoder.cpp create mode 100644 webrtc-java-media/src/main/cpp/src/media/JavaPlayerObserver.cpp create mode 100644 webrtc-java-media/src/main/cpp/src/media/MediaPacer.cpp create mode 100644 webrtc-java-media/src/main/cpp/src/media/MediaPlayer.cpp create mode 100644 webrtc-java-media/src/main/cpp/src/media/VideoDecoder.cpp create mode 100644 webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaFileSource.java create mode 100644 webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayer.java create mode 100644 webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerListener.java create mode 100644 webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerState.java create mode 100644 webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaFileSourceTest.java create mode 100644 webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerTest.java diff --git a/webrtc-java-media/src/main/cpp/include/JNI_MediaPlayer.h b/webrtc-java-media/src/main/cpp/include/JNI_MediaPlayer.h new file mode 100644 index 00000000..fd080bd9 --- /dev/null +++ b/webrtc-java-media/src/main/cpp/include/JNI_MediaPlayer.h @@ -0,0 +1,92 @@ +/* + * Copyright 2026 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 +/* Header for class dev_onvoid_webrtc_media_ffmpeg_MediaPlayer */ + +#ifndef _Included_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer +#define _Included_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer +#ifdef __cplusplus +extern "C" { +#endif + /* + * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer + * Method: create + * Signature: (JJJJ)J + */ + JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_create + (JNIEnv *, jobject, jlong, jlong, jlong, jlong); + + /* + * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer + * Method: start + * Signature: (J)V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_start + (JNIEnv *, jclass, jlong); + + /* + * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer + * Method: suspend + * Signature: (J)V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_suspend + (JNIEnv *, jclass, jlong); + + /* + * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer + * Method: seek + * Signature: (JJ)V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_seek + (JNIEnv *, jclass, jlong, jlong); + + /* + * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer + * Method: setLooping + * Signature: (JZ)V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_setLooping + (JNIEnv *, jclass, jlong, jboolean); + + /* + * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer + * Method: position + * Signature: (J)J + */ + JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_position + (JNIEnv *, jclass, jlong); + + /* + * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer + * Method: state + * Signature: (J)I + */ + JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_state + (JNIEnv *, jclass, jlong); + + /* + * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer + * Method: dispose + * Signature: (J)V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_dispose + (JNIEnv *, jclass, jlong); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/webrtc-java-media/src/main/cpp/include/media/AudioDecoder.h b/webrtc-java-media/src/main/cpp/include/media/AudioDecoder.h new file mode 100644 index 00000000..14a80c91 --- /dev/null +++ b/webrtc-java-media/src/main/cpp/include/media/AudioDecoder.h @@ -0,0 +1,106 @@ +/* + * Copyright 2026 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 WEBRTC_JAVA_MEDIA_AUDIO_DECODER_H_ +#define WEBRTC_JAVA_MEDIA_AUDIO_DECODER_H_ + +#include +#include + +extern "C" { +#include +#include +#include +} + +namespace ffmpeg +{ + // Decodes one audio stream into the only shape WebRTC accepts: interleaved + // signed 16-bit PCM, at most 48 kHz, mono or stereo, in chunks of exactly + // 10 ms. + // + // A source rarely arrives in that shape, so decoded audio goes through + // swresample and is then re-cut into 10 ms chunks, which is why this holds + // a buffer between calls: one decoded frame is seldom a whole number of + // chunks. + class AudioDecoder + { + public: + // The rate WebRTC works at internally, so resampling to it here + // saves it from doing the same work again later. + static constexpr int kSampleRate = 48000; + + // WebRTC takes 10 ms of audio at a time and nothing else. + static constexpr int kFramesPerChunk = kSampleRate / 100; + + AudioDecoder() = default; + ~AudioDecoder(); + + AudioDecoder(const AudioDecoder &) = delete; + AudioDecoder & operator=(const AudioDecoder &) = delete; + + // Opens a decoder for the given stream, which must outlive this + // decoder. Source audio of more than two channels is downmixed to + // stereo. Returns 0 or a negative AVERROR. + int Open(const AVStream * stream); + + void Close(); + + // Drops the decoder's state and the partial chunk being gathered, + // which is what a seek needs. + void Flush(); + + // Hands a packet to the decoder, or null to start draining at the + // end of the stream. Returns 0 or a negative AVERROR. + int SendPacket(const AVPacket * packet); + + // Takes the next 10 ms of audio, resampled and interleaved. + // + // Returns 0 when a chunk was produced, AVERROR(EAGAIN) when the + // decoder needs another packet first, AVERROR_EOF once draining + // has finished, or another negative AVERROR. The tail of a stream + // that does not fill a whole chunk is padded with silence, since a + // short chunk is not something WebRTC can be given. + int ReceiveChunk(std::vector & chunk, int64_t * timestamp_us); + + int GetChannels() const; + + private: + // Resamples one decoded frame into the pending buffer. + int Resample(const AVFrame * frame); + + // Moves one chunk out of the pending buffer. + void TakeChunk(std::vector & chunk, int64_t * timestamp_us); + + AVCodecContext * codec_context_ = nullptr; + SwrContext * swr_context_ = nullptr; + AVFrame * decoded_ = nullptr; + AVRational time_base_ = { 0, 1 }; + int channels_ = 0; + + // Resampled samples not yet handed out, interleaved. + std::vector pending_; + + // When the next chunk starts, in microseconds on the source's own + // timeline. Chunks advance it in exact 10 ms steps, so the audio + // this produces is perfectly regular whatever the container's + // packet timing looks like. + int64_t next_timestamp_us_ = 0; + bool have_timestamp_ = false; + }; +} + +#endif diff --git a/webrtc-java-media/src/main/cpp/include/media/JavaPlayerObserver.h b/webrtc-java-media/src/main/cpp/include/media/JavaPlayerObserver.h new file mode 100644 index 00000000..187337b8 --- /dev/null +++ b/webrtc-java-media/src/main/cpp/include/media/JavaPlayerObserver.h @@ -0,0 +1,67 @@ +/* + * Copyright 2026 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 WEBRTC_JAVA_MEDIA_JAVA_PLAYER_OBSERVER_H_ +#define WEBRTC_JAVA_MEDIA_JAVA_PLAYER_OBSERVER_H_ + +#include "media/MediaPlayerObserver.h" + +#include + +namespace ffmpeg +{ + // Passes a player's reports on to its Java MediaPlayer, which turns them + // into calls on whatever listener the application set. + // + // The calls arrive on the player's own thread, which the JVM knows nothing + // about, so each one attaches that thread, makes the call and detaches + // again. Attaching is not free, but these are state changes and errors, + // not frames: they happen a handful of times over a whole playback. + class JavaPlayerObserver : public MediaPlayerObserver + { + public: + // Keeps a global reference to the given Java MediaPlayer, so the + // player may outlive the call that created it. + JavaPlayerObserver(JNIEnv * env, jobject player); + ~JavaPlayerObserver() override; + + JavaPlayerObserver(const JavaPlayerObserver &) = delete; + JavaPlayerObserver & operator=(const JavaPlayerObserver &) = delete; + + void OnStateChanged(int state) override; + void OnEndOfStream() override; + void OnError(const std::string & message) override; + + private: + // Returns an environment for the calling thread, attaching it if + // it is not known to the JVM. Sets attached when it did, which is + // then the caller's to undo. + JNIEnv * Attach(bool * attached); + void Detach(bool attached); + + // A listener that throws must not be left to surface somewhere + // unrelated later, so anything pending is reported and cleared. + void ClearPendingException(JNIEnv * env); + + JavaVM * vm_ = nullptr; + jobject player_ = nullptr; + jmethodID on_state_changed_ = nullptr; + jmethodID on_end_of_stream_ = nullptr; + jmethodID on_error_ = nullptr; + }; +} + +#endif diff --git a/webrtc-java-media/src/main/cpp/include/media/MediaPacer.h b/webrtc-java-media/src/main/cpp/include/media/MediaPacer.h new file mode 100644 index 00000000..37773ee2 --- /dev/null +++ b/webrtc-java-media/src/main/cpp/include/media/MediaPacer.h @@ -0,0 +1,162 @@ +/* + * Copyright 2026 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 WEBRTC_JAVA_MEDIA_MEDIA_PACER_H_ +#define WEBRTC_JAVA_MEDIA_MEDIA_PACER_H_ + +#include "webrtc_java_api.h" + +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +namespace ffmpeg +{ + // Delivers decoded media to webrtc-java's custom sources in real time. + // + // WebRTC encodes and sends a frame the moment it arrives, so playback has + // to be paced rather than pushed as fast as it decodes. One thread does + // that: it sleeps until an item is due and then hands it over, which is + // the only thread that ever touches the extension API. + // + // Timing works off one mapping, made when the first item is delivered and + // kept from then on: a source's own presentation time plus a base offset + // is a time on the WebRTC clock. Every item carries that time with it, so + // the timeline that reaches the receiver is the source's own and not the + // pacing thread's, and audio stays lined up with video however much the + // thread is jostled. + // + // Video and audio are paced independently. They go to different sources + // and need no ordering between them, and keeping them independent is also + // what keeps a full video queue from starving audio. + class MediaPacer + { + public: + // The queues are what decouples decoding from playback. A second + // of audio and about a second of video is enough to ride out a + // slow decode without holding much memory. + static constexpr size_t kVideoCapacity = 60; + static constexpr size_t kAudioCapacity = 100; + + // The sources may be 0, in which case media of that kind is + // dropped rather than delivered. + MediaPacer(const webrtc_java_api * api, void * video_source, + void * audio_source); + ~MediaPacer(); + + MediaPacer(const MediaPacer &) = delete; + MediaPacer & operator=(const MediaPacer &) = delete; + + void Start(); + + // Stops the thread and drops whatever is still queued. Safe to + // call more than once. + void Stop(); + + // Hands over a decoded frame and its presentation time. Ownership + // of the frame passes to the pacer whether this succeeds or not. + // + // Blocks while the queue is full, which is the backpressure that + // keeps decoding from running ahead of playback. Returns false + // once the pacer has been stopped. + bool PushVideo(AVFrame * frame, int64_t timestamp_us); + + // Hands over one 10 ms chunk of interleaved 16-bit PCM. + bool PushAudio(std::vector && samples, int channels, + int64_t timestamp_us); + + void Pause(); + void Resume(); + + // Drops everything queued and forgets the clock mapping, so that + // the next item delivered starts a new one. This is what a seek + // needs: what is queued belongs to the position being left. + void Flush(); + + // True once everything handed over has been delivered. + bool IsDrained() const; + + // The presentation time of the item delivered last, which is where + // playback has got to. + int64_t GetPositionUs() const; + + private: + struct VideoItem + { + AVFrame * frame = nullptr; + int64_t timestamp_us = 0; + + VideoItem() = default; + VideoItem(AVFrame * f, int64_t ts) : frame(f), timestamp_us(ts) {} + ~VideoItem(); + + VideoItem(VideoItem && other) noexcept; + VideoItem & operator=(VideoItem && other) noexcept; + + VideoItem(const VideoItem &) = delete; + VideoItem & operator=(const VideoItem &) = delete; + }; + + struct AudioItem + { + std::vector samples; + int channels = 0; + int64_t timestamp_us = 0; + }; + + void Run(); + + // Both run without the lock held: the extension API delivers + // synchronously into WebRTC, which must not happen underneath a + // lock of ours. The time on the WebRTC clock is worked out by the + // caller while it still holds the lock and passed in, so that + // neither of these reads the mapping unguarded. + void DeliverVideo(VideoItem item, int64_t mapped_us); + void DeliverAudio(const AudioItem & item, int64_t mapped_us); + + const webrtc_java_api * api_; + void * video_source_; + void * audio_source_; + + std::thread thread_; + + mutable std::mutex mutex_; + std::condition_variable work_; + std::condition_variable video_space_; + std::condition_variable audio_space_; + + std::deque video_queue_; + std::deque audio_queue_; + + bool running_ = false; + bool paused_ = false; + + // Presentation time plus this is a time on the WebRTC clock. + int64_t base_us_ = 0; + bool have_base_ = false; + int64_t paused_at_us_ = 0; + int64_t position_us_ = 0; + }; +} + +#endif diff --git a/webrtc-java-media/src/main/cpp/include/media/MediaPlayer.h b/webrtc-java-media/src/main/cpp/include/media/MediaPlayer.h new file mode 100644 index 00000000..43c8a742 --- /dev/null +++ b/webrtc-java-media/src/main/cpp/include/media/MediaPlayer.h @@ -0,0 +1,161 @@ +/* + * Copyright 2026 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 WEBRTC_JAVA_MEDIA_MEDIA_PLAYER_H_ +#define WEBRTC_JAVA_MEDIA_MEDIA_PLAYER_H_ + +#include "media/AudioDecoder.h" +#include "media/MediaPacer.h" +#include "media/MediaPlayerObserver.h" +#include "media/MediaReader.h" +#include "media/VideoDecoder.h" +#include "webrtc_java_api.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace ffmpeg +{ + // Kept in step with the Java MediaPlayerState enum, which is what these + // values are reported to the observer as. + enum MediaPlayerState + { + kIdle = 0, + kPlaying = 1, + kPaused = 2, + kEnded = 3, + kClosed = 4 + }; + + // Plays a media source into webrtc-java's custom media sources. + // + // One thread reads packets, decodes them and hands the result to a + // MediaPacer, which delivers it in real time. That thread is the only one + // that touches the reader or the decoders, so the commands below do not + // act directly: they leave a request behind and wake it. + // + // Decoding runs ahead of playback only as far as the pacer's queues allow. + // Once those are full the decode thread blocks, which is what keeps a + // whole file from being decoded into memory at once. + class MediaPlayer + { + public: + // The sources may be 0, in which case media of that kind is + // decoded and dropped. The observer may be null. + MediaPlayer(std::unique_ptr reader, + const webrtc_java_api * api, void * video_source, + void * audio_source); + ~MediaPlayer(); + + MediaPlayer(const MediaPlayer &) = delete; + MediaPlayer & operator=(const MediaPlayer &) = delete; + + // Takes over the observer, which then lives as long as the + // player does. Call this before Initialize: the observer is read + // without the lock while it is being called, on the assumption + // that it does not change once the thread is running. + void SetObserver(std::unique_ptr observer); + + // Opens the decoders for whichever streams the source has. + // Returns 0 or a negative AVERROR. + int Initialize(); + + void Play(); + void Pause(); + + // Moves playback to the given position, in microseconds from the + // start. What is already queued is dropped. + void Seek(int64_t position_us); + + void SetLooping(bool looping); + bool IsLooping() const; + + // Stops playback and releases the thread. The player cannot be + // used afterwards. + void Close(); + + // Where playback has got to within the current pass, in + // microseconds. A looping player starts again from zero. + int64_t GetPositionUs() const; + + int GetState() const; + + private: + void Run(); + + // Reads and decodes until the source is exhausted. Returns false + // if playback should stop, either on error or on close. + bool PumpOnce(AVPacket * packet, bool * end_of_stream); + + // Pushes everything the decoders still hold, which is what the end + // of a stream and a loop both need. + void DrainDecoders(); + + int DecodeVideo(); + int DecodeAudio(); + + void PerformSeek(int64_t position_us); + void SetState(int state); + void ReportError(const std::string & message, int error); + + std::unique_ptr reader_; + std::unique_ptr pacer_; + VideoDecoder video_decoder_; + AudioDecoder audio_decoder_; + + const webrtc_java_api * api_; + bool has_video_ = false; + bool has_audio_ = false; + + std::thread thread_; + + mutable std::mutex mutex_; + std::condition_variable command_; + + std::unique_ptr observer_; + bool running_ = false; + bool playing_ = false; + bool closing_ = false; + int state_ = kIdle; + + bool seek_pending_ = false; + int64_t seek_position_us_ = 0; + + // Set once the source has run out and everything queued has been + // delivered, so that the thread stops pumping until something + // asks it to start again. + bool ended_ = false; + + // The largest presentation time seen, used to advance the loop + // offset when the container does not say how long it runs. + int64_t max_source_pts_us_ = 0; + + std::atomic looping_{ false }; + + // Added to every presentation time handed to the pacer, so that a + // source played again keeps producing times that increase. The + // pacer needs that: WebRTC drops a frame whose capture time does + // not advance. + std::atomic loop_offset_us_{ 0 }; + }; +} + +#endif diff --git a/webrtc-java-media/src/main/cpp/include/media/MediaPlayerObserver.h b/webrtc-java-media/src/main/cpp/include/media/MediaPlayerObserver.h new file mode 100644 index 00000000..5c9c2984 --- /dev/null +++ b/webrtc-java-media/src/main/cpp/include/media/MediaPlayerObserver.h @@ -0,0 +1,47 @@ +/* + * Copyright 2026 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 WEBRTC_JAVA_MEDIA_MEDIA_PLAYER_OBSERVER_H_ +#define WEBRTC_JAVA_MEDIA_MEDIA_PLAYER_OBSERVER_H_ + +#include + +namespace ffmpeg +{ + // What a player reports while it runs. + // + // Every call arrives on the player's own thread, never on the thread that + // asked for playback, so an implementation must not block: the thread it + // holds up is the one decoding the media. + class MediaPlayerObserver + { + public: + virtual ~MediaPlayerObserver() = default; + + // The player moved to another state, as a MediaPlayerState value. + virtual void OnStateChanged(int state) = 0; + + // The source ran out. A looping player never reports this, since + // it starts again instead. + virtual void OnEndOfStream() = 0; + + // Playback stopped because something went wrong, with the message + // FFmpeg gave for it. + virtual void OnError(const std::string & message) = 0; + }; +} + +#endif diff --git a/webrtc-java-media/src/main/cpp/include/media/MediaReader.h b/webrtc-java-media/src/main/cpp/include/media/MediaReader.h index 0bda73ed..bf38a4c7 100644 --- a/webrtc-java-media/src/main/cpp/include/media/MediaReader.h +++ b/webrtc-java-media/src/main/cpp/include/media/MediaReader.h @@ -73,10 +73,25 @@ namespace ffmpeg int GetChannels() const; const char * GetAudioCodecName() const; - private: - const AVStream * VideoStream() const; - const AVStream * AudioStream() const; + // The streams the decoders attach to, or null when the source has + // none of that kind. They belong to the reader and die with it. + const AVStream * GetVideoStream() const; + const AVStream * GetAudioStream() const; + + int GetVideoStreamIndex() const; + int GetAudioStreamIndex() const; + + // Reads the next packet of any stream into the given packet, which + // the caller unrefs. Returns 0, AVERROR_EOF once the source is + // exhausted, or another negative AVERROR. + int ReadPacket(AVPacket * packet); + // Moves to the keyframe at or before the given position, in + // microseconds from the start. The decoders have to be flushed + // afterwards, since what they hold belongs to the old position. + int Seek(int64_t position_us); + + private: AVFormatContext * format_context_ = nullptr; int video_stream_index_ = -1; int audio_stream_index_ = -1; diff --git a/webrtc-java-media/src/main/cpp/include/media/VideoDecoder.h b/webrtc-java-media/src/main/cpp/include/media/VideoDecoder.h new file mode 100644 index 00000000..673f96a4 --- /dev/null +++ b/webrtc-java-media/src/main/cpp/include/media/VideoDecoder.h @@ -0,0 +1,82 @@ +/* + * Copyright 2026 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 WEBRTC_JAVA_MEDIA_VIDEO_DECODER_H_ +#define WEBRTC_JAVA_MEDIA_VIDEO_DECODER_H_ + +extern "C" { +#include +#include +#include +} + +namespace ffmpeg +{ + // Decodes one video stream into I420, which is the only pixel format + // WebRTC takes. + // + // Almost all 8-bit H.264, VP8, VP9 and MPEG-4 content decodes to yuv420p + // already, and such a frame is handed on untouched, so the decoded picture + // reaches the encoder without a single copy. Anything else is converted + // with swscale first, which costs one copy and is the price of the format + // rather than of this design. + class VideoDecoder + { + public: + VideoDecoder() = default; + ~VideoDecoder(); + + VideoDecoder(const VideoDecoder &) = delete; + VideoDecoder & operator=(const VideoDecoder &) = delete; + + // Opens a decoder for the given stream, which must outlive this + // decoder. Returns 0 or a negative AVERROR. + int Open(const AVStream * stream); + + void Close(); + + // Drops everything the decoder holds, which is what a seek needs: + // the frames in flight belong to the position that was left. + void Flush(); + + // Hands a packet to the decoder, or null to start draining at the + // end of the stream. Returns 0 or a negative AVERROR. + int SendPacket(const AVPacket * packet); + + // Takes the next decoded frame, in I420 and owned by the caller, + // who frees it with av_frame_free. + // + // Returns 0 when a frame was produced, AVERROR(EAGAIN) when the + // decoder needs another packet first, AVERROR_EOF once draining + // has finished, or another negative AVERROR. + int ReceiveFrame(AVFrame ** frame, int64_t * timestamp_us); + + int GetWidth() const; + int GetHeight() const; + + private: + // Converts a decoded frame to I420. The returned frame is a new + // reference the caller owns. + int ConvertToI420(const AVFrame * source, AVFrame ** result); + + AVCodecContext * codec_context_ = nullptr; + SwsContext * sws_context_ = nullptr; + AVFrame * decoded_ = nullptr; + AVRational time_base_ = { 0, 1 }; + }; +} + +#endif diff --git a/webrtc-java-media/src/main/cpp/src/JNI_MediaPlayer.cpp b/webrtc-java-media/src/main/cpp/src/JNI_MediaPlayer.cpp new file mode 100644 index 00000000..13a6e2a6 --- /dev/null +++ b/webrtc-java-media/src/main/cpp/src/JNI_MediaPlayer.cpp @@ -0,0 +1,161 @@ +/* + * Copyright 2026 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_MediaPlayer.h" +#include "media/JavaPlayerObserver.h" +#include "media/MediaPlayer.h" +#include "media/MediaReader.h" +#include "webrtc_java_api.h" + +#include +#include + +extern "C" { +#include +} + +namespace +{ + std::string ErrorMessage(int error) + { + char buffer[AV_ERROR_MAX_STRING_SIZE] = { 0 }; + + if (av_strerror(error, buffer, sizeof(buffer)) < 0) { + return "Unknown FFmpeg error " + std::to_string(error); + } + + return buffer; + } + + void ThrowIOException(JNIEnv * env, const std::string & message) + { + jclass cls = env->FindClass("java/io/IOException"); + + if (cls != nullptr) { + env->ThrowNew(cls, message.c_str()); + env->DeleteLocalRef(cls); + } + } + + ffmpeg::MediaPlayer * PlayerOf(jlong handle) + { + return reinterpret_cast(handle); + } +} + +JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_create +(JNIEnv * env, jobject caller, jlong readerHandle, jlong tableAddress, + jlong videoSourceHandle, jlong audioSourceHandle) +{ + // The Java side detached the reader before this call, so it is ours now + // and has to be released here if anything goes wrong. + std::unique_ptr reader( + reinterpret_cast(readerHandle)); + + if (reader == nullptr) { + ThrowIOException(env, "The reader is closed"); + + return 0; + } + + const webrtc_java_api * api = + reinterpret_cast(tableAddress); + + if (api == nullptr) { + ThrowIOException(env, "The webrtc-java function table is not available"); + + return 0; + } + if (api->version != WEBRTC_JAVA_API_VERSION) { + ThrowIOException(env, "This module was built against webrtc-java interface version " + + std::to_string(WEBRTC_JAVA_API_VERSION) + ", but the loaded library provides " + + std::to_string(api->version)); + + return 0; + } + + auto player = std::make_unique(std::move(reader), api, + reinterpret_cast(videoSourceHandle), + reinterpret_cast(audioSourceHandle)); + + player->SetObserver(std::make_unique(env, caller)); + + int result = player->Initialize(); + + if (result < 0) { + ThrowIOException(env, "Opening the decoders failed: " + ErrorMessage(result)); + + return 0; + } + + return reinterpret_cast(player.release()); +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_start +(JNIEnv * env, jclass caller, jlong handle) +{ + if (handle != 0) { + PlayerOf(handle)->Play(); + } +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_suspend +(JNIEnv * env, jclass caller, jlong handle) +{ + if (handle != 0) { + PlayerOf(handle)->Pause(); + } +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_seek +(JNIEnv * env, jclass caller, jlong handle, jlong positionUs) +{ + if (handle != 0) { + PlayerOf(handle)->Seek(positionUs); + } +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_setLooping +(JNIEnv * env, jclass caller, jlong handle, jboolean looping) +{ + if (handle != 0) { + PlayerOf(handle)->SetLooping(looping == JNI_TRUE); + } +} + +JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_position +(JNIEnv * env, jclass caller, jlong handle) +{ + return handle != 0 ? PlayerOf(handle)->GetPositionUs() : 0; +} + +JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_state +(JNIEnv * env, jclass caller, jlong handle) +{ + // A player that is gone is closed, which is what the Java side reports + // once it has let go of the handle. + return handle != 0 ? PlayerOf(handle)->GetState() : ffmpeg::kClosed; +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_dispose +(JNIEnv * env, jclass caller, jlong handle) +{ + // Closing twice is allowed, so a handle that is already zero is simply + // nothing to do. + if (handle != 0) { + delete PlayerOf(handle); + } +} diff --git a/webrtc-java-media/src/main/cpp/src/media/AudioDecoder.cpp b/webrtc-java-media/src/main/cpp/src/media/AudioDecoder.cpp new file mode 100644 index 00000000..c1594cac --- /dev/null +++ b/webrtc-java-media/src/main/cpp/src/media/AudioDecoder.cpp @@ -0,0 +1,252 @@ +/* + * Copyright 2026 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/AudioDecoder.h" + +extern "C" { +#include +#include +} + +namespace ffmpeg +{ + AudioDecoder::~AudioDecoder() + { + Close(); + } + + int AudioDecoder::Open(const AVStream * stream) + { + Close(); + + if (stream == nullptr) { + return AVERROR(EINVAL); + } + + const AVCodec * codec = avcodec_find_decoder(stream->codecpar->codec_id); + + if (codec == nullptr) { + return AVERROR_DECODER_NOT_FOUND; + } + + codec_context_ = avcodec_alloc_context3(codec); + + if (codec_context_ == nullptr) { + return AVERROR(ENOMEM); + } + + int result = avcodec_parameters_to_context(codec_context_, stream->codecpar); + + if (result < 0) { + Close(); + + return result; + } + + codec_context_->pkt_timebase = stream->time_base; + + result = avcodec_open2(codec_context_, codec, nullptr); + + if (result < 0) { + Close(); + + return result; + } + + // WebRTC takes mono or stereo, so anything wider is downmixed. The + // downmix itself is swresample's job. + channels_ = codec_context_->ch_layout.nb_channels >= 2 ? 2 : 1; + + AVChannelLayout out_layout; + + av_channel_layout_default(&out_layout, channels_); + + result = swr_alloc_set_opts2(&swr_context_, + &out_layout, AV_SAMPLE_FMT_S16, kSampleRate, + &codec_context_->ch_layout, codec_context_->sample_fmt, + codec_context_->sample_rate, 0, nullptr); + + av_channel_layout_uninit(&out_layout); + + if (result < 0) { + Close(); + + return result; + } + + result = swr_init(swr_context_); + + if (result < 0) { + Close(); + + return result; + } + + decoded_ = av_frame_alloc(); + + if (decoded_ == nullptr) { + Close(); + + return AVERROR(ENOMEM); + } + + time_base_ = stream->time_base; + + return 0; + } + + void AudioDecoder::Close() + { + if (decoded_ != nullptr) { + av_frame_free(&decoded_); + } + if (swr_context_ != nullptr) { + swr_free(&swr_context_); + } + if (codec_context_ != nullptr) { + avcodec_free_context(&codec_context_); + } + + pending_.clear(); + + time_base_ = { 0, 1 }; + channels_ = 0; + next_timestamp_us_ = 0; + have_timestamp_ = false; + } + + void AudioDecoder::Flush() + { + if (codec_context_ != nullptr) { + avcodec_flush_buffers(codec_context_); + } + + pending_.clear(); + + // The next frame decoded tells where the audio now starts. + have_timestamp_ = false; + } + + int AudioDecoder::SendPacket(const AVPacket * packet) + { + if (codec_context_ == nullptr) { + return AVERROR(EINVAL); + } + + return avcodec_send_packet(codec_context_, packet); + } + + int AudioDecoder::ReceiveChunk(std::vector & chunk, int64_t * timestamp_us) + { + if (codec_context_ == nullptr || decoded_ == nullptr) { + return AVERROR(EINVAL); + } + + const size_t wanted = static_cast(kFramesPerChunk) * channels_; + + while (pending_.size() < wanted) { + int result = avcodec_receive_frame(codec_context_, decoded_); + + if (result == AVERROR_EOF) { + if (pending_.empty()) { + return AVERROR_EOF; + } + + // The last chunk of a stream rarely lands on a 10 ms boundary, + // and WebRTC has no way to take a short one, so the remainder + // is filled with silence. + pending_.resize(wanted, 0); + + break; + } + if (result < 0) { + return result; + } + + result = Resample(decoded_); + + av_frame_unref(decoded_); + + if (result < 0) { + return result; + } + } + + TakeChunk(chunk, timestamp_us); + + return 0; + } + + int AudioDecoder::Resample(const AVFrame * frame) + { + if (!have_timestamp_) { + int64_t pts = frame->best_effort_timestamp != AV_NOPTS_VALUE + ? frame->best_effort_timestamp : frame->pts; + + next_timestamp_us_ = pts != AV_NOPTS_VALUE + ? av_rescale_q(pts, time_base_, AV_TIME_BASE_Q) : 0; + have_timestamp_ = true; + } + + // swresample holds samples back when rates differ, and those come out + // of a later call, so the room needed is the delay plus this frame. + int64_t delay = swr_get_delay(swr_context_, codec_context_->sample_rate); + int64_t capacity = av_rescale_rnd(delay + frame->nb_samples, kSampleRate, + codec_context_->sample_rate, AV_ROUND_UP); + + if (capacity <= 0) { + return 0; + } + + const size_t offset = pending_.size(); + + pending_.resize(offset + static_cast(capacity) * channels_); + + uint8_t * output = reinterpret_cast(pending_.data() + offset); + + int converted = swr_convert(swr_context_, &output, static_cast(capacity), + const_cast(frame->data), frame->nb_samples); + + if (converted < 0) { + pending_.resize(offset); + + return converted; + } + + // swresample usually produces fewer samples than the room made for it. + pending_.resize(offset + static_cast(converted) * channels_); + + return 0; + } + + void AudioDecoder::TakeChunk(std::vector & chunk, int64_t * timestamp_us) + { + const size_t wanted = static_cast(kFramesPerChunk) * channels_; + + chunk.assign(pending_.begin(), pending_.begin() + wanted); + pending_.erase(pending_.begin(), pending_.begin() + wanted); + + *timestamp_us = next_timestamp_us_; + + // Exactly 10 ms on, whatever the container's own packet timing was. + next_timestamp_us_ += 10000; + } + + int AudioDecoder::GetChannels() const + { + return channels_; + } +} diff --git a/webrtc-java-media/src/main/cpp/src/media/JavaPlayerObserver.cpp b/webrtc-java-media/src/main/cpp/src/media/JavaPlayerObserver.cpp new file mode 100644 index 00000000..02d8c160 --- /dev/null +++ b/webrtc-java-media/src/main/cpp/src/media/JavaPlayerObserver.cpp @@ -0,0 +1,176 @@ +/* + * Copyright 2026 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/JavaPlayerObserver.h" + +namespace ffmpeg +{ + JavaPlayerObserver::JavaPlayerObserver(JNIEnv * env, jobject player) + { + if (env->GetJavaVM(&vm_) != JNI_OK) { + return; + } + + player_ = env->NewGlobalRef(player); + + if (player_ == nullptr) { + return; + } + + jclass cls = env->GetObjectClass(player_); + + if (cls == nullptr) { + return; + } + + on_state_changed_ = env->GetMethodID(cls, "onNativeStateChanged", "(I)V"); + on_end_of_stream_ = env->GetMethodID(cls, "onNativeEndOfStream", "()V"); + on_error_ = env->GetMethodID(cls, "onNativeError", "(Ljava/lang/String;)V"); + + env->DeleteLocalRef(cls); + + ClearPendingException(env); + } + + JavaPlayerObserver::~JavaPlayerObserver() + { + if (player_ == nullptr || vm_ == nullptr) { + return; + } + + bool attached = false; + JNIEnv * env = Attach(&attached); + + if (env != nullptr) { + env->DeleteGlobalRef(player_); + } + + player_ = nullptr; + + Detach(attached); + } + + void JavaPlayerObserver::OnStateChanged(int state) + { + if (on_state_changed_ == nullptr) { + return; + } + + bool attached = false; + JNIEnv * env = Attach(&attached); + + if (env != nullptr) { + env->CallVoidMethod(player_, on_state_changed_, static_cast(state)); + + ClearPendingException(env); + } + + Detach(attached); + } + + void JavaPlayerObserver::OnEndOfStream() + { + if (on_end_of_stream_ == nullptr) { + return; + } + + bool attached = false; + JNIEnv * env = Attach(&attached); + + if (env != nullptr) { + env->CallVoidMethod(player_, on_end_of_stream_); + + ClearPendingException(env); + } + + Detach(attached); + } + + void JavaPlayerObserver::OnError(const std::string & message) + { + if (on_error_ == nullptr) { + return; + } + + bool attached = false; + JNIEnv * env = Attach(&attached); + + if (env != nullptr) { + jstring text = env->NewStringUTF(message.c_str()); + + if (text != nullptr) { + env->CallVoidMethod(player_, on_error_, text); + + ClearPendingException(env); + + env->DeleteLocalRef(text); + } + else { + ClearPendingException(env); + } + } + + Detach(attached); + } + + JNIEnv * JavaPlayerObserver::Attach(bool * attached) + { + *attached = false; + + if (vm_ == nullptr) { + return nullptr; + } + + JNIEnv * env = nullptr; + + jint result = vm_->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6); + + if (result == JNI_OK) { + return env; + } + if (result != JNI_EDETACHED) { + return nullptr; + } + + // The player's own thread is not one the JVM started, so it has to be + // introduced before it can call anything. + if (vm_->AttachCurrentThreadAsDaemon(reinterpret_cast(&env), nullptr) != JNI_OK) { + return nullptr; + } + + *attached = true; + + return env; + } + + void JavaPlayerObserver::Detach(bool attached) + { + if (attached && vm_ != nullptr) { + vm_->DetachCurrentThread(); + } + } + + void JavaPlayerObserver::ClearPendingException(JNIEnv * env) + { + if (env->ExceptionCheck() == JNI_TRUE) { + // There is nowhere to throw this: the thread below is decoding, + // not running Java. Reporting and clearing keeps it from taking + // down the next unrelated JNI call instead. + env->ExceptionDescribe(); + env->ExceptionClear(); + } + } +} diff --git a/webrtc-java-media/src/main/cpp/src/media/MediaPacer.cpp b/webrtc-java-media/src/main/cpp/src/media/MediaPacer.cpp new file mode 100644 index 00000000..9d7fdba7 --- /dev/null +++ b/webrtc-java-media/src/main/cpp/src/media/MediaPacer.cpp @@ -0,0 +1,365 @@ +/* + * Copyright 2026 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/MediaPacer.h" +#include "media/AudioDecoder.h" + +#include +#include +#include + +namespace +{ + // Runs when WebRTC drops its last reference to the pixels of a pushed + // frame, on whichever thread that happens to be. + void ReleaseVideoFrame(void * opaque) + { + AVFrame * frame = static_cast(opaque); + + av_frame_free(&frame); + } +} + +namespace ffmpeg +{ + MediaPacer::VideoItem::~VideoItem() + { + if (frame != nullptr) { + av_frame_free(&frame); + } + } + + MediaPacer::VideoItem::VideoItem(VideoItem && other) noexcept + : frame(other.frame), timestamp_us(other.timestamp_us) + { + other.frame = nullptr; + } + + MediaPacer::VideoItem & MediaPacer::VideoItem::operator=(VideoItem && other) noexcept + { + if (this != &other) { + if (frame != nullptr) { + av_frame_free(&frame); + } + + frame = other.frame; + timestamp_us = other.timestamp_us; + other.frame = nullptr; + } + + return *this; + } + + MediaPacer::MediaPacer(const webrtc_java_api * api, void * video_source, + void * audio_source) + : api_(api), + video_source_(video_source), + audio_source_(audio_source) + { + } + + MediaPacer::~MediaPacer() + { + Stop(); + } + + void MediaPacer::Start() + { + std::lock_guard lock(mutex_); + + if (running_) { + return; + } + + running_ = true; + thread_ = std::thread(&MediaPacer::Run, this); + } + + void MediaPacer::Stop() + { + { + std::lock_guard lock(mutex_); + + if (!running_ && !thread_.joinable()) { + return; + } + + running_ = false; + + // Everyone waiting has to see that nothing more is coming: the + // pacing thread, and a decode thread held up by a full queue. + work_.notify_all(); + video_space_.notify_all(); + audio_space_.notify_all(); + } + + if (thread_.joinable()) { + thread_.join(); + } + + std::lock_guard lock(mutex_); + + video_queue_.clear(); + audio_queue_.clear(); + } + + bool MediaPacer::PushVideo(AVFrame * frame, int64_t timestamp_us) + { + std::unique_lock lock(mutex_); + + video_space_.wait(lock, [this] { + return !running_ || video_queue_.size() < kVideoCapacity; + }); + + if (!running_) { + // The frame became ours on the way in, so it is ours to drop. + av_frame_free(&frame); + + return false; + } + + video_queue_.emplace_back(frame, timestamp_us); + + work_.notify_one(); + + return true; + } + + bool MediaPacer::PushAudio(std::vector && samples, int channels, + int64_t timestamp_us) + { + std::unique_lock lock(mutex_); + + audio_space_.wait(lock, [this] { + return !running_ || audio_queue_.size() < kAudioCapacity; + }); + + if (!running_) { + return false; + } + + AudioItem item; + + item.samples = std::move(samples); + item.channels = channels; + item.timestamp_us = timestamp_us; + + audio_queue_.push_back(std::move(item)); + + work_.notify_one(); + + return true; + } + + void MediaPacer::Pause() + { + std::lock_guard lock(mutex_); + + if (paused_) { + return; + } + + paused_ = true; + paused_at_us_ = api_->now_us(); + + work_.notify_all(); + } + + void MediaPacer::Resume() + { + std::lock_guard lock(mutex_); + + if (!paused_) { + return; + } + + // What is queued is due that much later than it was, so the mapping + // moves with the pause instead of the queue draining all at once. + base_us_ += api_->now_us() - paused_at_us_; + paused_ = false; + + work_.notify_all(); + } + + void MediaPacer::Flush() + { + std::lock_guard lock(mutex_); + + video_queue_.clear(); + audio_queue_.clear(); + + // The next item delivered starts a new mapping, wherever it comes from. + have_base_ = false; + + video_space_.notify_all(); + audio_space_.notify_all(); + work_.notify_all(); + } + + bool MediaPacer::IsDrained() const + { + std::lock_guard lock(mutex_); + + return video_queue_.empty() && audio_queue_.empty(); + } + + int64_t MediaPacer::GetPositionUs() const + { + std::lock_guard lock(mutex_); + + return position_us_; + } + + void MediaPacer::Run() + { + std::unique_lock lock(mutex_); + + while (running_) { + if (paused_) { + work_.wait(lock); + + continue; + } + + const int64_t now = api_->now_us(); + int64_t next_due_us = std::numeric_limits::max(); + bool delivered = false; + + // The mapping is made once, from whichever kind of media is ready + // first, and holds for both from then on. + if (!have_base_ && (!video_queue_.empty() || !audio_queue_.empty())) { + int64_t first = !video_queue_.empty() + ? video_queue_.front().timestamp_us + : audio_queue_.front().timestamp_us; + + if (!video_queue_.empty() && !audio_queue_.empty()) { + first = std::min(first, audio_queue_.front().timestamp_us); + } + + base_us_ = now - first; + have_base_ = true; + } + + if (!video_queue_.empty()) { + const int64_t due = video_queue_.front().timestamp_us + base_us_; + + if (due <= now) { + VideoItem item = std::move(video_queue_.front()); + + video_queue_.pop_front(); + position_us_ = item.timestamp_us; + + video_space_.notify_one(); + + lock.unlock(); + DeliverVideo(std::move(item), due); + lock.lock(); + + delivered = true; + } + else { + next_due_us = std::min(next_due_us, due); + } + } + + if (!audio_queue_.empty()) { + const int64_t due = audio_queue_.front().timestamp_us + base_us_; + + if (due <= now) { + AudioItem item = std::move(audio_queue_.front()); + + audio_queue_.pop_front(); + position_us_ = std::max(position_us_, item.timestamp_us); + + audio_space_.notify_one(); + + lock.unlock(); + DeliverAudio(item, due); + lock.lock(); + + delivered = true; + } + else { + next_due_us = std::min(next_due_us, due); + } + } + + if (delivered) { + // Something else may be due already. + continue; + } + + if (next_due_us == std::numeric_limits::max()) { + // Nothing queued at all; a push or a stop wakes this. + work_.wait(lock); + } + else { + work_.wait_for(lock, std::chrono::microseconds(next_due_us - now)); + } + } + } + + void MediaPacer::DeliverVideo(VideoItem item, int64_t mapped_us) + { + AVFrame * frame = item.frame; + + if (video_source_ == nullptr || frame == nullptr) { + // Nothing to deliver to; the item frees the frame as it goes out + // of scope. + return; + } + + wj_i420_frame pushed = {}; + + pushed.width = frame->width; + pushed.height = frame->height; + pushed.y = frame->data[0]; + pushed.u = frame->data[1]; + pushed.v = frame->data[2]; + pushed.stride_y = frame->linesize[0]; + pushed.stride_u = frame->linesize[1]; + pushed.stride_v = frame->linesize[2]; + pushed.rotation = 0; + pushed.timestamp_us = mapped_us; + pushed.release = ReleaseVideoFrame; + pushed.opaque = frame; + + // The frame belongs to the release callback now, which runs whether + // the push succeeds or fails, so this item must not free it as well. + item.frame = nullptr; + + api_->video_source_push(video_source_, &pushed); + } + + void MediaPacer::DeliverAudio(const AudioItem & item, int64_t mapped_us) + { + if (audio_source_ == nullptr || item.samples.empty()) { + return; + } + + wj_audio_chunk chunk = {}; + + chunk.samples = item.samples.data(); + chunk.sample_rate = AudioDecoder::kSampleRate; + chunk.channels = item.channels; + chunk.frames = AudioDecoder::kFramesPerChunk; + chunk.timestamp_us = mapped_us; + + // The samples are copied during the call, so the item may go away as + // soon as this returns. + api_->audio_source_push(audio_source_, &chunk); + } +} diff --git a/webrtc-java-media/src/main/cpp/src/media/MediaPlayer.cpp b/webrtc-java-media/src/main/cpp/src/media/MediaPlayer.cpp new file mode 100644 index 00000000..72695e87 --- /dev/null +++ b/webrtc-java-media/src/main/cpp/src/media/MediaPlayer.cpp @@ -0,0 +1,515 @@ +/* + * Copyright 2026 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/MediaPlayer.h" + +#include +#include + +extern "C" { +#include +} + +namespace +{ + std::string ErrorText(int error) + { + char buffer[AV_ERROR_MAX_STRING_SIZE] = { 0 }; + + if (av_strerror(error, buffer, sizeof(buffer)) < 0) { + return "error " + std::to_string(error); + } + + return buffer; + } +} + +namespace ffmpeg +{ + MediaPlayer::MediaPlayer(std::unique_ptr reader, + const webrtc_java_api * api, void * video_source, void * audio_source) + : reader_(std::move(reader)), + api_(api) + { + pacer_ = std::make_unique(api, video_source, audio_source); + } + + MediaPlayer::~MediaPlayer() + { + Close(); + } + + void MediaPlayer::SetObserver(std::unique_ptr observer) + { + std::lock_guard lock(mutex_); + + observer_ = std::move(observer); + } + + int MediaPlayer::Initialize() + { + if (reader_ == nullptr || !reader_->IsOpen()) { + return AVERROR(EINVAL); + } + + if (reader_->HasVideo()) { + int result = video_decoder_.Open(reader_->GetVideoStream()); + + if (result < 0) { + return result; + } + + has_video_ = true; + } + + if (reader_->HasAudio()) { + int result = audio_decoder_.Open(reader_->GetAudioStream()); + + if (result < 0) { + return result; + } + + has_audio_ = true; + } + + if (!has_video_ && !has_audio_) { + return AVERROR_STREAM_NOT_FOUND; + } + + pacer_->Start(); + + { + std::lock_guard lock(mutex_); + + running_ = true; + thread_ = std::thread(&MediaPlayer::Run, this); + } + + return 0; + } + + void MediaPlayer::Play() + { + { + std::lock_guard lock(mutex_); + + if (closing_ || playing_) { + return; + } + + playing_ = true; + + if (ended_) { + // Playing again after the source ran out starts it over, + // which is what a listener would expect of a play button. + ended_ = false; + seek_pending_ = true; + seek_position_us_ = 0; + } + + command_.notify_all(); + } + + pacer_->Resume(); + + SetState(kPlaying); + } + + void MediaPlayer::Pause() + { + { + std::lock_guard lock(mutex_); + + if (closing_ || !playing_) { + return; + } + + playing_ = false; + } + + pacer_->Pause(); + + SetState(kPaused); + } + + void MediaPlayer::Seek(int64_t position_us) + { + { + std::lock_guard lock(mutex_); + + if (closing_) { + return; + } + + seek_pending_ = true; + seek_position_us_ = position_us; + ended_ = false; + + command_.notify_all(); + } + + // The decode thread may be held up by a full queue, and it cannot act + // on the request until it gets out of that. Dropping what is queued + // both frees it and throws away what belongs to the old position; the + // thread flushes again once the seek has actually happened. + pacer_->Flush(); + } + + void MediaPlayer::SetLooping(bool looping) + { + looping_.store(looping); + } + + bool MediaPlayer::IsLooping() const + { + return looping_.load(); + } + + void MediaPlayer::Close() + { + { + std::lock_guard lock(mutex_); + + if (closing_) { + return; + } + + closing_ = true; + playing_ = false; + + command_.notify_all(); + } + + // Stopping the pacer is what releases a decode thread waiting for room + // in a queue that nothing is draining any more. + pacer_->Stop(); + + if (thread_.joinable()) { + thread_.join(); + } + + video_decoder_.Close(); + audio_decoder_.Close(); + + if (reader_ != nullptr) { + reader_->Close(); + } + + SetState(kClosed); + } + + int64_t MediaPlayer::GetPositionUs() const + { + const int64_t position = pacer_->GetPositionUs() - loop_offset_us_.load(); + + return position > 0 ? position : 0; + } + + int MediaPlayer::GetState() const + { + std::lock_guard lock(mutex_); + + return state_; + } + + void MediaPlayer::Run() + { + AVPacket * packet = av_packet_alloc(); + + if (packet == nullptr) { + ReportError("Allocating a packet failed", AVERROR(ENOMEM)); + + return; + } + + for (;;) { + int64_t seek_to = 0; + bool do_seek = false; + + { + std::unique_lock lock(mutex_); + + command_.wait(lock, [this] { + return closing_ || seek_pending_ || (playing_ && !ended_); + }); + + if (closing_) { + break; + } + + if (seek_pending_) { + seek_pending_ = false; + seek_to = seek_position_us_; + do_seek = true; + } + } + + if (do_seek) { + PerformSeek(seek_to); + + continue; + } + + bool end_of_stream = false; + + if (!PumpOnce(packet, &end_of_stream)) { + break; + } + + if (!end_of_stream) { + continue; + } + + // Whatever the decoders still hold belongs to this pass, so it + // goes out before anything is rewound. + DrainDecoders(); + + if (looping_.load()) { + int64_t advance = reader_->GetDurationUs(); + + if (advance <= 0) { + // A container that does not say how long it runs still + // tells us where its last frame was. + advance = max_source_pts_us_ + 10000; + } + + loop_offset_us_.fetch_add(advance); + + reader_->Seek(0); + video_decoder_.Flush(); + audio_decoder_.Flush(); + + // The pacer keeps its queue and its mapping, so the seam + // between one pass and the next is not heard or seen. + continue; + } + + // Everything has been handed over, but not yet played out. + for (;;) { + std::unique_lock lock(mutex_); + + if (closing_ || pacer_->IsDrained()) { + break; + } + + command_.wait_for(lock, std::chrono::milliseconds(10)); + } + + { + std::lock_guard lock(mutex_); + + if (closing_) { + break; + } + + ended_ = true; + playing_ = false; + } + + SetState(kEnded); + + MediaPlayerObserver * observer = nullptr; + { + std::lock_guard lock(mutex_); + + observer = observer_.get(); + } + + if (observer != nullptr) { + observer->OnEndOfStream(); + } + } + + av_packet_free(&packet); + } + + bool MediaPlayer::PumpOnce(AVPacket * packet, bool * end_of_stream) + { + int result = reader_->ReadPacket(packet); + + if (result == AVERROR_EOF) { + *end_of_stream = true; + + return true; + } + if (result < 0) { + ReportError("Reading the source failed", result); + + return false; + } + + const int index = packet->stream_index; + bool video = has_video_ && index == reader_->GetVideoStreamIndex(); + bool audio = has_audio_ && index == reader_->GetAudioStreamIndex(); + + if (video) { + result = video_decoder_.SendPacket(packet); + } + else if (audio) { + result = audio_decoder_.SendPacket(packet); + } + else { + // A stream this player does not play, such as a subtitle track. + result = 0; + } + + av_packet_unref(packet); + + if (result < 0 && result != AVERROR(EAGAIN)) { + ReportError("Decoding failed", result); + + return false; + } + + if (video) { + return DecodeVideo() >= 0; + } + if (audio) { + return DecodeAudio() >= 0; + } + + return true; + } + + void MediaPlayer::DrainDecoders() + { + if (has_video_) { + video_decoder_.SendPacket(nullptr); + DecodeVideo(); + } + if (has_audio_) { + audio_decoder_.SendPacket(nullptr); + DecodeAudio(); + } + } + + int MediaPlayer::DecodeVideo() + { + for (;;) { + AVFrame * frame = nullptr; + int64_t timestamp_us = 0; + + int result = video_decoder_.ReceiveFrame(&frame, ×tamp_us); + + if (result == AVERROR(EAGAIN) || result == AVERROR_EOF) { + return 0; + } + if (result < 0) { + ReportError("Decoding video failed", result); + + return result; + } + + if (timestamp_us > max_source_pts_us_) { + max_source_pts_us_ = timestamp_us; + } + + // The frame belongs to the pacer from here on, whether or not it + // makes it into the queue. + if (!pacer_->PushVideo(frame, timestamp_us + loop_offset_us_.load())) { + return 0; + } + } + } + + int MediaPlayer::DecodeAudio() + { + for (;;) { + std::vector chunk; + int64_t timestamp_us = 0; + + int result = audio_decoder_.ReceiveChunk(chunk, ×tamp_us); + + if (result == AVERROR(EAGAIN) || result == AVERROR_EOF) { + return 0; + } + if (result < 0) { + ReportError("Decoding audio failed", result); + + return result; + } + + if (timestamp_us > max_source_pts_us_) { + max_source_pts_us_ = timestamp_us; + } + + if (!pacer_->PushAudio(std::move(chunk), audio_decoder_.GetChannels(), + timestamp_us + loop_offset_us_.load())) { + return 0; + } + } + } + + void MediaPlayer::PerformSeek(int64_t position_us) + { + int result = reader_->Seek(position_us); + + if (result < 0) { + ReportError("Seeking failed", result); + + return; + } + + video_decoder_.Flush(); + audio_decoder_.Flush(); + + // A seek starts a timeline of its own: the pacer maps the first item + // that arrives onto the clock afresh, so the offset that kept looping + // monotonic is no longer needed and would only skew the position. + loop_offset_us_.store(0); + max_source_pts_us_ = position_us; + + pacer_->Flush(); + } + + void MediaPlayer::SetState(int state) + { + MediaPlayerObserver * observer = nullptr; + + { + std::lock_guard lock(mutex_); + + if (state_ == state) { + return; + } + + state_ = state; + observer = observer_.get(); + } + + // Called with the lock released: an observer runs Java code, which + // must never happen underneath a lock of ours. + if (observer != nullptr) { + observer->OnStateChanged(state); + } + } + + void MediaPlayer::ReportError(const std::string & message, int error) + { + MediaPlayerObserver * observer = nullptr; + + { + std::lock_guard lock(mutex_); + + playing_ = false; + observer = observer_.get(); + } + + if (observer != nullptr) { + observer->OnError(message + ": " + ErrorText(error)); + } + } +} diff --git a/webrtc-java-media/src/main/cpp/src/media/MediaReader.cpp b/webrtc-java-media/src/main/cpp/src/media/MediaReader.cpp index 605d9fea..32493604 100644 --- a/webrtc-java-media/src/main/cpp/src/media/MediaReader.cpp +++ b/webrtc-java-media/src/main/cpp/src/media/MediaReader.cpp @@ -106,26 +106,26 @@ namespace ffmpeg bool MediaReader::HasVideo() const { - return VideoStream() != nullptr; + return GetVideoStream() != nullptr; } int MediaReader::GetVideoWidth() const { - const AVStream * stream = VideoStream(); + const AVStream * stream = GetVideoStream(); return stream != nullptr ? stream->codecpar->width : 0; } int MediaReader::GetVideoHeight() const { - const AVStream * stream = VideoStream(); + const AVStream * stream = GetVideoStream(); return stream != nullptr ? stream->codecpar->height : 0; } double MediaReader::GetFrameRate() const { - const AVStream * stream = VideoStream(); + const AVStream * stream = GetVideoStream(); if (stream == nullptr || stream->avg_frame_rate.den == 0) { return 0; @@ -136,7 +136,7 @@ namespace ffmpeg const char * MediaReader::GetVideoCodecName() const { - const AVStream * stream = VideoStream(); + const AVStream * stream = GetVideoStream(); return avcodec_get_name(stream != nullptr ? stream->codecpar->codec_id : AV_CODEC_ID_NONE); @@ -144,32 +144,67 @@ namespace ffmpeg bool MediaReader::HasAudio() const { - return AudioStream() != nullptr; + return GetAudioStream() != nullptr; } int MediaReader::GetSampleRate() const { - const AVStream * stream = AudioStream(); + const AVStream * stream = GetAudioStream(); return stream != nullptr ? stream->codecpar->sample_rate : 0; } int MediaReader::GetChannels() const { - const AVStream * stream = AudioStream(); + const AVStream * stream = GetAudioStream(); return stream != nullptr ? stream->codecpar->ch_layout.nb_channels : 0; } const char * MediaReader::GetAudioCodecName() const { - const AVStream * stream = AudioStream(); + const AVStream * stream = GetAudioStream(); return avcodec_get_name(stream != nullptr ? stream->codecpar->codec_id : AV_CODEC_ID_NONE); } - const AVStream * MediaReader::VideoStream() const + int MediaReader::GetVideoStreamIndex() const + { + return video_stream_index_; + } + + int MediaReader::GetAudioStreamIndex() const + { + return audio_stream_index_; + } + + int MediaReader::ReadPacket(AVPacket * packet) + { + if (format_context_ == nullptr) { + return AVERROR(EINVAL); + } + + return av_read_frame(format_context_, packet); + } + + int MediaReader::Seek(int64_t position_us) + { + if (format_context_ == nullptr) { + return AVERROR(EINVAL); + } + + if (position_us < 0) { + position_us = 0; + } + + // AVSEEK_FLAG_BACKWARD lands on the keyframe at or before the target, + // so that what follows can actually be decoded. Frames between that + // keyframe and the target are decoded and dropped by the caller. + return av_seek_frame(format_context_, -1, position_us, AVSEEK_FLAG_BACKWARD); + } + + const AVStream * MediaReader::GetVideoStream() const { if (format_context_ == nullptr || video_stream_index_ < 0) { return nullptr; @@ -178,7 +213,7 @@ namespace ffmpeg return format_context_->streams[video_stream_index_]; } - const AVStream * MediaReader::AudioStream() const + const AVStream * MediaReader::GetAudioStream() const { if (format_context_ == nullptr || audio_stream_index_ < 0) { return nullptr; diff --git a/webrtc-java-media/src/main/cpp/src/media/VideoDecoder.cpp b/webrtc-java-media/src/main/cpp/src/media/VideoDecoder.cpp new file mode 100644 index 00000000..b3da1c21 --- /dev/null +++ b/webrtc-java-media/src/main/cpp/src/media/VideoDecoder.cpp @@ -0,0 +1,238 @@ +/* + * Copyright 2026 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/VideoDecoder.h" + +extern "C" { +#include +#include +} + +namespace +{ + // yuv420p is what WebRTC calls I420. yuvj420p has the same planes and + // strides and differs only in the range the values cover, which is + // metadata rather than layout, so it needs no conversion either. + bool IsI420(int format) + { + return format == AV_PIX_FMT_YUV420P || format == AV_PIX_FMT_YUVJ420P; + } +} + +namespace ffmpeg +{ + VideoDecoder::~VideoDecoder() + { + Close(); + } + + int VideoDecoder::Open(const AVStream * stream) + { + Close(); + + if (stream == nullptr) { + return AVERROR(EINVAL); + } + + const AVCodec * codec = avcodec_find_decoder(stream->codecpar->codec_id); + + if (codec == nullptr) { + return AVERROR_DECODER_NOT_FOUND; + } + + codec_context_ = avcodec_alloc_context3(codec); + + if (codec_context_ == nullptr) { + return AVERROR(ENOMEM); + } + + int result = avcodec_parameters_to_context(codec_context_, stream->codecpar); + + if (result < 0) { + Close(); + + return result; + } + + // Without this the decoder cannot put a meaningful timestamp on a + // frame, and every frame would come back with AV_NOPTS_VALUE. + codec_context_->pkt_timebase = stream->time_base; + + // 0 lets libavcodec pick a thread count for the machine. Decoding is + // the one part of playback that can genuinely use several cores. + codec_context_->thread_count = 0; + + result = avcodec_open2(codec_context_, codec, nullptr); + + if (result < 0) { + Close(); + + return result; + } + + decoded_ = av_frame_alloc(); + + if (decoded_ == nullptr) { + Close(); + + return AVERROR(ENOMEM); + } + + time_base_ = stream->time_base; + + return 0; + } + + void VideoDecoder::Close() + { + if (decoded_ != nullptr) { + av_frame_free(&decoded_); + } + if (sws_context_ != nullptr) { + sws_freeContext(sws_context_); + + sws_context_ = nullptr; + } + if (codec_context_ != nullptr) { + avcodec_free_context(&codec_context_); + } + + time_base_ = { 0, 1 }; + } + + void VideoDecoder::Flush() + { + if (codec_context_ != nullptr) { + avcodec_flush_buffers(codec_context_); + } + } + + int VideoDecoder::SendPacket(const AVPacket * packet) + { + if (codec_context_ == nullptr) { + return AVERROR(EINVAL); + } + + return avcodec_send_packet(codec_context_, packet); + } + + int VideoDecoder::ReceiveFrame(AVFrame ** frame, int64_t * timestamp_us) + { + if (codec_context_ == nullptr || decoded_ == nullptr) { + return AVERROR(EINVAL); + } + + int result = avcodec_receive_frame(codec_context_, decoded_); + + if (result < 0) { + return result; + } + + // best_effort_timestamp is what libavcodec makes of a stream whose + // packets carry no presentation time of their own. + int64_t pts = decoded_->best_effort_timestamp != AV_NOPTS_VALUE + ? decoded_->best_effort_timestamp : decoded_->pts; + + *timestamp_us = pts != AV_NOPTS_VALUE + ? av_rescale_q(pts, time_base_, AV_TIME_BASE_Q) : 0; + + if (IsI420(decoded_->format)) { + // Hand over a reference to the decoded picture rather than a copy + // of it. The caller drops that reference once WebRTC is done. + AVFrame * reference = av_frame_alloc(); + + if (reference == nullptr) { + av_frame_unref(decoded_); + + return AVERROR(ENOMEM); + } + + result = av_frame_ref(reference, decoded_); + + av_frame_unref(decoded_); + + if (result < 0) { + av_frame_free(&reference); + + return result; + } + + *frame = reference; + + return 0; + } + + result = ConvertToI420(decoded_, frame); + + av_frame_unref(decoded_); + + return result; + } + + int VideoDecoder::ConvertToI420(const AVFrame * source, AVFrame ** result) + { + sws_context_ = sws_getCachedContext(sws_context_, + source->width, source->height, + static_cast(source->format), + source->width, source->height, AV_PIX_FMT_YUV420P, + SWS_BILINEAR, nullptr, nullptr, nullptr); + + if (sws_context_ == nullptr) { + return AVERROR(EINVAL); + } + + AVFrame * converted = av_frame_alloc(); + + if (converted == nullptr) { + return AVERROR(ENOMEM); + } + + converted->format = AV_PIX_FMT_YUV420P; + converted->width = source->width; + converted->height = source->height; + + int error = av_frame_get_buffer(converted, 0); + + if (error < 0) { + av_frame_free(&converted); + + return error; + } + + error = sws_scale(sws_context_, source->data, source->linesize, 0, + source->height, converted->data, converted->linesize); + + if (error < 0) { + av_frame_free(&converted); + + return error; + } + + *result = converted; + + return 0; + } + + int VideoDecoder::GetWidth() const + { + return codec_context_ != nullptr ? codec_context_->width : 0; + } + + int VideoDecoder::GetHeight() const + { + return codec_context_ != nullptr ? codec_context_->height : 0; + } +} diff --git a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaFileSource.java b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaFileSource.java new file mode 100644 index 00000000..1fced442 --- /dev/null +++ b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaFileSource.java @@ -0,0 +1,234 @@ +/* + * Copyright 2026 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.ffmpeg; + +import java.io.IOException; +import java.nio.file.Path; + +import dev.onvoid.webrtc.media.audio.CustomAudioSource; +import dev.onvoid.webrtc.media.video.CustomVideoSource; + +/** + * A media file or stream as a pair of media sources, ready to make tracks + * from. + *

+ * This is the short way to do what the module is for. It opens the source, + * makes a {@link CustomVideoSource} and a {@link CustomAudioSource} for + * whichever streams it actually has, and wires a {@link MediaPlayer} to feed + * them: + *

{@code
+ * MediaFileSource source = new MediaFileSource(Path.of("movie.mp4"));
+ *
+ * VideoTrack videoTrack = factory.createVideoTrack("video", source.getVideoSource());
+ * AudioTrack audioTrack = factory.createAudioTrack("audio", source.getAudioSource());
+ *
+ * peerConnection.addTrack(videoTrack, List.of("stream"));
+ * peerConnection.addTrack(audioTrack, List.of("stream"));
+ *
+ * source.play();
+ * }
+ *

+ * A source with no video has no video source, and likewise for audio, so check + * {@link #getVideoSource()} and {@link #getAudioSource()} for {@code null} + * before making a track, or ask {@link #getInfo()} first. + *

+ * A factory fed from here is sending pushed audio, so it cannot also send + * audio captured by its {@code AudioDeviceModule}. Use a separate factory if + * an application needs both. + *

+ * Closing this releases the player and both media sources. Dispose of any + * tracks made from them first. + * + * @author Alex Andres + */ +public class MediaFileSource implements AutoCloseable { + + private final MediaInfo info; + + private final CustomVideoSource videoSource; + + private final CustomAudioSource audioSource; + + private final MediaPlayer player; + + private boolean closed; + + + /** + * Opens the media file at the given path. + * + * @param path The path of the file to play. + * + * @throws IOException if the source cannot be opened or decoded. + */ + public MediaFileSource(Path path) throws IOException { + this(path.toAbsolutePath().toString()); + } + + /** + * Opens the given media source. + * + * @param source The path or URL of the source to play. + * + * @throws IOException if the source cannot be opened or decoded. + */ + public MediaFileSource(String source) throws IOException { + MediaReader reader = new MediaReader(source); + + // Read while the reader is still ours: the player takes it over. + info = reader.getInfo(); + + videoSource = info.hasVideo() ? new CustomVideoSource() : null; + audioSource = info.hasAudio() ? new CustomAudioSource() : null; + + try { + player = new MediaPlayer(reader, videoSource, audioSource); + } + catch (IOException | RuntimeException e) { + // The player did not take charge, so what was made here has to be + // unwound rather than left to leak. + disposeSources(); + + reader.close(); + + throw e; + } + } + + /** + * Returns what this source contains. + * + * @return The media information. + */ + public MediaInfo getInfo() { + return info; + } + + /** + * Returns the video source fed by this file, to make a video track from. + * + * @return The video source, or {@code null} if the source has no video. + */ + public CustomVideoSource getVideoSource() { + return videoSource; + } + + /** + * Returns the audio source fed by this file, to make an audio track from. + * + * @return The audio source, or {@code null} if the source has no audio. + */ + public CustomAudioSource getAudioSource() { + return audioSource; + } + + /** + * Returns the player driving this source, for anything beyond the methods + * below. + * + * @return The player. + */ + public MediaPlayer getPlayer() { + return player; + } + + /** + * Sets what to report playback events to. + * + * @param listener The listener, or {@code null} to stop reporting. + */ + public void setListener(MediaPlayerListener listener) { + player.setListener(listener); + } + + /** + * Starts or resumes playback. + */ + public void play() { + player.play(); + } + + /** + * Holds playback where it is. + */ + public void pause() { + player.pause(); + } + + /** + * Moves playback to the given position. + * + * @param positionUs The position in microseconds from the start. + */ + public void seek(long positionUs) { + player.seek(positionUs); + } + + /** + * Sets whether the source starts again when it runs out. + * + * @param looping True to play the source over and over. + */ + public void setLooping(boolean looping) { + player.setLooping(looping); + } + + /** + * Returns where playback has got to, in microseconds. + * + * @return The position in microseconds. + */ + public long getPositionUs() { + return player.getPositionUs(); + } + + /** + * Returns what the player is currently doing. + * + * @return The player state. + */ + public MediaPlayerState getState() { + return player.getState(); + } + + /** + * Stops playback and releases the player and both media sources. Closing + * a source that is already closed does nothing. + */ + @Override + public synchronized void close() { + if (closed) { + return; + } + + closed = true; + + player.close(); + + disposeSources(); + } + + private void disposeSources() { + if (videoSource != null) { + videoSource.dispose(); + } + if (audioSource != null) { + audioSource.dispose(); + } + } + +} diff --git a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayer.java b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayer.java new file mode 100644 index 00000000..cd09f8cc --- /dev/null +++ b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayer.java @@ -0,0 +1,249 @@ +/* + * Copyright 2026 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.ffmpeg; + +import java.io.IOException; +import java.util.Objects; + +import dev.onvoid.webrtc.internal.NativeApi; +import dev.onvoid.webrtc.media.audio.CustomAudioSource; +import dev.onvoid.webrtc.media.video.CustomVideoSource; + +/** + * Plays a media source into webrtc-java's custom media sources, so that a + * peer connection sends a file the way it would send a camera and microphone. + *

+ * Nothing is carried through Java: the player decodes in native code and hands + * the result straight to the native side of the given sources. Frames are + * delivered in real time and carry the presentation times of the source, so + * what reaches the receiver keeps the timing of the file rather than the + * timing of a thread. + *

+ * The player takes over the reader it is given. That reader must not be used + * or closed afterwards; closing the player releases it. + *

+ * Example: + *

{@code
+ * CustomVideoSource videoSource = new CustomVideoSource();
+ * CustomAudioSource audioSource = new CustomAudioSource();
+ *
+ * MediaPlayer player = new MediaPlayer(new MediaReader(path), videoSource, audioSource);
+ *
+ * VideoTrack videoTrack = factory.createVideoTrack("video", videoSource);
+ * AudioTrack audioTrack = factory.createAudioTrack("audio", audioSource);
+ *
+ * player.play();
+ * }
+ *

+ * A factory fed by this player is sending pushed audio, so it cannot also send + * audio captured by its {@code AudioDeviceModule}. Use a separate factory if + * an application needs both. + * + * @author Alex Andres + */ +public class MediaPlayer implements AutoCloseable { + + static { + FFmpeg.load(); + } + + /** Guards the handle against a close racing a command. */ + private final Object lock = new Object(); + + /** The native player, or 0 once this player has been closed. */ + private long handle; + + /** Read on the native player's thread, so never a stale value. */ + private volatile MediaPlayerListener listener; + + + /** + * Creates a player for the given source, delivering into the given custom + * media sources. At least one of them has to be present; media of a kind + * with no source is decoded and dropped. + * + * @param reader The source to play, which this player takes over. + * @param videoSource Where video goes, or {@code null} for none. + * @param audioSource Where audio goes, or {@code null} for none. + * + * @throws IOException if the source cannot be decoded. + * @throws IllegalArgumentException if both sources are {@code null}. + */ + public MediaPlayer(MediaReader reader, CustomVideoSource videoSource, + CustomAudioSource audioSource) throws IOException { + Objects.requireNonNull(reader, "MediaReader is null"); + + if (videoSource == null && audioSource == null) { + throw new IllegalArgumentException("A player needs at least one media source"); + } + + // Taking the reader over rather than sharing it: the native player + // owns it from here, and a Java reader that still held the same + // pointer would release it a second time. + long readerHandle = reader.detach(); + + if (readerHandle == 0) { + throw new IOException("The reader is closed"); + } + + handle = create(readerHandle, NativeApi.tableAddress(), + videoSource != null ? NativeApi.handleOf(videoSource) : 0, + audioSource != null ? NativeApi.handleOf(audioSource) : 0); + } + + /** + * Sets what to report playback events to, replacing whatever was set + * before. A listener of {@code null} stops reporting. + * + * @param listener The listener, or {@code null}. + */ + public void setListener(MediaPlayerListener listener) { + this.listener = listener; + } + + /** + * Starts or resumes playback. A player that has reached the end starts + * over. Playing a player that is already playing does nothing. + */ + public void play() { + synchronized (lock) { + start(handle); + } + } + + /** + * Holds playback where it is. Resuming carries on from there rather than + * delivering everything that fell due in the meantime. + */ + public void pause() { + synchronized (lock) { + suspend(handle); + } + } + + /** + * Moves playback to the given position. Whatever has been decoded but not + * yet delivered is dropped. + * + * @param positionUs The position in microseconds from the start. + */ + public void seek(long positionUs) { + synchronized (lock) { + seek(handle, positionUs); + } + } + + /** + * Sets whether the source starts again when it runs out. A looping player + * keeps its timing across the seam, and never reports an end of stream. + * + * @param looping True to play the source over and over. + */ + public void setLooping(boolean looping) { + synchronized (lock) { + setLooping(handle, looping); + } + } + + /** + * Returns where playback has got to within the current pass, in + * microseconds. A looping source starts again from zero. + * + * @return The position in microseconds. + */ + public long getPositionUs() { + synchronized (lock) { + return position(handle); + } + } + + /** + * Returns what the player is currently doing. + * + * @return The player state. + */ + public MediaPlayerState getState() { + synchronized (lock) { + return MediaPlayerState.of(state(handle)); + } + } + + /** + * Stops playback, releases the native player and the reader it took over, + * and waits for the player's thread to finish. Closing a player that is + * already closed does nothing. + */ + @Override + public void close() { + long closing; + + synchronized (lock) { + closing = handle; + + // Cleared first, so that a command arriving from another thread + // finds nothing to act on rather than a handle being freed. + handle = 0; + } + + dispose(closing); + } + + /** Called by native code on the player's thread. */ + private void onNativeStateChanged(int state) { + MediaPlayerListener current = listener; + + if (current != null) { + current.onStateChanged(MediaPlayerState.of(state)); + } + } + + /** Called by native code on the player's thread. */ + private void onNativeEndOfStream() { + MediaPlayerListener current = listener; + + if (current != null) { + current.onEndOfStream(); + } + } + + /** Called by native code on the player's thread. */ + private void onNativeError(String message) { + MediaPlayerListener current = listener; + + if (current != null) { + current.onError(message); + } + } + + private native long create(long readerHandle, long tableAddress, + long videoSourceHandle, long audioSourceHandle) throws IOException; + + private static native void start(long handle); + + private static native void suspend(long handle); + + private static native void seek(long handle, long positionUs); + + private static native void setLooping(long handle, boolean looping); + + private static native long position(long handle); + + private static native int state(long handle); + + private static native void dispose(long handle); + +} diff --git a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerListener.java b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerListener.java new file mode 100644 index 00000000..7e7ab7fe --- /dev/null +++ b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerListener.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 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.ffmpeg; + +/** + * What a {@link MediaPlayer} reports while it runs. + *

+ * Calls arrive on the player's own thread, not on the thread that asked for + * playback, and that thread is the one decoding the media. An implementation + * must therefore return promptly and must not call back into the player in a + * way that waits for it. + * + * @author Alex Andres + */ +public interface MediaPlayerListener { + + /** + * The player moved to another state. + * + * @param state The state it moved to. + */ + default void onStateChanged(MediaPlayerState state) { + } + + /** + * The source ran out and everything decoded from it has been delivered. A + * looping player never reports this, since it starts over instead. + */ + default void onEndOfStream() { + } + + /** + * Playback stopped because something went wrong. + * + * @param message What went wrong, as FFmpeg described it. + */ + default void onError(String message) { + } + +} diff --git a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerState.java b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerState.java new file mode 100644 index 00000000..682b7344 --- /dev/null +++ b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerState.java @@ -0,0 +1,66 @@ +/* + * Copyright 2026 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.ffmpeg; + +/** + * What a {@link MediaPlayer} is currently doing. + * + * @author Alex Andres + */ +public enum MediaPlayerState { + + /** Ready to play, but not playing. */ + IDLE, + + /** Media is being decoded and delivered in real time. */ + PLAYING, + + /** + * Playback is held where it is. Resuming carries on from there rather + * than delivering everything that fell due in the meantime. + */ + PAUSED, + + /** + * The source ran out and everything decoded from it has been delivered. + * Playing again starts it over. A looping player never reaches this. + */ + ENDED, + + /** The player has been closed and cannot be used again. */ + CLOSED; + + + /** Cached, because values() hands out a fresh array on every call. */ + private static final MediaPlayerState[] VALUES = values(); + + + /** + * Returns the state native code reported. The order of the constants above + * is the order the native {@code MediaPlayerState} enum uses, so the two + * have to be changed together. + * + * @param value The native state value. + * + * @return The matching state, or {@link #CLOSED} for a value this version + * does not know. + */ + static MediaPlayerState of(int value) { + return value >= 0 && value < VALUES.length ? VALUES[value] : CLOSED; + } + +} diff --git a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaReader.java b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaReader.java index affb63ab..da50e429 100644 --- a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaReader.java +++ b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaReader.java @@ -94,6 +94,23 @@ public synchronized void close() { dispose(closing); } + /** + * Hands the native reader over to a caller that takes responsibility for + * releasing it, and leaves this reader closed. This is how a + * {@link MediaPlayer} adopts a reader: two owners of the same pointer + * would release it twice. + * + * @return The native reader, or {@code 0} if it was already given away or + * closed. + */ + synchronized long detach() { + long detaching = handle; + + handle = 0; + + return detaching; + } + private static native long open(String source) throws IOException; private static native MediaInfo info(long handle); diff --git a/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaFileSourceTest.java b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaFileSourceTest.java new file mode 100644 index 00000000..45fc37ff --- /dev/null +++ b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaFileSourceTest.java @@ -0,0 +1,150 @@ +/* + * Copyright 2026 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.ffmpeg; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import dev.onvoid.webrtc.PeerConnectionFactory; +import dev.onvoid.webrtc.media.audio.AudioDeviceModule; +import dev.onvoid.webrtc.media.audio.AudioLayer; +import dev.onvoid.webrtc.media.audio.AudioTrack; +import dev.onvoid.webrtc.media.audio.AudioTrackSink; +import dev.onvoid.webrtc.media.video.VideoTrack; +import dev.onvoid.webrtc.media.video.VideoTrackSink; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +/** + * Tests the turnkey way to send a media file, which is the thing the issue + * behind this module actually asked for. + * + * @author Alex Andres + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Execution(ExecutionMode.SAME_THREAD) +class MediaFileSourceTest { + + private static final String ASSET = "/media-test.webm"; + + private AudioDeviceModule audioModule; + private PeerConnectionFactory factory; + + + @BeforeAll + void initFactory() { + audioModule = new AudioDeviceModule(AudioLayer.kDummyAudio); + factory = new PeerConnectionFactory(audioModule); + } + + @AfterAll + void disposeFactory() { + factory.dispose(); + audioModule.dispose(); + } + + @Test + void exposesWhatTheFileHas() throws Exception { + try (MediaFileSource source = new MediaFileSource(asset())) { + assertTrue(source.getInfo().hasVideo()); + assertTrue(source.getInfo().hasAudio()); + + assertNotNull(source.getVideoSource()); + assertNotNull(source.getAudioSource()); + + assertEquals(MediaPlayerState.IDLE, source.getState()); + } + } + + @Test + void sendsTheFileThroughTracks() throws Exception { + AtomicInteger frames = new AtomicInteger(); + AtomicInteger chunks = new AtomicInteger(); + CountDownLatch ended = new CountDownLatch(1); + + try (MediaFileSource source = new MediaFileSource(asset())) { + VideoTrack videoTrack = factory.createVideoTrack("video", source.getVideoSource()); + AudioTrack audioTrack = factory.createAudioTrack("audio", source.getAudioSource()); + + VideoTrackSink videoSink = frame -> frames.incrementAndGet(); + AudioTrackSink audioSink = (data, bits, rate, ch, count) -> chunks.incrementAndGet(); + + videoTrack.addSink(videoSink); + audioTrack.addSink(audioSink); + + source.setListener(new MediaPlayerListener() { + + @Override + public void onEndOfStream() { + ended.countDown(); + } + }); + + source.play(); + + assertTrue(ended.await(15, TimeUnit.SECONDS), "no end of stream"); + + assertTrue(frames.get() > 40, "video frames: " + frames.get()); + assertTrue(chunks.get() > 290, "audio chunks: " + chunks.get()); + + videoTrack.removeSink(videoSink); + audioTrack.removeSink(audioSink); + + // The tracks go before the source, which disposes of what they + // were made from. + videoTrack.dispose(); + audioTrack.dispose(); + } + } + + @Test + void missingFileFails() { + assertThrows(IOException.class, () -> new MediaFileSource(Paths.get("no-such-file.webm"))); + } + + @Test + void closesTwice() throws Exception { + MediaFileSource source = new MediaFileSource(asset()); + + source.close(); + source.close(); + } + + private static Path asset() throws Exception { + URL url = MediaFileSourceTest.class.getResource(ASSET); + + assertNotNull(url, "Test asset " + ASSET + " is missing"); + + return Paths.get(url.toURI()); + } + +} diff --git a/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerTest.java b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerTest.java new file mode 100644 index 00000000..e0786de0 --- /dev/null +++ b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerTest.java @@ -0,0 +1,338 @@ +/* + * Copyright 2026 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.ffmpeg; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import dev.onvoid.webrtc.PeerConnectionFactory; +import dev.onvoid.webrtc.media.audio.AudioDeviceModule; +import dev.onvoid.webrtc.media.audio.AudioLayer; +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.CustomVideoSource; +import dev.onvoid.webrtc.media.video.VideoTrack; +import dev.onvoid.webrtc.media.video.VideoTrackSink; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +/** + * Tests playing a media source into custom media sources. The media is + * observed where an application would see it, on the sinks of the tracks made + * from those sources, so what these assert is what a peer connection would be + * sent. + *

+ * The asset runs three seconds and is played in real time, so these tests take + * about as long as the media they play. + * + * @author Alex Andres + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Execution(ExecutionMode.SAME_THREAD) +class MediaPlayerTest { + + /** + * The committed asset: 320x240 VP8 at 15 fps, 48 kHz mono Opus, 3.008 s, + * with a keyframe every second so that seeking has somewhere to land. + */ + private static final String ASSET = "/media-test.webm"; + + /** 3.008 seconds at 15 frames a second. */ + private static final int EXPECTED_FRAMES = 45; + + /** 3.008 seconds in chunks of 10 ms. */ + private static final int EXPECTED_CHUNKS = 300; + + private AudioDeviceModule audioModule; + private PeerConnectionFactory factory; + + + @BeforeAll + void initFactory() { + // A dummy audio layer, because this factory sends pushed audio and + // must not also be capturing from a device. + audioModule = new AudioDeviceModule(AudioLayer.kDummyAudio); + factory = new PeerConnectionFactory(audioModule); + } + + @AfterAll + void disposeFactory() { + factory.dispose(); + audioModule.dispose(); + } + + @Test + void playsToEnd() throws Exception { + try (Playback playback = new Playback()) { + playback.player.play(); + + assertTrue(playback.ended.await(15, TimeUnit.SECONDS), "no end of stream"); + + // Allowing one frame either way: which side of the last frame + // interval the file ends on is the container's business. + assertTrue(Math.abs(playback.frames.get() - EXPECTED_FRAMES) <= 1, + "video frames: " + playback.frames.get()); + assertTrue(Math.abs(playback.chunks.get() - EXPECTED_CHUNKS) <= 2, + "audio chunks: " + playback.chunks.get()); + + assertEquals(MediaPlayerState.ENDED, playback.player.getState()); + } + } + + @Test + void deliversWhatWebRtcTakes() throws Exception { + try (Playback playback = new Playback()) { + playback.player.play(); + + assertTrue(playback.ended.await(15, TimeUnit.SECONDS), "no end of stream"); + + assertEquals(320, playback.width.get()); + assertEquals(240, playback.height.get()); + + // The audio has to arrive as 10 ms of 48 kHz PCM whatever the + // source was, which for this asset means it was resampled. + assertEquals(48000, playback.sampleRate.get()); + assertEquals(1, playback.channels.get()); + assertEquals(480, playback.framesPerChunk.get()); + } + } + + @Test + void pacesInRealTime() throws Exception { + try (Playback playback = new Playback()) { + long started = System.nanoTime(); + + playback.player.play(); + + assertTrue(playback.ended.await(15, TimeUnit.SECONDS), "no end of stream"); + + long elapsedMs = (System.nanoTime() - started) / 1_000_000; + + // Three seconds of media takes three seconds to play. The upper + // bound is loose because a busy machine can only ever be late. + assertTrue(elapsedMs > 2500, "finished too early: " + elapsedMs + " ms"); + assertTrue(elapsedMs < 8000, "finished too late: " + elapsedMs + " ms"); + } + } + + @Test + void loopsWithoutEnding() throws Exception { + try (Playback playback = new Playback()) { + playback.player.setLooping(true); + playback.player.play(); + + // Well past the length of the asset, so a player that did not + // start over would have run out by now. + assertFalse(playback.ended.await(4500, TimeUnit.MILLISECONDS), + "a looping player reported an end of stream"); + + assertTrue(playback.frames.get() > EXPECTED_FRAMES, + "stopped at the end of the first pass: " + playback.frames.get()); + assertEquals(MediaPlayerState.PLAYING, playback.player.getState()); + } + } + + @Test + void pauseHoldsPlayback() throws Exception { + try (Playback playback = new Playback()) { + playback.player.play(); + + Thread.sleep(800); + + playback.player.pause(); + + assertEquals(MediaPlayerState.PAUSED, playback.player.getState()); + + // Whatever was in flight when the pause landed may still arrive, + // so the count is read once things have settled. + Thread.sleep(200); + + int atPause = playback.frames.get(); + + Thread.sleep(700); + + assertEquals(atPause, playback.frames.get(), "frames kept arriving while paused"); + } + } + + @Test + void seekMovesPlayback() throws Exception { + try (Playback playback = new Playback()) { + // A seek lands on the keyframe at or before the target, so asking + // for 2.5 s starts playback at the 2 s keyframe, leaving about a + // second to play out. + playback.player.seek(2_500_000); + playback.player.play(); + + assertTrue(playback.ended.await(15, TimeUnit.SECONDS), "no end of stream"); + + // A second of the asset is fifteen frames, well short of a pass. + assertTrue(playback.frames.get() < EXPECTED_FRAMES / 2, + "played more than the tail: " + playback.frames.get()); + assertTrue(playback.frames.get() > 0, "played nothing after the seek"); + } + } + + @Test + void needsAtLeastOneSource() throws Exception { + MediaReader reader = new MediaReader(asset()); + + assertThrows(IllegalArgumentException.class, + () -> new MediaPlayer(reader, null, null)); + + reader.close(); + } + + @Test + void closesTwice() throws Exception { + Playback playback = new Playback(); + + playback.close(); + playback.close(); + } + + @Test + void adoptedReaderIsClosed() throws Exception { + MediaReader reader = new MediaReader(asset()); + + try (Playback playback = new Playback(reader)) { + // The player took the reader over, so the Java one is spent and + // must not be usable any more. + assertThrows(IllegalStateException.class, reader::getInfo); + } + } + + private static Path asset() throws Exception { + URL url = MediaPlayerTest.class.getResource(ASSET); + + assertNotNull(url, "Test asset " + ASSET + " is missing"); + + return Paths.get(url.toURI()); + } + + /** + * One playback: the sources, the tracks made from them, the sinks that + * count what arrives, and the player feeding it all. + */ + private final class Playback implements AutoCloseable { + + final CustomVideoSource videoSource = new CustomVideoSource(); + final CustomAudioSource audioSource = new CustomAudioSource(); + + final VideoTrack videoTrack; + final AudioTrack audioTrack; + final MediaPlayer player; + + final CountDownLatch ended = new CountDownLatch(1); + final AtomicInteger frames = new AtomicInteger(); + final AtomicInteger chunks = new AtomicInteger(); + final AtomicInteger width = new AtomicInteger(); + final AtomicInteger height = new AtomicInteger(); + final AtomicInteger sampleRate = new AtomicInteger(); + final AtomicInteger channels = new AtomicInteger(); + final AtomicInteger framesPerChunk = new AtomicInteger(); + final AtomicReference error = new AtomicReference<>(); + + private final VideoTrackSink videoSink = frame -> { + frames.incrementAndGet(); + width.set(frame.buffer.getWidth()); + height.set(frame.buffer.getHeight()); + }; + + private final AudioTrackSink audioSink = (data, bits, rate, ch, count) -> { + chunks.incrementAndGet(); + sampleRate.set(rate); + channels.set(ch); + framesPerChunk.set(count); + }; + + private boolean closed; + + + Playback() throws Exception { + this(new MediaReader(asset())); + } + + Playback(MediaReader reader) throws Exception { + videoTrack = factory.createVideoTrack("video", videoSource); + audioTrack = factory.createAudioTrack("audio", audioSource); + + videoTrack.addSink(videoSink); + audioTrack.addSink(audioSink); + + player = new MediaPlayer(reader, videoSource, audioSource); + player.setListener(new MediaPlayerListener() { + + @Override + public void onEndOfStream() { + ended.countDown(); + } + + @Override + public void onError(String message) { + error.set(message); + ended.countDown(); + } + }); + } + + @Override + public void close() { + if (closed) { + return; + } + + closed = true; + + player.close(); + + videoTrack.removeSink(videoSink); + audioTrack.removeSink(audioSink); + + videoTrack.dispose(); + audioTrack.dispose(); + videoSource.dispose(); + audioSource.dispose(); + + assertNull(error.get()); + } + + private void assertNull(String message) { + if (message != null) { + throw new AssertionError("playback reported an error: " + message); + } + } + } + +} diff --git a/webrtc-java-media/src/test/resources/media-test.webm b/webrtc-java-media/src/test/resources/media-test.webm index 0ac3f417f20b426e39cb0e1457864b144e80a2f9..fb16fd6a951eb67bafca0e48841b543b68f985a0 100644 GIT binary patch delta 33344 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 E2cB9nL;wH) delta 28999 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 HG#UQ|mnSlr From ddc4c4ad2b2a341d12e700d0ce2274cff584102c Mon Sep 17 00:00:00 2001 From: Alex Andres Date: Sun, 20 Sep 2026 16:06:17 +0200 Subject: [PATCH 6/7] fix: ship the media module's natives to anything depending on it webrtc-java-media declared no dependency on its own classifier artifact, so a project depending on the module got the Java classes and none of the native libraries, and failed at class-initialization time with "Native library 'avutil-59.dll' is not on the classpath". It now carries them the same way webrtc-java carries its own, through a dependency on itself with the platform classifier. Running the new example is what turned this up: it is the first thing to consume the module the way an application would, rather than from inside its own build where the staging directory is already on the class path. --- webrtc-java-media/pom.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/webrtc-java-media/pom.xml b/webrtc-java-media/pom.xml index cd0c9c3f..45c08edf 100644 --- a/webrtc-java-media/pom.xml +++ b/webrtc-java-media/pom.xml @@ -136,6 +136,16 @@ webrtc-java ${project.version} + + + + ${project.groupId} + ${project.artifactId} + ${project.version} + ${platform.classifier} + From 8bc6fe86b7bacefd13a76e69a412cb7631e04a70 Mon Sep 17 00:00:00 2001 From: Alex Andres Date: Sun, 20 Sep 2026 16:06:28 +0200 Subject: [PATCH 7/7] docs: add a media files guide and a runnable example The guide covers adding the module, sending a file with MediaFileSource, reading a source with MediaReader, controlling playback, following it with a listener, and driving your own media sources with MediaPlayer. It says plainly what can be played, that only local files work so far, and that the module is opt-in and built for windows-x86_64 only, so nobody plans around something that is not there yet. MediaFileExample is the same story as code: it opens a file, reports what it contains, makes tracks, adds them to a peer connection and follows playback to the end. Building it needs care, because the examples are a JPMS module and the media module is opt-in. A "requires" cannot be made conditional, and a descriptor naming a module that is not there fails the whole compile, so the example lives in a source directory of its own and is compiled by its own execution into an output directory of its own. That keeps its module descriptor off the sourcepath, leaves the modular build of the other examples untouched, and means a build without the profile does not see the example at all. exec-maven-plugin is pointed at that directory so the command the docs give works as written. When the media module joins the reactor by default, all of it collapses into one dependency and one "requires". --- docs/.vitepress/sidebar.ts | 1 + docs/guide/examples.md | 21 ++ docs/guide/media/media-files.md | 232 +++++++++++++++++ webrtc-examples/pom.xml | 71 ++++++ .../webrtc/examples/MediaFileExample.java | 240 ++++++++++++++++++ 5 files changed, 565 insertions(+) create mode 100644 docs/guide/media/media-files.md create mode 100644 webrtc-examples/src/media/java/dev/onvoid/webrtc/examples/MediaFileExample.java diff --git a/docs/.vitepress/sidebar.ts b/docs/.vitepress/sidebar.ts index ed659fbf..07228b5c 100644 --- a/docs/.vitepress/sidebar.ts +++ b/docs/.vitepress/sidebar.ts @@ -22,6 +22,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] { { text: 'Media Devices', link: '/media/media-devices' }, { text: 'Media Constraints', link: '/media/constraints' }, { text: 'Media Directionality', link: '/media/directionality' }, + { text: 'Media Files', link: '/media/media-files' }, ], }, { diff --git a/docs/guide/examples.md b/docs/guide/examples.md index 86ee625c..ae9191cb 100644 --- a/docs/guide/examples.md +++ b/docs/guide/examples.md @@ -52,6 +52,27 @@ The [`DesktopVideoExample`](https://github.com/devopvoid/webrtc-java/blob/master This example is particularly useful for applications that need to implement screen sharing or remote desktop functionality. +## Media File + +The [`MediaFileExample`](https://github.com/devopvoid/webrtc-java/blob/master/webrtc-examples/src/media/java/dev/onvoid/webrtc/examples/MediaFileExample.java) demonstrates how to send a media file over a peer connection, in place of a camera and a microphone. See the [Media Files](/guide/media/media-files) guide for the API it uses. + +**Key features demonstrated:** +- Opening a media file with a `MediaFileSource` +- Reading what the source contains from its `MediaInfo` +- Creating audio and video tracks from the media sources it feeds +- Adding those tracks to a peer connection +- Following playback through a `MediaPlayerListener` + +This example is useful for applications that stream pre-recorded media, or that need a dependable stand-in for a camera in testing. + +::: warning +This example needs the `webrtc-java-media` module, which is opt-in while it is being brought up on every platform, so it is built and run with the `with-media-extension` profile: + +```bash +mvn -Pwith-media-extension exec:java -D"exec.mainClass=dev.onvoid.webrtc.examples.MediaFileExample" -D"exec.args=movie.mp4" +``` +::: + ## Web Client The [`WebClientExample`](https://github.com/devopvoid/webrtc-java/blob/master/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/WebClientExample.java) demonstrates how to combine WebSocket signaling with WebRTC peer connections for real-time communication between web and Java clients. diff --git a/docs/guide/media/media-files.md b/docs/guide/media/media-files.md new file mode 100644 index 00000000..56874a1e --- /dev/null +++ b/docs/guide/media/media-files.md @@ -0,0 +1,232 @@ +# Media Files + +This guide explains how to send a media file over a peer connection instead of a camera and a microphone, using the `webrtc-java-media` module. It covers: + +- Adding the media module to a project +- Sending a file with `MediaFileSource` +- Reading what a source contains with `MediaReader` and `MediaInfo` +- Controlling playback and following it with a listener +- Feeding your own media sources with `MediaPlayer` + +Sending a file is a common need: a test pattern instead of a webcam, a pre-recorded briefing, a video that has to reach several participants. Without help, an application has to bring its own decoder and push I420 frames into a `CustomVideoSource` itself. The media module removes that work by decoding with [FFmpeg](https://ffmpeg.org) inside the library. + +Decoding happens entirely in native code. Frames never travel through Java: the module hands decoded pictures straight to the native side of a `CustomVideoSource`, and 10 ms chunks to a `CustomAudioSource`. They are paced in real time and carry the presentation times of the file, so what a receiver gets keeps the timing of the media rather than the timing of a Java thread. + +::: warning Opt-in while it is being brought up +The media module is not part of the default build yet, and its native library has so far been built for `windows-x86_64` only. Build it with the `with-media-extension` profile, as described below. +::: + +## Adding the Module + +The module builds FFmpeg from a submodule pinned to a release tag, so the submodule has to be present: + +```shell +git submodule update --init --depth 1 webrtc-java-media/third-party/ffmpeg +mvn install -Pwith-media-extension +``` + +Building FFmpeg needs `make` and `nasm`. On Windows they come from MSYS2: + +```shell +winget install MSYS2.MSYS2 +C:\msys64\usr\bin\bash -lc "pacman -S --needed make nasm diffutils pkgconf" +``` + +Maven still runs from an ordinary shell; the build enters MSYS2 and the Visual Studio environment on its own. The first build compiles FFmpeg, which takes a while; later builds reuse the install directory. + +Once installed, depend on it alongside `webrtc-java`: + +```xml + + dev.onvoid.webrtc + webrtc-java-media + 0.19.0-SNAPSHOT + +``` + +The classifier jar carries the module's native library together with the FFmpeg shared libraries it uses. Applications that do not use this module never download FFmpeg. + +## Sending a File + +`MediaFileSource` is the short way to do all of it. It opens the source, creates a media source for each kind of media the file actually has, and wires a player to feed them: + +```java +// Import required classes +import dev.onvoid.webrtc.media.ffmpeg.MediaFileSource; +import dev.onvoid.webrtc.media.audio.AudioTrack; +import dev.onvoid.webrtc.media.video.VideoTrack; +import java.nio.file.Path; +import java.util.List; + +MediaFileSource source = new MediaFileSource(Path.of("movie.mp4")); + +// Create tracks from the media sources the file feeds. +VideoTrack videoTrack = factory.createVideoTrack("video", source.getVideoSource()); +AudioTrack audioTrack = factory.createAudioTrack("audio", source.getAudioSource()); + +peerConnection.addTrack(videoTrack, List.of("stream")); +peerConnection.addTrack(audioTrack, List.of("stream")); + +// Start sending. +source.play(); +``` + +A file with no audio has no audio source, and likewise for video, so check before making a track: + +```java +if (source.getVideoSource() != null) { + VideoTrack videoTrack = factory.createVideoTrack("video", source.getVideoSource()); + peerConnection.addTrack(videoTrack, List.of("stream")); +} +``` + +::: warning A factory sends either pushed audio or captured audio +A `PeerConnectionFactory` fed from this module is sending pushed audio and cannot also send audio captured by its `AudioDeviceModule`. Give such a factory a dummy audio layer, and use a second factory if an application needs both: + +```java +AudioDeviceModule audioModule = new AudioDeviceModule(AudioLayer.kDummyAudio); +PeerConnectionFactory factory = new PeerConnectionFactory(audioModule); +``` +::: + +## Reading What a Source Contains + +`MediaReader` opens a source and reports what is in it, without playing anything: + +```java +// Import required classes +import dev.onvoid.webrtc.media.ffmpeg.MediaInfo; +import dev.onvoid.webrtc.media.ffmpeg.MediaReader; + +try (MediaReader reader = new MediaReader(Path.of("movie.mp4"))) { + MediaInfo info = reader.getInfo(); + + System.out.println("Runs " + info.getDurationUs() / 1_000_000.0 + " s"); + + if (info.hasVideo()) { + System.out.println(info.getVideoWidth() + "x" + info.getVideoHeight() + + " at " + info.getFrameRate() + " fps, " + info.getVideoCodec()); + } + if (info.hasAudio()) { + System.out.println(info.getSampleRate() + " Hz, " + + info.getChannels() + " channels, " + info.getAudioCodec()); + } +} +``` + +A `MediaFileSource` already has this, so there is no need to open the file twice: + +```java +MediaInfo info = source.getInfo(); +``` + +::: info +Duration is `0` for a source whose container does not say how long it runs, which is the case for live streams. Opening fails if a source holds nothing that can be played. +::: + +## Controlling Playback + +```java +source.play(); // start, or resume after a pause +source.pause(); // hold playback where it is +source.seek(30_000_000); // move to 30 seconds +source.setLooping(true); // start over instead of ending +source.getPositionUs(); // where playback has got to +source.getState(); // IDLE, PLAYING, PAUSED, ENDED or CLOSED +``` + +Looping keeps the timing across the seam, so a looping file works as a stand-in for a camera that never stops. A looping source never reports an end of stream. + +A seek lands on the keyframe at or before the position asked for, which is how far back the decoder has to go to produce a picture at all. How close that is to the position asked for depends on how often the media was encoded with keyframes. + +## Following Playback + +```java +// Import required classes +import dev.onvoid.webrtc.media.ffmpeg.MediaPlayerListener; +import dev.onvoid.webrtc.media.ffmpeg.MediaPlayerState; + +source.setListener(new MediaPlayerListener() { + + @Override + public void onStateChanged(MediaPlayerState state) { + System.out.println("Player state: " + state); + } + + @Override + public void onEndOfStream() { + System.out.println("The source ran out."); + } + + @Override + public void onError(String message) { + System.out.println("Playback failed: " + message); + } +}); +``` + +::: warning +Every call arrives on the player's own thread, and that thread is the one decoding the media. A listener must return promptly, and must not wait on the player. +::: + +## Feeding Your Own Media Sources + +`MediaFileSource` creates the media sources for you. An application that needs its own — to feed a source it already created, or to send only the video of a file — uses `MediaPlayer` directly: + +```java +// Import required classes +import dev.onvoid.webrtc.media.ffmpeg.MediaPlayer; +import dev.onvoid.webrtc.media.ffmpeg.MediaReader; +import dev.onvoid.webrtc.media.video.CustomVideoSource; + +CustomVideoSource videoSource = new CustomVideoSource(); + +// Video only: passing null for the audio source decodes and drops the audio. +MediaPlayer player = new MediaPlayer(new MediaReader(path), videoSource, null); + +VideoTrack videoTrack = factory.createVideoTrack("video", videoSource); + +player.play(); +``` + +::: info +The player takes over the reader it is given. That reader must not be used or closed afterwards; closing the player releases it. +::: + +## Closing + +Closing a `MediaFileSource` stops playback and releases the player along with both media sources. Release the senders a peer connection handed out, and the tracks, before that: + +```java +videoSender.dispose(); +audioSender.dispose(); +peerConnection.close(); + +source.close(); +``` + +## What Can Be Played + +The FFmpeg build is deliberately small, and carries only what this module plays: + +| | | +| --- | --- | +| **Containers** | MP4 and MOV, Matroska and WebM, AVI, MPEG-TS, FLV, WAV, MP3, Ogg, FLAC, AAC | +| **Video** | H.264, H.265/HEVC, VP8, VP9, MPEG-4, MJPEG | +| **Audio** | AAC, MP3, Opus, Vorbis, FLAC, PCM | + +Audio of any rate or layout is resampled to what WebRTC takes, which is 48 kHz 16-bit PCM in mono or stereo. Video that decodes to I420 — almost all 8-bit H.264, VP8, VP9 and MPEG-4 — reaches the encoder without being copied; anything else is converted first. + +Only local files play today. FFmpeg demuxes network sources just as well, so the same code will cover http, rtsp and rtmp once those protocols are turned on in the build. + +## Licensing + +The module uses FFmpeg under the LGPL version 2.1 or later. It is configured without `--enable-gpl` and without `--enable-nonfree`, and FFmpeg is linked dynamically and shipped as separate files inside the platform jar, so its libraries may be replaced with your own build, as the LGPL requires. The wrapper code is licensed under the Apache License 2.0 like the rest of webrtc-java. + +## Complete Example + +See `MediaFileExample` in the `webrtc-examples` module, which opens a file, reports what it contains, creates tracks, adds them to a peer connection and follows playback to the end. + +```shell +mvn -Pwith-media-extension -pl webrtc-examples compile +``` diff --git a/webrtc-examples/pom.xml b/webrtc-examples/pom.xml index 4c98d6ee..cf9437d3 100644 --- a/webrtc-examples/pom.xml +++ b/webrtc-examples/pom.xml @@ -94,4 +94,75 @@ 2.0.18 + + + + + with-media-extension + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + compile-media-example + compile + + compile + + + 17 + + ${project.basedir}/src/media/java + + ${project.build.directory}/media-classes + + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + ${project.build.directory}/media-classes + + + + + + + + + ${project.groupId} + webrtc-java-media + ${project.version} + + + + \ No newline at end of file diff --git a/webrtc-examples/src/media/java/dev/onvoid/webrtc/examples/MediaFileExample.java b/webrtc-examples/src/media/java/dev/onvoid/webrtc/examples/MediaFileExample.java new file mode 100644 index 00000000..cdbc3b05 --- /dev/null +++ b/webrtc-examples/src/media/java/dev/onvoid/webrtc/examples/MediaFileExample.java @@ -0,0 +1,240 @@ +/* + * Copyright 2026 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; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.Logger; + +import dev.onvoid.webrtc.PeerConnectionFactory; +import dev.onvoid.webrtc.PeerConnectionObserver; +import dev.onvoid.webrtc.RTCConfiguration; +import dev.onvoid.webrtc.RTCIceCandidate; +import dev.onvoid.webrtc.RTCIceConnectionState; +import dev.onvoid.webrtc.RTCPeerConnection; +import dev.onvoid.webrtc.RTCRtpSender; +import dev.onvoid.webrtc.media.audio.AudioDeviceModule; +import dev.onvoid.webrtc.media.audio.AudioLayer; +import dev.onvoid.webrtc.media.audio.AudioTrack; +import dev.onvoid.webrtc.media.ffmpeg.MediaFileSource; +import dev.onvoid.webrtc.media.ffmpeg.MediaInfo; +import dev.onvoid.webrtc.media.ffmpeg.MediaPlayerListener; +import dev.onvoid.webrtc.media.ffmpeg.MediaPlayerState; +import dev.onvoid.webrtc.media.video.VideoTrack; + +import java.util.List; + +/** + * Example demonstrating how to send a media file over a peer connection, in + * place of a camera and a microphone. + *

+ * This example shows how to: + *

    + *
  • Open a media file or network stream with a MediaFileSource
  • + *
  • Read what the source contains from its MediaInfo
  • + *
  • Create audio and video tracks from the sources it feeds
  • + *
  • Add those tracks to a peer connection
  • + *
  • Follow playback through a MediaPlayerListener
  • + *
+ *

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

+ * Note: this example only sets up the local side. A real application would + * reach a remote peer through a signaling channel, as + * {@link PeerConnectionExample} shows. + *

+ * Run it with the media file to send: + *

+ * java dev.onvoid.webrtc.examples.MediaFileExample movie.mp4
+ * 
+ *

+ * Only local files play today. FFmpeg demuxes network sources just as well, + * so the same code covers http, rtsp and rtmp once those protocols are turned + * on in the build. + * + * @author Alex Andres + */ +public class MediaFileExample { + + public static void main(String[] args) { + if (args.length < 1) { + System.out.println("Usage: MediaFileExample "); + System.out.println(" for example: MediaFileExample movie.mp4"); + return; + } + + // A factory that sends pushed audio must not also be capturing from a + // microphone, so this one is given a dummy audio layer. An application + // that needs both needs a second factory. + AudioDeviceModule audioModule = new AudioDeviceModule(AudioLayer.kDummyAudio); + PeerConnectionFactory factory = new PeerConnectionFactory(audioModule); + + // Opening reads the container and picks the streams to play. A source + // with nothing playable in it fails here. + try (MediaFileSource source = new MediaFileSource(args[0])) { + printInfo(source.getInfo()); + + LocalPeer localPeer = new LocalPeer(factory, source); + + CountDownLatch finished = new CountDownLatch(1); + + source.setListener(new MediaPlayerListener() { + + @Override + public void onStateChanged(MediaPlayerState state) { + System.out.println("Player state: " + state); + } + + @Override + public void onEndOfStream() { + System.out.println("Reached the end of the source."); + finished.countDown(); + } + + @Override + public void onError(String message) { + System.out.println("Playback failed: " + message); + finished.countDown(); + } + }); + + // Playing the source over and over is one line, and is what a + // stand-in for a camera usually wants: + // source.setLooping(true); + + source.play(); + + // Report where playback has got to while it runs. + while (!finished.await(1, TimeUnit.SECONDS)) { + System.out.printf(" at %.1f s, %d video frames and %d audio chunks sent%n", + source.getPositionUs() / 1_000_000.0, + localPeer.videoFrames.get(), localPeer.audioChunks.get()); + } + + // The tracks go before the source, which disposes of the media + // sources they were made from. + localPeer.dispose(); + } + catch (Exception e) { + Logger.getLogger(MediaFileExample.class.getName()) + .log(Level.SEVERE, "Error in MediaFileExample", e); + } + finally { + factory.dispose(); + audioModule.dispose(); + } + } + + private static void printInfo(MediaInfo info) { + System.out.printf("Source runs %.3f s%n", info.getDurationUs() / 1_000_000.0); + + if (info.hasVideo()) { + System.out.printf(" video: %dx%d at %.2f fps, %s%n", info.getVideoWidth(), + info.getVideoHeight(), info.getFrameRate(), info.getVideoCodec()); + } + else { + System.out.println(" video: none"); + } + + if (info.hasAudio()) { + System.out.printf(" audio: %d Hz, %d channel(s), %s%n", info.getSampleRate(), + info.getChannels(), info.getAudioCodec()); + } + else { + System.out.println(" audio: none"); + } + } + + /** + * A peer connection carrying the tracks a media file feeds. + */ + private static class LocalPeer implements PeerConnectionObserver { + + final AtomicInteger videoFrames = new AtomicInteger(); + final AtomicInteger audioChunks = new AtomicInteger(); + + private final RTCPeerConnection peerConnection; + private final VideoTrack videoTrack; + private final AudioTrack audioTrack; + private final RTCRtpSender videoSender; + private final RTCRtpSender audioSender; + + + LocalPeer(PeerConnectionFactory factory, MediaFileSource source) { + peerConnection = factory.createPeerConnection(new RTCConfiguration(), this); + + // A source with no video has no video source, and likewise for + // audio, so each track is only made if there is something to feed + // it. + if (source.getVideoSource() != null) { + videoTrack = factory.createVideoTrack("video", source.getVideoSource()); + + // Watching the track shows what the peer connection is being + // given. A real application would not need this. + videoTrack.addSink(frame -> videoFrames.incrementAndGet()); + + videoSender = peerConnection.addTrack(videoTrack, List.of("stream")); + } + else { + videoTrack = null; + videoSender = null; + } + + if (source.getAudioSource() != null) { + audioTrack = factory.createAudioTrack("audio", source.getAudioSource()); + audioTrack.addSink((data, bits, rate, channels, frames) -> + audioChunks.incrementAndGet()); + + audioSender = peerConnection.addTrack(audioTrack, List.of("stream")); + } + else { + audioTrack = null; + audioSender = null; + } + } + + void dispose() { + // An RTCRtpSender is not owned by the peer connection, so each one + // handed out by addTrack has to be released here. Doing it before + // the connection closes is what lets go of the tracks, and with + // them the media sources the file source owns. + if (videoSender != null) { + videoSender.dispose(); + } + if (audioSender != null) { + audioSender.dispose(); + } + + peerConnection.close(); + } + + @Override + public void onIceCandidate(RTCIceCandidate candidate) { + // A real application sends this to the remote peer over its + // signaling channel. + } + + @Override + public void onIceConnectionChange(RTCIceConnectionState state) { + System.out.println("ICE connection state: " + state); + } + } +}