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
This commit is contained in:
davotoula
2026-06-08 17:46:58 +02:00
parent 032e1bf7fb
commit be6acabdee
15 changed files with 311 additions and 64 deletions
@@ -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",
)
@@ -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,
@@ -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))
}
}
}
@@ -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))
@@ -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()
@@ -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<Spectrum>(replay = 1, extraBufferCapacity = 1)
/** The per-media-id flow this sink currently feeds; set by [PcmTapRegistry.bind]. */
@Volatile
var output: MutableSharedFlow<Spectrum>? = 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<Any, SpectrumAudioBufferSink>()
private val flowsByMediaId = ConcurrentHashMap<String, MutableSharedFlow<Spectrum>>()
private val sinkByPlayer = ConcurrentHashMap<Any, SpectrumAudioBufferSink>()
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<Spectrum>? = 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<Spectrum> = flowFor(mediaId)
private fun flowFor(mediaId: String): MutableSharedFlow<Spectrum> = flowsByMediaId.getOrPut(mediaId) { MutableSharedFlow(replay = 1, extraBufferCapacity = 1) }
}
@@ -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<Route.ComposeSettings> { ComposeSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.AudioVisualizerSettings> { AudioVisualizerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.HomeTabsSettings> { HomeTabsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
@@ -325,6 +325,8 @@ sealed class Route {
@Serializable object ReactionsSettings : Route()
@Serializable object AudioVisualizerSettings : Route()
@Serializable object BottomBarSettings : Route()
@Serializable object HomeTabsSettings : Route()
@@ -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)
}
@@ -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),
+11
View File
@@ -1526,6 +1526,7 @@
<string name="favorite_dvms_search_keywords" translatable="false">dvm, data vending machine, algo, algorithm, feeds</string>
<string name="reactions_search_keywords" translatable="false">emoji, like, reaction</string>
<string name="video_player_search_keywords" translatable="false">video, player, playback, autoplay, mute</string>
<string name="audio_visualizer_search_keywords" translatable="false">audio, visualizer, spectrum, bars, waves, radial, aurora, animation</string>
<string name="payment_targets_search_keywords" translatable="false">zap split, split, recipients, forward zaps</string>
<string name="call_settings_search_keywords" translatable="false">webrtc, video call, voice call, calls</string>
<string name="translations_search_keywords" translatable="false">language, translate, locale</string>
@@ -2336,6 +2337,16 @@
<string name="video_player_settings_action_cast">Cast to Device</string>
<string name="video_player_settings_action_cast_description">Send the video to a Chromecast receiver on your Wi-Fi (hidden for local files)</string>
<string name="audio_visualizer_settings">Audio Visualizer</string>
<string name="audio_visualizer_settings_description">Choose the animation shown while audio notes play.</string>
<string name="audio_visualizer_off">Off</string>
<string name="audio_visualizer_bars">Spectrum Bars</string>
<string name="audio_visualizer_waves">Color Waves</string>
<string name="audio_visualizer_radial">Radial Ring</string>
<string name="audio_visualizer_aurora">Aurora Glow</string>
<string name="audio_visualizer_classic">Classic Waveform</string>
<string name="audio_visualizer_static">Static Image</string>
<string name="profile_image_of_user">Profile Picture of %1$s</string>
<string name="relay_info">Relay %1$s</string>
<string name="expand_relay_list">Expand relay list</string>
@@ -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<Spectrum>(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<Spectrum>(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<Spectrum>(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<Spectrum>(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<Spectrum>(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())
}
}
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}