RMS Visualizer
This commit is contained in:
3
.idea/deploymentTargetSelector.xml
generated
3
.idea/deploymentTargetSelector.xml
generated
@@ -13,6 +13,9 @@
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="ExampleInstrumentedTest">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
@@ -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">
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user