Improvements
This commit is contained in:
@@ -1,160 +0,0 @@
|
|||||||
package dev.adriankuta.visualizer
|
|
||||||
|
|
||||||
import android.media.audiofx.Visualizer
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.State
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import dev.adriankuta.visualizer.domain.SoundToColorMapper
|
|
||||||
import dev.adriankuta.visualizer.domain.SoundToColorMapperFactory
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for audio visualization functionality.
|
|
||||||
* Follows Interface Segregation Principle.
|
|
||||||
*/
|
|
||||||
interface AudioVisualizerService {
|
|
||||||
val currentColor: State<Color>
|
|
||||||
val isActive: State<Boolean>
|
|
||||||
|
|
||||||
fun start()
|
|
||||||
fun stop()
|
|
||||||
fun release()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Implementation of AudioVisualizerService using Android's Visualizer API.
|
|
||||||
*
|
|
||||||
* Follows Single Responsibility Principle by focusing on audio visualization.
|
|
||||||
* Uses Dependency Injection for the sound-to-color mapper.
|
|
||||||
*/
|
|
||||||
class AndroidAudioVisualizer(
|
|
||||||
private val soundToColorMapper: SoundToColorMapper = SoundToColorMapperFactory.createDefault(),
|
|
||||||
private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Main)
|
|
||||||
) : AudioVisualizerService {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val SAMPLING_RATE = 16000 // Hz
|
|
||||||
private const val CAPTURE_SIZE = 1024 // Window size for audio capture
|
|
||||||
}
|
|
||||||
|
|
||||||
private var visualizer: Visualizer? = null
|
|
||||||
private var processingJob: Job? = null
|
|
||||||
|
|
||||||
private val _currentColor = mutableStateOf(Color.Black)
|
|
||||||
override val currentColor: State<Color> = _currentColor
|
|
||||||
|
|
||||||
private val _isActive = mutableStateOf(false)
|
|
||||||
override val isActive: State<Boolean> = _isActive
|
|
||||||
|
|
||||||
override fun start() {
|
|
||||||
try {
|
|
||||||
// Initialize visualizer with audio session ID 0 (output mix)
|
|
||||||
visualizer = Visualizer(0).apply {
|
|
||||||
captureSize = CAPTURE_SIZE
|
|
||||||
|
|
||||||
// Set up waveform data listener
|
|
||||||
setDataCaptureListener(
|
|
||||||
object : Visualizer.OnDataCaptureListener {
|
|
||||||
override fun onWaveFormDataCapture(
|
|
||||||
visualizer: Visualizer?,
|
|
||||||
waveform: ByteArray?,
|
|
||||||
samplingRate: Int
|
|
||||||
) {
|
|
||||||
waveform?.let { processWaveform(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFftDataCapture(
|
|
||||||
visualizer: Visualizer?,
|
|
||||||
fft: ByteArray?,
|
|
||||||
samplingRate: Int
|
|
||||||
) {
|
|
||||||
// Not used in this implementation
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Visualizer.getMaxCaptureRate() / 2, // Capture rate
|
|
||||||
true, // Waveform
|
|
||||||
false // FFT
|
|
||||||
)
|
|
||||||
|
|
||||||
enabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
_isActive.value = true
|
|
||||||
Timber.d("AudioVisualizer started successfully")
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Failed to start AudioVisualizer")
|
|
||||||
_isActive.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
try {
|
|
||||||
visualizer?.enabled = false
|
|
||||||
processingJob?.cancel()
|
|
||||||
_isActive.value = false
|
|
||||||
Timber.d("AudioVisualizer stopped")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Error stopping AudioVisualizer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun release() {
|
|
||||||
stop()
|
|
||||||
try {
|
|
||||||
visualizer?.release()
|
|
||||||
visualizer = null
|
|
||||||
soundToColorMapper.reset()
|
|
||||||
Timber.d("AudioVisualizer released")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Error releasing AudioVisualizer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun processWaveform(waveform: ByteArray) {
|
|
||||||
processingJob?.cancel()
|
|
||||||
processingJob = coroutineScope.launch(Dispatchers.Default) {
|
|
||||||
try {
|
|
||||||
val color = soundToColorMapper.mapSoundToColor(waveform)
|
|
||||||
|
|
||||||
// Update UI on main thread
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
_currentColor.value = color
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Error processing waveform")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory for creating AudioVisualizerService instances.
|
|
||||||
* Follows Factory Pattern and makes testing easier.
|
|
||||||
*/
|
|
||||||
object AudioVisualizerFactory {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a default Android audio visualizer.
|
|
||||||
*/
|
|
||||||
fun createDefault(coroutineScope: CoroutineScope): AudioVisualizerService {
|
|
||||||
return AndroidAudioVisualizer(
|
|
||||||
soundToColorMapper = SoundToColorMapperFactory.createDefault(),
|
|
||||||
coroutineScope = coroutineScope
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an Android audio visualizer with custom mapper.
|
|
||||||
*/
|
|
||||||
fun create(
|
|
||||||
soundToColorMapper: SoundToColorMapper,
|
|
||||||
coroutineScope: CoroutineScope
|
|
||||||
): AudioVisualizerService {
|
|
||||||
return AndroidAudioVisualizer(soundToColorMapper, coroutineScope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ package dev.adriankuta.visualizer.data
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface AudioVisualizer: AudioVisualizerController {
|
interface AudioVisualizer: AudioVisualizerController {
|
||||||
fun waveform(): Flow<WaveformFrame>
|
|
||||||
|
|
||||||
fun fft(): Flow<FftFrame>
|
fun visualizerFlow(): Flow<VisualizerFrame>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package dev.adriankuta.visualizer.data
|
package dev.adriankuta.visualizer.data
|
||||||
|
|
||||||
import android.media.audiofx.Visualizer
|
import android.media.audiofx.Visualizer
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.flow.conflate
|
import kotlinx.coroutines.flow.conflate
|
||||||
import kotlinx.coroutines.flow.filterIsInstance
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class AudioVisualizerImpl @Inject constructor(
|
class AudioVisualizerImpl @Inject constructor(
|
||||||
@@ -13,22 +14,14 @@ class AudioVisualizerImpl @Inject constructor(
|
|||||||
) : AudioVisualizer {
|
) : AudioVisualizer {
|
||||||
|
|
||||||
override fun start() {
|
override fun start() {
|
||||||
visualizer.enabled = true
|
// visualizer.enabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stop() {
|
override fun stop() {
|
||||||
visualizer.enabled = false
|
visualizer.enabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun waveform(): Flow<WaveformFrame> =
|
override fun visualizerFlow(): Flow<VisualizerFrame> = callbackFlow {
|
||||||
visualizerFlow()
|
|
||||||
.filterIsInstance(WaveformFrame::class)
|
|
||||||
|
|
||||||
override fun fft(): Flow<FftFrame> =
|
|
||||||
visualizerFlow()
|
|
||||||
.filterIsInstance(FftFrame::class)
|
|
||||||
|
|
||||||
private fun visualizerFlow(): Flow<VisualizerFrame> = callbackFlow {
|
|
||||||
val listener = object : Visualizer.OnDataCaptureListener {
|
val listener = object : Visualizer.OnDataCaptureListener {
|
||||||
override fun onFftDataCapture(
|
override fun onFftDataCapture(
|
||||||
visualizer: Visualizer?,
|
visualizer: Visualizer?,
|
||||||
@@ -47,13 +40,15 @@ class AudioVisualizerImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
visualizer.setDataCaptureListener(
|
val result = visualizer.setDataCaptureListener(
|
||||||
listener,
|
listener,
|
||||||
Visualizer.getMaxCaptureRate(),
|
Visualizer.getMaxCaptureRate(),
|
||||||
true,
|
true,
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
visualizer.enabled = true
|
||||||
|
|
||||||
awaitClose {
|
awaitClose {
|
||||||
runCatching {
|
runCatching {
|
||||||
visualizer.enabled = false
|
visualizer.enabled = false
|
||||||
@@ -63,13 +58,17 @@ class AudioVisualizerImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.conflate()
|
.conflate()
|
||||||
|
.flowOn(Dispatchers.Default)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface VisualizerFrame {
|
||||||
|
val data: ByteArray
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface VisualizerFrame
|
|
||||||
data class WaveformFrame(
|
data class WaveformFrame(
|
||||||
val data: ByteArray,
|
override val data: ByteArray,
|
||||||
) : VisualizerFrame
|
) : VisualizerFrame
|
||||||
|
|
||||||
data class FftFrame(
|
data class FftFrame(
|
||||||
val data: ByteArray,
|
override val data: ByteArray,
|
||||||
) : VisualizerFrame
|
) : VisualizerFrame
|
||||||
@@ -9,6 +9,9 @@ import dagger.hilt.components.SingletonComponent
|
|||||||
import dev.adriankuta.visualizer.data.AudioVisualizer
|
import dev.adriankuta.visualizer.data.AudioVisualizer
|
||||||
import dev.adriankuta.visualizer.data.AudioVisualizerController
|
import dev.adriankuta.visualizer.data.AudioVisualizerController
|
||||||
import dev.adriankuta.visualizer.data.AudioVisualizerImpl
|
import dev.adriankuta.visualizer.data.AudioVisualizerImpl
|
||||||
|
import dev.adriankuta.visualizer.domain.processors.FftToColor
|
||||||
|
import dev.adriankuta.visualizer.domain.processors.HSLSoundToColorMapper
|
||||||
|
import dev.adriankuta.visualizer.domain.processors.VisualizerProcessor
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -25,6 +28,11 @@ abstract class VisualizerModule {
|
|||||||
audioVisualizerImpl: AudioVisualizerImpl
|
audioVisualizerImpl: AudioVisualizerImpl
|
||||||
): AudioVisualizerController
|
): AudioVisualizerController
|
||||||
|
|
||||||
|
/*@Binds
|
||||||
|
abstract fun bindFftProcessor(
|
||||||
|
fftToColor: WaveformToColorProcessor
|
||||||
|
): VisualizerProcessor*/
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -33,5 +41,11 @@ abstract class VisualizerModule {
|
|||||||
captureSize = 1024
|
captureSize = 1024
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideProcessor(): VisualizerProcessor {
|
||||||
|
return HSLSoundToColorMapper()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,22 +2,28 @@ package dev.adriankuta.visualizer.domain
|
|||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import dev.adriankuta.visualizer.data.AudioVisualizer
|
import dev.adriankuta.visualizer.data.AudioVisualizer
|
||||||
|
import dev.adriankuta.visualizer.data.FftFrame
|
||||||
|
import dev.adriankuta.visualizer.data.VisualizerFrame
|
||||||
import dev.adriankuta.visualizer.data.WaveformFrame
|
import dev.adriankuta.visualizer.data.WaveformFrame
|
||||||
|
import dev.adriankuta.visualizer.domain.processors.VisualizerProcessor
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class ObserveVisualizerColorUseCase @Inject constructor(
|
class ObserveVisualizerColorUseCase @Inject constructor(
|
||||||
private val audioVisualizer: AudioVisualizer
|
private val audioVisualizer: AudioVisualizer,
|
||||||
|
private val processor: VisualizerProcessor,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val colorMapper = HSLSoundToColorMapper()
|
|
||||||
|
|
||||||
operator fun invoke(): Flow<Color> =
|
operator fun invoke(): Flow<Color> =
|
||||||
audioVisualizer.waveform()
|
audioVisualizer.visualizerFlow()
|
||||||
.map { processColor(it) }
|
.process(processor)
|
||||||
|
.onStart { emit(Color.Black) }
|
||||||
private fun processColor(waveformFrame: WaveformFrame): Color {
|
|
||||||
return colorMapper.mapSoundToColor(waveformFrame.data)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Flow<VisualizerFrame>.process(processor: VisualizerProcessor) =
|
||||||
|
this.filterIsInstance(WaveformFrame::class)
|
||||||
|
.map(processor::invoke)
|
||||||
|
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
package dev.adriankuta.visualizer.domain
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import dev.adriankuta.visualizer.HSL
|
|
||||||
import dev.adriankuta.visualizer.MovingAverage
|
|
||||||
import dev.adriankuta.visualizer.calculateRms
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for mapping sound data to colors.
|
|
||||||
* Follows the Interface Segregation Principle by defining a focused contract.
|
|
||||||
*/
|
|
||||||
interface SoundToColorMapper {
|
|
||||||
/**
|
|
||||||
* Maps audio waveform data to a color.
|
|
||||||
*
|
|
||||||
* @param waveform Audio waveform data as byte array
|
|
||||||
* @return Compose Color representing the mapped sound
|
|
||||||
*/
|
|
||||||
fun mapSoundToColor(waveform: ByteArray): Color
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets the internal state of the mapper.
|
|
||||||
*/
|
|
||||||
fun reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default implementation of SoundToColorMapper that uses HSL color space
|
|
||||||
* and moving average for smooth color transitions.
|
|
||||||
*
|
|
||||||
* Follows Single Responsibility Principle by focusing on sound-to-color mapping.
|
|
||||||
* Follows Dependency Inversion Principle by depending on abstractions.
|
|
||||||
*/
|
|
||||||
class HSLSoundToColorMapper(
|
|
||||||
private val baseHue: Float = 0f, // Blue hue as default
|
|
||||||
private val saturation: Float = 1f,
|
|
||||||
private val movingAverageWindowSize: Int = 1
|
|
||||||
) : SoundToColorMapper {
|
|
||||||
|
|
||||||
private val movingAverage = MovingAverage(movingAverageWindowSize)
|
|
||||||
private var rmsMax = 0.0
|
|
||||||
|
|
||||||
override fun mapSoundToColor(waveform: ByteArray): Color {
|
|
||||||
// Calculate RMS value for current frame
|
|
||||||
val currentRms = calculateRms(waveform)
|
|
||||||
|
|
||||||
// Update maximum RMS value for normalization
|
|
||||||
if (currentRms > rmsMax) {
|
|
||||||
rmsMax = currentRms
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate energy ratio (0-1)
|
|
||||||
val energyRatio = if (rmsMax > 0) currentRms / rmsMax else 0.0
|
|
||||||
|
|
||||||
// Apply moving average for smooth transitions
|
|
||||||
val adjustedLightness = movingAverage.process(energyRatio)
|
|
||||||
|
|
||||||
// Create HSL color and convert to Compose Color
|
|
||||||
val hslColor = HSL(
|
|
||||||
hue = baseHue,
|
|
||||||
saturation = saturation,
|
|
||||||
lightness = adjustedLightness.toFloat().coerceIn(0f, 1f)
|
|
||||||
)
|
|
||||||
|
|
||||||
return hslColor.toComposeColor()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun reset() {
|
|
||||||
movingAverage.reset()
|
|
||||||
rmsMax = 0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory for creating SoundToColorMapper instances.
|
|
||||||
* Follows the Factory Pattern and Open/Closed Principle.
|
|
||||||
*/
|
|
||||||
object SoundToColorMapperFactory {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a default HSL-based sound to color mapper.
|
|
||||||
*/
|
|
||||||
fun createDefault(): SoundToColorMapper = HSLSoundToColorMapper()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an HSL-based sound to color mapper with custom parameters.
|
|
||||||
*
|
|
||||||
* @param baseHue The base hue for the color (0-360)
|
|
||||||
* @param saturation The saturation level (0-1)
|
|
||||||
* @param movingAverageWindowSize Window size for moving average smoothing
|
|
||||||
*/
|
|
||||||
fun createHSLMapper(
|
|
||||||
baseHue: Float = 240f,
|
|
||||||
saturation: Float = 1f,
|
|
||||||
movingAverageWindowSize: Int = 7
|
|
||||||
): SoundToColorMapper = HSLSoundToColorMapper(baseHue, saturation, movingAverageWindowSize)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package dev.adriankuta.visualizer.domain.processors
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.colorspace.ColorSpaces
|
||||||
|
import dev.adriankuta.visualizer.data.FftFrame
|
||||||
|
import dev.adriankuta.visualizer.data.VisualizerFrame
|
||||||
|
import dev.adriankuta.visualizer.data.WaveformFrame
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.ln
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared utilities for brightness mapping.
|
||||||
|
*/
|
||||||
|
class BrightnessMapper(
|
||||||
|
private val hue: Float = 210f, // blue-ish
|
||||||
|
private val saturation: Float = 0.9f, // vivid color
|
||||||
|
private val gain: Float = 8f, // log mapping strength (higher -> more sensitive)
|
||||||
|
private val attack: Float = 0.35f, // 0..1, higher = faster rise
|
||||||
|
private val release: Float = 0.08f, // 0..1, lower = slower fall
|
||||||
|
private val peakDecay: Float = 0.995f // 0..1, how fast auto-gain reference decays
|
||||||
|
) {
|
||||||
|
private var smoothed = 0f
|
||||||
|
private var peak = 0.1f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param rawEnergy input in [0, +inf) (e.g., RMS or avg magnitude)
|
||||||
|
* @return Color with brightness (V) in [0,1]
|
||||||
|
*/
|
||||||
|
fun toColor(rawEnergy: Float): Color {
|
||||||
|
// 1) Log loudness mapping: map [0, +inf) → [0, 1]
|
||||||
|
// normalizedLog = ln(1 + gain * x) / ln(1 + gain)
|
||||||
|
val loud = if (rawEnergy <= 0f) 0f
|
||||||
|
else (ln(1f + gain * rawEnergy) / ln(1f + gain))
|
||||||
|
|
||||||
|
// 2) Peak/auto-gain tracking
|
||||||
|
peak = max(loud, peak * peakDecay)
|
||||||
|
val norm = if (peak > 1e-6f) loud / peak else loud
|
||||||
|
val clamped = norm.coerceIn(0f, 1f)
|
||||||
|
|
||||||
|
// 3) Attack/Release smoothing
|
||||||
|
val a = if (clamped > smoothed) attack else release
|
||||||
|
smoothed = smoothed + a * (clamped - smoothed)
|
||||||
|
|
||||||
|
// 4) HSV → Color (fixed hue & saturation; dynamic value)
|
||||||
|
return Color.hsv(
|
||||||
|
hue = hue.coerceIn(0f, 360f),
|
||||||
|
saturation = saturation.coerceIn(0f, 1f),
|
||||||
|
value = smoothed.coerceIn(0f, 1f),
|
||||||
|
colorSpace = ColorSpaces.Srgb
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visualizer waveform → brightness.
|
||||||
|
* Visualizer's waveform bytes are unsigned 8-bit: 0..255 with 128 ≈ 0.
|
||||||
|
*/
|
||||||
|
class WaveformToColorProcessor @Inject constructor(
|
||||||
|
) : VisualizerProcessor {
|
||||||
|
private val brightness: BrightnessMapper = BrightnessMapper(
|
||||||
|
hue = 210f,
|
||||||
|
saturation = 0.9f,
|
||||||
|
gain = 8f,
|
||||||
|
attack = 0.35f,
|
||||||
|
release = 0.08f,
|
||||||
|
peakDecay = 0.995f
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun invoke(frame: VisualizerFrame): Color = withContext(Dispatchers.Default) {
|
||||||
|
val bytes = frame.data
|
||||||
|
if (bytes.isEmpty()) {
|
||||||
|
return@withContext Color.Black
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to [-1, 1] floats, compute RMS.
|
||||||
|
var sumSq = 0.0
|
||||||
|
val inv = 1f / 128f
|
||||||
|
for (b in bytes) {
|
||||||
|
// unsigned -> center at 128 -> normalize
|
||||||
|
val s = ((b.toInt() and 0xFF) - 128) * inv
|
||||||
|
sumSq += (s * s)
|
||||||
|
}
|
||||||
|
val rms = sqrt(sumSq / bytes.size).toFloat() // 0..~1
|
||||||
|
|
||||||
|
brightness.toColor(rms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visualizer FFT → brightness.
|
||||||
|
* Visualizer's FFT bytes are 8-bit signed fixed-point: interleaved (re, im).
|
||||||
|
* We compute mean magnitude over bins. You can bias ranges by adding weights.
|
||||||
|
*/
|
||||||
|
class FftToColorProcessor @Inject constructor(
|
||||||
|
) : VisualizerProcessor {
|
||||||
|
private val brightness: BrightnessMapper = BrightnessMapper(
|
||||||
|
hue = 210f,
|
||||||
|
saturation = 0.9f,
|
||||||
|
gain = 10f,
|
||||||
|
attack = 0.30f,
|
||||||
|
release = 0.10f,
|
||||||
|
peakDecay = 0.996f
|
||||||
|
)
|
||||||
|
private val binWeighting: ((binIndex: Int, totalBins: Int) -> Float)? = null
|
||||||
|
|
||||||
|
override suspend fun invoke(frame: VisualizerFrame): Color = withContext(Dispatchers.Default) {
|
||||||
|
val bytes = frame.data
|
||||||
|
if (bytes.size < 2) {
|
||||||
|
return@withContext Color.Black
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each bin: (re, im) as signed bytes. Skip DC/nyquist if you like; here we include all valid pairs.
|
||||||
|
val pairCount = bytes.size / 2
|
||||||
|
var sumMag = 0.0
|
||||||
|
var weightSum = 0.0
|
||||||
|
|
||||||
|
var bin = 0
|
||||||
|
var i = 0
|
||||||
|
while (i + 1 < bytes.size) {
|
||||||
|
val re = bytes[i].toInt().toByte().toInt() // ensure sign
|
||||||
|
val im = bytes[i + 1].toInt().toByte().toInt()
|
||||||
|
val mag = sqrt((re * re + im * im).toDouble()) // magnitude in byte units
|
||||||
|
val w = binWeighting?.invoke(bin, pairCount)?.coerceAtLeast(0f)?.toDouble() ?: 1.0
|
||||||
|
sumMag += mag * w
|
||||||
|
weightSum += w
|
||||||
|
bin += 1
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Average magnitude → normalize to something close to [0,1].
|
||||||
|
// 8-bit range ≈ [-128,127], so typical magnitude can be up to ~181; average is far lower.
|
||||||
|
val avgMag = if (weightSum > 0.0) sumMag / weightSum else 0.0
|
||||||
|
val norm = (avgMag / 128.0).toFloat().coerceAtLeast(0f) // rough normalization
|
||||||
|
|
||||||
|
brightness.toColor(norm)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package dev.adriankuta.visualizer.domain.processors
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import dev.adriankuta.visualizer.data.FftFrame
|
||||||
|
import dev.adriankuta.visualizer.data.VisualizerFrame
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
class FftToColor @Inject constructor() : VisualizerProcessor {
|
||||||
|
|
||||||
|
override suspend fun invoke(frame: VisualizerFrame): Color = withContext(Dispatchers.Default) {
|
||||||
|
// --- Extract raw FFT bytes from your frame ---
|
||||||
|
// Adjust the property name if your FftFrame differs (e.g., `frame.data`, `frame.bytes`, etc.)
|
||||||
|
val fft: ByteArray = frame.data
|
||||||
|
|
||||||
|
if (fft.isEmpty()) return@withContext Color.Black
|
||||||
|
|
||||||
|
// Visualizer FFT format: interleaved [re0, im0, re1, im1, ...]
|
||||||
|
// Skip DC (bin 0: re0, im0) as it often behaves like noise/offset.
|
||||||
|
var sumMagnitude = 0.0
|
||||||
|
var bins = 0
|
||||||
|
|
||||||
|
var i = 2 // start from bin 1 (skip DC)
|
||||||
|
while (i + 1 < fft.size) {
|
||||||
|
val re = fft[i].toInt()
|
||||||
|
val im = fft[i + 1].toInt()
|
||||||
|
val mag = sqrt((re * re + im * im).toDouble())
|
||||||
|
sumMagnitude += mag
|
||||||
|
bins++
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
val avgMagnitude = if (bins > 0) sumMagnitude / bins else 0.0
|
||||||
|
|
||||||
|
// Normalize ~[0..1]. Each component is in [-128..127], so magnitude upper bound ~181.
|
||||||
|
// 128 is a practical normalization for stable visuals; then clamp.
|
||||||
|
val normalized = (avgMagnitude / 128.0).coerceIn(0.0, 1.0)
|
||||||
|
|
||||||
|
// Perceptual easing to avoid flicker and give more dynamic range at lower levels.
|
||||||
|
val eased = sqrt(normalized).toFloat()
|
||||||
|
|
||||||
|
// Simple noise gate so tiny ambient levels don’t glow.
|
||||||
|
val value = if (eased < NOISE_FLOOR) 0f else eased
|
||||||
|
|
||||||
|
// Single, stable color – only brightness changes with the music.
|
||||||
|
Color.hsv(BASE_HUE, BASE_SATURATION, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// Tune these to your taste (degrees 0..360, 0..1).
|
||||||
|
const val BASE_HUE = 0f // cool blue
|
||||||
|
const val BASE_SATURATION = 0.85f // vivid
|
||||||
|
const val NOISE_FLOOR = 0.05f // ignore very low energy
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package dev.adriankuta.visualizer.domain.processors
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import dev.adriankuta.visualizer.HSL
|
||||||
|
import dev.adriankuta.visualizer.MovingAverage
|
||||||
|
import dev.adriankuta.visualizer.calculateRms
|
||||||
|
import dev.adriankuta.visualizer.data.FftFrame
|
||||||
|
import dev.adriankuta.visualizer.data.VisualizerFrame
|
||||||
|
import dev.adriankuta.visualizer.data.WaveformFrame
|
||||||
|
import timber.log.Timber
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation of SoundToColorMapper that uses HSL color space
|
||||||
|
* and moving average for smooth color transitions.
|
||||||
|
*
|
||||||
|
* Follows Single Responsibility Principle by focusing on sound-to-color mapping.
|
||||||
|
* Follows Dependency Inversion Principle by depending on abstractions.
|
||||||
|
*/
|
||||||
|
class HSLSoundToColorMapper(
|
||||||
|
private val baseHue: Float = 0f, // Blue hue as default
|
||||||
|
private val saturation: Float = 1f,
|
||||||
|
private val movingAverageWindowSize: Int = 2
|
||||||
|
) : VisualizerProcessor {
|
||||||
|
|
||||||
|
private val movingAverage = MovingAverage(movingAverageWindowSize)
|
||||||
|
private var rmsMax = 0.0
|
||||||
|
|
||||||
|
override suspend fun invoke(frame: VisualizerFrame): Color {
|
||||||
|
val waveform = frame.data
|
||||||
|
// Calculate RMS value for current frame
|
||||||
|
val currentRms = calculateRms(waveform)
|
||||||
|
|
||||||
|
rmsMax -= 0.01
|
||||||
|
// Update maximum RMS value for normalization
|
||||||
|
if (currentRms > rmsMax) {
|
||||||
|
rmsMax = currentRms
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.d("Max RMS: %s, current %s", rmsMax, currentRms)
|
||||||
|
|
||||||
|
// Calculate energy ratio (0-1)
|
||||||
|
val energyRatio = if (rmsMax > 0) currentRms / rmsMax else 0.0
|
||||||
|
|
||||||
|
// Apply moving average for smooth transitions
|
||||||
|
val adjustedLightness = movingAverage.process(energyRatio)
|
||||||
|
|
||||||
|
// Create HSL color and convert to Compose Color
|
||||||
|
val hslColor = HSL(
|
||||||
|
hue = baseHue,
|
||||||
|
saturation = saturation,
|
||||||
|
lightness = adjustedLightness.toFloat().coerceIn(0f, 1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
return hslColor.toComposeColor()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package dev.adriankuta.visualizer.domain.processors
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import dev.adriankuta.visualizer.data.VisualizerFrame
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
interface VisualizerProcessor {
|
||||||
|
|
||||||
|
suspend operator fun invoke(frame: VisualizerFrame): Color
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.adriankuta.visualizer.ui.components
|
||||||
|
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun KeepScreenOn() {
|
||||||
|
val activity = LocalActivity.current
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
// Clear the flag when the composable is disposed
|
||||||
|
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,37 @@ import android.Manifest
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -24,35 +47,20 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.adriankuta.visualizer.AudioVisualizerFactory
|
import dev.adriankuta.visualizer.ui.components.KeepScreenOn
|
||||||
import dev.adriankuta.visualizer.AudioVisualizerService
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun VisualizerScreen(
|
|
||||||
viewModel: VisualizerScreenViewModel = hiltViewModel()
|
|
||||||
) {
|
|
||||||
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
}
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VisualizerScreen(
|
fun VisualizerScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
audioVisualizer: AudioVisualizerService = remember {
|
viewModel: VisualizerScreenViewModel = hiltViewModel()
|
||||||
AudioVisualizerFactory.createDefault(
|
|
||||||
CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val currentColor by audioVisualizer.currentColor
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val isActive by audioVisualizer.isActive
|
|
||||||
|
KeepScreenOn()
|
||||||
|
|
||||||
// Permission state management
|
// Permission state management
|
||||||
var hasAudioPermission by remember {
|
var hasAudioPermission by remember {
|
||||||
@@ -73,7 +81,7 @@ fun VisualizerScreen(
|
|||||||
hasAudioPermission = isGranted
|
hasAudioPermission = isGranted
|
||||||
if (isGranted) {
|
if (isGranted) {
|
||||||
Timber.d("Audio permission granted, starting visualizer")
|
Timber.d("Audio permission granted, starting visualizer")
|
||||||
audioVisualizer.start()
|
viewModel.start()
|
||||||
} else {
|
} else {
|
||||||
Timber.w("Audio permission denied")
|
Timber.w("Audio permission denied")
|
||||||
showPermissionRationale = true
|
showPermissionRationale = true
|
||||||
@@ -84,8 +92,9 @@ fun VisualizerScreen(
|
|||||||
fun startVisualizerWithPermissionCheck() {
|
fun startVisualizerWithPermissionCheck() {
|
||||||
when {
|
when {
|
||||||
hasAudioPermission -> {
|
hasAudioPermission -> {
|
||||||
audioVisualizer.start()
|
viewModel.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Timber.d("Requesting audio permission")
|
Timber.d("Requesting audio permission")
|
||||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
@@ -98,7 +107,7 @@ fun VisualizerScreen(
|
|||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
Lifecycle.Event.ON_START -> startVisualizerWithPermissionCheck()
|
Lifecycle.Event.ON_START -> startVisualizerWithPermissionCheck()
|
||||||
Lifecycle.Event.ON_STOP -> audioVisualizer.stop()
|
Lifecycle.Event.ON_STOP -> viewModel.stop()
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,7 +116,7 @@ fun VisualizerScreen(
|
|||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
audioVisualizer.release()
|
viewModel.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,26 +137,13 @@ fun VisualizerScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Color display area
|
// Color display area
|
||||||
Box(
|
ColorDisplay(uiState)
|
||||||
modifier = Modifier
|
|
||||||
.size(300.dp)
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(currentColor)
|
|
||||||
.padding(16.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
// Show color information
|
|
||||||
ColorInfoCard(
|
|
||||||
color = currentColor,
|
|
||||||
isActive = isActive
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
// Status indicator
|
// Status indicator
|
||||||
StatusIndicator(
|
StatusIndicator(
|
||||||
isActive = isActive,
|
isActive = uiState.isActive,
|
||||||
hasPermission = hasAudioPermission
|
hasPermission = hasAudioPermission
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -157,7 +153,7 @@ fun VisualizerScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = when {
|
text = when {
|
||||||
!hasAudioPermission -> "Audio permission is required to visualize sound"
|
!hasAudioPermission -> "Audio permission is required to visualize sound"
|
||||||
isActive -> "Play some music or make sounds to see the colors change!"
|
uiState.isActive -> "Play some music or make sounds to see the colors change!"
|
||||||
else -> "Starting audio visualizer..."
|
else -> "Starting audio visualizer..."
|
||||||
},
|
},
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
@@ -211,13 +207,35 @@ fun VisualizerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColorDisplay(uiState: VisualizerScreenUiState) {
|
||||||
|
val animatedColor by animateColorAsState(
|
||||||
|
uiState.color,
|
||||||
|
animationSpec = tween(50),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(300.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.drawBehind {
|
||||||
|
drawRect(animatedColor)
|
||||||
|
}
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
// Show color information
|
||||||
|
ColorInfoCard(
|
||||||
|
color = uiState.color,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card displaying color information.
|
* Card displaying color information.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColorInfoCard(
|
private fun ColorInfoCard(
|
||||||
color: Color,
|
color: Color,
|
||||||
isActive: Boolean,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ package dev.adriankuta.visualizer.view
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
data class VisualizerScreenUiState(
|
data class VisualizerScreenUiState(
|
||||||
val color: Color
|
val color: Color,
|
||||||
|
val isActive: Boolean = false
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,23 +1,54 @@
|
|||||||
|
@file:OptIn(FlowPreview::class)
|
||||||
|
|
||||||
package dev.adriankuta.visualizer.view
|
package dev.adriankuta.visualizer.view
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dev.adriankuta.visualizer.data.AudioVisualizerController
|
||||||
import dev.adriankuta.visualizer.domain.ObserveVisualizerColorUseCase
|
import dev.adriankuta.visualizer.domain.ObserveVisualizerColorUseCase
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.sample
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class VisualizerScreenViewModel @Inject constructor(
|
class VisualizerScreenViewModel @Inject constructor(
|
||||||
private val observeVisualizerColorUseCase: ObserveVisualizerColorUseCase
|
observeVisualizerColorUseCase: ObserveVisualizerColorUseCase,
|
||||||
|
private val audioVisualizerController: AudioVisualizerController
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val uiState = observeVisualizerColorUseCase()
|
private val _isActive = MutableStateFlow(false)
|
||||||
.stateIn(
|
|
||||||
|
val uiState = combine(
|
||||||
|
observeVisualizerColorUseCase().sample(50.milliseconds),
|
||||||
|
_isActive
|
||||||
|
) { color, isActive ->
|
||||||
|
VisualizerScreenUiState(
|
||||||
|
color = color,
|
||||||
|
isActive = isActive
|
||||||
|
)
|
||||||
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000),
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
initialValue = Color.Black
|
initialValue = VisualizerScreenUiState(
|
||||||
|
color = Color.Black,
|
||||||
|
isActive = false
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
audioVisualizerController.start()
|
||||||
|
_isActive.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
audioVisualizerController.stop()
|
||||||
|
_isActive.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user