fix(android): consolidate native media decoders

This commit is contained in:
edde746
2026-07-24 03:56:40 +02:00
parent fb45ff44f3
commit 54a002f9b7
26 changed files with 2101 additions and 65 deletions
+160 -28
View File
@@ -4,6 +4,7 @@ import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.util.Properties
import java.util.UUID
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
fun verifySha256(file: File, expected: String, identity: String) {
val digest = MessageDigest.getInstance("SHA-256")
@@ -63,7 +64,13 @@ val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
val mpvAar = "libmpv-release.aar"
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
val downloadLibmpv by tasks.registering {
val media3Version = "1.10.1"
val mpvFfmpegVersion = "8.0.1"
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
val mpvFfmpegSourceUrl = "https://ffmpeg.org/releases/ffmpeg-$mpvFfmpegVersion.tar.xz"
val mpvFfmpegDevelopmentDir = File(mpvDir, "ffmpeg-development")
val downloadLibmpv = tasks.register("downloadLibmpv") {
val aar = File(mpvDir, mpvAar)
val manifest = File(mpvDir, ".manifest")
inputs.property("version", mpvVersion)
@@ -77,7 +84,9 @@ val downloadLibmpv by tasks.registering {
staging.mkdirs()
val stagedAar = File(staging, mpvAar)
try {
exec { commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath) }
providers.exec {
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
}.result.get().assertNormalExitValue()
} catch (error: Exception) {
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
}
@@ -92,7 +101,7 @@ val downloadLibmpv by tasks.registering {
// Extract libc++_shared.so from the libmpv AAR so the app source set can package
// it with top merge priority (see packaging { jniLibs } and sourceSets below).
val extractMpvLibcxx by tasks.registering {
val extractMpvLibcxx = tasks.register("extractMpvLibcxx") {
dependsOn(downloadLibmpv)
val aar = File(mpvDir, mpvAar)
val outDir = File(mpvDir, "libcxx")
@@ -101,7 +110,7 @@ val extractMpvLibcxx by tasks.registering {
doLast {
outDir.deleteRecursively() // drop stale ABIs from a previous AAR version
outDir.mkdirs()
exec {
providers.exec {
commandLine(
"unzip",
"-q",
@@ -111,6 +120,119 @@ val extractMpvLibcxx by tasks.registering {
"-d",
outDir.absolutePath
)
}.result.get().assertNormalExitValue()
}
}
// Build the Media3 JNI adapter against the same shared FFmpeg libraries that
// libmpv packages. Headers are pinned to libmpv's FFmpeg version and remain
// build-only; the APK contains one FFmpeg implementation for both players.
val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment") {
dependsOn(downloadLibmpv)
val aar = File(mpvDir, mpvAar)
val manifest = File(mpvFfmpegDevelopmentDir, ".manifest")
val abis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
val libraries = listOf("avcodec", "avutil", "swresample")
inputs.file(aar)
inputs.property("ffmpegVersion", mpvFfmpegVersion)
inputs.property("sourceUrl", mpvFfmpegSourceUrl)
inputs.property("sourceSha256", mpvFfmpegSourceSha256)
outputs.files(
abis.flatMap { abi ->
libraries.map { library -> File(mpvFfmpegDevelopmentDir, "native/$abi/lib$library.so") }
}
)
outputs.files(
File(mpvFfmpegDevelopmentDir, "include/libavcodec/avcodec.h"),
File(mpvFfmpegDevelopmentDir, "include/libavutil/avconfig.h"),
File(mpvFfmpegDevelopmentDir, "include/libswresample/swresample.h"),
manifest
)
doLast {
val staging = File(
mpvFfmpegDevelopmentDir.parentFile,
"${mpvFfmpegDevelopmentDir.name}.staging-${UUID.randomUUID()}"
)
try {
val sourceArchive = File(staging, "ffmpeg-$mpvFfmpegVersion.tar.xz")
val includeDir = File(staging, "include")
val nativeDir = File(staging, "native")
staging.mkdirs()
try {
providers.exec {
commandLine("curl", "-sfL", mpvFfmpegSourceUrl, "-o", sourceArchive.absolutePath)
}.result.get().assertNormalExitValue()
} catch (error: Exception) {
throw GradleException("Failed to download FFmpeg $mpvFfmpegVersion headers", error)
}
verifySha256(sourceArchive, mpvFfmpegSourceSha256, "FFmpeg $mpvFfmpegVersion source")
val extractedSource = File(staging, "source").apply { mkdirs() }
try {
providers.exec {
commandLine(
"tar",
"-xJf",
sourceArchive.absolutePath,
"--strip-components=1",
"-C",
extractedSource.absolutePath
)
}.result.get().assertNormalExitValue()
} catch (error: Exception) {
throw GradleException("Failed to extract FFmpeg $mpvFfmpegVersion headers", error)
}
listOf("libavcodec", "libavutil", "libswresample").forEach { library ->
project.copy {
from(File(extractedSource, library)) {
include("*.h")
}
into(File(includeDir, library))
}
}
File(includeDir, "libavutil/avconfig.h").writeText(
"""
|/* Generated for Plezy's little-endian Android ABIs. */
|#ifndef AVUTIL_AVCONFIG_H
|#define AVUTIL_AVCONFIG_H
|#define AV_HAVE_BIGENDIAN 0
|#define AV_HAVE_FAST_UNALIGNED 0
|#endif /* AVUTIL_AVCONFIG_H */
|
""".trimMargin()
)
project.copy {
from(zipTree(aar)) {
include(
"jni/*/libavcodec.so",
"jni/*/libavutil.so",
"jni/*/libswresample.so"
)
eachFile {
path = path.removePrefix("jni/")
}
}
includeEmptyDirs = false
into(nativeDir)
}
val missing = abis.flatMap { abi ->
libraries.map { library -> File(nativeDir, "$abi/lib$library.so") }
}.filterNot(File::isFile)
if (missing.isNotEmpty()) {
throw GradleException(
"libmpv $mpvVersion is missing FFmpeg libraries: ${missing.joinToString { it.relativeTo(staging).path }}"
)
}
File(staging, ".manifest").writeText(
"mpv=$mpvVersion\nffmpeg=$mpvFfmpegVersion\nsourceSha256=$mpvFfmpegSourceSha256\n"
)
sourceArchive.delete()
extractedSource.deleteRecursively()
promoteDirectory(staging, mpvFfmpegDevelopmentDir)
} finally {
staging.deleteRecursively()
}
}
}
@@ -137,7 +259,7 @@ val doviArtifacts = mapOf(
)
val doviBaseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
val downloadLibdovi by tasks.registering {
val downloadLibdovi = tasks.register("downloadLibdovi") {
val manifest = File(doviDir, ".manifest")
inputs.property("version", doviVersion)
inputs.property("baseUrl", doviBaseUrl)
@@ -159,7 +281,9 @@ val downloadLibdovi by tasks.registering {
val archive = File(downloads, archiveName)
val sourceUrl = "$doviBaseUrl/$archiveName"
try {
exec { commandLine("curl", "-sfL", sourceUrl, "-o", archive.absolutePath) }
providers.exec {
commandLine("curl", "-sfL", sourceUrl, "-o", archive.absolutePath)
}.result.get().assertNormalExitValue()
} catch (error: Exception) {
throw GradleException("Failed to download $archiveName v$doviVersion", error)
}
@@ -167,7 +291,9 @@ val downloadLibdovi by tasks.registering {
val outDir = File(staging, "$abi/lib").apply { mkdirs() }
try {
exec { commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath) }
providers.exec {
commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath)
}.result.get().assertNormalExitValue()
} catch (error: Exception) {
throw GradleException("Failed to extract $archiveName", error)
}
@@ -195,15 +321,12 @@ val downloadLibdovi by tasks.registering {
android {
namespace = "com.edde746.plezy"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
buildToolsVersion = "36.1.0"
ndkVersion = "29.0.14206865"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
@@ -214,12 +337,14 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild {
cmake {
arguments += listOf(
"-DDOVI_ENABLE_LIBDOVI=ON",
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}"
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}",
"-DMPV_FFMPEG_ROOT=${mpvFfmpegDevelopmentDir.absolutePath}"
)
}
}
@@ -235,6 +360,7 @@ android {
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "4.1.2"
}
}
@@ -291,16 +417,22 @@ android {
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
flutter {
source = "../.."
}
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
dependsOn(downloadLibdovi)
dependsOn(downloadLibdovi, prepareMpvFfmpegDevelopment)
}
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
dependsOn(downloadLibmpv, extractMpvLibcxx)
dependsOn(downloadLibmpv, extractMpvLibcxx, prepareMpvFfmpegDevelopment)
}
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
// extracted libmpv libc++ directory present during input discovery.
@@ -310,24 +442,22 @@ tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders"
dependencies {
implementation(files(File(mpvDir, mpvAar)))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
// Android TV Watch Next integration
implementation("androidx.tvprovider:tvprovider:1.0.0")
implementation("androidx.tvprovider:tvprovider:1.1.0")
// Media3 ExoPlayer for Android
implementation("androidx.media3:media3-exoplayer:1.9.2")
implementation("androidx.media3:media3-exoplayer-hls:1.9.2")
implementation("androidx.media3:media3-ui:1.9.2")
implementation("androidx.media3:media3-common:1.9.2")
implementation("androidx.media3:media3-decoder:$media3Version")
implementation("androidx.media3:media3-exoplayer:$media3Version")
implementation("androidx.media3:media3-exoplayer-hls:$media3Version")
implementation("androidx.media3:media3-ui:$media3Version")
implementation("androidx.media3:media3-common:$media3Version")
// Cronet for HTTP/2 multiplexing + better connection management
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
implementation("androidx.media3:media3-datasource-cronet:$media3Version")
implementation("org.chromium.net:cronet-embedded:143.7445.0")
// FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.)
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
// Keeping libass in-project lets its static core share the app's native
// packaging rules.
implementation(project(":libass"))
@@ -335,5 +465,7 @@ dependencies {
testImplementation("junit:junit:4.13.2")
// Real android.util.* implementations for tests exercising media3 classes
// (MatroskaExtractor uses SparseArray, which is a no-op stub on plain JVM)
testImplementation("org.robolectric:robolectric:4.15.1")
testImplementation("org.robolectric:robolectric:4.16.1")
androidTestImplementation("androidx.test:runner:1.7.0")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
}
@@ -0,0 +1,160 @@
/*
* Copyright (C) 2026 Plezy contributors
*
* 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 androidx.media3.decoder.ffmpeg;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.net.Uri;
import android.os.Handler;
import android.os.HandlerThread;
import androidx.annotation.Nullable;
import androidx.media3.common.MediaItem;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.datasource.DefaultDataSource;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.Renderer;
import androidx.media3.exoplayer.RenderersFactory;
import androidx.media3.exoplayer.audio.DefaultAudioSink;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.source.ProgressiveMediaSource;
import androidx.media3.extractor.DefaultExtractorsFactory;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public final class PlezyFfmpegPlaybackTest {
private static final String[] FIXTURES = {
"ffmpeg/stereo.flac",
"ffmpeg/surround_5_1.flac",
"ffmpeg/surround_7_1.flac",
"ffmpeg/planar_5_1.m4a",
"ffmpeg/surround_5_1_eac3.mka",
"ffmpeg/surround_5_1_dts.mka",
"ffmpeg/surround_5_1_truehd.mka"
};
@Test
public void sharedDecoderUsesLibmpvFfmpegAndPlaysAllFixtures() throws Exception {
assertTrue("FFmpeg JNI library is unavailable", FfmpegLibrary.isAvailable());
String version = FfmpegLibrary.getVersion();
assertTrue("Expected libmpv's FFmpeg 8, got " + version, version != null && version.startsWith("Lavc62."));
for (String fixture : FIXTURES) {
playToEnd(fixture);
}
}
private static void playToEnd(String fixture) throws Exception {
Context instrumentationContext = InstrumentationRegistry.getInstrumentation().getContext();
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
File fixtureFile = copyFixture(instrumentationContext, context, fixture);
HandlerThread playbackThread = new HandlerThread("ffmpeg-test-player");
playbackThread.start();
Handler handler = new Handler(playbackThread.getLooper());
CountDownLatch completed = new CountDownLatch(1);
AtomicReference<ExoPlayer> playerReference = new AtomicReference<>();
AtomicReference<Throwable> errorReference = new AtomicReference<>();
handler.post(
() -> {
try {
RenderersFactory renderersFactory =
(eventHandler,
videoRendererEventListener,
audioRendererEventListener,
textRendererOutput,
metadataRendererOutput) ->
new Renderer[] {
new FfmpegAudioRenderer(
eventHandler, audioRendererEventListener, new DefaultAudioSink.Builder().build())
};
ExoPlayer player =
new ExoPlayer.Builder(context, renderersFactory)
.setLooper(playbackThread.getLooper())
.build();
playerReference.set(player);
player.addListener(
new Player.Listener() {
@Override
public void onPlayerError(PlaybackException error) {
errorReference.set(error);
completed.countDown();
}
@Override
public void onPlaybackStateChanged(@Player.State int playbackState) {
if (playbackState == Player.STATE_ENDED) {
completed.countDown();
}
}
});
MediaSource source =
new ProgressiveMediaSource.Factory(
new DefaultDataSource.Factory(context), new DefaultExtractorsFactory())
.createMediaSource(MediaItem.fromUri(Uri.fromFile(fixtureFile)));
player.setMediaSource(source);
player.prepare();
player.play();
} catch (Throwable error) {
errorReference.set(error);
completed.countDown();
}
});
boolean finished = completed.await(20, TimeUnit.SECONDS);
CountDownLatch released = new CountDownLatch(1);
handler.post(
() -> {
@Nullable ExoPlayer player = playerReference.get();
if (player != null) player.release();
playbackThread.quitSafely();
released.countDown();
});
boolean teardownFinished = released.await(5, TimeUnit.SECONDS);
playbackThread.join(5000);
boolean fixtureDeleted = fixtureFile.delete();
assertTrue("Player teardown timed out for " + fixture, teardownFinished);
assertTrue("Playback timed out for " + fixture, finished);
assertNull("Playback failed for " + fixture, errorReference.get());
assertTrue("Fixture cleanup failed for " + fixture, fixtureDeleted);
}
private static File copyFixture(
Context instrumentationContext, Context targetContext, String fixture) throws Exception {
File output = File.createTempFile("ffmpeg-fixture-", null, targetContext.getCacheDir());
try (InputStream input = instrumentationContext.getAssets().open(fixture);
OutputStream sink = new FileOutputStream(output)) {
byte[] buffer = new byte[8192];
int count;
while ((count = input.read(buffer)) != -1) {
sink.write(buffer, 0, count);
}
}
return output;
}
}
@@ -0,0 +1,149 @@
package com.edde746.plezy.exoplayer
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.audio.ChannelMixingMatrix
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PlezyAudioModePlaybackTest {
private enum class AudioMode {
PASSTHROUGH_ALLOWED,
FORCE_DECODED,
DOWNMIX_NORMALIZED,
DOWNMIX_UNNORMALIZED,
NORMALIZATION
}
@Test
fun appAudioPipelinePlaysAcrossOutputModes() {
playToEnd("ffmpeg/surround_5_1_dts.mka", AudioMode.PASSTHROUGH_ALLOWED)
playToEnd("ffmpeg/surround_5_1_truehd.mka", AudioMode.FORCE_DECODED)
playToEnd("ffmpeg/surround_5_1.flac", AudioMode.DOWNMIX_NORMALIZED)
playToEnd("ffmpeg/surround_7_1.flac", AudioMode.DOWNMIX_UNNORMALIZED)
playToEnd("ffmpeg/surround_5_1_eac3.mka", AudioMode.NORMALIZATION)
}
private fun playToEnd(fixture: String, mode: AudioMode) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val fixtureFile = copyFixture(instrumentation.context, context, fixture)
val playbackThread = HandlerThread("plezy-audio-mode-test").apply { start() }
val handler = Handler(playbackThread.looper)
val completed = CountDownLatch(1)
val playerReference = AtomicReference<ExoPlayer>()
val errorReference = AtomicReference<Throwable>()
val outputPolicyConsulted = AtomicBoolean(false)
val normalizationAttachAttempted = AtomicBoolean(false)
val normalization = AudioNormalizationEffect { _, _, _ -> }
val downmixActive = AtomicBoolean(false)
handler.post {
try {
val factory = PlezyRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
shouldBlockDirectAudioOutput = { format ->
val encoded = format.sampleMimeType != null && format.sampleMimeType != MimeTypes.AUDIO_RAW
if (encoded) outputPolicyConsulted.set(true)
encoded && mode != AudioMode.PASSTHROUGH_ALLOWED
}
if (mode == AudioMode.DOWNMIX_NORMALIZED || mode == AudioMode.DOWNMIX_UNNORMALIZED) {
val normalize = mode == AudioMode.DOWNMIX_NORMALIZED
for (channelCount in DownmixMatrices.MIN_DOWNMIX_INPUT_CHANNELS..DownmixMatrices.MAX_DOWNMIX_INPUT_CHANNELS) {
val coefficients = DownmixMatrices.stereoCoefficients(channelCount, centerBoostDb = 6, normalize = normalize)!!
channelMixProcessor.putChannelMixingMatrix(ChannelMixingMatrix(channelCount, 2, coefficients))
}
}
}
val player = ExoPlayer.Builder(context, factory)
.setLooper(playbackThread.looper)
.build()
playerReference.set(player)
player.addListener(object : Player.Listener {
override fun onAudioSessionIdChanged(audioSessionId: Int) {
if (mode == AudioMode.NORMALIZATION) {
normalizationAttachAttempted.set(true)
normalization.attach(audioSessionId, channelCount = 6)
}
}
override fun onPlayerError(error: PlaybackException) {
errorReference.set(error)
completed.countDown()
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
downmixActive.set(factory.channelMixProcessor.isActive)
completed.countDown()
}
}
})
val source = ProgressiveMediaSource.Factory(
DefaultDataSource.Factory(context),
DefaultExtractorsFactory()
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixtureFile)))
player.setMediaSource(source)
player.prepare()
player.play()
} catch (error: Throwable) {
errorReference.set(error)
completed.countDown()
}
}
val finished = completed.await(20, TimeUnit.SECONDS)
val released = CountDownLatch(1)
handler.post {
playerReference.get()?.release()
normalization.release()
playbackThread.quitSafely()
released.countDown()
}
val teardownFinished = released.await(5, TimeUnit.SECONDS)
playbackThread.join(5_000)
val fixtureDeleted = fixtureFile.delete()
assertTrue("Player teardown timed out for $mode / $fixture", teardownFinished)
assertTrue("Playback timed out for $mode / $fixture", finished)
assertNull("Playback failed for $mode / $fixture", errorReference.get())
assertTrue("Audio output policy was not consulted for $mode / $fixture", outputPolicyConsulted.get())
if (mode == AudioMode.DOWNMIX_NORMALIZED || mode == AudioMode.DOWNMIX_UNNORMALIZED) {
assertTrue("Downmix processor was inactive for $mode / $fixture", downmixActive.get())
}
if (mode == AudioMode.NORMALIZATION) {
assertTrue("Normalization did not receive an audio session", normalizationAttachAttempted.get())
}
assertTrue("Fixture cleanup failed for $mode / $fixture", fixtureDeleted)
}
private fun copyFixture(instrumentationContext: Context, targetContext: Context, fixture: String): File {
val output = File.createTempFile("audio-mode-fixture-", null, targetContext.cacheDir)
instrumentationContext.assets.open(fixture).use { input ->
output.outputStream().use(input::copyTo)
}
return output
}
}
+21 -1
View File
@@ -1,8 +1,9 @@
cmake_minimum_required(VERSION 3.22.1)
project(dovi_bridge)
project(plezy_native)
option(DOVI_ENABLE_LIBDOVI "Link real libdovi" ON)
set(DOVI_LIBDOVI_PREBUILT_ROOT "" CACHE PATH "Path to prebuilt libdovi")
set(MPV_FFMPEG_ROOT "" CACHE PATH "Path to libmpv's FFmpeg headers and shared libraries")
add_library(dovi_bridge SHARED dovi_bridge.cpp)
@@ -17,3 +18,22 @@ else()
target_compile_definitions(dovi_bridge PRIVATE DOVI_REAL_LINKED=0)
target_link_libraries(dovi_bridge log)
endif()
foreach(ffmpeg_library avcodec avutil swresample)
add_library(${ffmpeg_library} SHARED IMPORTED)
set_target_properties(${ffmpeg_library} PROPERTIES
IMPORTED_LOCATION "${MPV_FFMPEG_ROOT}/native/${ANDROID_ABI}/lib${ffmpeg_library}.so")
endforeach()
add_library(ffmpegJNI SHARED
media3_ffmpeg_decoder/ffmpeg_jni.cc)
target_compile_features(ffmpegJNI PRIVATE cxx_std_17)
target_include_directories(ffmpegJNI PRIVATE
"${MPV_FFMPEG_ROOT}/include"
"${CMAKE_CURRENT_SOURCE_DIR}/media3_ffmpeg_decoder")
target_link_libraries(ffmpegJNI
avcodec
avutil
swresample
android
log)
+17 -31
View File
@@ -2,6 +2,7 @@
#include <jni.h>
#include <cstring>
#include <memory>
#include <new>
#include <vector>
@@ -18,6 +19,7 @@ static constexpr jint CONVERT_FAILED = -1;
static constexpr jint DESTINATION_TOO_SMALL = -2;
static constexpr jint MAX_RPU_INPUT_SIZE = 8192;
static constexpr size_t MAX_RPU_OUTPUT_SIZE = 16384;
static constexpr int MAX_ERROR_LOG_LENGTH = 256;
extern "C" JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81(
JNIEnv* env, jclass, jbyteArray payload, jint payload_offset, jint payload_length, jbyteArray output,
@@ -62,60 +64,47 @@ extern "C" JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_na
return CONVERT_FAILED;
}
// Try dovi_parse_unspec62_nalu first (handles escaped NALs), fallback to dovi_parse_rpu
// The input is a complete (possibly escaped) HEVC UNSPEC62 NAL. Parsing it as
// a raw RPU after a framed-parser error can reinterpret malformed/truncated
// NAL bytes as valid metadata.
const auto rpu_len = static_cast<size_t>(payload_length);
DoviRpuOpaque* rpu = dovi_parse_unspec62_nalu(scratch.data(), rpu_len);
using RpuPtr = std::unique_ptr<DoviRpuOpaque, decltype(&dovi_rpu_free)>;
RpuPtr rpu(dovi_parse_unspec62_nalu(scratch.data(), rpu_len), dovi_rpu_free);
if (rpu == nullptr) {
return CONVERT_FAILED;
}
const char* err = dovi_rpu_get_error(rpu);
const char* err = dovi_rpu_get_error(rpu.get());
if (err != nullptr) {
// Fallback: try dovi_parse_rpu (raw RPU without NAL framing)
dovi_rpu_free(rpu);
rpu = dovi_parse_rpu(scratch.data(), rpu_len);
if (rpu == nullptr) {
return CONVERT_FAILED;
}
err = dovi_rpu_get_error(rpu);
if (err != nullptr) {
LOGW("RPU parse failed: %s", err);
dovi_rpu_free(rpu);
return CONVERT_FAILED;
}
LOGW("RPU NAL parse failed: %.*s", MAX_ERROR_LOG_LENGTH, err);
return CONVERT_FAILED;
}
// Mode 2 matches Kodi's P8.1 compatibility path and sets luma/chroma curves to no-op.
int32_t ret = dovi_convert_rpu_with_mode(rpu, static_cast<uint8_t>(mode));
int32_t ret = dovi_convert_rpu_with_mode(rpu.get(), static_cast<uint8_t>(mode));
if (ret != 0) {
err = dovi_rpu_get_error(rpu);
LOGW("RPU conversion failed (mode %d): %s", mode, err ? err : "unknown");
dovi_rpu_free(rpu);
err = dovi_rpu_get_error(rpu.get());
LOGW("RPU conversion failed (mode %d): %.*s", mode, MAX_ERROR_LOG_LENGTH, err ? err : "unknown");
return CONVERT_FAILED;
}
// Write back as UNSPEC62 NAL
const DoviData* out = dovi_write_unspec62_nalu(rpu);
using DoviDataPtr = std::unique_ptr<const DoviData, decltype(&dovi_data_free)>;
DoviDataPtr out(dovi_write_unspec62_nalu(rpu.get()), dovi_data_free);
if (out == nullptr || out->data == nullptr || out->len == 0) {
err = dovi_rpu_get_error(rpu);
LOGW("RPU write failed: %s", err ? err : "unknown");
if (out != nullptr) dovi_data_free(out);
dovi_rpu_free(rpu);
err = dovi_rpu_get_error(rpu.get());
LOGW("RPU write failed: %.*s", MAX_ERROR_LOG_LENGTH, err ? err : "unknown");
return CONVERT_FAILED;
}
if (out->len > MAX_RPU_OUTPUT_SIZE) {
LOGW("RPU output unexpectedly large (%zu bytes), discarding", out->len);
dovi_data_free(out);
dovi_rpu_free(rpu);
return CONVERT_FAILED;
}
const auto writable = static_cast<size_t>(logical_output_len - output_offset);
if (out->len > writable) {
dovi_data_free(out);
dovi_rpu_free(rpu);
return DESTINATION_TOO_SMALL;
}
@@ -124,9 +113,6 @@ extern "C" JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_na
const bool write_failed = env->ExceptionCheck();
const auto written = static_cast<jint>(out->len);
dovi_data_free(out);
dovi_rpu_free(rpu);
return write_failed ? CONVERT_FAILED : written;
#endif
}
@@ -0,0 +1,48 @@
/*
* Copyright (C) 2026 Plezy contributors
*
* 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 PLEZY_FFMPEG_AUDIO_BUFFER_H_
#define PLEZY_FFMPEG_AUDIO_BUFFER_H_
#include <limits.h>
#include <stdint.h>
namespace plezy {
namespace ffmpeg {
inline bool CheckedAudioByteCount(int sample_count, int channel_count, int bytes_per_sample, int* byte_count) {
if (sample_count < 0 || channel_count <= 0 || bytes_per_sample <= 0 || byte_count == nullptr) {
return false;
}
const int64_t size = static_cast<int64_t>(sample_count) * channel_count * bytes_per_sample;
if (size > INT_MAX) {
return false;
}
*byte_count = static_cast<int>(size);
return true;
}
inline bool CheckedAddByteCount(int current_size, int additional_size, int* total_size) {
if (current_size < 0 || additional_size < 0 || total_size == nullptr || current_size > INT_MAX - additional_size) {
return false;
}
*total_size = current_size + additional_size;
return true;
}
} // namespace ffmpeg
} // namespace plezy
#endif // PLEZY_FFMPEG_AUDIO_BUFFER_H_
@@ -0,0 +1,505 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* 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 <android/log.h>
#include <jni.h>
#include "ffmpeg_audio_buffer.h"
extern "C" {
#ifdef __cplusplus
#define __STDC_CONSTANT_MACROS
#ifdef _STDINT_H
#undef _STDINT_H
#endif
#include <stdint.h>
#endif
#include <libavcodec/avcodec.h>
#include <libavutil/channel_layout.h>
#include <libavutil/error.h>
#include <libavutil/opt.h>
#include <libswresample/swresample.h>
}
#define LOG_TAG "ffmpeg_jni"
#define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__))
#define LOGD(...) ((void)__android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__))
#define LIBRARY_FUNC(RETURN_TYPE, NAME, ...) \
extern "C" { \
JNIEXPORT RETURN_TYPE \
Java_androidx_media3_decoder_ffmpeg_FfmpegLibrary_##NAME(JNIEnv* env, jobject thiz, ##__VA_ARGS__); \
} \
JNIEXPORT RETURN_TYPE Java_androidx_media3_decoder_ffmpeg_FfmpegLibrary_##NAME( \
JNIEnv* env, jobject thiz, ##__VA_ARGS__)
#define AUDIO_DECODER_FUNC(RETURN_TYPE, NAME, ...) \
extern "C" { \
JNIEXPORT RETURN_TYPE \
Java_androidx_media3_decoder_ffmpeg_FfmpegAudioDecoder_##NAME(JNIEnv* env, jobject thiz, ##__VA_ARGS__); \
} \
JNIEXPORT RETURN_TYPE Java_androidx_media3_decoder_ffmpeg_FfmpegAudioDecoder_##NAME( \
JNIEnv* env, jobject thiz, ##__VA_ARGS__)
#define ERROR_STRING_BUFFER_LENGTH 256
// Output format corresponding to AudioFormat.ENCODING_PCM_16BIT.
static const AVSampleFormat OUTPUT_FORMAT_PCM_16BIT = AV_SAMPLE_FMT_S16;
// Output format corresponding to AudioFormat.ENCODING_PCM_FLOAT.
static const AVSampleFormat OUTPUT_FORMAT_PCM_FLOAT = AV_SAMPLE_FMT_FLT;
// LINT.IfChange
static const int AUDIO_DECODER_ERROR_INVALID_DATA = -1;
static const int AUDIO_DECODER_ERROR_OTHER = -2;
// LINT.ThenChange(../java/androidx/media3/decoder/ffmpeg/FfmpegAudioDecoder.java)
namespace {
struct ResampleState {
SwrContext* context;
AVChannelLayout input_channel_layout;
AVSampleFormat input_sample_format;
AVSampleFormat output_sample_format;
int sample_rate;
};
} // namespace
static bool resampleConfigurationMatches(
const ResampleState* state, const AVCodecContext* context, const AVFrame* frame);
static int configureResampler(ResampleState* state, const AVCodecContext* context, const AVFrame* frame);
static void releaseResampleState(ResampleState* state);
static jmethodID growOutputBufferMethod;
/**
* Returns the AVCodec with the specified name, or NULL if it is not available.
*/
static const AVCodec* getCodecByName(JNIEnv* env, jstring codecName);
/**
* Allocates and opens a new AVCodecContext for the specified codec, passing the
* provided extraData as initialization data for the decoder if it is non-NULL.
* Returns the created context.
*/
static AVCodecContext* createContext(
JNIEnv* env, const AVCodec* codec, jbyteArray extraData, jboolean outputFloat, jint rawSampleRate,
jint rawChannelCount);
namespace {
struct GrowOutputBufferCallback {
uint8_t* operator()(int requiredSize) const;
JNIEnv* env;
jobject thiz;
jobject decoderOutputBuffer;
};
} // namespace
/**
* Decodes the packet into the output buffer, returning the number of bytes
* written, or a negative AUDIO_DECODER_ERROR constant value in the case of an
* error.
*/
static int decodePacket(
AVCodecContext* context, AVPacket* packet, uint8_t* outputBuffer, int outputSize,
GrowOutputBufferCallback growBuffer);
/**
* Transforms ffmpeg AVERROR into a negative AUDIO_DECODER_ERROR constant value.
*/
static int transformError(int errorNumber);
/**
* Outputs a log message describing the avcodec error number.
*/
static void logError(const char* functionName, int errorNumber);
/**
* Releases the specified context.
*/
static void releaseContext(AVCodecContext* context);
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv* env;
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
LOGE("JNI_OnLoad: GetEnv failed");
return -1;
}
jclass clazz = env->FindClass("androidx/media3/decoder/ffmpeg/FfmpegAudioDecoder");
if (!clazz) {
LOGE("JNI_OnLoad: FindClass failed");
return -1;
}
growOutputBufferMethod = env->GetMethodID(
clazz, "growOutputBuffer",
"(Landroidx/media3/decoder/"
"SimpleDecoderOutputBuffer;I)Ljava/nio/ByteBuffer;");
if (!growOutputBufferMethod) {
LOGE("JNI_OnLoad: GetMethodID failed");
return -1;
}
return JNI_VERSION_1_6;
}
LIBRARY_FUNC(jstring, ffmpegGetVersion) { return env->NewStringUTF(LIBAVCODEC_IDENT); }
LIBRARY_FUNC(jint, ffmpegGetInputBufferPaddingSize) { return (jint)AV_INPUT_BUFFER_PADDING_SIZE; }
LIBRARY_FUNC(jboolean, ffmpegHasDecoder, jstring codecName) { return getCodecByName(env, codecName) != nullptr; }
AUDIO_DECODER_FUNC(
jlong, ffmpegInitialize, jstring codecName, jbyteArray extraData, jboolean outputFloat, jint rawSampleRate,
jint rawChannelCount) {
const AVCodec* codec = getCodecByName(env, codecName);
if (!codec) {
LOGE("Codec not found.");
return 0L;
}
return (jlong)createContext(env, codec, extraData, outputFloat, rawSampleRate, rawChannelCount);
}
AUDIO_DECODER_FUNC(
jint, ffmpegDecode, jlong context, jobject inputData, jint inputSize, jobject decoderOutputBuffer,
jobject outputData, jint outputSize) {
if (!context) {
LOGE("Context must be non-NULL.");
return -1;
}
if (!inputData || !decoderOutputBuffer || !outputData) {
LOGE("Input and output buffers must be non-NULL.");
return -1;
}
if (inputSize < 0) {
LOGE("Invalid input buffer size: %d.", inputSize);
return -1;
}
if (outputSize < 0) {
LOGE("Invalid output buffer length: %d", outputSize);
return -1;
}
const jlong inputCapacity = env->GetDirectBufferCapacity(inputData);
const jlong outputCapacity = env->GetDirectBufferCapacity(outputData);
if (inputCapacity < inputSize || outputCapacity < outputSize) {
LOGE("Buffer size exceeds direct buffer capacity.");
return -1;
}
uint8_t* inputBuffer = (uint8_t*)env->GetDirectBufferAddress(inputData);
uint8_t* outputBuffer = (uint8_t*)env->GetDirectBufferAddress(outputData);
AVPacket* packet = av_packet_alloc();
if (!packet) {
LOGE("Failed to allocate packet.");
return -1;
}
packet->data = inputBuffer;
packet->size = inputSize;
const int ret = decodePacket(
(AVCodecContext*)context, packet, outputBuffer, outputSize,
GrowOutputBufferCallback{env, thiz, decoderOutputBuffer});
av_packet_free(&packet);
return ret;
}
uint8_t* GrowOutputBufferCallback::operator()(int requiredSize) const {
jobject newOutputData = env->CallObjectMethod(thiz, growOutputBufferMethod, decoderOutputBuffer, requiredSize);
if (env->ExceptionCheck()) {
LOGE("growOutputBuffer() failed");
env->ExceptionDescribe();
return nullptr;
}
if (env->GetDirectBufferCapacity(newOutputData) < requiredSize) {
LOGE("growOutputBuffer() returned an undersized or non-direct buffer.");
return nullptr;
}
return static_cast<uint8_t*>(env->GetDirectBufferAddress(newOutputData));
}
AUDIO_DECODER_FUNC(jint, ffmpegGetChannelCount, jlong context) {
if (!context) {
LOGE("Context must be non-NULL.");
return -1;
}
return ((AVCodecContext*)context)->ch_layout.nb_channels;
}
AUDIO_DECODER_FUNC(jint, ffmpegGetSampleRate, jlong context) {
if (!context) {
LOGE("Context must be non-NULL.");
return -1;
}
return ((AVCodecContext*)context)->sample_rate;
}
AUDIO_DECODER_FUNC(jlong, ffmpegReset, jlong jContext, jbyteArray extraData) {
AVCodecContext* context = (AVCodecContext*)jContext;
if (!context) {
LOGE("Tried to reset without a context.");
return 0L;
}
AVCodecID codecId = context->codec_id;
if (codecId == AV_CODEC_ID_TRUEHD) {
jboolean outputFloat = (jboolean)(context->request_sample_fmt == OUTPUT_FORMAT_PCM_FLOAT);
// Release and recreate the context if the codec is TrueHD.
// TODO: Figure out why flushing doesn't work for this codec.
releaseContext(context);
const AVCodec* codec = avcodec_find_decoder(codecId);
if (!codec) {
LOGE("Unexpected error finding codec %d.", codecId);
return 0L;
}
return (jlong)createContext(
env, codec, extraData, outputFloat,
/* rawSampleRate= */ -1,
/* rawChannelCount= */ -1);
}
avcodec_flush_buffers(context);
return (jlong)context;
}
AUDIO_DECODER_FUNC(void, ffmpegRelease, jlong context) {
if (context) {
releaseContext((AVCodecContext*)context);
}
}
static const AVCodec* getCodecByName(JNIEnv* env, jstring codecName) {
if (!codecName) {
return nullptr;
}
const char* codecNameChars = env->GetStringUTFChars(codecName, nullptr);
const AVCodec* codec = avcodec_find_decoder_by_name(codecNameChars);
env->ReleaseStringUTFChars(codecName, codecNameChars);
return codec;
}
static AVCodecContext* createContext(
JNIEnv* env, const AVCodec* codec, jbyteArray extraData, jboolean outputFloat, jint rawSampleRate,
jint rawChannelCount) {
AVCodecContext* context = avcodec_alloc_context3(codec);
if (!context) {
LOGE("Failed to allocate context.");
return nullptr;
}
context->request_sample_fmt = outputFloat ? OUTPUT_FORMAT_PCM_FLOAT : OUTPUT_FORMAT_PCM_16BIT;
if (extraData) {
jsize size = env->GetArrayLength(extraData);
if (size > INT_MAX - AV_INPUT_BUFFER_PADDING_SIZE) {
LOGE("Extradata is too large.");
releaseContext(context);
return nullptr;
}
context->extradata_size = size;
context->extradata = (uint8_t*)av_mallocz(static_cast<size_t>(size) + AV_INPUT_BUFFER_PADDING_SIZE);
if (!context->extradata) {
LOGE("Failed to allocate extradata.");
releaseContext(context);
return nullptr;
}
env->GetByteArrayRegion(extraData, 0, size, (jbyte*)context->extradata);
}
if (context->codec_id == AV_CODEC_ID_PCM_MULAW || context->codec_id == AV_CODEC_ID_PCM_ALAW) {
context->sample_rate = rawSampleRate;
av_channel_layout_default(&context->ch_layout, rawChannelCount);
}
context->err_recognition = AV_EF_IGNORE_ERR;
int result = avcodec_open2(context, codec, nullptr);
if (result < 0) {
logError("avcodec_open2", result);
releaseContext(context);
return nullptr;
}
return context;
}
static bool resampleConfigurationMatches(
const ResampleState* state, const AVCodecContext* context, const AVFrame* frame) {
return state && state->context && state->input_sample_format == static_cast<AVSampleFormat>(frame->format) &&
state->output_sample_format == context->request_sample_fmt && state->sample_rate == frame->sample_rate &&
av_channel_layout_compare(&state->input_channel_layout, &frame->ch_layout) == 0;
}
static int configureResampler(ResampleState* state, const AVCodecContext* context, const AVFrame* frame) {
SwrContext* nextContext = nullptr;
const AVSampleFormat inputSampleFormat = static_cast<AVSampleFormat>(frame->format);
int result = swr_alloc_set_opts2(
&nextContext, // ps
&frame->ch_layout, // out_ch_layout
context->request_sample_fmt, // out_sample_fmt
frame->sample_rate, // out_sample_rate
&frame->ch_layout, // in_ch_layout
inputSampleFormat, // in_sample_fmt
frame->sample_rate, // in_sample_rate
0, // log_offset
nullptr // log_ctx
);
if (result < 0) {
logError("swr_alloc_set_opts2", result);
return result;
}
result = swr_init(nextContext);
if (result < 0) {
logError("swr_init", result);
swr_free(&nextContext);
return result;
}
AVChannelLayout nextInputChannelLayout = {};
result = av_channel_layout_copy(&nextInputChannelLayout, &frame->ch_layout);
if (result < 0) {
logError("av_channel_layout_copy", result);
swr_free(&nextContext);
return result;
}
swr_free(&state->context);
av_channel_layout_uninit(&state->input_channel_layout);
state->context = nextContext;
state->input_channel_layout = nextInputChannelLayout;
state->input_sample_format = inputSampleFormat;
state->output_sample_format = context->request_sample_fmt;
state->sample_rate = frame->sample_rate;
return 0;
}
static void releaseResampleState(ResampleState* state) {
if (!state) {
return;
}
swr_free(&state->context);
av_channel_layout_uninit(&state->input_channel_layout);
av_free(state);
}
static int decodePacket(
AVCodecContext* context, AVPacket* packet, uint8_t* outputBuffer, int outputSize,
GrowOutputBufferCallback growBuffer) {
int result = avcodec_send_packet(context, packet);
if (result) {
logError("avcodec_send_packet", result);
return transformError(result);
}
int outSize = 0;
while (true) {
AVFrame* frame = av_frame_alloc();
if (!frame) {
LOGE("Failed to allocate output frame.");
return AUDIO_DECODER_ERROR_INVALID_DATA;
}
result = avcodec_receive_frame(context, frame);
if (result) {
av_frame_free(&frame);
if (result == AVERROR(EAGAIN)) {
break;
}
logError("avcodec_receive_frame", result);
return transformError(result);
}
const AVSampleFormat sampleFormat = static_cast<AVSampleFormat>(frame->format);
const int channelCount = frame->ch_layout.nb_channels;
const int sampleRate = frame->sample_rate;
const int sampleCount = frame->nb_samples;
if (sampleFormat == AV_SAMPLE_FMT_NONE || channelCount <= 0 || sampleRate <= 0 || sampleCount < 0 ||
!frame->extended_data || !av_channel_layout_check(&frame->ch_layout)) {
LOGE("Decoder returned an invalid audio frame.");
av_frame_free(&frame);
return AUDIO_DECODER_ERROR_INVALID_DATA;
}
ResampleState* resampleState = static_cast<ResampleState*>(context->opaque);
if (!resampleState) {
resampleState = static_cast<ResampleState*>(av_mallocz(sizeof(ResampleState)));
if (!resampleState) {
LOGE("Failed to allocate resampler state.");
av_frame_free(&frame);
return AUDIO_DECODER_ERROR_OTHER;
}
context->opaque = resampleState;
}
if (!resampleConfigurationMatches(resampleState, context, frame)) {
result = configureResampler(resampleState, context, frame);
if (result < 0) {
av_frame_free(&frame);
return transformError(result);
}
}
const int bytesPerSample = av_get_bytes_per_sample(context->request_sample_fmt);
const int outputSampleCapacity = swr_get_out_samples(resampleState->context, sampleCount);
int outputByteCapacity;
int requiredOutputSize;
if (!plezy::ffmpeg::CheckedAudioByteCount(
outputSampleCapacity, channelCount, bytesPerSample, &outputByteCapacity) ||
!plezy::ffmpeg::CheckedAddByteCount(outSize, outputByteCapacity, &requiredOutputSize)) {
LOGE("Decoded audio output size is invalid or too large.");
av_frame_free(&frame);
return AUDIO_DECODER_ERROR_INVALID_DATA;
}
if (requiredOutputSize > outputSize) {
LOGD(
"Output buffer size (%d) too small for output data (%d), "
"reallocating buffer.",
outputSize, requiredOutputSize);
outputSize = requiredOutputSize;
outputBuffer = growBuffer(outputSize);
if (!outputBuffer) {
LOGE("Failed to reallocate output buffer.");
av_frame_free(&frame);
return AUDIO_DECODER_ERROR_OTHER;
}
}
uint8_t* frameOutput = outputBuffer + outSize;
uint8_t* outputPlanes[] = {frameOutput};
result = swr_convert(
resampleState->context, outputPlanes, outputSampleCapacity, (const uint8_t**)frame->extended_data, sampleCount);
av_frame_free(&frame);
if (result < 0) {
logError("swr_convert", result);
return AUDIO_DECODER_ERROR_INVALID_DATA;
}
int writtenByteCount;
int nextOutSize;
if (!plezy::ffmpeg::CheckedAudioByteCount(result, channelCount, bytesPerSample, &writtenByteCount) ||
writtenByteCount > outputByteCapacity ||
!plezy::ffmpeg::CheckedAddByteCount(outSize, writtenByteCount, &nextOutSize)) {
LOGE("Resampler returned an invalid output sample count.");
return AUDIO_DECODER_ERROR_INVALID_DATA;
}
outSize = nextOutSize;
}
return outSize;
}
static int transformError(int errorNumber) {
return errorNumber == AVERROR_INVALIDDATA ? AUDIO_DECODER_ERROR_INVALID_DATA : AUDIO_DECODER_ERROR_OTHER;
}
static void logError(const char* functionName, int errorNumber) {
char buffer[ERROR_STRING_BUFFER_LENGTH];
av_strerror(errorNumber, buffer, sizeof(buffer));
LOGE("Error in %s: %s", functionName, buffer);
}
static void releaseContext(AVCodecContext* context) {
if (!context) {
return;
}
releaseResampleState(static_cast<ResampleState*>(context->opaque));
context->opaque = nullptr;
avcodec_free_context(&context);
}
@@ -0,0 +1,294 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* 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 androidx.media3.decoder.ffmpeg;
import static com.google.common.base.Preconditions.checkNotNull;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.Util;
import androidx.media3.decoder.DecoderInputBuffer;
import androidx.media3.decoder.SimpleDecoder;
import androidx.media3.decoder.SimpleDecoderOutputBuffer;
import java.nio.ByteBuffer;
import java.util.List;
/** Media3 audio decoder backed by the FFmpeg libraries packaged by libmpv. */
final class FfmpegAudioDecoder
extends SimpleDecoder<DecoderInputBuffer, SimpleDecoderOutputBuffer, FfmpegDecoderException> {
private static final int INITIAL_OUTPUT_BUFFER_SIZE_16BIT = 65535;
private static final int INITIAL_OUTPUT_BUFFER_SIZE_32BIT = INITIAL_OUTPUT_BUFFER_SIZE_16BIT * 2;
private static final int AUDIO_DECODER_ERROR_INVALID_DATA = -1;
private static final int AUDIO_DECODER_ERROR_OTHER = -2;
private static final byte[] FLAC_STREAM_MARKER = {'f', 'L', 'a', 'C'};
private static final int FLAC_METADATA_TYPE_STREAM_INFO = 0;
private static final int FLAC_METADATA_BLOCK_HEADER_SIZE = 4;
private static final int FLAC_STREAM_INFO_DATA_SIZE = 34;
private final String codecName;
@Nullable private final byte[] extraData;
private final @C.PcmEncoding int encoding;
private int outputBufferSize;
private long nativeContext;
private boolean hasOutputFormat;
private volatile int channelCount;
private volatile int sampleRate;
FfmpegAudioDecoder(
Format format,
int numInputBuffers,
int numOutputBuffers,
int initialInputBufferSize,
boolean outputFloat)
throws FfmpegDecoderException {
super(new DecoderInputBuffer[numInputBuffers], new SimpleDecoderOutputBuffer[numOutputBuffers]);
if (!FfmpegLibrary.isAvailable()) {
throw new FfmpegDecoderException("Failed to load decoder native libraries.");
}
String mimeType = checkNotNull(format.sampleMimeType);
codecName = checkNotNull(FfmpegLibrary.getCodecName(mimeType));
extraData = getExtraData(mimeType, format.initializationData);
encoding = outputFloat ? C.ENCODING_PCM_FLOAT : C.ENCODING_PCM_16BIT;
outputBufferSize =
outputFloat ? INITIAL_OUTPUT_BUFFER_SIZE_32BIT : INITIAL_OUTPUT_BUFFER_SIZE_16BIT;
nativeContext =
ffmpegInitialize(codecName, extraData, outputFloat, format.sampleRate, format.channelCount);
if (nativeContext == 0) {
throw new FfmpegDecoderException("Initialization failed.");
}
setInitialInputBufferSize(initialInputBufferSize);
}
@Override
public String getName() {
return "ffmpeg" + FfmpegLibrary.getVersion() + "-" + codecName;
}
@Override
protected DecoderInputBuffer createInputBuffer() {
return new DecoderInputBuffer(
DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT,
FfmpegLibrary.getInputBufferPaddingSize());
}
@Override
protected SimpleDecoderOutputBuffer createOutputBuffer() {
return new SimpleDecoderOutputBuffer(this::releaseOutputBuffer);
}
@Override
protected FfmpegDecoderException createUnexpectedDecodeException(Throwable error) {
return new FfmpegDecoderException("Unexpected decode error", error);
}
@Override
@Nullable
protected FfmpegDecoderException decode(
DecoderInputBuffer inputBuffer, SimpleDecoderOutputBuffer outputBuffer, boolean reset) {
if (reset) {
nativeContext = ffmpegReset(nativeContext, extraData);
if (nativeContext == 0) {
return new FfmpegDecoderException("Error resetting (see logcat).");
}
}
ByteBuffer inputData = Util.castNonNull(inputBuffer.data);
int inputSize = inputData.limit();
ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, outputBufferSize);
int result =
ffmpegDecode(
nativeContext, inputData, inputSize, outputBuffer, outputData, outputBufferSize);
if (result == AUDIO_DECODER_ERROR_OTHER) {
return new FfmpegDecoderException("Error decoding (see logcat).");
} else if (result == AUDIO_DECODER_ERROR_INVALID_DATA) {
outputBuffer.shouldBeSkipped = true;
return null;
} else if (result == 0) {
outputBuffer.shouldBeSkipped = true;
return null;
}
if (!hasOutputFormat) {
channelCount = ffmpegGetChannelCount(nativeContext);
sampleRate = ffmpegGetSampleRate(nativeContext);
if (sampleRate == 0 && "alac".equals(codecName)) {
checkNotNull(extraData);
ParsableByteArray parsableExtraData = new ParsableByteArray(extraData);
parsableExtraData.setPosition(extraData.length - 4);
sampleRate = parsableExtraData.readUnsignedIntToInt();
}
hasOutputFormat = true;
}
outputData = checkNotNull(outputBuffer.data);
outputData.position(0);
outputData.limit(result);
return null;
}
@SuppressWarnings("unused")
private ByteBuffer growOutputBuffer(SimpleDecoderOutputBuffer outputBuffer, int requiredSize) {
outputBufferSize = requiredSize;
return outputBuffer.grow(requiredSize);
}
@Override
public void release() {
super.release();
ffmpegRelease(nativeContext);
nativeContext = 0;
}
int getChannelCount() {
return channelCount;
}
int getSampleRate() {
return sampleRate;
}
@C.PcmEncoding
int getEncoding() {
return encoding;
}
@Nullable
private static byte[] getExtraData(String mimeType, List<byte[]> initializationData) {
switch (mimeType) {
case MimeTypes.AUDIO_AAC:
case MimeTypes.AUDIO_OPUS:
return initializationData.get(0);
case MimeTypes.AUDIO_ALAC:
return getAlacExtraData(initializationData);
case MimeTypes.AUDIO_VORBIS:
return getVorbisExtraData(initializationData);
case MimeTypes.AUDIO_FLAC:
return getFlacExtraData(initializationData);
default:
return null;
}
}
private static byte[] getAlacExtraData(List<byte[]> initializationData) {
byte[] magicCookie = initializationData.get(0);
int alacAtomLength = 12 + magicCookie.length;
ByteBuffer alacAtom = ByteBuffer.allocate(alacAtomLength);
alacAtom.putInt(alacAtomLength);
alacAtom.putInt(0x616c6163);
alacAtom.putInt(0);
alacAtom.put(magicCookie, 0, magicCookie.length);
return alacAtom.array();
}
private static byte[] getVorbisExtraData(List<byte[]> initializationData) {
byte[] header0 = initializationData.get(0);
byte[] header1 = initializationData.get(1);
byte[] extraData = new byte[header0.length + header1.length + 6];
extraData[0] = (byte) (header0.length >> 8);
extraData[1] = (byte) (header0.length & 0xFF);
System.arraycopy(header0, 0, extraData, 2, header0.length);
extraData[header0.length + 2] = 0;
extraData[header0.length + 3] = 0;
extraData[header0.length + 4] = (byte) (header1.length >> 8);
extraData[header0.length + 5] = (byte) (header1.length & 0xFF);
System.arraycopy(header1, 0, extraData, header0.length + 6, header1.length);
return extraData;
}
@Nullable
private static byte[] getFlacExtraData(List<byte[]> initializationData) {
for (int i = 0; i < initializationData.size(); i++) {
@Nullable byte[] streamInfo = extractFlacStreamInfo(initializationData.get(i));
if (streamInfo != null) {
return streamInfo;
}
}
return null;
}
@Nullable
private static byte[] extractFlacStreamInfo(byte[] data) {
int offset = 0;
if (arrayStartsWith(data, FLAC_STREAM_MARKER)) {
offset = FLAC_STREAM_MARKER.length;
}
if (data.length - offset == FLAC_STREAM_INFO_DATA_SIZE) {
byte[] streamInfo = new byte[FLAC_STREAM_INFO_DATA_SIZE];
System.arraycopy(data, offset, streamInfo, 0, FLAC_STREAM_INFO_DATA_SIZE);
return streamInfo;
}
if (data.length >= offset + FLAC_METADATA_BLOCK_HEADER_SIZE) {
int type = data[offset] & 0x7F;
int length =
((data[offset + 1] & 0xFF) << 16)
| ((data[offset + 2] & 0xFF) << 8)
| (data[offset + 3] & 0xFF);
if (type == FLAC_METADATA_TYPE_STREAM_INFO
&& length == FLAC_STREAM_INFO_DATA_SIZE
&& data.length >= offset + FLAC_METADATA_BLOCK_HEADER_SIZE + FLAC_STREAM_INFO_DATA_SIZE) {
byte[] streamInfo = new byte[FLAC_STREAM_INFO_DATA_SIZE];
System.arraycopy(
data,
offset + FLAC_METADATA_BLOCK_HEADER_SIZE,
streamInfo,
0,
FLAC_STREAM_INFO_DATA_SIZE);
return streamInfo;
}
}
return null;
}
private static boolean arrayStartsWith(byte[] data, byte[] prefix) {
if (data.length < prefix.length) {
return false;
}
for (int i = 0; i < prefix.length; i++) {
if (data[i] != prefix[i]) {
return false;
}
}
return true;
}
private native long ffmpegInitialize(
String codecName,
@Nullable byte[] extraData,
boolean outputFloat,
int rawSampleRate,
int rawChannelCount);
private native int ffmpegDecode(
long context,
ByteBuffer inputData,
int inputSize,
SimpleDecoderOutputBuffer decoderOutputBuffer,
ByteBuffer outputData,
int outputSize);
private native int ffmpegGetChannelCount(long context);
private native int ffmpegGetSampleRate(long context);
private native long ffmpegReset(long context, @Nullable byte[] extraData);
private native void ffmpegRelease(long context);
}
@@ -0,0 +1,126 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* 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 androidx.media3.decoder.ffmpeg;
import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY;
import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_SUPPORTED_WITH_TRANSCODING;
import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_UNSUPPORTED;
import static com.google.common.base.Preconditions.checkNotNull;
import android.os.Handler;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.TraceUtil;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.decoder.CryptoConfig;
import androidx.media3.exoplayer.audio.AudioRendererEventListener;
import androidx.media3.exoplayer.audio.AudioSink;
import androidx.media3.exoplayer.audio.AudioSink.SinkFormatSupport;
import androidx.media3.exoplayer.audio.DecoderAudioRenderer;
/** Decodes and renders audio using the FFmpeg libraries shared with libmpv. */
@UnstableApi
public final class FfmpegAudioRenderer extends DecoderAudioRenderer<FfmpegAudioDecoder> {
private static final String TAG = "FfmpegAudioRenderer";
private static final int NUM_BUFFERS = 16;
private static final int DEFAULT_INPUT_BUFFER_SIZE = 960 * 6;
public FfmpegAudioRenderer(
@Nullable Handler eventHandler,
@Nullable AudioRendererEventListener eventListener,
AudioSink audioSink) {
super(eventHandler, eventListener, audioSink);
}
@Override
public String getName() {
return TAG;
}
@Override
protected @C.FormatSupport int supportsFormatInternal(Format format) {
String mimeType = checkNotNull(format.sampleMimeType);
if (!FfmpegLibrary.isAvailable() || !MimeTypes.isAudio(mimeType)) {
return C.FORMAT_UNSUPPORTED_TYPE;
} else if (!FfmpegLibrary.supportsFormat(mimeType)
|| (!sinkSupportsFormat(format, C.ENCODING_PCM_16BIT)
&& !sinkSupportsFormat(format, C.ENCODING_PCM_FLOAT))) {
return C.FORMAT_UNSUPPORTED_SUBTYPE;
} else if (format.cryptoType != C.CRYPTO_TYPE_NONE) {
return C.FORMAT_UNSUPPORTED_DRM;
} else {
return C.FORMAT_HANDLED;
}
}
@Override
public @AdaptiveSupport int supportsMixedMimeTypeAdaptation() {
return ADAPTIVE_NOT_SEAMLESS;
}
@Override
protected FfmpegAudioDecoder createDecoder(Format format, @Nullable CryptoConfig cryptoConfig)
throws FfmpegDecoderException {
TraceUtil.beginSection("createFfmpegAudioDecoder");
int initialInputBufferSize =
format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE;
FfmpegAudioDecoder decoder =
new FfmpegAudioDecoder(
format, NUM_BUFFERS, NUM_BUFFERS, initialInputBufferSize, shouldOutputFloat(format));
TraceUtil.endSection();
return decoder;
}
@Override
protected Format getOutputFormat(FfmpegAudioDecoder decoder) {
checkNotNull(decoder);
return new Format.Builder()
.setSampleMimeType(MimeTypes.AUDIO_RAW)
.setChannelCount(decoder.getChannelCount())
.setSampleRate(decoder.getSampleRate())
.setPcmEncoding(decoder.getEncoding())
.build();
}
private boolean sinkSupportsFormat(Format inputFormat, @C.PcmEncoding int pcmEncoding) {
return sinkSupportsFormat(
Util.getPcmFormat(pcmEncoding, inputFormat.channelCount, inputFormat.sampleRate));
}
private boolean shouldOutputFloat(Format inputFormat) {
if (!sinkSupportsFormat(inputFormat, C.ENCODING_PCM_16BIT)) {
return true;
}
@SinkFormatSupport
int formatSupport =
getSinkFormatSupport(
Util.getPcmFormat(
C.ENCODING_PCM_FLOAT, inputFormat.channelCount, inputFormat.sampleRate));
switch (formatSupport) {
case SINK_FORMAT_SUPPORTED_DIRECTLY:
return !MimeTypes.AUDIO_AC3.equals(inputFormat.sampleMimeType);
case SINK_FORMAT_UNSUPPORTED:
case SINK_FORMAT_SUPPORTED_WITH_TRANSCODING:
default:
return false;
}
}
}
@@ -0,0 +1,30 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* 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 androidx.media3.decoder.ffmpeg;
import androidx.media3.decoder.DecoderException;
/** Thrown when an FFmpeg decoder error occurs. */
final class FfmpegDecoderException extends DecoderException {
FfmpegDecoderException(String message) {
super(message);
}
FfmpegDecoderException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,126 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* 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 androidx.media3.decoder.ffmpeg;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.LibraryLoader;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
/** Configures and queries the FFmpeg libraries shared with libmpv. */
@UnstableApi
final class FfmpegLibrary {
private static final String TAG = "FfmpegLibrary";
private static final LibraryLoader LOADER =
new LibraryLoader("ffmpegJNI") {
@Override
protected void loadLibrary(String name) {
System.loadLibrary(name);
}
};
@Nullable private static String version;
private static int inputBufferPaddingSize = C.LENGTH_UNSET;
private FfmpegLibrary() {}
/** Returns whether the JNI adapter and libmpv's FFmpeg libraries can be loaded. */
static boolean isAvailable() {
return LOADER.isAvailable();
}
/** Returns the linked FFmpeg version. The native libraries must be available. */
static String getVersion() {
String cachedVersion = version;
if (cachedVersion == null) {
cachedVersion = ffmpegGetVersion();
version = cachedVersion;
}
return cachedVersion;
}
/** Returns the required FFmpeg input-buffer padding. The native libraries must be available. */
static int getInputBufferPaddingSize() {
if (inputBufferPaddingSize == C.LENGTH_UNSET) {
inputBufferPaddingSize = ffmpegGetInputBufferPaddingSize();
}
return inputBufferPaddingSize;
}
/** Returns whether the linked FFmpeg build supports the MIME type. */
static boolean supportsFormat(String mimeType) {
@Nullable String codecName = getCodecName(mimeType);
if (codecName == null) {
return false;
}
if (!ffmpegHasDecoder(codecName)) {
Log.w(TAG, "No " + codecName + " decoder available in libmpv's FFmpeg build.");
return false;
}
return true;
}
/** Returns the FFmpeg decoder name for a supported MIME type. */
@Nullable
static String getCodecName(String mimeType) {
switch (mimeType) {
case MimeTypes.AUDIO_AAC:
return "aac";
case MimeTypes.AUDIO_MPEG:
case MimeTypes.AUDIO_MPEG_L1:
case MimeTypes.AUDIO_MPEG_L2:
return "mp3";
case MimeTypes.AUDIO_AC3:
return "ac3";
case MimeTypes.AUDIO_E_AC3:
case MimeTypes.AUDIO_E_AC3_JOC:
return "eac3";
case MimeTypes.AUDIO_TRUEHD:
return "truehd";
case MimeTypes.AUDIO_DTS:
case MimeTypes.AUDIO_DTS_HD:
return "dca";
case MimeTypes.AUDIO_VORBIS:
return "vorbis";
case MimeTypes.AUDIO_OPUS:
return "opus";
case MimeTypes.AUDIO_AMR_NB:
return "amrnb";
case MimeTypes.AUDIO_AMR_WB:
return "amrwb";
case MimeTypes.AUDIO_FLAC:
return "flac";
case MimeTypes.AUDIO_ALAC:
return "alac";
case MimeTypes.AUDIO_MLAW:
return "pcm_mulaw";
case MimeTypes.AUDIO_ALAW:
return "pcm_alaw";
default:
return null;
}
}
private static native String ffmpegGetVersion();
private static native int ffmpegGetInputBufferPaddingSize();
private static native boolean ffmpegHasDecoder(String codecName);
}
@@ -17,6 +17,7 @@ object DownmixMatrices {
const val MIN_DOWNMIX_INPUT_CHANNELS = 3
const val MAX_DOWNMIX_INPUT_CHANNELS = 8
const val MAX_CENTER_BOOST_DB = 12
const val MAX_MIXING_CHANNELS = 12
const val SURROUND_GAIN = 0.70710678f // -3 dB
private const val BACK_CENTER_GAIN = 0.5f // SURROUND_GAIN split across both outputs
@@ -65,6 +66,15 @@ object DownmixMatrices {
return flat
}
fun identityCoefficients(channelCount: Int): FloatArray {
require(channelCount in 1..MAX_MIXING_CHANNELS)
return FloatArray(channelCount * channelCount).apply {
for (channel in 0 until channelCount) {
this[channel * channelCount + channel] = 1f
}
}
}
private fun fl() = floatArrayOf(1f, 0f)
private fun fr() = floatArrayOf(0f, 1f)
private fun left(gain: Float) = floatArrayOf(gain, 0f)
@@ -51,10 +51,21 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
* "No mixing matrix for input channel count". ExoPlayerCore swaps in
* downmix matrices via [DownmixMatrices] when the setting is enabled.
*/
val channelMixProcessor = ChannelMixingAudioProcessor().apply {
for (count in 1..12) putChannelMixingMatrix(ChannelMixingMatrix.create(count, count))
private val identityChannelMixingMatrices = Array(DownmixMatrices.MAX_MIXING_CHANNELS) { index ->
val channelCount = index + 1
ChannelMixingMatrix(
channelCount,
channelCount,
DownmixMatrices.identityCoefficients(channelCount)
)
}
val channelMixProcessor = ChannelMixingAudioProcessor().apply {
for (matrix in identityChannelMixingMatrices) putChannelMixingMatrix(matrix)
}
fun identityChannelMixingMatrix(channelCount: Int): ChannelMixingMatrix = identityChannelMixingMatrices[channelCount - 1]
/** Returns whether direct encoded output should be hidden so decoded PCM output can be selected. */
var shouldBlockDirectAudioOutput: ((Format) -> Boolean)? = null
@@ -75,9 +86,9 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
allowedVideoJoiningTimeMs: Long,
out: ArrayList<Renderer>
) {
// Let super build the full list (it also appends extension renderers reflectively,
// e.g. the jellyfin ffmpeg artifact's video renderer), then swap the stock
// MediaCodecVideoRenderer for the DV-sanitizing variant at the same index.
// Let super build the full list (including optional extension renderers),
// then swap the stock MediaCodecVideoRenderer for the DV-sanitizing variant
// at the same index.
super.buildVideoRenderers(
context,
extensionRendererMode,
+15
View File
@@ -0,0 +1,15 @@
cmake_minimum_required(VERSION 3.22.1)
project(dovi_bridge_test LANGUAGES CXX)
enable_testing()
add_executable(dovi_bridge_test dovi_bridge_test.cpp)
target_compile_features(dovi_bridge_test PRIVATE cxx_std_17)
target_include_directories(dovi_bridge_test PRIVATE fakes)
add_test(NAME dovi_bridge_test COMMAND dovi_bridge_test)
add_executable(ffmpeg_audio_buffer_test ffmpeg_audio_buffer_test.cpp)
target_compile_features(ffmpeg_audio_buffer_test PRIVATE cxx_std_17)
add_test(NAME ffmpeg_audio_buffer_test COMMAND ffmpeg_audio_buffer_test)
@@ -0,0 +1,259 @@
#include <cstdarg>
#include <cstdio>
#include <string>
#include <vector>
#define DOVI_REAL_LINKED 1
#include "../../main/cpp/dovi_bridge.cpp"
struct DoviRpuOpaque {
std::string error;
};
enum class ParseResult { kValid, kError, kNull };
enum class WriteResult { kValid, kNull, kEmpty, kOversized };
static ParseResult parse_result;
static WriteResult write_result;
static int conversion_result;
static std::string parser_error;
static std::string last_log;
static std::vector<uint8_t> last_unspec_input;
static std::vector<uint8_t> encoded_output;
static int unspec_parse_calls;
static int raw_parse_calls;
static int conversion_calls;
static int write_calls;
static int rpu_free_calls;
static int data_free_calls;
extern "C" int __android_log_print(int, const char*, const char* format, ...) {
char buffer[2048];
va_list args;
va_start(args, format);
std::vsnprintf(buffer, sizeof(buffer), format, args);
va_end(args);
last_log = buffer;
return static_cast<int>(last_log.size());
}
extern "C" DoviRpuOpaque* dovi_parse_unspec62_nalu(const uint8_t* data, size_t len) {
++unspec_parse_calls;
last_unspec_input.assign(data, data + len);
if (parse_result == ParseResult::kNull) return nullptr;
return new DoviRpuOpaque{parse_result == ParseResult::kError ? parser_error : ""};
}
// A raw parse deliberately succeeds so the malformed-NAL test detects any
// regression that retries framed input through dovi_parse_rpu.
extern "C" DoviRpuOpaque* dovi_parse_rpu(const uint8_t*, size_t) {
++raw_parse_calls;
return new DoviRpuOpaque{};
}
extern "C" const char* dovi_rpu_get_error(const DoviRpuOpaque* rpu) {
return rpu->error.empty() ? nullptr : rpu->error.c_str();
}
extern "C" void dovi_rpu_free(DoviRpuOpaque* rpu) {
++rpu_free_calls;
delete rpu;
}
extern "C" int32_t dovi_convert_rpu_with_mode(DoviRpuOpaque* rpu, uint8_t) {
++conversion_calls;
if (conversion_result != 0) rpu->error = "conversion error";
return conversion_result;
}
extern "C" const DoviData* dovi_write_unspec62_nalu(DoviRpuOpaque*) {
++write_calls;
if (write_result == WriteResult::kNull) return nullptr;
if (write_result == WriteResult::kEmpty) return new DoviData{nullptr, 0};
const size_t len = write_result == WriteResult::kOversized ? MAX_RPU_OUTPUT_SIZE + 1 : encoded_output.size();
auto* bytes = new uint8_t[len];
for (size_t i = 0; i < len; ++i) {
bytes[i] = write_result == WriteResult::kOversized ? 0 : encoded_output[i];
}
return new DoviData{bytes, len};
}
extern "C" void dovi_data_free(const DoviData* data) {
++data_free_calls;
delete[] data->data;
delete data;
}
static void resetFakes() {
parse_result = ParseResult::kValid;
write_result = WriteResult::kValid;
conversion_result = 0;
parser_error.clear();
last_log.clear();
last_unspec_input.clear();
encoded_output = {0x7c, 0x01, 0x19, 0x08, 0x55};
unspec_parse_calls = 0;
raw_parse_calls = 0;
conversion_calls = 0;
write_calls = 0;
rpu_free_calls = 0;
data_free_calls = 0;
}
static _jbyteArray byteArray(const std::vector<uint8_t>& bytes) {
_jbyteArray array;
array.bytes.reserve(bytes.size());
for (uint8_t byte : bytes) array.bytes.push_back(static_cast<jbyte>(byte));
return array;
}
static jint convert(
JNIEnv& env, _jbyteArray& payload, _jbyteArray& output, jint output_offset = 0, jint output_capacity = -1) {
if (output_capacity < 0) output_capacity = static_cast<jint>(output.bytes.size());
return Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81(
&env, nullptr, &payload, 0, static_cast<jint>(payload.bytes.size()), &output, output_offset, output_capacity, 2);
}
#define CHECK(condition) \
do { \
if (!(condition)) { \
std::fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #condition); \
return false; \
} \
} while (false)
static bool validUnspec62NalConvertsAndFreesAllocations() {
resetFakes();
const std::vector<uint8_t> framed_nal = {0x7c, 0x01, 0x19, 0x08, 0x22};
auto payload = byteArray(framed_nal);
auto output = byteArray(std::vector<uint8_t>(16, 0));
JNIEnv env;
const jint result = convert(env, payload, output, 2);
CHECK(result == static_cast<jint>(encoded_output.size()));
CHECK(last_unspec_input == framed_nal);
CHECK(raw_parse_calls == 0);
CHECK(unspec_parse_calls == 1);
CHECK(conversion_calls == 1);
CHECK(write_calls == 1);
CHECK(rpu_free_calls == 1);
CHECK(data_free_calls == 1);
for (size_t i = 0; i < encoded_output.size(); ++i) {
CHECK(static_cast<uint8_t>(output.bytes[i + 2]) == encoded_output[i]);
}
return true;
}
static bool malformedTruncatedNalFailsWithoutRawFallback() {
resetFakes();
parse_result = ParseResult::kError;
parser_error.assign(2048, 'x');
auto payload = byteArray({0x7c, 0x01, 0x19});
auto output = byteArray(std::vector<uint8_t>(16, 0));
JNIEnv env;
const jint result = convert(env, payload, output);
CHECK(result == CONVERT_FAILED);
CHECK(unspec_parse_calls == 1);
CHECK(raw_parse_calls == 0);
CHECK(conversion_calls == 0);
CHECK(write_calls == 0);
CHECK(rpu_free_calls == 1);
CHECK(data_free_calls == 0);
const std::string prefix = "RPU NAL parse failed: ";
CHECK(last_log == prefix + std::string(MAX_ERROR_LOG_LENGTH, 'x'));
return true;
}
static bool nullParserResultFailsWithoutFreeingNonexistentAllocation() {
resetFakes();
parse_result = ParseResult::kNull;
auto payload = byteArray({0x7c, 0x01});
auto output = byteArray(std::vector<uint8_t>(16, 0));
JNIEnv env;
CHECK(convert(env, payload, output) == CONVERT_FAILED);
CHECK(raw_parse_calls == 0);
CHECK(rpu_free_calls == 0);
CHECK(data_free_calls == 0);
return true;
}
static bool conversionFailureFreesRpu() {
resetFakes();
conversion_result = -1;
auto payload = byteArray({0x7c, 0x01, 0x19, 0x08});
auto output = byteArray(std::vector<uint8_t>(16, 0));
JNIEnv env;
CHECK(convert(env, payload, output) == CONVERT_FAILED);
CHECK(rpu_free_calls == 1);
CHECK(data_free_calls == 0);
return true;
}
static bool unusableWriterResultFreesBothAllocations() {
resetFakes();
write_result = WriteResult::kEmpty;
auto payload = byteArray({0x7c, 0x01, 0x19, 0x08});
auto output = byteArray(std::vector<uint8_t>(16, 0));
JNIEnv env;
CHECK(convert(env, payload, output) == CONVERT_FAILED);
CHECK(rpu_free_calls == 1);
CHECK(data_free_calls == 1);
return true;
}
static bool destinationTooSmallFreesBothAllocations() {
resetFakes();
auto payload = byteArray({0x7c, 0x01, 0x19, 0x08});
auto output = byteArray(std::vector<uint8_t>(4, 0));
JNIEnv env;
CHECK(convert(env, payload, output) == DESTINATION_TOO_SMALL);
CHECK(rpu_free_calls == 1);
CHECK(data_free_calls == 1);
return true;
}
static bool jniWriteFailureFreesBothAllocations() {
resetFakes();
auto payload = byteArray({0x7c, 0x01, 0x19, 0x08});
auto output = byteArray(std::vector<uint8_t>(16, 0));
JNIEnv env;
env.fail_next_write = true;
CHECK(convert(env, payload, output) == CONVERT_FAILED);
CHECK(rpu_free_calls == 1);
CHECK(data_free_calls == 1);
return true;
}
int main() {
struct TestCase {
const char* name;
bool (*run)();
};
const TestCase tests[] = {
{"valid conversion", validUnspec62NalConvertsAndFreesAllocations},
{"malformed truncated NAL", malformedTruncatedNalFailsWithoutRawFallback},
{"null parser result", nullParserResultFailsWithoutFreeingNonexistentAllocation},
{"conversion failure", conversionFailureFreesRpu},
{"unusable writer result", unusableWriterResultFreesBothAllocations},
{"destination too small", destinationTooSmallFreesBothAllocations},
{"JNI write failure", jniWriteFailureFreesBothAllocations},
};
for (const TestCase& test : tests) {
if (!test.run()) {
std::fprintf(stderr, "FAILED: %s\n", test.name);
return 1;
}
}
std::printf("Passed %zu dovi_bridge tests\n", sizeof(tests) / sizeof(tests[0]));
return 0;
}
@@ -0,0 +1,14 @@
#pragma once
#define ANDROID_LOG_INFO 4
#define ANDROID_LOG_WARN 5
#ifdef __cplusplus
extern "C" {
#endif
int __android_log_print(int priority, const char* tag, const char* format, ...);
#ifdef __cplusplus
}
#endif
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
#define JNIEXPORT
#define JNICALL
#define JNI_TRUE 1
#define JNI_FALSE 0
using jboolean = uint8_t;
using jbyte = int8_t;
using jint = int32_t;
using jsize = jint;
struct _jclass {};
using jclass = _jclass*;
struct _jbyteArray {
std::vector<jbyte> bytes;
};
using jbyteArray = _jbyteArray*;
struct _jstring {
std::string value;
};
using jstring = _jstring*;
class JNIEnv {
public:
bool exception_pending = false;
bool fail_next_write = false;
jsize GetArrayLength(jbyteArray array) { return static_cast<jsize>(array->bytes.size()); }
void GetByteArrayRegion(jbyteArray array, jsize offset, jsize length, jbyte* destination) {
if (offset < 0 || length < 0 || offset > GetArrayLength(array) || length > GetArrayLength(array) - offset) {
exception_pending = true;
return;
}
std::memcpy(destination, array->bytes.data() + offset, static_cast<size_t>(length));
}
void SetByteArrayRegion(jbyteArray array, jsize offset, jsize length, const jbyte* source) {
if (fail_next_write) {
fail_next_write = false;
exception_pending = true;
return;
}
if (offset < 0 || length < 0 || offset > GetArrayLength(array) || length > GetArrayLength(array) - offset) {
exception_pending = true;
return;
}
std::memcpy(array->bytes.data() + offset, source, static_cast<size_t>(length));
}
jboolean ExceptionCheck() const { return exception_pending ? JNI_TRUE : JNI_FALSE; }
jstring NewStringUTF(const char* value) { return new _jstring{value}; }
};
@@ -0,0 +1,76 @@
#include "../../main/cpp/media3_ffmpeg_decoder/ffmpeg_audio_buffer.h"
#include <climits>
#include <cstdio>
namespace {
bool check(bool condition, const char* message) {
if (!condition) std::fprintf(stderr, "%s\n", message);
return condition;
}
bool computesPackedPcmSizes() {
int bytes = -1;
return check(plezy::ffmpeg::CheckedAudioByteCount(1024, 2, 2, &bytes), "stereo PCM size rejected") &&
check(bytes == 4096, "wrong stereo PCM size") &&
check(plezy::ffmpeg::CheckedAudioByteCount(1024, 6, 4, &bytes), "5.1 float PCM size rejected") &&
check(bytes == 24576, "wrong 5.1 float PCM size") &&
check(plezy::ffmpeg::CheckedAudioByteCount(1024, 8, 2, &bytes), "7.1 PCM size rejected") &&
check(bytes == 16384, "wrong 7.1 PCM size");
}
bool usesConvertedSampleCount() {
int capacity = -1;
int written = -1;
return check(plezy::ffmpeg::CheckedAudioByteCount(2048, 6, 2, &capacity), "output capacity rejected") &&
check(plezy::ffmpeg::CheckedAudioByteCount(1536, 6, 2, &written), "converted sample count rejected") &&
check(capacity == 24576, "wrong output capacity") && check(written == 18432, "wrong converted byte count") &&
check(written < capacity, "converted bytes must not equal an unused upper bound");
}
bool rejectsInvalidAndOverflowingSizes() {
int bytes = 7;
const int largestSafeStereoSampleCount = INT_MAX / 4;
return check(!plezy::ffmpeg::CheckedAudioByteCount(-1, 2, 2, &bytes), "negative samples accepted") &&
check(!plezy::ffmpeg::CheckedAudioByteCount(1, 0, 2, &bytes), "zero channels accepted") &&
check(!plezy::ffmpeg::CheckedAudioByteCount(1, 2, 0, &bytes), "zero sample size accepted") &&
check(
!plezy::ffmpeg::CheckedAudioByteCount(largestSafeStereoSampleCount + 1, 2, 2, &bytes),
"multiplication overflow accepted") &&
check(!plezy::ffmpeg::CheckedAddByteCount(INT_MAX, 1, &bytes), "addition overflow accepted") &&
check(!plezy::ffmpeg::CheckedAddByteCount(-1, 1, &bytes), "negative accumulated size accepted");
}
bool acceptsEmptyOutputAndIntBoundary() {
int bytes = -1;
int total = -1;
return check(plezy::ffmpeg::CheckedAudioByteCount(0, 8, 4, &bytes), "empty output rejected") &&
check(bytes == 0, "empty output is not zero bytes") &&
check(plezy::ffmpeg::CheckedAddByteCount(INT_MAX - 4, 4, &total), "INT_MAX boundary rejected") &&
check(total == INT_MAX, "wrong INT_MAX boundary sum");
}
} // namespace
int main() {
struct TestCase {
const char* name;
bool (*run)();
};
const TestCase tests[] = {
{"packed PCM sizes", computesPackedPcmSizes},
{"converted sample count", usesConvertedSampleCount},
{"invalid and overflowing sizes", rejectsInvalidAndOverflowingSizes},
{"empty output and boundary", acceptsEmptyOutputAndIntBoundary},
};
for (const TestCase& test : tests) {
if (!test.run()) {
std::fprintf(stderr, "FAILED: %s\n", test.name);
return 1;
}
}
std::printf("Passed %zu ffmpeg_audio_buffer tests\n", sizeof(tests) / sizeof(tests[0]));
return 0;
}
@@ -92,6 +92,19 @@ class DownmixMatricesTest {
}
}
@Test
fun identityCoefficientsPreserveEveryChannel() {
for (channels in 1..DownmixMatrices.MAX_MIXING_CHANNELS) {
val matrix = DownmixMatrices.identityCoefficients(channels)
assertEquals(channels * channels, matrix.size)
for (input in 0 until channels) {
for (output in 0 until channels) {
assertEquals(if (input == output) 1f else 0f, matrix[input * channels + output], 0f)
}
}
}
}
@Test
fun passThroughCountsReturnNull() {
for (channels in intArrayOf(1, 2, 9, 12)) {