mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
+2
-2
@@ -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",
|
||||
)
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+19
-14
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -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))
|
||||
|
||||
+3
-3
@@ -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
-23
@@ -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()
|
||||
|
||||
+172
@@ -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)
|
||||
}
|
||||
+1
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
+18
-6
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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
|
||||
}
|
||||
|
||||
+20
-9
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user