From 80737d2a68631207ba96750f30deb241c03fb938 Mon Sep 17 00:00:00 2001 From: darkflame91 Date: Wed, 13 May 2026 14:06:38 +0530 Subject: [PATCH] Add 'SPDIF/ARC Mode' toggle and functionality This change forces all multichannel audio to be transcoded to AC3 5.1. This is meant for TV's/devices that support SPDIF and/or ARC, but not eARC. Therefore they are limited to 6 channels and the AC3 codec (many such devices support DTS codec as well, but not as universally as AC3). However, they frequently advertise support for more channels and newer codecs, silently downmixing such audio streams internally. This change ensures that the client can still output multichannel audio that is compatible with the device. This change does not force video transcode, or alter existing video stream transcoding behavior. This change primarily: 1. Adds a toggle to enable this mode. 2. Manages potentially conflicting 'Downmix to Stereo' and 'AC3 Supported' toggles. 3. For video libraries, if toggle is enabled, sets max channels to 6 and invalidates all non-AC3 codecs for multichannel audio. 4. For music libraries, if toggle is enabled, sets max channels to 6 and requests transcode to AC3 in mka container for multichannel audio. Similar to Experimental PR #947. 1. UT's added to validate and prevent potential toggle conflicts. 2. Manually tested on Firestick 4k (1st gen) in video+audio libraries: - Stereo audio content plays with DirectPlay - Multichannel AC3 content plays with DirectPlay - Multichannel non-AC3 content plays with DirectStream N/A LLM's were used for planning, initial draft and critical review. All code changes were manually reviewed and vetted. --- .../wholphin/preferences/AppPreference.kt | 33 ++++- .../preferences/AppPreferencesSerializer.kt | 2 + .../wholphin/services/DeviceProfileService.kt | 3 + .../wholphin/services/MusicService.kt | 44 ++++++- .../wholphin/ui/playback/PlaybackViewModel.kt | 18 ++- .../util/profile/DeviceProfileUtils.kt | 43 +++++- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 3 + .../wholphin/test/TestAudioSettings.kt | 122 ++++++++++++++++++ 9 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestAudioSettings.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index ecaa0fac9..6b6fd22db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -391,7 +391,10 @@ sealed interface AppPreference { defaultValue = true, getter = { it.playbackPreferences.overrides.ac3Supported }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { ac3Supported = value } + prefs.updatePlaybackOverrides { + ac3Supported = value + if (!value) spdifArcSurroundAudio = false + } }, summaryOn = R.string.enabled, summaryOff = R.string.disabled, @@ -402,7 +405,10 @@ sealed interface AppPreference { defaultValue = false, getter = { it.playbackPreferences.overrides.downmixStereo }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { downmixStereo = value } + prefs.updatePlaybackOverrides { + downmixStereo = value + if (value) spdifArcSurroundAudio = false + } }, summaryOn = R.string.enabled, summaryOff = R.string.disabled, @@ -455,6 +461,28 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) + val SpdifArcSurroundAudio = + AppSwitchPreference( + title = R.string.spdif_arc_surround_audio, + defaultValue = false, + getter = { it.playbackPreferences.overrides.spdifArcSurroundAudio }, + setter = { prefs, value -> + if (value) { + prefs.updatePlaybackOverrides { + spdifArcSurroundAudio = true + ac3Supported = true + downmixStereo = false + } + } else { + prefs.updatePlaybackOverrides { + spdifArcSurroundAudio = false + } + } + }, + summaryOn = R.string.spdif_arc_surround_audio_summary_on, + summaryOff = R.string.spdif_arc_surround_audio_summary_off, + ) + val CinemaMode = AppSwitchPreference( title = R.string.cinema_mode, @@ -1108,6 +1136,7 @@ private val ExoPlayerSettings = AppPreference.FfmpegPreference, AppPreference.DownMixStereo, AppPreference.Ac3Supported, + AppPreference.SpdifArcSurroundAudio, AppPreference.AssSubtitleMode, AppPreference.DirectPlayPgs, AppPreference.DirectPlayDoviProfile7, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index 47dae5ec1..51b843b22 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -68,6 +68,8 @@ class AppPreferencesSerializer AppPreference.FfmpegPreference.defaultValue assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue + spdifArcSurroundAudio = + AppPreference.SpdifArcSurroundAudio.defaultValue }.build() mpvOptions = diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index 4405092ee..9d6c315f2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -50,6 +50,7 @@ class DeviceProfileService decodeAv1 = prefs.overrides.decodeAv1, jellyfinTenEleven = serverVersion != null && serverVersion >= ServerVersion(10, 11, 0), + spdifArcSurroundAudio = prefs.overrides.spdifArcSurroundAudio, ) if (deviceProfile == null || this@DeviceProfileService.configuration != newConfig) { this@DeviceProfileService.configuration = newConfig @@ -64,6 +65,7 @@ class DeviceProfileService dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay, decodeAv1 = prefs.overrides.decodeAv1, jellyfinTenEleven = newConfig.jellyfinTenEleven, + spdifArcSurroundAudio = newConfig.spdifArcSurroundAudio, ) } this@DeviceProfileService.deviceProfile!! @@ -83,4 +85,5 @@ data class DeviceProfileConfiguration( val dolbyVisionELDirectPlay: Boolean, val decodeAv1: Boolean, val jellyfinTenEleven: Boolean, + val spdifArcSurroundAudio: Boolean = false, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index 6eddb51c4..020f629d2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -24,6 +24,7 @@ import com.github.damontecres.wholphin.util.BlockingList 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.profile.Codec import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -44,6 +45,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 +74,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 = _state @@ -198,7 +201,7 @@ class MusicService val mediaItems = items .filter { it.type == BaseItemKind.AUDIO } - .map(::convert) + .map { convert(it) } withContext(Dispatchers.Main) { player.setMediaItems(mediaItems) player.shuffleModeEnabled = shuffled @@ -250,7 +253,7 @@ class MusicService list .getBlocking(it) ?.takeIf { it.type == BaseItemKind.AUDIO } - ?.let(::convert) + ?.let { convert(it) } } else { Timber.v("Skipping $remaining") remaining-- @@ -266,12 +269,39 @@ class MusicService /** * Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag */ - private fun convert(audio: BaseItem): MediaItem { + private suspend fun convert(audio: BaseItem): MediaItem { + val spdifMode = + userPreferencesService + .getCurrent() + .appPreferences.playbackPreferences.overrides.spdifArcSurroundAudio + + val needsAc3Transcode = + spdifMode && + audio.data.mediaSources + ?.firstOrNull() + ?.mediaStreams + ?.any { stream -> + stream.type == MediaStreamType.AUDIO && + (stream.channels ?: 0) > 2 && + stream.codec != Codec.Audio.AC3 + } ?: false + 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 -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 4a73afd9e..99f38406a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -55,6 +55,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 @@ -653,7 +654,8 @@ class PlaybackViewModel } else { mpvDeviceProfile }, - maxAudioChannels = null, + maxAudioChannels = + if (preferences.appPreferences.playbackPreferences.overrides.spdifArcSurroundAudio) 6 else null, audioStreamIndex = audioIndex, subtitleStreamIndex = subtitleIndex, mediaSourceId = currentItemPlayback.sourceId?.toServerString(), @@ -844,12 +846,24 @@ class PlaybackViewModel userInitiated: Boolean, ): Boolean = withContext(Dispatchers.IO) { - // TODO there's probably no reason why we can't add external subtitles? Timber.v("changeStreams direct play") + // Don't attempt DirectPlay if audio transcoding is required + if (preferences.appPreferences.playbackPreferences.overrides.spdifArcSurroundAudio && audioIndex != null) { + currentPlayback.mediaSourceInfo.mediaStreams + .orEmpty() + .firstOrNull { it.index == audioIndex } + ?.let { + if (it.channels.gt(2) && it.codec != Codec.Audio.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(Dispatchers.Main) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index 2de5310ea..da6bd0b70 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -71,6 +71,7 @@ fun createDeviceProfile( dolbyVisionELDirectPlay: Boolean, decodeAv1: Boolean, jellyfinTenEleven: Boolean, + spdifArcSurroundAudio: Boolean = false, ) = buildDeviceProfile { val allowedAudioCodecs = when { @@ -142,7 +143,11 @@ fun createDeviceProfile( if (supportsHevc) videoCodec(Codec.Video.HEVC) videoCodec(Codec.Video.H264) - audioCodec(*allowedAudioCodecs) + if (spdifArcSurroundAudio) { + audioCodec(Codec.Audio.AC3) + } else { + audioCodec(*allowedAudioCodecs) + } copyTimestamps = false enableSubtitlesInManifest = true @@ -156,7 +161,11 @@ fun createDeviceProfile( container = Codec.Container.TS protocol = MediaStreamProtocol.HLS - audioCodec(Codec.Audio.AAC) + if (spdifArcSurroundAudio) { + audioCodec(Codec.Audio.AC3) + } else { + audioCodec(Codec.Audio.AAC) + } } // / Direct play profiles @@ -415,6 +424,29 @@ fun createDeviceProfile( } } + // SPDIF/ARC mode: restrict non-AC3 multichannel audio to stereo + // to force server to transcode only multichannel to AC3 + if (spdifArcSurroundAudio) { + 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 + } + } + } + } + // / HDR exclude list // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK @@ -522,7 +554,12 @@ fun createDeviceProfile( type = CodecType.VIDEO_AUDIO conditions { - ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals if (downMixAudio) 2 else 8 + ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals + when { + spdifArcSurroundAudio -> 6 + downMixAudio -> 2 + else -> 8 + } } } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index a29c59acd..2fd0e7ce4 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -57,6 +57,7 @@ message PlaybackOverrides{ bool direct_play_dolby_vision_e_l = 6; bool decode_av1 = 7; AssPlaybackMode ass_playback_mode = 8; + bool spdif_arc_surround_audio = 9; } message PlaybackPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 15ab01823..ec78ee4be 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -788,4 +788,7 @@ + SPDIF/ARC Surround Audio + Force all audio to AC3 5.1 for SPDIF/ARC output + Use default audio settings diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestAudioSettings.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestAudioSettings.kt new file mode 100644 index 000000000..e7ed6193f --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestAudioSettings.kt @@ -0,0 +1,122 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile +import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs +import org.junit.Assert +import org.junit.Test + +class TestAudioSettings { + @Test + fun `spdif enabling sets ac3 and disables downmix`() { + val prefs = AppPreferences.getDefaultInstance() + + val result = AppPreference.SpdifArcSurroundAudio.setter.invoke(prefs, true) + + val spdifValue = AppPreference.SpdifArcSurroundAudio.getter.invoke(result) + val ac3Value = AppPreference.Ac3Supported.getter.invoke(result) + val downmixValue = AppPreference.DownMixStereo.getter.invoke(result) + + Assert.assertTrue("Spdif should be ON", spdifValue) + Assert.assertTrue("AC3 should be auto-enabled", ac3Value) + Assert.assertFalse("Downmix should be auto-disabled", downmixValue) + } + + @Test + fun `spdif disabling only affects spdif`() { + val basePrefs = + AppPreference.SpdifArcSurroundAudio.setter.invoke( + AppPreferences.getDefaultInstance(), + true, + ) + + val result = AppPreference.SpdifArcSurroundAudio.setter.invoke(basePrefs, false) + + val spdifValue = AppPreference.SpdifArcSurroundAudio.getter.invoke(result) + val ac3Value = AppPreference.Ac3Supported.getter.invoke(result) + val downmixValue = AppPreference.DownMixStereo.getter.invoke(result) + + Assert.assertFalse("Spdif should be OFF", spdifValue) + Assert.assertTrue("AC3 should remain enabled", ac3Value) + Assert.assertFalse("Downmix should remain disabled", downmixValue) + } + + @Test + fun `ac3 disabling auto-disables spdif`() { + val basePrefs = + AppPreference.SpdifArcSurroundAudio.setter.invoke( + AppPreferences.getDefaultInstance(), + true, + ) + + val result = AppPreference.Ac3Supported.setter.invoke(basePrefs, false) + + val spdifValue = AppPreference.SpdifArcSurroundAudio.getter.invoke(result) + val ac3Value = AppPreference.Ac3Supported.getter.invoke(result) + + Assert.assertFalse("Spdif should auto-disable when AC3 is turned off", spdifValue) + Assert.assertFalse("AC3 should be OFF", ac3Value) + } + + @Test + fun `downmix enabling auto-disables spdif`() { + val basePrefs = + AppPreference.SpdifArcSurroundAudio.setter.invoke( + AppPreferences.getDefaultInstance(), + true, + ) + + val result = AppPreference.DownMixStereo.setter.invoke(basePrefs, true) + + val spdifValue = AppPreference.SpdifArcSurroundAudio.getter.invoke(result) + val downmixValue = AppPreference.DownMixStereo.getter.invoke(result) + + Assert.assertFalse("Spdif should auto-disable when downmix is turned on", spdifValue) + Assert.assertTrue("Downmix should be ON", downmixValue) + } + + @Test + fun `enabling ac3 does not alter spdif or downmix`() { + val basePrefs = + AppPreference.Ac3Supported.setter.invoke( + AppPreferences.getDefaultInstance(), + false, + ) + + val result = AppPreference.Ac3Supported.setter.invoke(basePrefs, true) + + val spdifValue = AppPreference.SpdifArcSurroundAudio.getter.invoke(result) + val ac3Value = AppPreference.Ac3Supported.getter.invoke(result) + val downmixValue = AppPreference.DownMixStereo.getter.invoke(result) + + Assert.assertFalse("Spdif should remain OFF", spdifValue) + Assert.assertTrue("AC3 should be ON", ac3Value) + Assert.assertFalse("Downmix should remain OFF", downmixValue) + } + + @Test + fun `disabling downmix does not alter spdif or ac3`() { + val basePrefs = + AppPreference.DownMixStereo.setter.invoke( + AppPreference.SpdifArcSurroundAudio.setter.invoke( + AppPreferences.getDefaultInstance(), + true, + ), + true, + ) + + val spdifValueInitial = AppPreference.SpdifArcSurroundAudio.getter.invoke(basePrefs) + Assert.assertFalse("Spdif should turn OFF", spdifValueInitial) + + val result = AppPreference.DownMixStereo.setter.invoke(basePrefs, false) + + val spdifValue = AppPreference.SpdifArcSurroundAudio.getter.invoke(result) + val ac3Value = AppPreference.Ac3Supported.getter.invoke(result) + val downmixValue = AppPreference.DownMixStereo.getter.invoke(result) + + Assert.assertFalse("Spdif should remain OFF", spdifValue) + Assert.assertTrue("AC3 should remain ON", ac3Value) + Assert.assertFalse("Downmix should be OFF", downmixValue) + } +}