Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion docs/guide/media/media-recording.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,24 @@ try (MediaRecorder recorder = new MediaRecorder(Paths.get("call.mkv"))) {

A sender's frames are recorded as they leave the encoder, a receiver's as they go to the decoder. Senders and receivers of any number of peer connections can go into one file, e.g. both sides of a call.

`stop()` waits until everything received so far is written and the file is finished, and returns whether the file holds a recording. A recorder that never got any media to write deletes its file rather than leave an unplayable one behind. `close()` stops a recording that still runs.
`stop()` waits until everything received so far is written and the file is finished and closed, and returns whether the file holds a recording. A recorder that never got any media to write deletes its file rather than leave an unplayable one behind. `close()` stops a recording that still runs.

How the recording ended, `getResult()` tells once the recorder is stopped, and keeps telling after `close()`:

| Result | File | Meaning |
| --- | --- | --- |
| `COMPLETE` | kept | Everything was written, and the file was finished and closed. |
| `PARTIAL` | kept | Media went into the file, but writing, finishing or closing it failed later. What made it into the file is kept, since it may still play. |
| `EMPTY` | deleted | No media arrived before the stop. |
| `FAILED` | deleted | Writing failed before any media went into the file. |

```java
recorder.stop();

if (recorder.getResult() == MediaRecorderResult.PARTIAL) {
System.out.println("Incomplete recording in " + recorder.getFile());
}
```

::: warning
Keep the senders and receivers undisposed while recording. The recorder asks them for key frames, and a disposed one cannot be asked.
Expand Down Expand Up @@ -85,6 +102,8 @@ recorder.setListener(new MediaRecorderListener() {

Calls arrive in order on a thread the recorder keeps for them, so a listener may take its time and may even stop the recorder.

`onError` also hears of a file that cannot be finished or closed when the recorder stops, e.g. because the last data does not fit on the disk. The recording then ends as `PARTIAL`.

## How Tracks Are Recorded

- **Video starts at a key frame.** A decoder can do nothing with the frames before one, so they are skipped. The recorder asks the sender or receiver for a key frame, so recording starts within moments instead of waiting until WebRTC sends one of its own accord, which it rarely does.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
import dev.onvoid.webrtc.media.audio.CustomAudioSource;
import dev.onvoid.webrtc.media.recorder.MediaRecorder;
import dev.onvoid.webrtc.media.recorder.MediaRecorderListener;
import dev.onvoid.webrtc.media.recorder.MediaRecorderResult;
import dev.onvoid.webrtc.media.video.CustomVideoSource;
import dev.onvoid.webrtc.media.video.NativeI420Buffer;
import dev.onvoid.webrtc.media.video.VideoFrame;
Expand Down Expand Up @@ -170,7 +171,8 @@ public void onError(String message) {
Thread.sleep(TimeUnit.SECONDS.toMillis(RECORD_SECONDS));

if (recorder.stop()) {
LOG.info("Recorded " + RECORD_SECONDS + " seconds into " + recorder.getFile());
LOG.info("Recorded " + RECORD_SECONDS + " seconds into " + recorder.getFile()
+ (recorder.getResult() == MediaRecorderResult.PARTIAL ? ", incomplete" : ""));
}
}
finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -689,10 +689,18 @@ String stopRecording() {
return "Not recording";
}

boolean recorded = running.stop();
running.close();

return recorded ? "Saved " + recording : "Nothing was recorded";
switch (running.getResult()) {
case COMPLETE:
return "Saved " + recording;
case PARTIAL:
return "Saved " + recording + ", but writing it failed";
case FAILED:
return "Recording failed";
default:
return "Nothing was recorded";
}
}

/**
Expand Down
4 changes: 2 additions & 2 deletions webrtc-media/src/main/cpp/include/JNI_MediaRecorder.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ extern "C" {
/*
* Class: dev_onvoid_webrtc_media_recorder_MediaRecorder
* Method: stop
* Signature: (J)Z
* Signature: (J)I
*/
JNIEXPORT jboolean JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_stop
JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_stop
(JNIEnv *, jclass, jlong);

/*
Expand Down
68 changes: 68 additions & 0 deletions webrtc-media/src/main/cpp/include/JNI_WriteFault.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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.h>
/* Header for class dev_onvoid_webrtc_media_recorder_WriteFault */

#ifndef _Included_dev_onvoid_webrtc_media_recorder_WriteFault
#define _Included_dev_onvoid_webrtc_media_recorder_WriteFault
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: dev_onvoid_webrtc_media_recorder_WriteFault
* Method: failHeader
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failHeader
(JNIEnv *, jclass);

/*
* Class: dev_onvoid_webrtc_media_recorder_WriteFault
* Method: failPacket
* Signature: (I)V
*/
JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failPacket
(JNIEnv *, jclass, jint);

/*
* Class: dev_onvoid_webrtc_media_recorder_WriteFault
* Method: failTrailer
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failTrailer
(JNIEnv *, jclass);

/*
* Class: dev_onvoid_webrtc_media_recorder_WriteFault
* Method: failClose
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failClose
(JNIEnv *, jclass);

/*
* Class: dev_onvoid_webrtc_media_recorder_WriteFault
* Method: disarm
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_disarm
(JNIEnv *, jclass);

#ifdef __cplusplus
}
#endif
#endif
31 changes: 28 additions & 3 deletions webrtc-media/src/main/cpp/include/media/MediaRecorder.h
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ namespace ffmpeg
class MediaRecorder
{
public:
// How a recording ended, in the order of the Java
// MediaRecorderResult.
enum class Result
{
// Every frame taken was written, and the file was finished
// and closed.
kComplete,
// Media went into the file, but writing, finishing or closing
// it failed later.
kPartial,
// Nothing went wrong, and no media went into the file.
kEmpty,
// Writing failed before any media went into the file.
kFailed
};

MediaRecorder(const webrtc_java_api * api, std::string path);

// Stops the recording if it still runs.
Expand All @@ -80,9 +96,10 @@ namespace ffmpeg
void Start();

// Detaches from every track, writes out what is queued, finishes
// the file and waits for the writer thread. Returns whether any
// media was written. Stopping twice does nothing more.
bool Stop();
// the file, waits for the writer thread and closes the file.
// Returns how the recording ended. Stopping twice does nothing
// more, and returns the same.
Result Stop();

private:
// What the observer callback knows about a track. Guarded by
Expand Down Expand Up @@ -135,6 +152,10 @@ namespace ffmpeg
bool MaybeWriteHeader(bool finishing);

void Finish();

// Closes the file if it is open, and fails the recording if that
// does.
void Close();
void Fail(const std::string & message, int error);
void Warn(const std::string & message);

Expand All @@ -155,6 +176,8 @@ namespace ffmpeg
int64_t first_ready_us_ = 0;
// Also read by Stop(), on the thread that stops.
std::atomic<bool> header_written_{ false };
// Read by Stop() once the writer has ended; the thread that stops
// sets it when it closes a file that had no writer.
bool failed_ = false;

// When recording started, in the clock of the frames.
Expand All @@ -175,6 +198,8 @@ namespace ffmpeg
bool stopping_ = false;
bool started_ = false;
bool stopped_ = false;
// Set by the first Stop(), once the file is closed.
Result result_ = Result::kEmpty;
};
}

Expand Down
54 changes: 54 additions & 0 deletions webrtc-media/src/main/cpp/include/media/WriteFault.h
Original file line number Diff line number Diff line change
@@ -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.
*/

#ifndef WEBRTC_JAVA_MEDIA_WRITE_FAULT_H_
#define WEBRTC_JAVA_MEDIA_WRITE_FAULT_H_

namespace ffmpeg
{
// Makes the writing of a recording fail on purpose, so that the tests can
// see what a recorder does when a disk fills up or a file cannot be
// closed, which no disk does on demand.
//
// Only the tests arm it, through JNI_WriteFault, and a call that is not
// armed pays a single atomic load. The call itself still runs, so the
// file is as it would be; only the result it reports is replaced. A
// fault fires once, for the recorder that runs into it first.
class WriteFault
{
public:
enum class Call
{
kHeader,
kPacket,
kTrailer,
kClose
};

// Fails the call that follows the given number of calls of the
// same kind, once, with the given AVERROR.
static void Fail(Call call, int error, int after_calls);

// Forgets a fault that has not fired.
static void Disarm();

// Returns the result of the given call, or the error it is to
// fail with if a fault is armed for it, which disarms the fault.
static int Check(Call call, int result);
};
}

#endif
10 changes: 6 additions & 4 deletions webrtc-media/src/main/cpp/src/JNI_MediaRecorder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,17 @@ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_start
});
}

JNIEXPORT jboolean JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_stop
JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_stop
(JNIEnv * env, jclass caller, jlong handle)
{
const jint failed = static_cast<jint>(ffmpeg::MediaRecorder::Result::kFailed);

if (handle == 0) {
return JNI_FALSE;
return failed;
}

return ffmpeg::CallFromJava<jboolean>(env, JNI_FALSE, [&]() -> jboolean {
return RecorderOf(handle)->Stop() ? JNI_TRUE : JNI_FALSE;
return ffmpeg::CallFromJava<jint>(env, failed, [&]() -> jint {
return static_cast<jint>(RecorderOf(handle)->Stop());
});
}

Expand Down
60 changes: 60 additions & 0 deletions webrtc-media/src/main/cpp/src/JNI_WriteFault.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* 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_WriteFault.h"
#include "media/WriteFault.h"

#include <cerrno>

extern "C" {
#include <libavutil/error.h>
}

// The Java class this belongs to exists in the tests only, so that nothing but
// them can make a recording fail. Writing fails as on a full disk, closing as
// on a file system that loses what it was to keep.

using ffmpeg::WriteFault;

JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failHeader
(JNIEnv * env, jclass caller)
{
WriteFault::Fail(WriteFault::Call::kHeader, AVERROR(ENOSPC), 0);
}

JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failPacket
(JNIEnv * env, jclass caller, jint afterPackets)
{
WriteFault::Fail(WriteFault::Call::kPacket, AVERROR(ENOSPC), afterPackets);
}

JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failTrailer
(JNIEnv * env, jclass caller)
{
WriteFault::Fail(WriteFault::Call::kTrailer, AVERROR(ENOSPC), 0);
}

JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failClose
(JNIEnv * env, jclass caller)
{
WriteFault::Fail(WriteFault::Call::kClose, AVERROR(EIO), 0);
}

JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_disarm
(JNIEnv * env, jclass caller)
{
WriteFault::Disarm();
}
Loading
Loading