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