Add audio visualisers

feat(audio): show selected live visualiser for audio notes
feat(audio): add change/read accessors for audio-visualizer preference
feat(audio): expose synced audio-visualizer preference flow
feat(audio): add media prefs to synced-settings internal model
fix(audio): thread-safe tap registry, reset spectrum on track reuse
feat(audio): tap decoded PCM via TeeAudioProcessor in pooled players
test(audio): unit-test PCM sink with synthetic sine waves
feat(audio): add PCM-tap registry and FFT audio-buffer sink
fix(audio): continuous viz clock, safe peak-normalize, OFF layout, palette guards
feat(audio): add AudioVisualizer dispatcher composable
feat(audio): add renderer interface, canvas scaffold, registry, and all five styles
feat(audio): add deterministic synthetic spectrum for previews
feat(audio): add VisualizerStyle enum and palette
refactor(audio): drop Visualizer FFT helper, add peak normalization
feat(audio): add Hann windowing + PCM-to-float conversion
feat(audio): add pure-Kotlin radix-2 FFT for the visualiser
This commit is contained in:
davotoula
2026-06-08 17:46:58 +02:00
parent 46e29ad3f8
commit 032e1bf7fb
31 changed files with 1583 additions and 18 deletions
@@ -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
@@ -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<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
@@ -253,6 +264,11 @@ class AccountLanguagePreferences(
): String? = languagePreferences.value["$source,$target"]
}
@Stable
class AccountMediaPreferences(
val audioVisualizer: MutableStateFlow<VisualizerStyle>,
)
@Stable
class AccountSecurityPreferences(
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
@@ -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",
)
@@ -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,
)
@@ -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)
}
}
}
}
@@ -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
@@ -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 {
@@ -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<Spectrum>(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<Any, SpectrumAudioBufferSink>()
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<Spectrum>? = sinks[playerKey]?.frames
}
@@ -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<VisualizerStyle> = account.settings.syncedSettings.media.audioVisualizer
fun changeAudioVisualizer(style: VisualizerStyle) {
account.settings.changeAudioVisualizer(style)
}
fun updateZapAmounts(
amountSet: List<Long>,
selectedZapType: LnZapEvent.ZapType,
@@ -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())
}
}
@@ -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 }
}
@@ -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<Spectrum>,
modifier: Modifier = Modifier,
palette: VisualizerPalette = VisualizerPalette.DEFAULT,
) {
VisualizerRegistry.forStyle(style).Render(spectrum, palette, modifier)
}
@@ -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
}
}
@@ -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 CooleyTukey 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() }
}
}
@@ -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<Spectrum>,
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)
}
}
@@ -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<Spectrum> =
flow {
var startMs = -1L
while (true) {
val ms = withFrameMillis { it }
if (startMs < 0) startMs = ms
emit(frame((ms - startMs) / 1000f, binCount))
}
}
}
@@ -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()
}
}
@@ -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<VisualizerRenderer> =
listOf(OffRenderer, BarsRenderer, WavesRenderer, RadialRenderer, AuroraRenderer)
private val byStyle = all.associateBy { it.style }
fun forStyle(style: VisualizerStyle): VisualizerRenderer = byStyle[style] ?: OffRenderer
}
@@ -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<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
)
}
@@ -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
}
}
@@ -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<Spectrum>,
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,
)
}
}
}
}
@@ -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<Spectrum>,
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,
)
}
}
}
}
@@ -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<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
) {
// Draws nothing, but honors the modifier so the reserved layout region doesn't collapse.
Box(modifier)
}
}
@@ -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<Spectrum>,
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,
)
}
}
}
}
@@ -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<Spectrum>,
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,
)
}
}
}
}
@@ -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())
}
}
@@ -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])
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}