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> </DropdownSelection>
<DialogSelection /> <DialogSelection />
</SelectionState> </SelectionState>
<SelectionState runConfigName="ExampleInstrumentedTest">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates> </selectionStates>
</component> </component>
</project> </project>

1
.idea/misc.xml generated
View File

@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <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.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.HSLSoundToColorMapper
import dev.adriankuta.visualizer.domain.processors.VisualizerProcessor import dev.adriankuta.visualizer.domain.processors.VisualizerProcessor
import javax.inject.Singleton import javax.inject.Singleton
@@ -28,10 +27,10 @@ abstract class VisualizerModule {
audioVisualizerImpl: AudioVisualizerImpl audioVisualizerImpl: AudioVisualizerImpl
): AudioVisualizerController ): AudioVisualizerController
/*@Binds @Binds
abstract fun bindFftProcessor( abstract fun bindProcessor(
fftToColor: WaveformToColorProcessor fftToColor: HSLSoundToColorMapper
): VisualizerProcessor*/ ): VisualizerProcessor
companion object { companion object {
@Provides @Provides
@@ -42,10 +41,10 @@ abstract class VisualizerModule {
} }
} }
@Provides /*@Provides
@Singleton @Singleton
fun provideProcessor(): VisualizerProcessor { fun provideProcessor(): VisualizerProcessor {
return HSLSoundToColorMapper() return HSLSoundToColorMapper()
} }*/
} }
} }

View File

@@ -4,11 +4,11 @@ import androidx.compose.ui.graphics.Color
import dev.adriankuta.visualizer.HSL import dev.adriankuta.visualizer.HSL
import dev.adriankuta.visualizer.MovingAverage import dev.adriankuta.visualizer.MovingAverage
import dev.adriankuta.visualizer.calculateRms 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.VisualizerFrame
import dev.adriankuta.visualizer.data.WaveformFrame
import timber.log.Timber import timber.log.Timber
import kotlin.reflect.KClass import javax.inject.Inject
/** /**
* Default implementation of SoundToColorMapper that uses HSL color space * 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 Single Responsibility Principle by focusing on sound-to-color mapping.
* Follows Dependency Inversion Principle by depending on abstractions. * Follows Dependency Inversion Principle by depending on abstractions.
*/ */
class HSLSoundToColorMapper( class HSLSoundToColorMapper @Inject constructor(
private val baseHue: Float = 0f, // Blue hue as default private val rmsValueProvider: RmsValueProvider
private val saturation: Float = 1f,
private val movingAverageWindowSize: Int = 2
) : VisualizerProcessor { ) : 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 val movingAverage = MovingAverage(movingAverageWindowSize)
private var rmsMax = 0.0 private var rmsMax = 0.0
@@ -36,6 +37,7 @@ class HSLSoundToColorMapper(
if (currentRms > rmsMax) { if (currentRms > rmsMax) {
rmsMax = currentRms rmsMax = currentRms
} }
rmsValueProvider.emit(RmsValue(currentRms, rmsMax))
Timber.d("Max RMS: %s, current %s", rmsMax, currentRms) 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.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -35,6 +36,8 @@ 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.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
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
@@ -47,6 +50,7 @@ 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.data.RmsValue
import dev.adriankuta.visualizer.ui.components.KeepScreenOn import dev.adriankuta.visualizer.ui.components.KeepScreenOn
import timber.log.Timber import timber.log.Timber
@@ -136,8 +140,17 @@ fun VisualizerScreen(
modifier = Modifier.padding(bottom = 32.dp) modifier = Modifier.padding(bottom = 32.dp)
) )
// Color display area // Color display and RMS progress bar area
ColorDisplay(uiState) Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
ColorDisplay(uiState)
RmsProgressBar(
rmsValue = uiState.rmsValue,
modifier = Modifier
)
}
Spacer(modifier = Modifier.height(32.dp)) 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 package dev.adriankuta.visualizer.view
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import dev.adriankuta.visualizer.data.RmsValue
data class VisualizerScreenUiState( data class VisualizerScreenUiState(
val color: Color, 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 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.data.AudioVisualizerController
import dev.adriankuta.visualizer.data.RmsValueProvider
import dev.adriankuta.visualizer.domain.ObserveVisualizerColorUseCase import dev.adriankuta.visualizer.domain.ObserveVisualizerColorUseCase
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,25 +21,29 @@ import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel @HiltViewModel
class VisualizerScreenViewModel @Inject constructor( class VisualizerScreenViewModel @Inject constructor(
observeVisualizerColorUseCase: ObserveVisualizerColorUseCase, observeVisualizerColorUseCase: ObserveVisualizerColorUseCase,
private val audioVisualizerController: AudioVisualizerController private val audioVisualizerController: AudioVisualizerController,
private val rmsValueProvider: RmsValueProvider
) : ViewModel() { ) : ViewModel() {
private val _isActive = MutableStateFlow(false) private val _isActive = MutableStateFlow(false)
val uiState = combine( val uiState = combine(
observeVisualizerColorUseCase().sample(50.milliseconds), observeVisualizerColorUseCase().sample(50.milliseconds),
_isActive _isActive,
) { color, isActive -> rmsValueProvider.observe().sample(50.milliseconds)
) { color, isActive, rmsValue ->
VisualizerScreenUiState( VisualizerScreenUiState(
color = color, color = color,
isActive = isActive isActive = isActive,
rmsValue = rmsValue
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = VisualizerScreenUiState( initialValue = VisualizerScreenUiState(
color = Color.Black, 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)
}
}