RMS Visualizer

This commit is contained in:
Adrian Kuta (DZCQIWG)
2025-10-07 22:13:34 +02:00
parent 4bc3a0a096
commit ea5fc9e97d
9 changed files with 170 additions and 40 deletions

View File

@@ -13,6 +13,9 @@
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="ExampleInstrumentedTest">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

1
.idea/misc.xml generated
View File

@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

View File

@@ -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
)

View File

@@ -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()
}
}*/
}
}

View File

@@ -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)

View File

@@ -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
// 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)
)
}
}
}
}
}

View File

@@ -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
)

View File

@@ -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
)
)

View File

@@ -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)
}
}