fix(android): consolidate native media decoders
This commit is contained in:
+160
-28
@@ -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")
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+160
@@ -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;
|
||||
}
|
||||
}
|
||||
+149
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user