From 54a002f9b7271603d6eecba16079990dd1f83d28 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:43:42 +0200 Subject: [PATCH] fix(android): consolidate native media decoders --- android/app/build.gradle.kts | 188 ++++++- .../androidTest/assets/ffmpeg/planar_5_1.m4a | Bin 0 -> 962 bytes .../src/androidTest/assets/ffmpeg/stereo.flac | Bin 0 -> 8316 bytes .../assets/ffmpeg/surround_5_1.flac | Bin 0 -> 8382 bytes .../assets/ffmpeg/surround_5_1_dts.mka | Bin 0 -> 19460 bytes .../assets/ffmpeg/surround_5_1_eac3.mka | Bin 0 -> 10825 bytes .../assets/ffmpeg/surround_5_1_truehd.mka | Bin 0 -> 4371 bytes .../assets/ffmpeg/surround_7_1.flac | Bin 0 -> 8352 bytes .../ffmpeg/PlezyFfmpegPlaybackTest.java | 160 ++++++ .../exoplayer/PlezyAudioModePlaybackTest.kt | 149 ++++++ android/app/src/main/cpp/CMakeLists.txt | 22 +- android/app/src/main/cpp/dovi_bridge.cpp | 48 +- .../ffmpeg_audio_buffer.h | 48 ++ .../cpp/media3_ffmpeg_decoder/ffmpeg_jni.cc | 505 ++++++++++++++++++ .../decoder/ffmpeg/FfmpegAudioDecoder.java | 294 ++++++++++ .../decoder/ffmpeg/FfmpegAudioRenderer.java | 126 +++++ .../ffmpeg/FfmpegDecoderException.java | 30 ++ .../media3/decoder/ffmpeg/FfmpegLibrary.java | 126 +++++ .../plezy/exoplayer/DownmixMatrices.kt | 10 + .../plezy/exoplayer/PlezyRenderersFactory.kt | 21 +- android/app/src/test/cpp/CMakeLists.txt | 15 + android/app/src/test/cpp/dovi_bridge_test.cpp | 259 +++++++++ android/app/src/test/cpp/fakes/android/log.h | 14 + android/app/src/test/cpp/fakes/jni.h | 62 +++ .../src/test/cpp/ffmpeg_audio_buffer_test.cpp | 76 +++ .../plezy/exoplayer/DownmixMatricesTest.kt | 13 + 26 files changed, 2101 insertions(+), 65 deletions(-) create mode 100644 android/app/src/androidTest/assets/ffmpeg/planar_5_1.m4a create mode 100644 android/app/src/androidTest/assets/ffmpeg/stereo.flac create mode 100644 android/app/src/androidTest/assets/ffmpeg/surround_5_1.flac create mode 100644 android/app/src/androidTest/assets/ffmpeg/surround_5_1_dts.mka create mode 100644 android/app/src/androidTest/assets/ffmpeg/surround_5_1_eac3.mka create mode 100644 android/app/src/androidTest/assets/ffmpeg/surround_5_1_truehd.mka create mode 100644 android/app/src/androidTest/assets/ffmpeg/surround_7_1.flac create mode 100644 android/app/src/androidTest/java/androidx/media3/decoder/ffmpeg/PlezyFfmpegPlaybackTest.java create mode 100644 android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/PlezyAudioModePlaybackTest.kt create mode 100644 android/app/src/main/cpp/media3_ffmpeg_decoder/ffmpeg_audio_buffer.h create mode 100644 android/app/src/main/cpp/media3_ffmpeg_decoder/ffmpeg_jni.cc create mode 100644 android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegAudioDecoder.java create mode 100644 android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegAudioRenderer.java create mode 100644 android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegDecoderException.java create mode 100644 android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegLibrary.java create mode 100644 android/app/src/test/cpp/CMakeLists.txt create mode 100644 android/app/src/test/cpp/dovi_bridge_test.cpp create mode 100644 android/app/src/test/cpp/fakes/android/log.h create mode 100644 android/app/src/test/cpp/fakes/jni.h create mode 100644 android/app/src/test/cpp/ffmpeg_audio_buffer_test.cpp diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6bc34608..884827cb 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") } diff --git a/android/app/src/androidTest/assets/ffmpeg/planar_5_1.m4a b/android/app/src/androidTest/assets/ffmpeg/planar_5_1.m4a new file mode 100644 index 0000000000000000000000000000000000000000..bb923c72c2746d7c1047ac75db596d13d7eebedb GIT binary patch literal 962 zcmb_bOKaOe5T2DE_~3&>9yLJ)1v|GSI3k!{0`A51(4I8eRFWwamniRtTpP_7JaAotJ&<6Scs-0YDud+Xf?`%t9py* z_9ab3rF9jCbF>tw`I!V3r1TGpBtbkL<%t6Gir#ySe2!T-mSYop7jS+t7irB>Au!KY z>_w!;+>-~d=)K&YVcVBRgW7(e&C`l{WBTF zDZ`?}*)->jAEC>4-c$xY$B2QNOvfM%t)3)p%gk=e*k?ZrH&ma+fs=93)@B%hpKGJ@ z1K^kw6&-P-EUafzr3Mpj71H&!`{z5Vf(cv& zIohPh#8IqQ(&I_1SPSSjIvD|3xH8Y`68GTy2A&Js+nDRm%nfZu;WChO>|t)}(@3%s zhXL=J>x=t)thOGWM!?)ttU8=Y

J{1iECr+7iQ)Yyw6&iEkvlk6|py*44Y@fz=s QlW<~wg=4(JLyV65AMZ(mvH$=8 literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/ffmpeg/stereo.flac b/android/app/src/androidTest/assets/ffmpeg/stereo.flac new file mode 100644 index 0000000000000000000000000000000000000000..e18b45bf2fff8fdacb50957a2c59c0a7b65f93da GIT binary patch literal 8316 zcmeIutqQ_W6u|K_5sPTJAdcZcw3vo2*i3?HU9b(^jS35E3wsW$4`DR>0DA!O0Zg*w zJh0>=2>;8u9L`5MKO4ns5t$erEIR~*t+suXWLZX^b7Rl9Yp{D literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/ffmpeg/surround_5_1.flac b/android/app/src/androidTest/assets/ffmpeg/surround_5_1.flac new file mode 100644 index 0000000000000000000000000000000000000000..859dad9cf5611195edfa36cf4610b26356f6b0f4 GIT binary patch literal 8382 zcmeIu!3qIU6b9f!Q);j%k$<0mYsV8n*J)$7}Az~z8DZl@{u#A-9g;bLMlx7#qc zHE&OOwTprP1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG|g-vvT{ TEa@1%LQ8~xPR3HvBy;`(?Vu{K literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/ffmpeg/surround_5_1_dts.mka b/android/app/src/androidTest/assets/ffmpeg/surround_5_1_dts.mka new file mode 100644 index 0000000000000000000000000000000000000000..039584d0754284af2c63e829d8195a59b880ec7b GIT binary patch literal 19460 zcmeI4T}V@57{|}VN-^*Ql#s|J-k1?4BoY$a+=!{uaI>f`a&EYbv5KZ9M7X2V&5uZn z2uey>H<}n$PGrFbQ5X>=fnEreSKef0T?84fvyb!kD#H6*ybK1Eq#BAshVG6nx@O^Ft=X0JpUT2 zCK#hd`Tj)uc^h;Wu8m(mv^3qFsZkkRH)_r8E#+lLj!K`>vNP(fYv-dOU3mG4!tQ;s zVbxdPIsAT3+vioCjrA3IBB|cO)s;6>@wO|MM1}K}w`6d(e&n0 zRlQc%p!IBP&iT<28nFD|%{Kplb`MLBW|1DPtRnJgN~^_en;+BpyxPVFwMk!Jr}f+} zEj=FnNQU?!6jb@!l={`6d#?7~)T$`>PBw{x;Og9TDgIWrJ#7!|su0q@U;7jehh2hD zzU=Vt*%X9=4M#A%=3jP5*WyQ;?CLIjvRf_Eqzh{#AxDL*lDM2U1eiiJ5iFa zAuX8%U@#@pVj`jpNlXhE zu}X|sj96;odCMezgLtC=)I(YTNW_A)2nv8eS^!AI0-*(r*bh1#Hui2F=EMJ@fVO$<-aSlBDTduL>Z741%oM(784O=NMc&Rh^@hh#fYUQp0`Zm zH;6Y1Ks}@dfJ7`vi=Y4qqy>OPED&12h;77(#fT+?#a}>1mH)2Hir5wt5oJJH6bzP06{|Cl%m#hE) literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/ffmpeg/surround_5_1_eac3.mka b/android/app/src/androidTest/assets/ffmpeg/surround_5_1_eac3.mka new file mode 100644 index 0000000000000000000000000000000000000000..856791e50bd4eace134bdbdee6299828651ba2f6 GIT binary patch literal 10825 zcmb1gy}x*|Q(GgW({~{L)X3uWxsk)EsUtVBq$s~QJJG2fDAd}>BoW+@&d2})TB3r! zvr7)_Z|Rw^RLFOCckt>KQO}iW!D|{9LU>{!>#~8okG{KmfO0}1htq@CG%`C8@Y4@z7*I~*b*?gIJf(*BmyTUGY1gF4&!dLv`;(GLF4_hsen z_jJr@WKh~Vr@1sQrK6Fdt<5ps)zR5FY*lk$VrX+C)5D4L8`-vYI6N-^o0pW*s1W+D zkpUDm4h{QTxJBDEi;EW|A81HFG0SyvyQ`nGzl&>-OFNnyi?1Xf=uSphntozFvg%1d zA0`8RXk-EMp+ah6vT^!}g|3S`Ttb5!Lp=TcT-qla7+8T3Pz4k)$UAQ<%~0LnqUQG7 z_<1A4VlS3P28IUia+d%0?FYc_bpVomY!ZoW@}E~fKR@5D=I3YfsQPp3DbW4%x`)aOjU7H<+>ST}x z>*M`$)<8u&u;~Vgm6w-;4A_2t7RZEnln848%ClE|e0&_Js2rpi8{m8Q?k&)OZ9hLp zflNS&?GGRg@8xVN-T}b_Y`TGBj6nZ^47hKX4Ke{W)A0O|R1uD9O!vF@L2SEnh zets5Y0&2QB0Mx|12c(mE56~;XpxE62;$Z>o<>h4{V`_c^gYw!|l!W^Oq`tgg&IS|| zkgSQt1fVZL2IS8-1(|@F071(6fMMAO3JWZH!9qZ9f(*$0{0tPG*HJRi1E7jQksv@W z9UjSI;1dKmhYXKoG4SOCD2Gs^v>5ngF$l_u1E9bhtv-N(K!u8<0Th_S6Nm?Zn}G_W z%|O)F*Jv{k-jW+_1|nL;ph94H)b}8tj5Y&7p+I@RV6+(s>g-dgoOl3=6Do8XN1K7b z2H*iuV2(Befq^jE3+HJA0|VYYGUxpbK0nKP?=Ld-_QjrV?(W>xwWHbF59kG%cZH=kjg%qz z;>(1cdtd&%=E4T*)GdAgN||qOOx&E$i&b=fG`jC%^w!fi(q4H{=DI$P^|kL?@%oNq z-yWCA+%v~__w3vjlgXY~?bgOnQ(e7px6FTc=uO#pCMI6(n2go3O%|Gdk_lTDv2QfG zZmorObtPJpQd{a+-9Ud`!>aYZx{$9Q<=1tgHMzg_p9i+3P85zl4Ar$lbyd}@I(K(x z&y(%jCdb5lO`+PxHR~IE{jYdDHIr``bK^zktoop*AvYSG-{!k^RVD)>R6?XN|Mg1~ zox46HCdqLcR7ynA_mF$nqoGxscaO)f(SCjjH92;aJ=`t%Z~a}!Y_^Tb78bn}E5 zYYs|qPa-Yn+35Mfzen>zi)FhqAw`5LqNi|^_vtEDi*7<%2gmh<*m^2pGIwww0nc$0 z;n#sTWWtfGosft=Vm>5F0#7nu<^PKGWaGeLneb$YlFA4l(Tn7V3ie@CM?^}3TAHS$ z7gIfpkigVHf|R5%H4=!B#neRNlyhN9AqP&>(lq7TF*UOYaZD}5L%Cs0t&jtk#?%H? z@Dx*6I?8)glqt7M9e#e(e=FyuLgf>R!6}q?O1TU#sg+w3%7kK-H+{*Jmyn>Hm;PP3 zg9wx#R22ov1y~k-3{xja;3qIGg=NvXwKPq2^_b3pjiBqm^d?ZFJBsN{$U&FHvVl`~BbeR{ndxJg&W0THLn_LY-y+5R`XajAP}t>sgJN(B<=#*( zV?bSQ9aAP0tNdh=DK94pyJ4CxFNdNGm8zmZc?E0)LlDzjp%V2V)%5`LGd;!Hl-~_% zOk-LqP}2ma_rO@{#`IoTELxB0LQq3HFujjRlpe*@!y+WLG)w6?ri(zMS-`XkHjTL& z)B7O@a|F`|AO~{{)5R>pkcu+pizU%-E~3jVg&Zc8{kAJiD+q-mc%e! z&ms(|C{rGkJpPg*y4+FN<@|ER;8evJO_ztDC`YBL zC{P{-HIAT`3e?eyX(Nk}z_ba}I8vBC0w;nai|Iy?Ahww|I6g}|3Mu;;;>c(;@Viw%^3r=UIlB*j{>b%lpJrwAUwbj9VE_OC literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/ffmpeg/surround_7_1.flac b/android/app/src/androidTest/assets/ffmpeg/surround_7_1.flac new file mode 100644 index 0000000000000000000000000000000000000000..6c456a8b6633da6c91029e32d94b0ed997c8f627 GIT binary patch literal 8352 zcmYfENpxmlU{Dfb5CT#PK*YU+?*jva(1D^|fi-(XWlG&xjQ0xVTx@4yV9?_Qs_{uI zOEWXlGXx?7BSxTrC=jRSCFiH47TKamHiFC>1*0J_8UmvsFd71*Aut*OqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O dLp}ul|A?w!P{!pA?#uswNL4T{!X>Y$4gfbo9I^la literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/java/androidx/media3/decoder/ffmpeg/PlezyFfmpegPlaybackTest.java b/android/app/src/androidTest/java/androidx/media3/decoder/ffmpeg/PlezyFfmpegPlaybackTest.java new file mode 100644 index 00000000..4613b966 --- /dev/null +++ b/android/app/src/androidTest/java/androidx/media3/decoder/ffmpeg/PlezyFfmpegPlaybackTest.java @@ -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 playerReference = new AtomicReference<>(); + AtomicReference 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; + } +} diff --git a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/PlezyAudioModePlaybackTest.kt b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/PlezyAudioModePlaybackTest.kt new file mode 100644 index 00000000..48cae68d --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/PlezyAudioModePlaybackTest.kt @@ -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() + val errorReference = AtomicReference() + 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 + } +} diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt index 690da4ad..a9ef5873 100644 --- a/android/app/src/main/cpp/CMakeLists.txt +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -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) diff --git a/android/app/src/main/cpp/dovi_bridge.cpp b/android/app/src/main/cpp/dovi_bridge.cpp index 71a6c194..4a163fcc 100644 --- a/android/app/src/main/cpp/dovi_bridge.cpp +++ b/android/app/src/main/cpp/dovi_bridge.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -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(payload_length); - DoviRpuOpaque* rpu = dovi_parse_unspec62_nalu(scratch.data(), rpu_len); + using RpuPtr = std::unique_ptr; + 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(mode)); + int32_t ret = dovi_convert_rpu_with_mode(rpu.get(), static_cast(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; + 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(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(out->len); - dovi_data_free(out); - dovi_rpu_free(rpu); - return write_failed ? CONVERT_FAILED : written; #endif } diff --git a/android/app/src/main/cpp/media3_ffmpeg_decoder/ffmpeg_audio_buffer.h b/android/app/src/main/cpp/media3_ffmpeg_decoder/ffmpeg_audio_buffer.h new file mode 100644 index 00000000..a28b0edd --- /dev/null +++ b/android/app/src/main/cpp/media3_ffmpeg_decoder/ffmpeg_audio_buffer.h @@ -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 +#include + +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(sample_count) * channel_count * bytes_per_sample; + if (size > INT_MAX) { + return false; + } + *byte_count = static_cast(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_ diff --git a/android/app/src/main/cpp/media3_ffmpeg_decoder/ffmpeg_jni.cc b/android/app/src/main/cpp/media3_ffmpeg_decoder/ffmpeg_jni.cc new file mode 100644 index 00000000..698be220 --- /dev/null +++ b/android/app/src/main/cpp/media3_ffmpeg_decoder/ffmpeg_jni.cc @@ -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 +#include + +#include "ffmpeg_audio_buffer.h" + +extern "C" { +#ifdef __cplusplus +#define __STDC_CONSTANT_MACROS +#ifdef _STDINT_H +#undef _STDINT_H +#endif +#include +#endif +#include +#include +#include +#include +#include +} + +#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(&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(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) + 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(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(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(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(context->opaque); + if (!resampleState) { + resampleState = static_cast(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(context->opaque)); + context->opaque = nullptr; + avcodec_free_context(&context); +} diff --git a/android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegAudioDecoder.java b/android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegAudioDecoder.java new file mode 100644 index 00000000..b05ef958 --- /dev/null +++ b/android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegAudioDecoder.java @@ -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 { + + 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 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 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 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 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); +} diff --git a/android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegAudioRenderer.java b/android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegAudioRenderer.java new file mode 100644 index 00000000..be9bcab8 --- /dev/null +++ b/android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegAudioRenderer.java @@ -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 { + + 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; + } + } +} diff --git a/android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegDecoderException.java b/android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegDecoderException.java new file mode 100644 index 00000000..9ac77ebb --- /dev/null +++ b/android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegDecoderException.java @@ -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); + } +} diff --git a/android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegLibrary.java b/android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegLibrary.java new file mode 100644 index 00000000..dde150b9 --- /dev/null +++ b/android/app/src/main/java/androidx/media3/decoder/ffmpeg/FfmpegLibrary.java @@ -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); +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DownmixMatrices.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DownmixMatrices.kt index ad039271..0b5d7ef6 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DownmixMatrices.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DownmixMatrices.kt @@ -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) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt index e98b8e8d..ae0b826c 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt @@ -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 ) { - // 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, diff --git a/android/app/src/test/cpp/CMakeLists.txt b/android/app/src/test/cpp/CMakeLists.txt new file mode 100644 index 00000000..6d47e475 --- /dev/null +++ b/android/app/src/test/cpp/CMakeLists.txt @@ -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) diff --git a/android/app/src/test/cpp/dovi_bridge_test.cpp b/android/app/src/test/cpp/dovi_bridge_test.cpp new file mode 100644 index 00000000..252c17ed --- /dev/null +++ b/android/app/src/test/cpp/dovi_bridge_test.cpp @@ -0,0 +1,259 @@ +#include +#include +#include +#include + +#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 last_unspec_input; +static std::vector 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(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& bytes) { + _jbyteArray array; + array.bytes.reserve(bytes.size()); + for (uint8_t byte : bytes) array.bytes.push_back(static_cast(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(output.bytes.size()); + return Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81( + &env, nullptr, &payload, 0, static_cast(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 framed_nal = {0x7c, 0x01, 0x19, 0x08, 0x22}; + auto payload = byteArray(framed_nal); + auto output = byteArray(std::vector(16, 0)); + JNIEnv env; + + const jint result = convert(env, payload, output, 2); + + CHECK(result == static_cast(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(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(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(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(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(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(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(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; +} diff --git a/android/app/src/test/cpp/fakes/android/log.h b/android/app/src/test/cpp/fakes/android/log.h new file mode 100644 index 00000000..523a7c22 --- /dev/null +++ b/android/app/src/test/cpp/fakes/android/log.h @@ -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 diff --git a/android/app/src/test/cpp/fakes/jni.h b/android/app/src/test/cpp/fakes/jni.h new file mode 100644 index 00000000..e63d3dd5 --- /dev/null +++ b/android/app/src/test/cpp/fakes/jni.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include +#include + +#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 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(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(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(length)); + } + + jboolean ExceptionCheck() const { return exception_pending ? JNI_TRUE : JNI_FALSE; } + + jstring NewStringUTF(const char* value) { return new _jstring{value}; } +}; diff --git a/android/app/src/test/cpp/ffmpeg_audio_buffer_test.cpp b/android/app/src/test/cpp/ffmpeg_audio_buffer_test.cpp new file mode 100644 index 00000000..3824ea2f --- /dev/null +++ b/android/app/src/test/cpp/ffmpeg_audio_buffer_test.cpp @@ -0,0 +1,76 @@ +#include "../../main/cpp/media3_ffmpeg_decoder/ffmpeg_audio_buffer.h" + +#include +#include + +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; +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/DownmixMatricesTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/DownmixMatricesTest.kt index 9f297f8d..d066128b 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/DownmixMatricesTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/DownmixMatricesTest.kt @@ -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)) {