diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 92b0224654..a899b593e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm @@ -325,6 +326,15 @@ class AccountSettings( return false } + fun changeAudioVisualizer(style: VisualizerStyle): Boolean { + if (syncedSettings.media.audioVisualizer.value != style) { + syncedSettings.media.audioVisualizer.tryEmit(style) + saveAccountSettings() + return true + } + return false + } + fun defaultNwcWallet(): NwcWalletEntryNorm? { val id = defaultNwcWalletId.value val wallets = nwcWallets.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index f10791a1d5..faf85a36dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.collections.immutable.ImmutableList @@ -62,6 +63,10 @@ class AccountSyncedSettings( AccountVideoPlayerPreferences( MutableStateFlow(mergeWithDefaultVideoPlayerButtons(internalSettings.videoPlayer.buttonItems).toImmutableList()), ) + val media = + AccountMediaPreferences( + MutableStateFlow(VisualizerStyle.fromName(internalSettings.media.audioVisualizer)), + ) fun toInternal(): AccountSyncedSettingsInternal = AccountSyncedSettingsInternal( @@ -92,6 +97,7 @@ class AccountSyncedSettings( security.addClientTag.value, ), videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value), + media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name), ) fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) { @@ -160,6 +166,11 @@ class AccountSyncedSettings( if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) { videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems) } + + val newAudioVisualizer = VisualizerStyle.fromName(syncedSettingsInternal.media.audioVisualizer) + if (media.audioVisualizer.value != newAudioVisualizer) { + media.audioVisualizer.tryEmit(newAudioVisualizer) + } } fun dontTranslateFromFilteredBySpokenLanguages(): Set = languages.dontTranslateFrom.value - getLanguagesSpokenByUser() @@ -253,6 +264,11 @@ class AccountLanguagePreferences( ): String? = languagePreferences.value["$source,$target"] } +@Stable +class AccountMediaPreferences( + val audioVisualizer: MutableStateFlow, +) + @Stable class AccountSecurityPreferences( val showSensitiveContent: MutableStateFlow = MutableStateFlow(null), 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 bdc1ab134e..fe40b6eda2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -155,6 +155,7 @@ class AccountSyncedSettingsInternal( val languages: AccountLanguagePreferencesInternal = AccountLanguagePreferencesInternal(), val security: AccountSecurityPreferencesInternal = AccountSecurityPreferencesInternal(), val videoPlayer: AccountVideoPlayerPreferencesInternal = AccountVideoPlayerPreferencesInternal(), + val media: AccountMediaPreferencesInternal = AccountMediaPreferencesInternal(), ) @Serializable @@ -197,3 +198,9 @@ class AccountSecurityPreferencesInternal( var sendKind0EventsToLocalRelay: Boolean = false, var addClientTag: Boolean = true, ) + +@Serializable +class AccountMediaPreferencesInternal( + // Stored as VisualizerStyle.name; defaults to WAVES. + var audioVisualizer: String = "WAVES", +) 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 2cae673cab..2b869c45d7 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 @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -38,6 +39,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.ContentFrame @@ -180,10 +182,12 @@ fun RenderVideoPlayer( modifier = Modifier.align(Alignment.Center), ) + val visualizerStyle by accountViewModel.audioVisualizerFlow().collectAsStateWithLifecycle() AudioPlayingAnimation( - controllerState, - mediaItem.src.waveformData, - Modifier.fillMaxSize().align(Alignment.Center), + controllerState = controllerState, + waveform = mediaItem.src.waveformData, + 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 28afe97b33..ff23590ba3 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 @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.wavefront +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -27,11 +29,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color 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.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 fun Tracks.isAudio() = groups.isNotEmpty() && groups.none { it.type == C.TRACK_TYPE_VIDEO } @@ -39,11 +45,10 @@ fun Tracks.isAudio() = groups.isNotEmpty() && groups.none { it.type == C.TRACK_T fun AudioPlayingAnimation( controllerState: MediaControllerState, waveform: WaveformData?, + style: VisualizerStyle, modifier: Modifier = Modifier, hasBlurhash: Boolean = false, ) { - if (hasBlurhash) return - var isAudio by remember { mutableStateOf(controllerState.controller.currentTracks.isAudio()) } DisposableEffect(controllerState.controller) { @@ -58,14 +63,29 @@ fun AudioPlayingAnimation( onDispose { controllerState.controller.removeListener(listener) } } - if (isAudio) { - if (waveform != null) { - Waveform(waveform, controllerState, modifier) - } else { - FakeWaveformAnimation( - mediaControllerState = controllerState, - modifier = modifier, - ) + if (!isAudio) return + + when { + // NIP-A0 voice notes etc. that ship a precomputed waveform keep their seek bar. + waveform != null -> Waveform(waveform, controllerState, modifier) + + // Visualizer disabled: draw nothing so any blurhash/cover backdrop shows through. + style == VisualizerStyle.OFF -> Unit + + 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) + } } } } 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 eaa31e31e6..57a539551b 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 @@ -25,7 +25,11 @@ import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.audio.TeeAudioProcessor import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher @@ -38,18 +42,37 @@ class ExoPlayerBuilder( val videoCache: VideoCache, val dataSourceFactory: DataSource.Factory, ) { - fun build(context: Context): ExoPlayer = - ExoPlayer - .Builder(context) + fun build(context: Context): ExoPlayer { + val sink = PcmTapRegistry.newSink() + + val renderersFactory = + object : DefaultRenderersFactory(context) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean, + ): AudioSink = + DefaultAudioSink + .Builder(context) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setAudioProcessors(arrayOf(TeeAudioProcessor(sink))) + .build() + } + + return ExoPlayer + .Builder(context, renderersFactory) .apply { setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory)) setLoadControl(feedTunedLoadControl()) }.build() .apply { + PcmTapRegistry.register(this, sink) addListener(AspectRatioCacher(MediaAspectRatioCache)) addListener(KeepVideosPlaying(this)) addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache)) } + } companion object { // Default DefaultLoadControl buffers 50s ahead before slowing down. Every visible video 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 dbdc7f1012..b5c09d5945 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,6 +209,7 @@ class ExoPlayerPool( coldPool.add(player) } } else { + PcmTapRegistry.unregister(player) player.release() // Release if pool is full. } } @@ -223,8 +224,14 @@ class ExoPlayerPool( warmPool.clear() copy } - warmSnapshot.forEach { it.player.release() } - coldPool.forEach { it.release() } + warmSnapshot.forEach { + PcmTapRegistry.unregister(it.player) + it.player.release() + } + coldPool.forEach { + PcmTapRegistry.unregister(it) + it.release() + } coldPool.clear() } }.invokeOnCompletion { 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 new file mode 100644 index 0000000000..c779e3d453 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/PcmTapRegistry.kt @@ -0,0 +1,120 @@ +/* + * 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.service.playback.playerPool + +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.audio.TeeAudioProcessor +import com.vitorpamplona.amethyst.commons.audio.AudioWindow +import com.vitorpamplona.amethyst.commons.audio.Fft +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.normalizedToPeak +import com.vitorpamplona.amethyst.commons.audio.toLogBins +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import java.nio.ByteBuffer +import java.nio.ByteOrder +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. + * + * 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. + */ +@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) + + private val window = AudioWindow.hann(fftSize) + private val mono = ShortArray(fftSize) + private var filled = 0 + private var channels = 1 + private var encoding = C.ENCODING_PCM_16BIT + + @kotlin.OptIn(ExperimentalCoroutinesApi::class) + override fun flush( + sampleRateHz: Int, + channelCount: Int, + encoding: Int, + ) { + 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() + } + + override fun handleBuffer(buffer: ByteBuffer) { + if (encoding != C.ENCODING_PCM_16BIT) return + val pcm = buffer.order(ByteOrder.LITTLE_ENDIAN) + while (pcm.remaining() >= 2 * channels) { + val sample = pcm.short // first channel of the frame + for (c in 1 until channels) pcm.short // skip remaining channels + mono[filled++] = sample + if (filled == fftSize) { + emitSpectrum() + filled = 0 + } + } + } + + // 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))) + } +} + +/** Maps each pooled player to its live spectrum stream. */ +@OptIn(UnstableApi::class) +object PcmTapRegistry { + private val sinks = ConcurrentHashMap() + + fun newSink(): SpectrumAudioBufferSink = SpectrumAudioBufferSink() + + fun register( + playerKey: Any, + sink: SpectrumAudioBufferSink, + ) { + sinks[playerKey] = sink + } + + fun unregister(playerKey: Any) { + sinks.remove(playerKey) + } + + fun spectrumFor(playerKey: Any): Flow? = sinks[playerKey]?.frames +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 956d8e8f97..57ce7751e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel @@ -1309,6 +1310,12 @@ class AccountViewModel( account.changeVideoPlayerButtonItems(items) } + fun audioVisualizerFlow(): StateFlow = account.settings.syncedSettings.media.audioVisualizer + + fun changeAudioVisualizer(style: VisualizerStyle) { + account.settings.changeAudioVisualizer(style) + } + fun updateZapAmounts( amountSet: List, selectedZapType: LnZapEvent.ZapType, 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 new file mode 100644 index 0000000000..82aec52e37 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/SpectrumAudioBufferSinkTest.kt @@ -0,0 +1,127 @@ +/* + * 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.service.playback.playerPool + +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.PI +import kotlin.math.sin + +@OptIn(UnstableApi::class) +class SpectrumAudioBufferSinkTest { + private val fftSize = 64 + private val binCount = 16 + + private fun sineShorts( + k: Int, + n: Int, + ): ShortArray = ShortArray(n) { (sin(2.0 * PI * k * it / n) * 30000).toInt().toShort() } + + private fun monoPcm(samples: ShortArray): ByteBuffer { + val bb = ByteBuffer.allocate(samples.size * 2).order(ByteOrder.LITTLE_ENDIAN) + for (s in samples) bb.putShort(s) + bb.flip() + return bb + } + + private fun interleavedStereoPcm( + left: ShortArray, + right: ShortArray, + ): ByteBuffer { + val bb = ByteBuffer.allocate(left.size * 4).order(ByteOrder.LITTLE_ENDIAN) + for (i in left.indices) { + bb.putShort(left[i]) + bb.putShort(right[i]) + } + bb.flip() + return bb + } + + private fun maxBin(spectrumBins: FloatArray): Int = spectrumBins.indices.maxByOrNull { spectrumBins[it] } ?: -1 + + @Test + fun lowFrequencySineLightsLowBins() { + val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount) + sink.flush(48000, 1, C.ENCODING_PCM_16BIT) + sink.handleBuffer(monoPcm(sineShorts(k = 2, n = fftSize))) + + val bins = + sink.frames.replayCache + .last() + .bins + assertEquals(binCount, bins.size) + assertTrue("expected low bin to dominate, got ${maxBin(bins)}", maxBin(bins) < binCount / 2) + } + + @Test + fun highFrequencySineLightsHighBins() { + val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount) + sink.flush(48000, 1, C.ENCODING_PCM_16BIT) + sink.handleBuffer(monoPcm(sineShorts(k = 28, n = fftSize))) + + val bins = + sink.frames.replayCache + .last() + .bins + assertTrue("expected high bin to dominate, got ${maxBin(bins)}", maxBin(bins) >= binCount / 2) + } + + @Test + fun stereoIsDownmixedFromFirstChannel() { + val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount) + sink.flush(48000, 2, C.ENCODING_PCM_16BIT) + sink.handleBuffer(interleavedStereoPcm(sineShorts(k = 2, n = fftSize), ShortArray(fftSize))) + + val bins = + sink.frames.replayCache + .last() + .bins + assertTrue(maxBin(bins) < binCount / 2) + } + + @Test + fun emitsOnlyAfterAFullFftWindowAccumulates() { + val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount) + 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()) + + sink.handleBuffer(monoPcm(full.copyOfRange(fftSize / 2, fftSize))) + assertEquals(1, sink.frames.replayCache.size) + } + + @Test + fun nonPcm16EncodingEmitsNothing() { + val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount) + 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()) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrum.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrum.kt new file mode 100644 index 0000000000..58ed39e1eb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrum.kt @@ -0,0 +1,65 @@ +/* + * 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.commons.audio + +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.log10 + +/** One frame of frequency-domain magnitudes, ordered low→high Hz and normalized 0f..1f. */ +class Spectrum( + val bins: FloatArray, +) + +/** + * Groups a linear-frequency magnitude array (index 0 = DC, last index = Nyquist) + * into [binCount] log-spaced buckets so bass/mid get more bars than the treble + * tail. Output is normalized 0f..1f using [floorDb] as the silence floor. + * Bin 0 (DC) is always excluded from the output. + */ +fun FloatArray.toLogBins( + binCount: Int, + floorDb: Float = -60f, +): FloatArray { + if (isEmpty() || binCount <= 0) return FloatArray(0) + val step = ln(size.toDouble()) / binCount + val out = FloatArray(binCount) + for (b in 0 until binCount) { + val lo = exp(step * b).toInt().coerceAtLeast(1) + val hi = exp(step * (b + 1)).toInt().coerceAtMost(size).coerceAtLeast(lo + 1) + var peak = 0f + for (i in lo until hi) if (i < size && this[i] > peak) peak = this[i] + val db = if (peak > 0f) 20f * log10(peak) else floorDb + out[b] = ((db - floorDb) / -floorDb).coerceIn(0f, 1f) + } + return out +} + +fun silentSpectrum(binCount: Int): Spectrum = Spectrum(FloatArray(binCount)) + +/** Returns a NEW array scaled so the largest value becomes 1f. An all-zero input is returned as a zero-filled copy. */ +fun FloatArray.normalizedToPeak(): FloatArray { + var peak = 0f + for (v in this) if (v > peak) peak = v + if (peak <= 0f) return copyOf() + val inv = 1f / peak + return FloatArray(size) { this[it] * inv } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioVisualizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioVisualizer.kt new file mode 100644 index 0000000000..e52e29aaa0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioVisualizer.kt @@ -0,0 +1,36 @@ +/* + * 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.commons.audio + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.Flow + +/** Renders [spectrum] using whichever [style] the user selected. */ +@Composable +fun AudioVisualizer( + style: VisualizerStyle, + spectrum: Flow, + modifier: Modifier = Modifier, + palette: VisualizerPalette = VisualizerPalette.DEFAULT, +) { + VisualizerRegistry.forStyle(style).Render(spectrum, palette, modifier) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioWindow.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioWindow.kt new file mode 100644 index 0000000000..1bf01dd835 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioWindow.kt @@ -0,0 +1,44 @@ +/* + * 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.commons.audio + +import kotlin.math.PI +import kotlin.math.cos + +object AudioWindow { + /** Hann window of length [n]. */ + fun hann(n: Int): FloatArray { + if (n <= 1) return FloatArray(n) { 1f } + return FloatArray(n) { (0.5 - 0.5 * cos(2.0 * PI * it / (n - 1))).toFloat() } + } + + /** + * Converts 16-bit PCM [samples] to normalized floats in [-1, 1], multiplies by + * [window], and pads/truncates to `window.size`. + */ + fun shortsToWindowed( + samples: ShortArray, + window: FloatArray, + ): FloatArray = + FloatArray(window.size) { i -> + if (i < samples.size) (samples[i] / 32768f) * window[i] else 0f + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/Fft.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/Fft.kt new file mode 100644 index 0000000000..70adf29e0d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/Fft.kt @@ -0,0 +1,98 @@ +/* + * 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.commons.audio + +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Minimal in-place iterative radix-2 Cooley–Tukey FFT (pure Kotlin, no JNI). + * Adapted from Nayuki's public-domain "Free small FFT". Only power-of-2 sizes + * are supported, which is all an audio visualiser needs (256/512/1024). + */ +object Fft { + /** In-place forward transform. [re]/[im] must be the same power-of-2 length. */ + fun transform( + re: DoubleArray, + im: DoubleArray, + ) { + val n = re.size + require(n != 0 && (n and (n - 1)) == 0) { "FFT size must be a power of 2, was $n" } + + var j = 0 + for (i in 1 until n) { + var bit = n shr 1 + while (j and bit != 0) { + j = j xor bit + bit = bit shr 1 + } + j = j or bit + if (i < j) { + val tr = re[i] + re[i] = re[j] + re[j] = tr + val ti = im[i] + im[i] = im[j] + im[j] = ti + } + } + + var len = 2 + while (len <= n) { + val ang = -2.0 * PI / len + val wLenRe = cos(ang) + val wLenIm = sin(ang) + var i = 0 + while (i < n) { + var wRe = 1.0 + var wIm = 0.0 + val half = len / 2 + for (k in 0 until half) { + val aRe = re[i + k] + val aIm = im[i + k] + val bRe = re[i + k + half] * wRe - im[i + k + half] * wIm + val bIm = re[i + k + half] * wIm + im[i + k + half] * wRe + re[i + k] = aRe + bRe + im[i + k] = aIm + bIm + re[i + k + half] = aRe - bRe + im[i + k + half] = aIm - bIm + val nextWRe = wRe * wLenRe - wIm * wLenIm + wIm = wRe * wLenIm + wIm * wLenRe + wRe = nextWRe + } + i += len + } + len = len shl 1 + } + } + + /** Forward-transforms real [signal] and returns magnitudes for bins 0..N/2 (DC..Nyquist). */ + fun magnitudes(signal: FloatArray): FloatArray { + val n = signal.size + require(n != 0 && (n and (n - 1)) == 0) { "FFT size must be a power of 2, was $n" } + val re = DoubleArray(n) { signal[it].toDouble() } + val im = DoubleArray(n) + transform(re, im) + return FloatArray(n / 2 + 1) { sqrt(re[it] * re[it] + im[it] * im[it]).toFloat() } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumCanvas.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumCanvas.kt new file mode 100644 index 0000000000..01b9a5035d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumCanvas.kt @@ -0,0 +1,77 @@ +/* + * 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.commons.audio + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.DrawScope +import kotlinx.coroutines.flow.Flow + +/** + * Collects [spectrum] into a decayed [FloatArray] and runs a monotonic time clock, + * then calls [draw] inside the Canvas draw lambda. The fast-changing state is read + * ONLY in the draw lambda, so new frames trigger the draw phase, never recomposition. + */ +@Composable +fun SpectrumCanvas( + spectrum: Flow, + palette: VisualizerPalette, + modifier: Modifier, + decay: Float = 0.85f, + draw: DrawScope.(bins: FloatArray, timeSec: Float, palette: VisualizerPalette) -> Unit, +) { + val smoothed = remember { mutableStateOf(FloatArray(0)) } + LaunchedEffect(spectrum, decay) { + var prev = FloatArray(0) + spectrum.collect { frame -> + val next = + FloatArray(frame.bins.size) { i -> + val prior = if (i < prev.size) prev[i] * decay else 0f + if (frame.bins[i] > prior) frame.bins[i] else prior + } + smoothed.value = next + prev = next + } + } + + // Monotonic, never-resetting clock. Accumulates elapsed seconds so the time value + // passed to renderers is continuous — a repeating transition would jump back to 0 + // at its boundary and cause a visible phase discontinuity in sine-based renderers. + val timeSec = remember { mutableStateOf(0f) } + LaunchedEffect(Unit) { + var last = 0L + while (true) { + withFrameMillis { ms -> + if (last != 0L) timeSec.value += (ms - last) / 1000f + last = ms + } + } + } + + Canvas(modifier) { + draw(smoothed.value, timeSec.value, palette) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SyntheticSpectrum.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SyntheticSpectrum.kt new file mode 100644 index 0000000000..1b20a490b4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SyntheticSpectrum.kt @@ -0,0 +1,63 @@ +/* + * 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.commons.audio + +import androidx.compose.runtime.withFrameMillis +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlin.math.PI +import kotlin.math.exp +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sin + +/** Deterministic decorative spectrum for previews and demos (no real audio). */ +object SyntheticSpectrum { + fun frame( + timeSec: Float, + binCount: Int, + ): Spectrum { + val t = timeSec + val bins = + FloatArray(binCount) { i -> + val f = i / binCount.toFloat() + val beat = (0.5 + 0.5 * sin(t * 2.0 * PI * 1.9)).pow(6.0) + val bass = exp((-f * 9.0)) * (0.55 + 0.9 * beat) + val mid = exp(-((f - 0.35) * 4.0).pow(2.0)) * (0.35 + 0.25 * sin(t * 5.0 + i)) + val treble = exp(-((f - 0.8) * 3.5).pow(2.0)) * max(0.0, sin(t * 14.0 + i * 2.3)) * 0.4 + val v = bass + mid + treble + 0.04 * sin(t * 3.0 + i * 0.7) + min(1.0, max(0.0, v)).toFloat() + } + return Spectrum(bins) + } + + /** Compose-friendly stream that emits one [frame] per display frame. */ + fun flow(binCount: Int = 48): Flow = + flow { + var startMs = -1L + while (true) { + val ms = withFrameMillis { it } + if (startMs < 0) startMs = ms + emit(frame((ms - startMs) / 1000f, binCount)) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerPalette.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerPalette.kt new file mode 100644 index 0000000000..03cda17cff --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerPalette.kt @@ -0,0 +1,45 @@ +/* + * 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.commons.audio + +import androidx.compose.runtime.Immutable + +/** + * Hue anchors (degrees, 0..360) the renderers use to colour the spectrum. Defaults + * give the cyan→magenta / bass-purple / treble-pink look from the design mockups. + */ +@Immutable +data class VisualizerPalette( + val lowHue: Float = 280f, + val midHue: Float = 200f, + val highHue: Float = 330f, + val saturation: Float = 0.92f, + val lightness: Float = 0.6f, +) { + init { + require(saturation in 0f..1f) { "saturation must be in 0f..1f, was $saturation" } + require(lightness in 0f..1f) { "lightness must be in 0f..1f, was $lightness" } + } + + companion object { + val DEFAULT = VisualizerPalette() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistry.kt new file mode 100644 index 0000000000..20b665c385 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistry.kt @@ -0,0 +1,36 @@ +/* + * 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.commons.audio + +import com.vitorpamplona.amethyst.commons.audio.renderers.AuroraRenderer +import com.vitorpamplona.amethyst.commons.audio.renderers.BarsRenderer +import com.vitorpamplona.amethyst.commons.audio.renderers.OffRenderer +import com.vitorpamplona.amethyst.commons.audio.renderers.RadialRenderer +import com.vitorpamplona.amethyst.commons.audio.renderers.WavesRenderer + +object VisualizerRegistry { + val all: List = + listOf(OffRenderer, BarsRenderer, WavesRenderer, RadialRenderer, AuroraRenderer) + + private val byStyle = all.associateBy { it.style } + + fun forStyle(style: VisualizerStyle): VisualizerRenderer = byStyle[style] ?: OffRenderer +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRenderer.kt new file mode 100644 index 0000000000..f1493271ba --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRenderer.kt @@ -0,0 +1,37 @@ +/* + * 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.commons.audio + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.Flow + +/** A single visualiser style. Implementations draw [spectrum] frames onto a Canvas. */ +interface VisualizerRenderer { + val style: VisualizerStyle + + @Composable + fun Render( + spectrum: Flow, + palette: VisualizerPalette, + modifier: Modifier, + ) +} 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 new file mode 100644 index 0000000000..d763e198e4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyle.kt @@ -0,0 +1,36 @@ +/* + * 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.commons.audio + +enum class VisualizerStyle { + OFF, + BARS, + WAVES, + RADIAL, + AURORA, + ; + + companion object { + val DEFAULT = WAVES + + fun fromName(name: String?): VisualizerStyle = entries.firstOrNull { it.name == name } ?: DEFAULT + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/AuroraRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/AuroraRenderer.kt new file mode 100644 index 0000000000..154fc7716f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/AuroraRenderer.kt @@ -0,0 +1,93 @@ +/* + * 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.commons.audio.renderers + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas +import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette +import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import kotlinx.coroutines.flow.Flow +import kotlin.math.sin + +object AuroraRenderer : VisualizerRenderer { + override val style = VisualizerStyle.AURORA + + private class Ribbon( + val hue: (VisualizerPalette) -> Float, + val yo: Float, + val sp: Float, + val band: Float, + ) + + private val ribbons = + listOf( + Ribbon({ it.midHue - 40f }, 0.5f, 1.0f, 0.15f), + Ribbon({ it.lowHue }, 0.55f, 1.4f, 0.45f), + Ribbon({ it.highHue }, 0.45f, 0.7f, 0.75f), + ) + + @Composable + override fun Render( + spectrum: Flow, + palette: VisualizerPalette, + modifier: Modifier, + ) { + SpectrumCanvas(spectrum, palette, modifier) { bins, t, pal -> + if (bins.isEmpty()) return@SpectrumCanvas + val n = bins.size + val w = size.width + val h = size.height + for (r in ribbons) { + val idx = (r.band * (n - 1)).toInt().coerceIn(0, n - 1) + val amp = (0.25f + bins[idx] * 0.9f) * h * 0.4f + val path = Path() + var x = 0f + while (x <= w) { + val f = x / w + val y = h * r.yo + sin(f * 6f * r.sp + t * 1.5f * r.sp) * amp + sin(f * 13f + t) * amp * 0.25f + if (x == 0f) path.moveTo(x, y) else path.lineTo(x, y) + x += 5f + } + val hue = (((r.hue(pal)) % 360f) + 360f) % 360f + drawPath( + path = path, + brush = + Brush.horizontalGradient( + 0f to Color.hsl(hue, pal.saturation, pal.lightness, 0f), + 0.5f to Color.hsl(hue, pal.saturation, pal.lightness, 0.55f), + 1f to Color.hsl(hue, pal.saturation, pal.lightness, 0f), + ), + style = Stroke(width = 22f + bins[idx] * 26f, cap = StrokeCap.Round), + blendMode = BlendMode.Plus, + ) + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/BarsRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/BarsRenderer.kt new file mode 100644 index 0000000000..0f2293ecf9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/BarsRenderer.kt @@ -0,0 +1,68 @@ +/* + * 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.commons.audio.renderers + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas +import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette +import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import kotlinx.coroutines.flow.Flow +import kotlin.math.min + +object BarsRenderer : VisualizerRenderer { + override val style = VisualizerStyle.BARS + + @Composable + override fun Render( + spectrum: Flow, + palette: VisualizerPalette, + modifier: Modifier, + ) { + SpectrumCanvas(spectrum, palette, modifier) { bins, _, pal -> + if (bins.isEmpty()) return@SpectrumCanvas + val n = bins.size + val gap = 2f + val barW = (size.width - gap * (n - 1)) / n + if (barW <= 0f) return@SpectrumCanvas + val mid = size.height / 2f + val radius = CornerRadius(min(barW / 2f, 3f), min(barW / 2f, 3f)) + for (i in 0 until n) { + val v = bins[i].coerceIn(0f, 1f) + val half = v * (size.height / 2f - 2f) + if (half <= 0f) continue + val hue = pal.midHue + (pal.highHue - pal.midHue) * (i / n.toFloat()) + drawRoundRect( + color = Color.hsl(((hue % 360f) + 360f) % 360f, pal.saturation, pal.lightness), + topLeft = Offset(i * (barW + gap), mid - half), + size = Size(barW, half * 2f), + cornerRadius = radius, + ) + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/OffRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/OffRenderer.kt new file mode 100644 index 0000000000..5587fe06c8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/OffRenderer.kt @@ -0,0 +1,44 @@ +/* + * 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.commons.audio.renderers + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette +import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import kotlinx.coroutines.flow.Flow + +object OffRenderer : VisualizerRenderer { + override val style = VisualizerStyle.OFF + + @Composable + override fun Render( + spectrum: Flow, + palette: VisualizerPalette, + modifier: Modifier, + ) { + // Draws nothing, but honors the modifier so the reserved layout region doesn't collapse. + Box(modifier) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/RadialRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/RadialRenderer.kt new file mode 100644 index 0000000000..e33247e106 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/RadialRenderer.kt @@ -0,0 +1,89 @@ +/* + * 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.commons.audio.renderers + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas +import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette +import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import kotlinx.coroutines.flow.Flow +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin + +object RadialRenderer : VisualizerRenderer { + override val style = VisualizerStyle.RADIAL + + @Composable + override fun Render( + spectrum: Flow, + palette: VisualizerPalette, + modifier: Modifier, + ) { + SpectrumCanvas(spectrum, palette, modifier) { bins, _, pal -> + if (bins.isEmpty()) return@SpectrumCanvas + val n = bins.size + val cx = size.width / 2f + val cy = size.height / 2f + val minDim = min(size.width, size.height) + val r0 = minDim * 0.16f + + var energy = 0f + val lowCount = min(8, n) + for (i in 0 until lowCount) energy += bins[i] + energy /= lowCount + + val coreR = r0 * (1.4f + energy) + drawCircle( + brush = + Brush.radialGradient( + 0f to Color.hsl(pal.highHue, pal.saturation, 0.7f, 0.9f), + 1f to Color.hsl(pal.highHue, pal.saturation, 0.6f, 0f), + center = Offset(cx, cy), + radius = coreR, + ), + radius = coreR, + center = Offset(cx, cy), + ) + + for (i in 0 until n) { + val a = i / n.toFloat() * (2f * PI.toFloat()) - PI.toFloat() / 2f + val len = r0 + bins[i].coerceIn(0f, 1f) * minDim * 0.32f + val hue = (((pal.midHue + (pal.highHue - pal.midHue) * (i / n.toFloat())) % 360f) + 360f) % 360f + drawLine( + color = Color.hsl(hue, pal.saturation, pal.lightness), + start = Offset(cx + cos(a) * r0, cy + sin(a) * r0), + end = Offset(cx + cos(a) * len, cy + sin(a) * len), + strokeWidth = 2.4f, + cap = StrokeCap.Round, + ) + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/WavesRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/WavesRenderer.kt new file mode 100644 index 0000000000..0a46f4cf82 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/WavesRenderer.kt @@ -0,0 +1,92 @@ +/* + * 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.commons.audio.renderers + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas +import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette +import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import kotlinx.coroutines.flow.Flow +import kotlin.math.sin + +object WavesRenderer : VisualizerRenderer { + override val style = VisualizerStyle.WAVES + + private class Layer( + val lo: Float, + val hi: Float, + val hue: (VisualizerPalette) -> Float, + val amp: Float, + val phase: Float, + ) + + private val layers = + listOf( + Layer(0f, 0.33f, { it.lowHue }, 0.95f, 0f), + Layer(0.2f, 0.7f, { it.midHue }, 0.8f, 1.5f), + Layer(0.5f, 1f, { it.highHue }, 0.7f, 3f), + ) + + @Composable + override fun Render( + spectrum: Flow, + palette: VisualizerPalette, + modifier: Modifier, + ) { + SpectrumCanvas(spectrum, palette, modifier) { bins, t, pal -> + if (bins.isEmpty()) return@SpectrumCanvas + val n = bins.size + val w = size.width + val h = size.height + for (layer in layers) { + val path = Path().apply { moveTo(0f, h) } + var x = 0f + while (x <= w) { + val f = x / w + val idx = ((layer.lo + (layer.hi - layer.lo) * f) * (n - 1)).toInt().coerceIn(0, n - 1) + val v = bins[idx] * layer.amp + val wob = 0.5f + 0.5f * sin(f * 8f + layer.phase + t * 2f) + path.lineTo(x, h - v * h * (0.45f + 0.55f * wob)) + x += 6f + } + path.lineTo(w, h) + path.close() + val hue = ((layer.hue(pal) % 360f) + 360f) % 360f + drawPath( + path = path, + brush = + Brush.verticalGradient( + 0f to Color.hsl(hue, pal.saturation, pal.lightness, 0.85f), + 1f to Color.hsl(hue, pal.saturation, pal.lightness * 0.8f, 0.05f), + ), + blendMode = BlendMode.Plus, + ) + } + } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrumTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrumTest.kt new file mode 100644 index 0000000000..999bd5f46e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrumTest.kt @@ -0,0 +1,56 @@ +/* + * 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.commons.audio + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertTrue + +class AudioSpectrumTest { + @Test + fun normalizedToPeakScalesMaxToOne() { + val out = floatArrayOf(0f, 2f, 4f, 1f).normalizedToPeak() + assertEquals(1f, out[2], 1e-6f) + assertEquals(0.5f, out[1], 1e-6f) + } + + @Test + fun normalizedToPeakHandlesAllZero() { + val out = floatArrayOf(0f, 0f, 0f).normalizedToPeak() + assertTrue(out.all { it == 0f }) + } + + @Test + fun normalizedToPeakReturnsNewArrayEvenForAllZero() { + val input = floatArrayOf(0f, 0f, 0f) + assertNotSame(input, input.normalizedToPeak()) + } + + @Test + fun toLogBinsProducesRequestedCountClampedZeroToOne() { + val mags = FloatArray(256) { if (it < 4) 1f else 0f } + val bins = mags.toLogBins(32) + assertEquals(32, bins.size) + assertTrue(bins.all { it in 0f..1f }) + assertTrue(bins.first() > bins.last()) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioWindowTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioWindowTest.kt new file mode 100644 index 0000000000..884e359777 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioWindowTest.kt @@ -0,0 +1,46 @@ +/* + * 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.commons.audio + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AudioWindowTest { + @Test + fun hannIsZeroAtEndsAndOneInMiddle() { + val w = AudioWindow.hann(9) + assertEquals(0f, w.first(), 1e-5f) + assertEquals(0f, w.last(), 1e-5f) + assertEquals(1f, w[4], 1e-4f) + } + + @Test + fun shortsToWindowedNormalizesAndPadsToWindowLength() { + val window = FloatArray(4) { 1f } + val out = AudioWindow.shortsToWindowed(shortArrayOf(32767, -32768), window) + assertEquals(4, out.size) + assertTrue(out[0] in 0.99f..1.01f) + assertTrue(out[1] in -1.01f..-0.99f) + assertEquals(0f, out[2]) + assertEquals(0f, out[3]) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/FftTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/FftTest.kt new file mode 100644 index 0000000000..3e7109fa98 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/FftTest.kt @@ -0,0 +1,64 @@ +/* + * 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.commons.audio + +import kotlin.math.PI +import kotlin.math.sin +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FftTest { + @Test + fun sineProducesPeakAtItsBin() { + val n = 64 + val k = 8 + val signal = FloatArray(n) { sin(2.0 * PI * k * it / n).toFloat() } + + val mags = Fft.magnitudes(signal) + + assertEquals(n / 2 + 1, mags.size) + val peakBin = mags.indices.maxByOrNull { mags[it] } + assertEquals(k, peakBin) + } + + @Test + fun dcOffsetLandsInBinZero() { + val n = 32 + val signal = FloatArray(n) { 0.5f } + + val mags = Fft.magnitudes(signal) + + val peakBin = mags.indices.maxByOrNull { mags[it] } + assertEquals(0, peakBin) + } + + @Test + fun rejectsNonPowerOfTwo() { + var threw = false + try { + Fft.magnitudes(FloatArray(30)) + } catch (e: IllegalArgumentException) { + threw = true + } + assertTrue(threw) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/SyntheticSpectrumTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/SyntheticSpectrumTest.kt new file mode 100644 index 0000000000..e2b5564491 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/SyntheticSpectrumTest.kt @@ -0,0 +1,55 @@ +/* + * 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.commons.audio + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SyntheticSpectrumTest { + @Test + fun frameHasRequestedBinsClampedZeroToOne() { + val frame = SyntheticSpectrum.frame(timeSec = 1.0f, binCount = 48) + assertEquals(48, frame.bins.size) + assertTrue(frame.bins.all { it in 0f..1f }) + } + + @Test + fun frameIsDeterministic() { + val a = SyntheticSpectrum.frame(2.0f, 32).bins + val b = SyntheticSpectrum.frame(2.0f, 32).bins + assertTrue(a.contentEquals(b)) + } + + @Test + fun bassBinsCarryMoreEnergyThanTreble() { + var low = 0f + var high = 0f + var t = 0f + repeat(20) { + val bins = SyntheticSpectrum.frame(t, 32).bins + low += bins.take(6).sum() + high += bins.takeLast(6).sum() + t += 0.05f + } + assertTrue(low > high) + } +} 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 new file mode 100644 index 0000000000..b667631fae --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistryTest.kt @@ -0,0 +1,41 @@ +/* + * 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.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) + } + } + + @Test + fun registryCoversEveryStyleExactlyOnce() { + val styles = VisualizerRegistry.all.map { it.style }.toSet() + assertTrue(styles.containsAll(VisualizerStyle.entries.toList())) + assertEquals(VisualizerStyle.entries.size, VisualizerRegistry.all.size) + } +} 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 new file mode 100644 index 0000000000..92a0b5b5a4 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyleTest.kt @@ -0,0 +1,39 @@ +/* + * 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.commons.audio + +import kotlin.test.Test +import kotlin.test.assertEquals + +class VisualizerStyleTest { + @Test + 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)) + } + + @Test + fun defaultIsWaves() { + assertEquals(VisualizerStyle.WAVES, VisualizerStyle.DEFAULT) + } +}