feat(audio): bitstream passthrough by default on Android TV
mpv's ao_audiotrack hardcoded stereo IEC61937 frames, which cannot carry TrueHD MAT or DTS-HD MA 8-channel bursts, and the app fed audio-spdif an empty list in self-defense because mpv force-passthroughs every codec named there with no decode fallback. The mpv path therefore decoded everything, and passthrough had to be enabled by hand on every install. The Kotlin audio route probe (AudioOutputPolicy) now feeds mpv a per-route audio-spdif list, the fork's multichannel IEC61937 patch (pinned libmpv v1.1.3) provides the burst geometry, and E-AC3, TrueHD and DTS-HD MA bitstream on the mpv path. Android TV installs with no stored preference default to passthrough on, on both backends; an explicit user choice is preserved. Verified by route probing and mpv logs on Shield and Box R; no AVR was in the loop, so receiver-side decode is unconfirmed.
This commit is contained in:
@@ -76,24 +76,40 @@ internal fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
|
||||
else -> false
|
||||
}
|
||||
|
||||
/** The IEC 61937 track shape a codec's spdif burst rides. */
|
||||
internal enum class MpvIecShape { STEREO_48K, STEREO_192K, SURROUND_192K }
|
||||
|
||||
private class MpvSpdifCodec(val name: String, val encoding: Int, val shape: MpvIecShape)
|
||||
|
||||
/**
|
||||
* mpv `audio-spdif` codec names and the exact platform encoding a route must
|
||||
* advertise to carry that bitstream.
|
||||
* mpv `audio-spdif` codec names, the platform encoding a route must advertise to carry that
|
||||
* bitstream, and the IEC 61937 track shape the codec's burst rides.
|
||||
*
|
||||
* Only the codecs mpv's audiotrack AO can physically carry are listed. That AO opens every
|
||||
* spdif format as a stereo `ENCODING_IEC61937` track and clamps the track rate to
|
||||
* `getNativeOutputSampleRate` (the 48kHz mixer rate), so E-AC3 (a 192kHz burst), TrueHD
|
||||
* (192kHz/8ch MAT) and DTS-HD MA (192kHz/8ch) can never leave that AO intact. Naming one
|
||||
* anyway force-passes the codec into a track that cannot carry it (#1991); those streams
|
||||
* decode to PCM instead, like every other unnamed codec.
|
||||
* `ad_spdif` fixes the geometry per codec: AC3 and the DTS core are stereo frames at the mixer
|
||||
* rate, E-AC3 a stereo frame at 192kHz, TrueHD as MAT and DTS-HD MA 8-channel 192kHz bursts
|
||||
* (`audio/decode/ad_spdif.c:216-267`). Only AC3 and the DTS core used to be listed because
|
||||
* libmpv's `ao_audiotrack` squeezed every burst into a stereo track at the mixer rate; v1.1.0
|
||||
* skips that clamp for spdif and takes the channel mask from the burst instead
|
||||
* (`audio/out/ao_audiotrack.c:678-731`).
|
||||
*
|
||||
* `dts-hd` supersedes plain `dts`: that literal is what selects the lossless `spdif_dts_hd`
|
||||
* decoder, and it enables spdif for the whole `dts` codec while doing so, with the core burst
|
||||
* still chosen per file for tracks that are not HD (`ad_spdif.c:240-249`, `:400-418`). HRA rides
|
||||
* a 2ch/192kHz burst under the same name, so gating it on the 8-channel carrier is the
|
||||
* conservative choice.
|
||||
*/
|
||||
private val MPV_SPDIF_CODECS: List<Pair<String, Int>> = listOf(
|
||||
"ac3" to C.ENCODING_AC3,
|
||||
"dts" to C.ENCODING_DTS
|
||||
private val MPV_SPDIF_CODECS: List<MpvSpdifCodec> = listOf(
|
||||
MpvSpdifCodec("ac3", C.ENCODING_AC3, MpvIecShape.STEREO_48K),
|
||||
MpvSpdifCodec("eac3", C.ENCODING_E_AC3, MpvIecShape.STEREO_192K),
|
||||
MpvSpdifCodec("truehd", C.ENCODING_DOLBY_TRUEHD, MpvIecShape.SURROUND_192K),
|
||||
MpvSpdifCodec("dts", C.ENCODING_DTS, MpvIecShape.STEREO_48K),
|
||||
MpvSpdifCodec("dts-hd", C.ENCODING_DTS_HD, MpvIecShape.SURROUND_192K)
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an `audio-spdif` value naming only the codecs [supportsEncoding] advertises.
|
||||
* Builds an `audio-spdif` value naming only the codecs the route can carry: [supportsEncoding]
|
||||
* advertises the codec's encoding *and* [supportsShape] takes the track shape its burst needs.
|
||||
* Plain `dts` is dropped whenever `dts-hd` qualifies, which already covers the core burst.
|
||||
*
|
||||
* mpv force-passes through every codec named here and has no decode fallback, so an
|
||||
* unsupported name leaves the file rendering video against a dead audio output (#1703).
|
||||
@@ -103,19 +119,27 @@ private val MPV_SPDIF_CODECS: List<Pair<String, Int>> = listOf(
|
||||
* channel counts above the route's PCM maximum, neither of which describes what an IEC
|
||||
* 61937 track carries.
|
||||
*/
|
||||
internal fun mpvSpdifCodecs(supportsEncoding: (Int) -> Boolean): String = MPV_SPDIF_CODECS
|
||||
.filter { (_, encoding) -> supportsEncoding(encoding) }
|
||||
.joinToString(",") { (codec, _) -> codec }
|
||||
internal fun mpvSpdifCodecs(
|
||||
supportsEncoding: (Int) -> Boolean,
|
||||
supportsShape: (MpvIecShape) -> Boolean
|
||||
): String {
|
||||
val carried = MPV_SPDIF_CODECS.filter { supportsEncoding(it.encoding) && supportsShape(it.shape) }
|
||||
val dtsHd = carried.any { it.name == "dts-hd" }
|
||||
return (if (dtsHd) carried.filterNot { it.name == "dts" } else carried).joinToString(",") { it.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* [mpvSpdifCodecs] resolved against the audio route [context] is currently routed to.
|
||||
*
|
||||
* Two conditions, both required:
|
||||
* - The route must accept the exact track shape mpv opens for spdif output — stereo
|
||||
* IEC 61937 at the 48kHz mixer rate ([supportsMpvIecShape]). Advertising the raw AC3/DTS
|
||||
* encodings only says the receiver decodes them, not that the HAL takes an IEC 61937
|
||||
* AudioTrack: #1991's Shield bitstreams AC3 through ExoPlayer's raw path while every mpv
|
||||
* spdif attempt strands playback on a dead audio output, leaving nothing but AAC playable.
|
||||
* Two conditions per codec, both required:
|
||||
* - The route must accept the exact track shape mpv opens for that codec's burst — stereo
|
||||
* IEC 61937 at 48kHz ([supportsMpvIecShape]), stereo at 192kHz
|
||||
* ([supportsMpvHighRateIecShape]) or 192kHz/7.1 ([supportsIecCarrier]). Advertising the raw
|
||||
* encoding only says the receiver decodes it, not that the HAL takes an IEC 61937 AudioTrack:
|
||||
* #1991's Shield bitstreams AC3 through ExoPlayer's raw path while every mpv spdif attempt
|
||||
* strands playback on a dead audio output, leaving nothing but AAC playable. The shapes are
|
||||
* independent, so none of them may veto the whole list: a route that takes the 192kHz carrier
|
||||
* but not the 48kHz stereo frame still bitstreams TrueHD and DTS-HD MA.
|
||||
* - The receiver must decode the codec itself — the raw encoding on the current
|
||||
* [AudioCapabilities] — because IEC 61937 is transport, not transcoding.
|
||||
*/
|
||||
@@ -124,10 +148,6 @@ internal fun mpvSpdifCodecs(supportsEncoding: (Int) -> Boolean): String = MPV_SP
|
||||
@Suppress("DEPRECATION")
|
||||
@OptIn(UnstableApi::class)
|
||||
internal fun supportedMpvSpdifCodecs(context: Context): String {
|
||||
if (!supportsMpvIecShape(context)) {
|
||||
Log.i(TAG, "Route takes no stereo IEC 61937 track; mpv will decode instead of bitstreaming")
|
||||
return ""
|
||||
}
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
@@ -138,12 +158,29 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
|
||||
Log.w(TAG, "Audio route capabilities unavailable; mpv will decode instead of bitstreaming", error)
|
||||
return ""
|
||||
}
|
||||
return mpvSpdifCodecs(capabilities::supportsEncoding)
|
||||
// Every shape costs real route probes and is shared by more than one codec, so probe each once.
|
||||
val probed = HashMap<MpvIecShape, Boolean>(3)
|
||||
val codecs = mpvSpdifCodecs(capabilities::supportsEncoding) { shape ->
|
||||
probed.getOrPut(shape) { routeTakesIecShape(context, shape) }
|
||||
}
|
||||
if (codecs.isEmpty()) {
|
||||
Log.i(TAG, "Route takes no IEC 61937 track mpv can fill; mpv will decode instead of bitstreaming")
|
||||
} else {
|
||||
Log.i(TAG, "mpv will bitstream: $codecs")
|
||||
}
|
||||
return codecs
|
||||
}
|
||||
|
||||
/** The track shape mpv's audiotrack AO opens for spdif output: stereo IEC 61937 at the mixer rate. */
|
||||
private fun routeTakesIecShape(context: Context, shape: MpvIecShape): Boolean = when (shape) {
|
||||
MpvIecShape.STEREO_48K -> supportsMpvIecShape(context)
|
||||
MpvIecShape.STEREO_192K -> supportsMpvHighRateIecShape(context)
|
||||
MpvIecShape.SURROUND_192K -> supportsIecCarrier(context)
|
||||
}
|
||||
|
||||
/** The track shape mpv's audiotrack AO opens for an AC3 or DTS-core burst: stereo at the mixer rate. */
|
||||
private const val MPV_IEC_SAMPLE_RATE = 48_000
|
||||
private const val MPV_IEC_CHANNEL_COUNT = 2
|
||||
private const val MPV_IEC_HIGH_SAMPLE_RATE = 192_000
|
||||
|
||||
internal fun supportsMpvIecShape(context: Context): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
@@ -161,6 +198,23 @@ internal fun supportsMpvIecShape(context: Context): Boolean = iecRouteSupported(
|
||||
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_SAMPLE_RATE, MPV_IEC_CHANNEL_COUNT) }
|
||||
)
|
||||
|
||||
/** E-AC3's geometry: the stereo shape at the 192kHz burst rate, same route tiering as the others. */
|
||||
internal fun supportsMpvHighRateIecShape(context: Context): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
canSizeBuffer = { canSizeIecBuffer(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO) },
|
||||
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
|
||||
// check cannot see through the injected lambdas.
|
||||
bitstreamSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
iecBitstreamSupported(iecProbeFormat(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
directPlaybackSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
iecDirectPlaybackSupported(iecProbeFormat(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_HIGH_SAMPLE_RATE, MPV_IEC_CHANNEL_COUNT) }
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether this route can carry a packed bitstream inside IEC 61937 at 192kHz/7.1 — TrueHD as MAT
|
||||
* (#1804) and DTS-HD MA as DTS type IV (#1988) both ride this exact tuple.
|
||||
|
||||
@@ -94,24 +94,70 @@ class AudioOutputPolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListNamesOnlyCodecsMpvsStereoIecTrackCanCarry() {
|
||||
// eac3 (a 192kHz burst), truehd and dts-hd (192kHz/8ch) never survive mpv's audiotrack
|
||||
// AO, which opens every spdif format as a stereo IEC 61937 track at the mixer rate;
|
||||
// naming them strands playback on a dead audio output (#1991).
|
||||
assertEquals("ac3,dts", mpvSpdifCodecs { true })
|
||||
fun spdifListNamesEveryCodecARouteWithAllShapesCanCarry() {
|
||||
// libmpv v1.1.0's audiotrack AO opens each burst at its own rate and channel mask, so a route
|
||||
// that takes all three shapes and advertises everything bitstreams the lossless codecs too.
|
||||
// `dts-hd` supersedes plain `dts`: ad_spdif picks the core burst per file for non-HD tracks.
|
||||
assertEquals("ac3,eac3,truehd,dts-hd", spdifCodecs(allEncodings, allShapes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListOnAStereo48kOnlyRouteNamesTheCoreCodecsOnly() {
|
||||
// E-AC3 (192kHz), TrueHD MAT and DTS-HD MA (192kHz/8ch) have no track to ride here.
|
||||
assertEquals("ac3,dts", spdifCodecs(allEncodings, setOf(MpvIecShape.STEREO_48K)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dtsHdFallsBackToThePlainCoreWithoutTheCarrierShape() {
|
||||
// Advertising ENCODING_DTS_HD says the receiver decodes it, not that the route takes the
|
||||
// 192kHz/8ch track its burst needs (#1988); naming `dts-hd` anyway strands playback.
|
||||
assertEquals(
|
||||
"dts",
|
||||
spdifCodecs(setOf(C.ENCODING_DTS, C.ENCODING_DTS_HD), setOf(MpvIecShape.STEREO_48K))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eac3IsNotNamedWithoutThe192kStereoShape() {
|
||||
assertEquals("", spdifCodecs(setOf(C.ENCODING_E_AC3), setOf(MpvIecShape.STEREO_48K)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trueHdIsNotNamedWithoutTheCarrierShape() {
|
||||
assertEquals(
|
||||
"",
|
||||
spdifCodecs(
|
||||
setOf(C.ENCODING_DOLBY_TRUEHD),
|
||||
setOf(MpvIecShape.STEREO_48K, MpvIecShape.STEREO_192K)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainDtsSurvivesWhenTheRouteDoesNotAdvertiseDtsHd() {
|
||||
// The core burst is stereo/48k, so it rides a full-shape route unchanged when only the
|
||||
// lossless encoding is missing.
|
||||
assertEquals("dts", spdifCodecs(setOf(C.ENCODING_DTS), allShapes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListDropsCodecsTheRouteCannotBitstream() {
|
||||
// Google TV Streamer over HDMI to a Dolby-only sink: AC3 bitstreams, DTS does not (#1703).
|
||||
// Google TV Streamer over HDMI to a Dolby-only sink: AC3/E-AC3 bitstream, DTS does not (#1703).
|
||||
val dolbyOnlyRoute = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3)
|
||||
|
||||
assertEquals("ac3", mpvSpdifCodecs { encoding -> encoding in dolbyOnlyRoute })
|
||||
assertEquals("ac3,eac3", spdifCodecs(dolbyOnlyRoute, allShapes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListIsEmptyForPcmOnlyRoutes() {
|
||||
assertEquals("", mpvSpdifCodecs { false })
|
||||
assertEquals("", mpvSpdifCodecs({ false }, { false }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListIsEmptyWhenTheRouteTakesNoIecTrackAtAll() {
|
||||
// Every encoding advertised, but no IEC 61937 track shape opens: mpv has no decode fallback
|
||||
// for a named codec, so nothing may be named (#1991).
|
||||
assertEquals("", spdifCodecs(allEncodings, emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -204,4 +250,17 @@ class AudioOutputPolicyTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Every encoding the spdif table can ask for, i.e. a receiver that decodes all of them. */
|
||||
private val allEncodings = setOf(
|
||||
C.ENCODING_AC3,
|
||||
C.ENCODING_E_AC3,
|
||||
C.ENCODING_DOLBY_TRUEHD,
|
||||
C.ENCODING_DTS,
|
||||
C.ENCODING_DTS_HD
|
||||
)
|
||||
|
||||
private val allShapes = MpvIecShape.values().toSet()
|
||||
|
||||
private fun spdifCodecs(encodings: Set<Int>, shapes: Set<MpvIecShape>): String = mpvSpdifCodecs({ it in encodings }, { it in shapes })
|
||||
}
|
||||
|
||||
@@ -267,12 +267,16 @@ class _AudioPassthroughPref extends Pref<bool> {
|
||||
bool readFrom(BaseSharedPreferencesService svc) {
|
||||
final stored = svc.readNullableBool(key);
|
||||
if (stored != null) return stored;
|
||||
// Android TV on ExoPlayer defaults to bitstreaming AC3/EAC3/DTS to the TV/AVR
|
||||
// (Media3 picks bitstream vs PCM via AudioCapabilities), preserving surround.
|
||||
// Scoped to ExoPlayer — the mpv backend force-sets audio-spdif with no decode
|
||||
// fallback. (#1458)
|
||||
// Android TV defaults to bitstreaming Dolby/DTS to the TV/AVR, preserving
|
||||
// surround. Both backends decide from the same source — the sink's
|
||||
// advertised capabilities: Media3 via AudioCapabilities, mpv via the
|
||||
// route-probed audio-spdif list (supportedMpvSpdifCodecs), which names
|
||||
// only codecs the live route accepts rather than forcing the whole set.
|
||||
// That probe is the only safety net on the mpv path: ao_audiotrack fails
|
||||
// the open outright when a route lied about a format, with no decode
|
||||
// fallback behind it (#1458, #1703).
|
||||
// TODO: Default Apple TV to on once the #1300 Atmos sink is hardware-verified.
|
||||
return Platform.isAndroid && PlatformDetector.isTV() && svc.read(SettingsService.useExoPlayer);
|
||||
return Platform.isAndroid && PlatformDetector.isTV();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
Reference in New Issue
Block a user