Experimental option to transcode surround sound audio to AC3 (#947)

## Description
Adds an experimental setting to prefer transcoding audio with more than
2 channels to AC3. Stereo audio does not transcode.

This is basically a workaround for HDMI ARC (not eARC) or optical audio
devices because instead of 2 uncompressed PCM channels, 6 AC3 channels
(ie 5.1) is possible.

An downside of enabling this is that embedded subtitles be burned in
causing a video transcode as well.

### Related issues
Fixes #255
Incorporates some code from #1410

### Testing
Just on the emulator so far to observe if the settings trigger the
transcode or not

## Screenshots
N/A

## AI or LLM usage
None

---------

Co-authored-by: darkflame91 <darkflame91@gmail.com>
This commit is contained in:
Damontecres 2026-07-09 13:15:42 -04:00 • committed by GitHub
parent f2287a85e8
commit d481aea85a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 205 additions and 54 deletions

View file

@ -53,7 +53,10 @@ sealed interface AppPreference<Pref, T> {
value: T?,
): String? = null
fun validate(value: T): PreferenceValidation = PreferenceValidation.Valid
fun validate(
prefs: Pref,
value: T,
): PreferenceValidation = PreferenceValidation.Valid
companion object {
val SkipForward =
@ -391,18 +394,25 @@ sealed interface AppPreference<Pref, T> {
defaultValue = true,
getter = { it.playbackPreferences.overrides.ac3Supported },
setter = { prefs, value ->
prefs.updatePlaybackOverrides { ac3Supported = value }
if (!value) prefs.updateExperimentalPreferences { preferAc3Surround = false }
prefs.updatePlaybackOverrides {
ac3Supported = value
}
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val DownMixStereo =
AppSwitchPreference<AppPreferences>(
title = R.string.downmix_stereo,
defaultValue = false,
getter = { it.playbackPreferences.overrides.downmixStereo },
setter = { prefs, value ->
prefs.updatePlaybackOverrides { downmixStereo = value }
if (value) prefs.updateExperimentalPreferences { preferAc3Surround = false }
prefs.updatePlaybackOverrides {
downmixStereo = value
}
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
@ -1311,11 +1321,16 @@ data class AppSwitchPreference<Pref>(
override val defaultValue: Boolean,
override val getter: (prefs: Pref) -> Boolean,
override val setter: (prefs: Pref, value: Boolean) -> Pref,
val validator: (value: Boolean) -> PreferenceValidation = { PreferenceValidation.Valid },
val validator: (prefs: Pref, value: Boolean) -> PreferenceValidation = { _, _ -> PreferenceValidation.Valid },
@param:StringRes val summary: Int? = null,
@param:StringRes val summaryOn: Int? = null,
@param:StringRes val summaryOff: Int? = null,
) : AppPreference<Pref, Boolean> {
override fun validate(
prefs: Pref,
value: Boolean,
): PreferenceValidation = validator.invoke(prefs, value)
override fun summary(
context: Context,
value: Boolean?,

View file

@ -4,6 +4,7 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
object ExperimentalPreference {
val Enable =
@ -35,6 +36,30 @@ object ExperimentalPreference {
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val PreferAc3ForSurround =
AppSwitchPreference<AppPreferences>(
title = R.string.prefer_ac3_for_surround,
defaultValue = true,
getter = { it.experimentalPreferences.preferAc3Surround },
setter = { prefs, value ->
prefs.updateExperimentalPreferences {
preferAc3Surround = value
}
},
summary = R.string.prefer_ac3_for_surround_summary,
validator = { prefs, value ->
prefs.playbackPreferences.overrides.let {
if (value && !it.ac3Supported) {
PreferenceValidation.Invalid("AC3 support is not enabled")
} else if (value && it.downmixStereo) {
PreferenceValidation.Invalid("Always downmixing to stereo")
} else {
PreferenceValidation.Valid
}
}
},
)
}
val experimentalPreferences =
@ -45,6 +70,7 @@ val experimentalPreferences =
preferences =
listOf(
ExperimentalPreference.VideoTunneling,
ExperimentalPreference.PreferAc3ForSurround,
),
),
)

View file

@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
import com.github.damontecres.wholphin.preferences.PlaybackOverrides
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
@ -34,20 +36,17 @@ class DeviceProfileService
private var deviceProfile: DeviceProfile? = null
suspend fun getOrCreateDeviceProfile(
prefs: PlaybackPreferences,
appPrefs: AppPreferences,
serverVersion: ServerVersion?,
): DeviceProfile =
withContext(WholphinDispatchers.Default) {
val prefs = appPrefs.playbackPreferences
mutex.withLock {
val newConfig =
DeviceProfileConfiguration(
maxBitrate = prefs.maxBitrate.toInt(),
isAC3Enabled = prefs.overrides.ac3Supported,
downMixAudio = prefs.overrides.downmixStereo,
assPlaybackMode = prefs.overrides.assPlaybackMode,
pgsDirectPlay = prefs.overrides.directPlayPgs,
dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL,
decodeAv1 = prefs.overrides.decodeAv1,
overrides = prefs.overrides,
experimental = appPrefs.experimentalPreferences,
jellyfinTenEleven =
serverVersion != null && serverVersion >= ServerVersion(10, 11, 0),
)
@ -57,12 +56,13 @@ class DeviceProfileService
createDeviceProfile(
mediaTest = mediaCodecCapabilitiesTest,
maxBitrate = newConfig.maxBitrate,
isAC3Enabled = newConfig.isAC3Enabled,
downMixAudio = newConfig.downMixAudio,
assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
pgsDirectPlay = newConfig.pgsDirectPlay,
dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay,
isAC3Enabled = newConfig.overrides.ac3Supported,
downMixAudio = newConfig.overrides.downmixStereo,
assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
pgsDirectPlay = newConfig.overrides.directPlayPgs,
dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL,
decodeAv1 = prefs.overrides.decodeAv1,
preferAc3ForSurround = appPrefs.experimentalPreferences.preferAc3Surround,
jellyfinTenEleven = newConfig.jellyfinTenEleven,
)
}
@ -76,11 +76,7 @@ class DeviceProfileService
*/
data class DeviceProfileConfiguration(
val maxBitrate: Int,
val isAC3Enabled: Boolean,
val downMixAudio: Boolean,
val assPlaybackMode: AssPlaybackMode,
val pgsDirectPlay: Boolean,
val dolbyVisionELDirectPlay: Boolean,
val decodeAv1: Boolean,
val overrides: PlaybackOverrides,
val experimental: ExperimentalPreferences,
val jellyfinTenEleven: Boolean,
)

View file

@ -61,10 +61,10 @@ class MediaReportService
.getItem(itemId = item.id)
.content.mediaSources
val sourcesJson = json.encodeToString(sources)
val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences
val appPreferences = userPreferencesService.getCurrent().appPreferences
val serverVersion = serverRepository.currentServer?.serverVersion
val deviceProfile =
deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion)
deviceProfileService.getOrCreateDeviceProfile(appPreferences, serverVersion)
val deviceProfileJson = json.encodeToString(deviceProfile)
val body =
"""
@ -76,7 +76,14 @@ class MediaReportService
model=${Build.MODEL}
apiLevel=${Build.VERSION.SDK_INT}
playbackPrefs=${playbackPrefs.toString().replace("\n", ", ").replace("\t", " ")}
playbackPrefs=${
appPreferences.playbackPreferences.toString().replace("\n", ", ")
.replace("\t", " ")
}
experimental=${
appPreferences.experimentalPreferences.toString().replace("\n", ", ")
.replace("\t", " ")
}
deviceProfile=$deviceProfileJson

View file

@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.data.model.AudioItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.seekBack
@ -25,6 +26,7 @@ import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.PlaybackItemState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.profile.Codec
import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
@ -44,6 +46,7 @@ import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage
@ -72,6 +75,7 @@ class MusicService
private val playerFactory: PlayerFactory,
private val serverRepository: ServerRepository,
private val imageUrlService: ImageUrlService,
private val userPreferencesService: UserPreferencesService,
) {
private val _state = MutableStateFlow(MusicServiceState.EMPTY)
val state: StateFlow<MusicServiceState> = _state
@ -92,6 +96,11 @@ class MusicService
private var activityTracker: TrackActivityPlaybackListener? = null
private var websocketJob: Job? = null
private suspend fun preferAc3Surround() =
userPreferencesService
.getCurrent()
.appPreferences.experimentalPreferences.preferAc3Surround
/**
* Start music playback
*
@ -195,10 +204,11 @@ class MusicService
shuffled: Boolean,
) {
Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled)
val preferAc3Surround = preferAc3Surround()
val mediaItems =
items
.filter { it.type == BaseItemKind.AUDIO }
.map(::convert)
.map { convert(it, preferAc3Surround) }
withContext(WholphinDispatchers.Main) {
player.setMediaItems(mediaItems)
player.shuffleModeEnabled = shuffled
@ -215,7 +225,8 @@ class MusicService
index: Int? = null,
) {
if (item.type == BaseItemKind.AUDIO) {
val mediaItem = convert(item)
val preferAc3Surround = preferAc3Surround()
val mediaItem = convert(item, preferAc3Surround)
withContext(WholphinDispatchers.Main) {
if (index != null) {
player.addMediaItem(index, mediaItem)
@ -242,6 +253,7 @@ class MusicService
list: BlockingList<BaseItem?>,
startIndex: Int,
) = loading {
val preferAc3Surround = preferAc3Surround()
var remaining = startIndex
list.indices
.chunked(25)
@ -252,7 +264,7 @@ class MusicService
list
.getBlocking(it)
?.takeIf { it.type == BaseItemKind.AUDIO }
?.let(::convert)
?.let { convert(it, preferAc3Surround) }
} else {
Timber.v("Skipping $remaining")
remaining--
@ -268,12 +280,36 @@ class MusicService
/**
* Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag
*/
private fun convert(audio: BaseItem): MediaItem {
private fun convert(
audio: BaseItem,
preferAc3Surround: Boolean,
): MediaItem {
val needsAc3Transcode =
preferAc3Surround &&
audio.data.mediaSources
?.firstOrNull()
?.mediaStreams
?.any { stream ->
stream.type == MediaStreamType.AUDIO &&
stream.channels.gt(2)
stream.codec != Codec.Audio.AC3
} == true
val url =
api.universalAudioApi.getUniversalAudioStreamUrl(
itemId = audio.id,
container = audioFormats,
)
if (needsAc3Transcode) {
api.universalAudioApi.getUniversalAudioStreamUrl(
itemId = audio.id,
container = listOf("mka"),
transcodingContainer = "mka",
maxAudioChannels = 6,
transcodingAudioChannels = 6,
audioCodec = Codec.Audio.AC3,
)
} else {
api.universalAudioApi.getUniversalAudioStreamUrl(
itemId = audio.id,
container = audioFormats,
)
}
Timber.i("url=%s", url)
val imageUrl =
audio.data.albumId?.let { albumId ->
@ -354,7 +390,7 @@ class MusicService
* Play this item next after the current, ie add the item as the next index in the queue
*/
suspend fun playNext(song: BaseItem) {
val mediaItem = convert(song)
val mediaItem = convert(song, preferAc3Surround())
onMain {
player.addMediaItem(state.value.currentIndex + 1, mediaItem)
if (player.mediaItemCount == 1) {

View file

@ -54,6 +54,7 @@ import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
@ -646,7 +647,7 @@ class PlaybackViewModel
deviceProfile =
if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) {
deviceProfileService.getOrCreateDeviceProfile(
preferences.appPreferences.playbackPreferences,
preferences.appPreferences,
serverRepository.currentServer?.serverVersion,
)
} else {
@ -855,9 +856,24 @@ class PlaybackViewModel
// TODO there's probably no reason why we can't add external subtitles?
Timber.v("changeStreams direct play")
// TODO Better way to handle unsupported types in general is needed
// This is a workaround for switching to a non AC3 track when the user wants audio transcoded to AC3
if (preferences.appPreferences.experimentalPreferences.preferAc3Surround && audioIndex != null) {
currentPlayback.mediaSourceInfo.mediaStreams
.orEmpty()
.firstOrNull { it.index == audioIndex }
?.let {
if (it.channels.gt(2) && it.codec != Codec.Audio.AC3) {
// User wants to transcode audio into AC3
return@withContext false
}
}
}
val source = currentPlayback.mediaSourceInfo
val externalSubtitle = source.findExternalSubtitle(subtitleIndex)
// TODO there's probably no reason why we can't add external subtitles?
if (externalSubtitle == null) {
val result =
withContext(WholphinDispatchers.Main) {

View file

@ -621,7 +621,7 @@ fun PreferencesContent(
value = value,
onNavigate = viewModel.navigationManager::navigateTo,
onValueChange = { newValue ->
val validation = pref.validate(newValue)
val validation = pref.validate(preferences, newValue)
when (validation) {
is PreferenceValidation.Invalid -> {
// TODO?

View file

@ -162,7 +162,7 @@ fun SubtitlePreferencesContent(
value = value,
onNavigate = viewModel.navigationManager::navigateTo,
onValueChange = { newValue ->
val validation = pref.validate(newValue)
val validation = pref.validate(preferences, newValue)
when (validation) {
is PreferenceValidation.Invalid -> {
// TODO?

View file

@ -71,6 +71,7 @@ fun createDeviceProfile(
dolbyVisionELDirectPlay: Boolean,
decodeAv1: Boolean,
jellyfinTenEleven: Boolean,
preferAc3ForSurround: Boolean,
) = buildDeviceProfile {
val allowedAudioCodecs =
when {
@ -132,31 +133,61 @@ fun createDeviceProfile(
// / Transcoding profiles
// Video
transcodingProfile {
type = DlnaProfileType.VIDEO
context = EncodingContext.STREAMING
if (preferAc3ForSurround) {
transcodingProfile {
type = DlnaProfileType.VIDEO
context = EncodingContext.STREAMING
container = Codec.Container.TS
protocol = MediaStreamProtocol.HLS
container = Codec.Container.TS
protocol = MediaStreamProtocol.HLS
if (supportsHevc) videoCodec(Codec.Video.HEVC)
videoCodec(Codec.Video.H264)
if (supportsHevc) videoCodec(Codec.Video.HEVC)
videoCodec(Codec.Video.H264)
audioCodec(*allowedAudioCodecs)
audioCodec(Codec.Audio.AC3)
copyTimestamps = false
enableSubtitlesInManifest = true
copyTimestamps = false
enableSubtitlesInManifest = true
}
} else {
transcodingProfile {
type = DlnaProfileType.VIDEO
context = EncodingContext.STREAMING
container = Codec.Container.TS
protocol = MediaStreamProtocol.HLS
if (supportsHevc) videoCodec(Codec.Video.HEVC)
videoCodec(Codec.Video.H264)
audioCodec(*allowedAudioCodecs)
copyTimestamps = false
enableSubtitlesInManifest = true
}
}
// Audio
transcodingProfile {
type = DlnaProfileType.AUDIO
context = EncodingContext.STREAMING
if (preferAc3ForSurround) {
transcodingProfile {
type = DlnaProfileType.AUDIO
context = EncodingContext.STREAMING
container = Codec.Container.TS
protocol = MediaStreamProtocol.HLS
container = Codec.Container.TS
protocol = MediaStreamProtocol.HLS
audioCodec(Codec.Audio.AAC)
audioCodec(Codec.Audio.AC3)
}
} else {
transcodingProfile {
type = DlnaProfileType.AUDIO
context = EncodingContext.STREAMING
container = Codec.Container.TS
protocol = MediaStreamProtocol.HLS
audioCodec(Codec.Audio.AAC)
}
}
// / Direct play profiles
@ -530,6 +561,27 @@ fun createDeviceProfile(
}
}
if (preferAc3ForSurround) {
supportedAudioCodecs
.filterNot { it == Codec.Audio.AC3 }
.forEach { audioCodec ->
codecProfile {
type = CodecType.VIDEO_AUDIO
codec = audioCodec
conditions {
ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals 2
}
}
codecProfile {
type = CodecType.AUDIO
codec = audioCodec
conditions {
ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals 2
}
}
}
}
// Audio channel profile
codecProfile {
type = CodecType.VIDEO_AUDIO

View file

@ -215,6 +215,7 @@ message MusicPreferences {
message ExperimentalPreferences {
bool enabled = 1;
bool video_tunneling_enabled = 2;
bool prefer_ac3_surround = 3;
}
message AppPreferences {

View file

@ -551,6 +551,8 @@
<string name="display_presets">Display presets</string>
<string name="display_presets_description">Built-in presets to quickly style all rows</string>
<string name="customize_home_summary">Choose rows and images on the home page</string>
<string name="prefer_ac3_for_surround_summary"><![CDATA[Transcode non-stereo audio to AC3/Dolby Digital]]></string>
<string name="prefer_ac3_for_surround">Use AC3 for surround sound audio</string>
<string name="start_after">Start after</string>
<string name="animate">Animate</string>
<string name="max_age_rating">Max age rating</string>