mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
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:
@@ -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),
|
||||
|
||||
+7
@@ -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",
|
||||
)
|
||||
|
||||
+7
-3
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+30
-10
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-3
@@ -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
|
||||
|
||||
+9
-2
@@ -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 {
|
||||
|
||||
+120
@@ -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
|
||||
}
|
||||
+7
@@ -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,
|
||||
|
||||
+127
@@ -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())
|
||||
}
|
||||
}
|
||||
+65
@@ -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 }
|
||||
}
|
||||
+36
@@ -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 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() }
|
||||
}
|
||||
}
|
||||
+77
@@ -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)
|
||||
}
|
||||
}
|
||||
+63
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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()
|
||||
}
|
||||
}
|
||||
+36
@@ -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
|
||||
}
|
||||
+37
@@ -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,
|
||||
)
|
||||
}
|
||||
+36
@@ -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
|
||||
}
|
||||
}
|
||||
+93
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -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)
|
||||
}
|
||||
}
|
||||
+89
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -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())
|
||||
}
|
||||
}
|
||||
+46
@@ -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)
|
||||
}
|
||||
}
|
||||
+55
@@ -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)
|
||||
}
|
||||
}
|
||||
+41
@@ -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)
|
||||
}
|
||||
}
|
||||
+39
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user