RMS Visualizer
This commit is contained in:
		| @@ -0,0 +1,26 @@ | ||||
| package dev.adriankuta.visualizer.data | ||||
|  | ||||
| import kotlinx.coroutines.channels.BufferOverflow | ||||
| import kotlinx.coroutines.flow.MutableSharedFlow | ||||
| import kotlinx.coroutines.flow.SharedFlow | ||||
| import javax.inject.Inject | ||||
| import javax.inject.Singleton | ||||
|  | ||||
| @Singleton | ||||
| class RmsValueProvider @Inject constructor() { | ||||
|  | ||||
|     private val currentRms = | ||||
|         MutableSharedFlow<RmsValue>(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) | ||||
|  | ||||
|     fun emit(value: RmsValue) { | ||||
|         currentRms.tryEmit(value) | ||||
|     } | ||||
|  | ||||
|     fun observe(): SharedFlow<RmsValue> = currentRms | ||||
| } | ||||
|  | ||||
| data class RmsValue( | ||||
|     val current: Double, | ||||
|     val max: Double, | ||||
|     val min: Double = 0.0 | ||||
| ) | ||||
| @@ -9,7 +9,6 @@ import dagger.hilt.components.SingletonComponent | ||||
| import dev.adriankuta.visualizer.data.AudioVisualizer | ||||
| import dev.adriankuta.visualizer.data.AudioVisualizerController | ||||
| 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 | ||||
| @@ -28,10 +27,10 @@ abstract class VisualizerModule { | ||||
|         audioVisualizerImpl: AudioVisualizerImpl | ||||
|     ): AudioVisualizerController | ||||
|  | ||||
|     /*@Binds | ||||
|     abstract fun bindFftProcessor( | ||||
|         fftToColor: WaveformToColorProcessor | ||||
|     ): VisualizerProcessor*/ | ||||
|     @Binds | ||||
|     abstract fun bindProcessor( | ||||
|         fftToColor: HSLSoundToColorMapper | ||||
|     ): VisualizerProcessor | ||||
|  | ||||
|     companion object { | ||||
|         @Provides | ||||
| @@ -42,10 +41,10 @@ abstract class VisualizerModule { | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         @Provides | ||||
|         /*@Provides | ||||
|         @Singleton | ||||
|         fun provideProcessor(): VisualizerProcessor { | ||||
|             return HSLSoundToColorMapper() | ||||
|         } | ||||
|         }*/ | ||||
|     } | ||||
| } | ||||
| @@ -4,11 +4,11 @@ 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.RmsValue | ||||
| import dev.adriankuta.visualizer.data.RmsValueProvider | ||||
| import dev.adriankuta.visualizer.data.VisualizerFrame | ||||
| import dev.adriankuta.visualizer.data.WaveformFrame | ||||
| import timber.log.Timber | ||||
| import kotlin.reflect.KClass | ||||
| import javax.inject.Inject | ||||
|  | ||||
| /** | ||||
|  * Default implementation of SoundToColorMapper that uses HSL color space | ||||
| @@ -17,11 +17,12 @@ import kotlin.reflect.KClass | ||||
|  * 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 | ||||
| class HSLSoundToColorMapper @Inject constructor( | ||||
|     private val rmsValueProvider: RmsValueProvider | ||||
| ) : VisualizerProcessor { | ||||
|     private val baseHue: Float = 0f // Blue hue as default | ||||
|     private val saturation: Float = 1f | ||||
|     private val movingAverageWindowSize: Int = 2 | ||||
|  | ||||
|     private val movingAverage = MovingAverage(movingAverageWindowSize) | ||||
|     private var rmsMax = 0.0 | ||||
| @@ -36,6 +37,7 @@ class HSLSoundToColorMapper( | ||||
|         if (currentRms > rmsMax) { | ||||
|             rmsMax = currentRms | ||||
|         } | ||||
|         rmsValueProvider.emit(RmsValue(currentRms, rmsMax)) | ||||
|  | ||||
|         Timber.d("Max RMS: %s, current %s", rmsMax, currentRms) | ||||
|  | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import android.content.pm.PackageManager | ||||
| import androidx.activity.compose.rememberLauncherForActivityResult | ||||
| import androidx.activity.result.contract.ActivityResultContracts | ||||
| import androidx.compose.animation.animateColorAsState | ||||
| import androidx.compose.animation.core.animateFloatAsState | ||||
| import androidx.compose.animation.core.tween | ||||
| import androidx.compose.foundation.background | ||||
| import androidx.compose.foundation.layout.Arrangement | ||||
| @@ -35,6 +36,8 @@ import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.draw.clip | ||||
| import androidx.compose.ui.draw.drawBehind | ||||
| import androidx.compose.ui.geometry.Offset | ||||
| import androidx.compose.ui.geometry.Size | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.text.font.FontWeight | ||||
| @@ -47,6 +50,7 @@ import androidx.lifecycle.Lifecycle | ||||
| import androidx.lifecycle.LifecycleEventObserver | ||||
| import androidx.lifecycle.compose.LocalLifecycleOwner | ||||
| import androidx.lifecycle.compose.collectAsStateWithLifecycle | ||||
| import dev.adriankuta.visualizer.data.RmsValue | ||||
| import dev.adriankuta.visualizer.ui.components.KeepScreenOn | ||||
| import timber.log.Timber | ||||
|  | ||||
| @@ -136,8 +140,17 @@ fun VisualizerScreen( | ||||
|             modifier = Modifier.padding(bottom = 32.dp) | ||||
|         ) | ||||
|  | ||||
|         // Color display area | ||||
|         ColorDisplay(uiState) | ||||
|         // Color display and RMS progress bar area | ||||
|         Row( | ||||
|             horizontalArrangement = Arrangement.spacedBy(16.dp), | ||||
|             verticalAlignment = Alignment.CenterVertically | ||||
|         ) { | ||||
|             ColorDisplay(uiState) | ||||
|             RmsProgressBar( | ||||
|                 rmsValue = uiState.rmsValue, | ||||
|                 modifier = Modifier | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         Spacer(modifier = Modifier.height(32.dp)) | ||||
|  | ||||
| @@ -316,3 +329,101 @@ private fun StatusIndicator( | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Vertical progress bar showing RMS values. | ||||
|  */ | ||||
| @Composable | ||||
| private fun RmsProgressBar( | ||||
|     rmsValue: RmsValue?, | ||||
|     modifier: Modifier = Modifier | ||||
| ) { | ||||
|     Card( | ||||
|         modifier = modifier, | ||||
|         colors = CardDefaults.cardColors( | ||||
|             containerColor = MaterialTheme.colorScheme.surface | ||||
|         ), | ||||
|         elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) | ||||
|     ) { | ||||
|         Column( | ||||
|             modifier = Modifier.padding(16.dp), | ||||
|             horizontalAlignment = Alignment.CenterHorizontally | ||||
|         ) { | ||||
|             Text( | ||||
|                 text = "RMS Level", | ||||
|                 fontSize = 14.sp, | ||||
|                 fontWeight = FontWeight.Medium, | ||||
|                 color = MaterialTheme.colorScheme.onSurface | ||||
|             ) | ||||
|  | ||||
|             Spacer(modifier = Modifier.height(8.dp)) | ||||
|  | ||||
|             if (rmsValue != null) { | ||||
|                 val animatedProgress by animateFloatAsState( | ||||
|                     targetValue = if (rmsValue.max > 0) { | ||||
|                         (rmsValue.current / rmsValue.max).coerceIn(0.0, 1.0).toFloat() | ||||
|                     } else { | ||||
|                         0f | ||||
|                     }, | ||||
|                     animationSpec = tween(50) | ||||
|                 ) | ||||
|                 val animatedColor by animateColorAsState( | ||||
|                     when { | ||||
|                         animatedProgress > 0.8f -> Color.Red | ||||
|                         animatedProgress > 0.6f -> Color.Yellow | ||||
|                         else -> Color.Green | ||||
|                     }, | ||||
|                     animationSpec = tween(50) | ||||
|                 ) | ||||
|  | ||||
|                 // Vertical progress bar | ||||
|                 Box( | ||||
|                     modifier = Modifier | ||||
|                         .width(40.dp) | ||||
|                         .height(200.dp) | ||||
|                         .clip(RoundedCornerShape(20.dp)) | ||||
|                         .background(MaterialTheme.colorScheme.surfaceVariant) | ||||
|                 ) { | ||||
|                     Box( | ||||
|                         modifier = Modifier | ||||
|                             .fillMaxSize() | ||||
|                             .clip(RoundedCornerShape(20.dp)) | ||||
|                             .drawBehind { | ||||
|                                 val fillHeight = size.height * animatedProgress | ||||
|                                 drawRect( | ||||
|                                     color = animatedColor, | ||||
|                                     topLeft = Offset(0f, size.height - fillHeight), | ||||
|                                     size = Size(size.width, fillHeight) | ||||
|                                 ) | ||||
|                             } | ||||
|                     ) | ||||
|                 } | ||||
|  | ||||
|                 Spacer(modifier = Modifier.height(8.dp)) | ||||
|  | ||||
|                 // Show current value | ||||
|                 Text( | ||||
|                     text = String.format("%.3f", rmsValue.current), | ||||
|                     fontSize = 12.sp, | ||||
|                     color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) | ||||
|                 ) | ||||
|             } else { | ||||
|                 // Placeholder when no RMS data | ||||
|                 Box( | ||||
|                     modifier = Modifier | ||||
|                         .width(40.dp) | ||||
|                         .height(200.dp) | ||||
|                         .clip(RoundedCornerShape(20.dp)) | ||||
|                         .background(MaterialTheme.colorScheme.surfaceVariant), | ||||
|                     contentAlignment = Alignment.Center | ||||
|                 ) { | ||||
|                     Text( | ||||
|                         text = "No Data", | ||||
|                         fontSize = 10.sp, | ||||
|                         color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| package dev.adriankuta.visualizer.view | ||||
|  | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import dev.adriankuta.visualizer.data.RmsValue | ||||
|  | ||||
| data class VisualizerScreenUiState( | ||||
|     val color: Color, | ||||
|     val isActive: Boolean = false | ||||
|     val isActive: Boolean = false, | ||||
|     val rmsValue: RmsValue? = null | ||||
| ) | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel | ||||
| import androidx.lifecycle.viewModelScope | ||||
| import dagger.hilt.android.lifecycle.HiltViewModel | ||||
| import dev.adriankuta.visualizer.data.AudioVisualizerController | ||||
| import dev.adriankuta.visualizer.data.RmsValueProvider | ||||
| import dev.adriankuta.visualizer.domain.ObserveVisualizerColorUseCase | ||||
| import kotlinx.coroutines.FlowPreview | ||||
| import kotlinx.coroutines.flow.MutableStateFlow | ||||
| @@ -20,25 +21,29 @@ import kotlin.time.Duration.Companion.milliseconds | ||||
| @HiltViewModel | ||||
| class VisualizerScreenViewModel @Inject constructor( | ||||
|     observeVisualizerColorUseCase: ObserveVisualizerColorUseCase, | ||||
|     private val audioVisualizerController: AudioVisualizerController | ||||
|     private val audioVisualizerController: AudioVisualizerController, | ||||
|     private val rmsValueProvider: RmsValueProvider | ||||
| ) : ViewModel() { | ||||
|  | ||||
|     private val _isActive = MutableStateFlow(false) | ||||
|  | ||||
|     val uiState = combine( | ||||
|         observeVisualizerColorUseCase().sample(50.milliseconds), | ||||
|         _isActive | ||||
|     ) { color, isActive -> | ||||
|         _isActive, | ||||
|         rmsValueProvider.observe().sample(50.milliseconds) | ||||
|     ) { color, isActive, rmsValue -> | ||||
|         VisualizerScreenUiState( | ||||
|             color = color, | ||||
|             isActive = isActive | ||||
|             isActive = isActive, | ||||
|             rmsValue = rmsValue | ||||
|         ) | ||||
|     }.stateIn( | ||||
|         scope = viewModelScope, | ||||
|         started = SharingStarted.WhileSubscribed(5_000), | ||||
|         initialValue = VisualizerScreenUiState( | ||||
|             color = Color.Black, | ||||
|             isActive = false | ||||
|             isActive = false, | ||||
|             rmsValue = null | ||||
|         ) | ||||
|     ) | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Adrian Kuta (DZCQIWG)
					Adrian Kuta (DZCQIWG)