diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index c22827c..c92cbca 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -13,6 +13,9 @@ + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 74dd639..b2c751a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/app/src/main/java/dev/adriankuta/visualizer/data/RmsValueProvider.kt b/app/src/main/java/dev/adriankuta/visualizer/data/RmsValueProvider.kt new file mode 100644 index 0000000..86f5fed --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/data/RmsValueProvider.kt @@ -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(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + fun emit(value: RmsValue) { + currentRms.tryEmit(value) + } + + fun observe(): SharedFlow = currentRms +} + +data class RmsValue( + val current: Double, + val max: Double, + val min: Double = 0.0 +) \ No newline at end of file diff --git a/app/src/main/java/dev/adriankuta/visualizer/di/VisualizerModule.kt b/app/src/main/java/dev/adriankuta/visualizer/di/VisualizerModule.kt index 5fb3561..310c2e0 100644 --- a/app/src/main/java/dev/adriankuta/visualizer/di/VisualizerModule.kt +++ b/app/src/main/java/dev/adriankuta/visualizer/di/VisualizerModule.kt @@ -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() - } + }*/ } } \ No newline at end of file diff --git a/app/src/main/java/dev/adriankuta/visualizer/domain/processors/SoundToColorMapper.kt b/app/src/main/java/dev/adriankuta/visualizer/domain/processors/SoundToColorMapper.kt index 3ef121c..a5f2fd6 100644 --- a/app/src/main/java/dev/adriankuta/visualizer/domain/processors/SoundToColorMapper.kt +++ b/app/src/main/java/dev/adriankuta/visualizer/domain/processors/SoundToColorMapper.kt @@ -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) diff --git a/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreen.kt b/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreen.kt index 7f0a0bb..e85b15a 100644 --- a/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreen.kt +++ b/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreen.kt @@ -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) + ) + } + } + } + } +} diff --git a/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenUiState.kt b/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenUiState.kt index 0fb15db..15d64fc 100644 --- a/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenUiState.kt +++ b/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenUiState.kt @@ -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 ) diff --git a/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenViewModel.kt b/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenViewModel.kt index ae2e596..b255b95 100644 --- a/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenViewModel.kt +++ b/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenViewModel.kt @@ -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 ) ) diff --git a/app/src/test/java/dev/adriankuta/visualizer/ExampleUnitTest.kt b/app/src/test/java/dev/adriankuta/visualizer/ExampleUnitTest.kt deleted file mode 100644 index d96b7fa..0000000 --- a/app/src/test/java/dev/adriankuta/visualizer/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.adriankuta.visualizer - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file