From be6acabdeed63ecc66916ef87c7d8587afe2eb02 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 7 Jun 2026 13:18:11 +0200 Subject: [PATCH] Add audio-visualizer settings feat(audio): wire audio-visualizer settings into navigation and menu feat(audio): add audio-visualizer settings screen with live previews feat(audio): add audio-visualizer settings strings fix(audio): move visualizer setting to Account section; render at feed size feat(audio): add Classic (default) and Static visualiser styles --- .../model/AccountSyncedSettingsInternal.kt | 4 +- .../playback/composable/RenderVideoPlayer.kt | 1 + .../wavefront/AudioPlayingAnimation.kt | 33 ++-- .../playback/playerPool/ExoPlayerBuilder.kt | 15 +- .../playback/playerPool/ExoPlayerPool.kt | 6 +- .../playback/playerPool/PcmTapRegistry.kt | 61 ++++--- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../settings/AudioVisualizerSettingsScreen.kt | 172 ++++++++++++++++++ .../settings/SettingsCatalogBuilder.kt | 1 + amethyst/src/main/res/values/strings.xml | 11 ++ .../playerPool/SpectrumAudioBufferSinkTest.kt | 24 ++- .../amethyst/commons/audio/VisualizerStyle.kt | 6 +- .../commons/audio/VisualizerRegistryTest.kt | 29 ++- .../commons/audio/VisualizerStyleTest.kt | 8 +- 15 files changed, 311 insertions(+), 64 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AudioVisualizerSettingsScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index fe40b6eda2..4bcbf6b2df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -201,6 +201,6 @@ class AccountSecurityPreferencesInternal( @Serializable class AccountMediaPreferencesInternal( - // Stored as VisualizerStyle.name; defaults to WAVES. - var audioVisualizer: String = "WAVES", + // Stored as VisualizerStyle.name; defaults to CLASSIC (the app's classic audio animation). + var audioVisualizer: String = "CLASSIC", ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index 2b869c45d7..fd737e02af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -186,6 +186,7 @@ fun RenderVideoPlayer( AudioPlayingAnimation( controllerState = controllerState, waveform = mediaItem.src.waveformData, + mediaId = mediaItem.src.videoUri, style = visualizerStyle, modifier = Modifier.fillMaxSize().align(Alignment.Center), hasBlurhash = hasBlurhash, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt index ff23590ba3..9b22e9d44c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt @@ -21,7 +21,8 @@ package com.vitorpamplona.amethyst.service.playback.composable.wavefront import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.requiredHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -30,14 +31,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.Tracks import com.vitorpamplona.amethyst.commons.audio.AudioVisualizer +import com.vitorpamplona.amethyst.commons.audio.SyntheticSpectrum import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.WaveformData import com.vitorpamplona.amethyst.service.playback.playerPool.PcmTapRegistry +import kotlinx.coroutines.flow.flowOf fun Tracks.isAudio() = groups.isNotEmpty() && groups.none { it.type == C.TRACK_TYPE_VIDEO } @@ -45,6 +49,7 @@ fun Tracks.isAudio() = groups.isNotEmpty() && groups.none { it.type == C.TRACK_T fun AudioPlayingAnimation( controllerState: MediaControllerState, waveform: WaveformData?, + mediaId: String, style: VisualizerStyle, modifier: Modifier = Modifier, hasBlurhash: Boolean = false, @@ -69,23 +74,23 @@ fun AudioPlayingAnimation( // NIP-A0 voice notes etc. that ship a precomputed waveform keep their seek bar. waveform != null -> Waveform(waveform, controllerState, modifier) + // The app's classic animated waveform. + style == VisualizerStyle.CLASSIC -> FakeWaveformAnimation(mediaControllerState = controllerState, modifier = modifier) + + // A still, non-animated bar graphic for users who prefer no motion. + style == VisualizerStyle.STATIC -> { + val frozen = remember { flowOf(SyntheticSpectrum.frame(0f, 48)) } + AudioVisualizer(style = VisualizerStyle.BARS, spectrum = frozen, modifier = modifier.fillMaxWidth().requiredHeight(72.dp)) + } + // Visualizer disabled: draw nothing so any blurhash/cover backdrop shows through. style == VisualizerStyle.OFF -> Unit + // Live FFT styles (BARS / WAVES / RADIAL / AURORA). else -> { - val spectrum = remember(controllerState.controller) { PcmTapRegistry.spectrumFor(controllerState.controller) } - if (spectrum != null) { - // Dim the cover/blurhash backdrop behind the live visualizer. - val drawModifier = if (hasBlurhash) modifier.background(Color.Black.copy(alpha = 0.45f)) else modifier - AudioVisualizer( - style = style, - spectrum = spectrum, - modifier = drawModifier.fillMaxSize(), - ) - } else if (!hasBlurhash) { - // No live PCM available and no backdrop: keep the decorative fallback. - FakeWaveformAnimation(mediaControllerState = controllerState, modifier = modifier) - } + val spectrum = remember(mediaId) { PcmTapRegistry.spectrumFor(mediaId) } + val drawModifier = if (hasBlurhash) modifier.background(Color.Black.copy(alpha = 0.45f)) else modifier + AudioVisualizer(style = style, spectrum = spectrum, modifier = drawModifier.fillMaxWidth().requiredHeight(72.dp)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index 57a539551b..3a46279de2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.service.playback.playerPool import android.content.Context import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource import androidx.media3.exoplayer.DefaultLoadControl @@ -67,7 +69,18 @@ class ExoPlayerBuilder( setLoadControl(feedTunedLoadControl()) }.build() .apply { - PcmTapRegistry.register(this, sink) + PcmTapRegistry.registerPlayer(this, sink) + addListener( + object : Player.Listener { + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + PcmTapRegistry.bind(mediaItem?.mediaId, sink) + } + }, + ) + PcmTapRegistry.bind(currentMediaItem?.mediaId, sink) addListener(AspectRatioCacher(MediaAspectRatioCache)) addListener(KeepVideosPlaying(this)) addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index b5c09d5945..5ab3f8cd7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -209,7 +209,7 @@ class ExoPlayerPool( coldPool.add(player) } } else { - PcmTapRegistry.unregister(player) + PcmTapRegistry.unregisterPlayer(player) player.release() // Release if pool is full. } } @@ -225,11 +225,11 @@ class ExoPlayerPool( copy } warmSnapshot.forEach { - PcmTapRegistry.unregister(it.player) + PcmTapRegistry.unregisterPlayer(it.player) it.player.release() } coldPool.forEach { - PcmTapRegistry.unregister(it) + PcmTapRegistry.unregisterPlayer(it) it.release() } coldPool.clear() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/PcmTapRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/PcmTapRegistry.kt index c779e3d453..36b9e269b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/PcmTapRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/PcmTapRegistry.kt @@ -38,22 +38,22 @@ import java.util.concurrent.ConcurrentHashMap /** * A Media3 [TeeAudioProcessor.AudioBufferSink] that turns the decoded 16-bit PCM of the - * currently-playing track into a live [Spectrum] stream ([frames]) via FFT + log binning. + * currently-playing track into a live [Spectrum] stream via FFT + log binning, emitting into + * whichever per-media-id [output] flow [PcmTapRegistry] has currently bound it to. * - * Only 16-bit PCM is handled; other encodings are ignored. Note that ExoPlayer audio - * *offload* and *passthrough* modes bypass the audio-processor chain entirely, so no PCM - * reaches this sink in those modes — the [frames] flow simply stays empty and the - * visualizer renders idle (it never shows a stale/wrong spectrum, thanks to the replay-cache - * reset in [flush]). Amethyst does not enable audio offload, so this is not expected in - * practice. + * Only 16-bit PCM is handled; other encodings are ignored. ExoPlayer audio *offload* and + * *passthrough* modes bypass the processor chain, so no PCM reaches this sink in those modes — + * the bound flow stays empty and the visualizer renders idle (never stale). Amethyst does not + * enable offload, so this is not expected in practice. */ @OptIn(UnstableApi::class) class SpectrumAudioBufferSink( private val fftSize: Int = 1024, private val binCount: Int = 48, ) : TeeAudioProcessor.AudioBufferSink { - // replay = 1 so a renderer subscribing mid-playback gets the latest frame immediately. - val frames = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + /** The per-media-id flow this sink currently feeds; set by [PcmTapRegistry.bind]. */ + @Volatile + var output: MutableSharedFlow? = null private val window = AudioWindow.hann(fftSize) private val mono = ShortArray(fftSize) @@ -61,7 +61,7 @@ class SpectrumAudioBufferSink( private var channels = 1 private var encoding = C.ENCODING_PCM_16BIT - @kotlin.OptIn(ExperimentalCoroutinesApi::class) + @OptIn(ExperimentalCoroutinesApi::class) override fun flush( sampleRateHz: Int, channelCount: Int, @@ -70,9 +70,8 @@ class SpectrumAudioBufferSink( this.channels = channelCount.coerceAtLeast(1) this.encoding = encoding filled = 0 - // Drop any spectrum from the previously-played track so a freshly subscribing - // collector doesn't briefly see the old track's last frame on pooled-player reuse. - frames.resetReplayCache() + // Drop any spectrum from the previous track so a fresh subscriber doesn't see it on reuse. + output?.resetReplayCache() } override fun handleBuffer(buffer: ByteBuffer) { @@ -89,32 +88,48 @@ class SpectrumAudioBufferSink( } } - // Allocates a few short-lived arrays per FFT window (~47/s at 48kHz/1024). Acceptable for - // now; pre-allocated working buffers would be the optimization if audio-thread GC shows up. + // Allocates a few short-lived arrays per FFT window (~47/s at 48kHz/1024). Acceptable for now; + // pre-allocated working buffers would be the optimization if audio-thread GC shows up. private fun emitSpectrum() { val windowed = AudioWindow.shortsToWindowed(mono, window) val mags = Fft.magnitudes(windowed).normalizedToPeak() - frames.tryEmit(Spectrum(mags.toLogBins(binCount))) + output?.tryEmit(Spectrum(mags.toLogBins(binCount))) } } -/** Maps each pooled player to its live spectrum stream. */ +/** + * Routes each pooled player's decoded-PCM spectrum to a stable, per-media-id flow that the UI can + * subscribe to by media URL. The flow is created on demand so a UI subscriber can attach before + * playback binds the sink (avoids a compose-vs-playback race). + */ @OptIn(UnstableApi::class) object PcmTapRegistry { - private val sinks = ConcurrentHashMap() + private val flowsByMediaId = ConcurrentHashMap>() + private val sinkByPlayer = ConcurrentHashMap() fun newSink(): SpectrumAudioBufferSink = SpectrumAudioBufferSink() - fun register( + fun registerPlayer( playerKey: Any, sink: SpectrumAudioBufferSink, ) { - sinks[playerKey] = sink + sinkByPlayer[playerKey] = sink } - fun unregister(playerKey: Any) { - sinks.remove(playerKey) + /** Points [sink]'s output at the flow for [mediaId] (the item it is now playing), or detaches it. */ + fun bind( + mediaId: String?, + sink: SpectrumAudioBufferSink, + ) { + sink.output = mediaId?.let { flowFor(it) } } - fun spectrumFor(playerKey: Any): Flow? = sinks[playerKey]?.frames + fun unregisterPlayer(playerKey: Any) { + sinkByPlayer.remove(playerKey)?.output = null + } + + /** Stable spectrum stream for a media URL. Frames arrive once a player is bound to it and plays. */ + fun spectrumFor(mediaId: String): Flow = flowFor(mediaId) + + private fun flowFor(mediaId: String): MutableSharedFlow = flowsByMediaId.getOrPut(mediaId) { MutableSharedFlow(replay = 1, extraBufferCapacity = 1) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b05ec5d244..95264c003d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -170,6 +170,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsS import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.ScheduledPostsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AudioVisualizerSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BlockedUsersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen @@ -375,6 +376,7 @@ fun BuildNavigation( composableFromEnd { ComposeSettingsScreen(accountViewModel, nav) } composableFromEnd { UserSettingsScreen(accountViewModel, nav) } composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } + composableFromEnd { AudioVisualizerSettingsScreen(accountViewModel, nav) } composableFromEnd { BottomBarSettingsScreen(accountViewModel, nav) } composableFromEnd { HomeTabsSettingsScreen(accountViewModel, nav) } composableFromEnd { ProfileUiSettingsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index f0faa2f41e..9c592b4e8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -325,6 +325,8 @@ sealed class Route { @Serializable object ReactionsSettings : Route() + @Serializable object AudioVisualizerSettings : Route() + @Serializable object BottomBarSettings : Route() @Serializable object HomeTabsSettings : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AudioVisualizerSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AudioVisualizerSettingsScreen.kt new file mode 100644 index 0000000000..50003fc497 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AudioVisualizerSettingsScreen.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.audio.AudioVisualizer +import com.vitorpamplona.amethyst.commons.audio.SyntheticSpectrum +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.service.playback.composable.wavefront.FakeWaveformAnimation +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.flow.flowOf + +@Composable +fun AudioVisualizerSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(id = R.string.audio_visualizer_settings), nav) + }, + ) { padding -> + AudioVisualizerSettingsContent(accountViewModel, Modifier.padding(padding)) + } +} + +@Composable +fun AudioVisualizerSettingsContent( + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val selected by accountViewModel.audioVisualizerFlow().collectAsStateWithLifecycle() + + LazyColumn(modifier = modifier.fillMaxWidth()) { + item { + Text( + text = stringRes(R.string.audio_visualizer_settings_description), + style = MaterialTheme.typography.bodyMedium, + color = Color.Gray, + modifier = Modifier.padding(16.dp), + ) + } + items(VisualizerStyle.entries) { style -> + VisualizerStyleRow( + style = style, + selected = style == selected, + onClick = { accountViewModel.changeAudioVisualizer(style) }, + ) + } + item { Spacer(Modifier.height(16.dp)) } + } +} + +@Composable +private fun VisualizerStyleRow( + style: VisualizerStyle, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RadioButton(selected = selected, onClick = onClick) + Column(Modifier.weight(1f)) { + Text(visualizerStyleName(style), style = MaterialTheme.typography.bodyLarge) + } + Box( + modifier = + Modifier + .size(width = 120.dp, height = 56.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF0C0C10)), + ) { + when (style) { + VisualizerStyle.OFF -> Unit + VisualizerStyle.CLASSIC -> { + val transition = rememberInfiniteTransition(label = "classicPreview") + val anim by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1500, easing = LinearEasing), RepeatMode.Restart), + label = "classicProgress", + ) + val progress = remember { mutableFloatStateOf(0f) } + progress.floatValue = anim + FakeWaveformAnimation(progress, 40, Modifier.fillMaxWidth().height(56.dp)) + } + VisualizerStyle.STATIC -> { + val frozen = remember { flowOf(SyntheticSpectrum.frame(0f, 48)) } + AudioVisualizer(style = VisualizerStyle.BARS, spectrum = frozen, modifier = Modifier.fillMaxWidth().height(56.dp)) + } + else -> { + val preview = remember { SyntheticSpectrum.flow(48) } + AudioVisualizer(style = style, spectrum = preview, modifier = Modifier.fillMaxWidth().height(56.dp)) + } + } + } + } +} + +@Composable +fun visualizerStyleName(style: VisualizerStyle): String = + when (style) { + VisualizerStyle.CLASSIC -> stringRes(R.string.audio_visualizer_classic) + VisualizerStyle.OFF -> stringRes(R.string.audio_visualizer_off) + VisualizerStyle.BARS -> stringRes(R.string.audio_visualizer_bars) + VisualizerStyle.WAVES -> stringRes(R.string.audio_visualizer_waves) + VisualizerStyle.RADIAL -> stringRes(R.string.audio_visualizer_radial) + VisualizerStyle.AURORA -> stringRes(R.string.audio_visualizer_aurora) + VisualizerStyle.STATIC -> stringRes(R.string.audio_visualizer_static) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index d6c9909f5a..14bc2e4141 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -71,6 +71,7 @@ fun buildSettingsCatalog( symEntry(R.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, R.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds), symEntry(R.string.reactions, MaterialSymbols.FavoriteBorder, R.string.reactions_search_keywords, Route.UpdateReactionType), symEntry(R.string.video_player_settings, MaterialSymbols.VideoSettings, R.string.video_player_search_keywords, Route.VideoPlayerSettings), + symEntry(R.string.audio_visualizer_settings, MaterialSymbols.MusicNote, R.string.audio_visualizer_search_keywords, Route.AudioVisualizerSettings), symEntry(R.string.zaps, MaterialSymbols.Bolt, R.string.zaps_search_keywords, Route.UpdateZapAmount()), symEntry(R.string.payment_targets, MaterialSymbols.Payment, R.string.payment_targets_search_keywords, Route.EditPaymentTargets), symEntry(R.string.security_filters, MaterialSymbols.Security, R.string.security_filters_search_keywords, Route.SecurityFilters), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index debeb95ff7..0d63f89d64 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1526,6 +1526,7 @@ dvm, data vending machine, algo, algorithm, feeds emoji, like, reaction video, player, playback, autoplay, mute + audio, visualizer, spectrum, bars, waves, radial, aurora, animation zap split, split, recipients, forward zaps webrtc, video call, voice call, calls language, translate, locale @@ -2336,6 +2337,16 @@ Cast to Device Send the video to a Chromecast receiver on your Wi-Fi (hidden for local files) + Audio Visualizer + Choose the animation shown while audio notes play. + Off + Spectrum Bars + Color Waves + Radial Ring + Aurora Glow + Classic Waveform + Static Image + Profile Picture of %1$s Relay %1$s Expand relay list diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/SpectrumAudioBufferSinkTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/SpectrumAudioBufferSinkTest.kt index 82aec52e37..6e9ce2ea35 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/SpectrumAudioBufferSinkTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/SpectrumAudioBufferSinkTest.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.service.playback.playerPool import androidx.annotation.OptIn import androidx.media3.common.C import androidx.media3.common.util.UnstableApi +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import kotlinx.coroutines.flow.MutableSharedFlow import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -66,11 +68,13 @@ class SpectrumAudioBufferSinkTest { @Test fun lowFrequencySineLightsLowBins() { val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount) + val out = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + sink.output = out sink.flush(48000, 1, C.ENCODING_PCM_16BIT) sink.handleBuffer(monoPcm(sineShorts(k = 2, n = fftSize))) val bins = - sink.frames.replayCache + out.replayCache .last() .bins assertEquals(binCount, bins.size) @@ -80,11 +84,13 @@ class SpectrumAudioBufferSinkTest { @Test fun highFrequencySineLightsHighBins() { val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount) + val out = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + sink.output = out sink.flush(48000, 1, C.ENCODING_PCM_16BIT) sink.handleBuffer(monoPcm(sineShorts(k = 28, n = fftSize))) val bins = - sink.frames.replayCache + out.replayCache .last() .bins assertTrue("expected high bin to dominate, got ${maxBin(bins)}", maxBin(bins) >= binCount / 2) @@ -93,11 +99,13 @@ class SpectrumAudioBufferSinkTest { @Test fun stereoIsDownmixedFromFirstChannel() { val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount) + val out = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + sink.output = out sink.flush(48000, 2, C.ENCODING_PCM_16BIT) sink.handleBuffer(interleavedStereoPcm(sineShorts(k = 2, n = fftSize), ShortArray(fftSize))) val bins = - sink.frames.replayCache + out.replayCache .last() .bins assertTrue(maxBin(bins) < binCount / 2) @@ -106,22 +114,26 @@ class SpectrumAudioBufferSinkTest { @Test fun emitsOnlyAfterAFullFftWindowAccumulates() { val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount) + val out = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + sink.output = out sink.flush(48000, 1, C.ENCODING_PCM_16BIT) val full = sineShorts(k = 4, n = fftSize) sink.handleBuffer(monoPcm(full.copyOfRange(0, fftSize / 2))) - assertTrue("no frame should emit before a full window", sink.frames.replayCache.isEmpty()) + assertTrue("no frame should emit before a full window", out.replayCache.isEmpty()) sink.handleBuffer(monoPcm(full.copyOfRange(fftSize / 2, fftSize))) - assertEquals(1, sink.frames.replayCache.size) + assertEquals(1, out.replayCache.size) } @Test fun nonPcm16EncodingEmitsNothing() { val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount) + val out = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + sink.output = out sink.flush(48000, 1, C.ENCODING_PCM_FLOAT) sink.handleBuffer(monoPcm(sineShorts(k = 4, n = fftSize))) - assertTrue("non-16-bit PCM must not emit a spectrum", sink.frames.replayCache.isEmpty()) + assertTrue("non-16-bit PCM must not emit a spectrum", out.replayCache.isEmpty()) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyle.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyle.kt index d763e198e4..dd6b5af441 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyle.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyle.kt @@ -21,15 +21,17 @@ package com.vitorpamplona.amethyst.commons.audio enum class VisualizerStyle { - OFF, + CLASSIC, BARS, WAVES, RADIAL, AURORA, + STATIC, + OFF, ; companion object { - val DEFAULT = WAVES + val DEFAULT = CLASSIC fun fromName(name: String?): VisualizerStyle = entries.firstOrNull { it.name == name } ?: DEFAULT } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistryTest.kt index b667631fae..f3488090ec 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistryTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistryTest.kt @@ -22,20 +22,31 @@ package com.vitorpamplona.amethyst.commons.audio import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertTrue class VisualizerRegistryTest { @Test - fun everyStyleHasARenderer() { - for (style in VisualizerStyle.entries) { - assertEquals(style, VisualizerRegistry.forStyle(style).style) - } + fun registryCoversTheFiveSpectrumStylesExactlyOnce() { + val styles = VisualizerRegistry.all.map { it.style }.toSet() + assertEquals( + setOf( + VisualizerStyle.OFF, + VisualizerStyle.BARS, + VisualizerStyle.WAVES, + VisualizerStyle.RADIAL, + VisualizerStyle.AURORA, + ), + styles, + ) + assertEquals(5, VisualizerRegistry.all.size) } @Test - fun registryCoversEveryStyleExactlyOnce() { - val styles = VisualizerRegistry.all.map { it.style }.toSet() - assertTrue(styles.containsAll(VisualizerStyle.entries.toList())) - assertEquals(VisualizerStyle.entries.size, VisualizerRegistry.all.size) + fun forStyleReturnsMatchingRendererForSpectrumStylesAndFallsBackForOthers() { + for (renderer in VisualizerRegistry.all) { + assertEquals(renderer.style, VisualizerRegistry.forStyle(renderer.style).style) + } + // CLASSIC and STATIC have no spectrum renderer; the dispatcher falls back to OFF. + assertEquals(VisualizerStyle.OFF, VisualizerRegistry.forStyle(VisualizerStyle.CLASSIC).style) + assertEquals(VisualizerStyle.OFF, VisualizerRegistry.forStyle(VisualizerStyle.STATIC).style) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyleTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyleTest.kt index 92a0b5b5a4..0af8faecc1 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyleTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyleTest.kt @@ -28,12 +28,12 @@ class VisualizerStyleTest { fun parsesByNameWithSafeDefault() { assertEquals(VisualizerStyle.WAVES, VisualizerStyle.fromName("WAVES")) assertEquals(VisualizerStyle.RADIAL, VisualizerStyle.fromName("RADIAL")) - assertEquals(VisualizerStyle.WAVES, VisualizerStyle.fromName("nonsense")) - assertEquals(VisualizerStyle.WAVES, VisualizerStyle.fromName(null)) + assertEquals(VisualizerStyle.CLASSIC, VisualizerStyle.fromName("nonsense")) + assertEquals(VisualizerStyle.CLASSIC, VisualizerStyle.fromName(null)) } @Test - fun defaultIsWaves() { - assertEquals(VisualizerStyle.WAVES, VisualizerStyle.DEFAULT) + fun defaultIsClassic() { + assertEquals(VisualizerStyle.CLASSIC, VisualizerStyle.DEFAULT) } }