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

@ -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) {