RMS Visualizer
This commit is contained in:
3
.idea/deploymentTargetSelector.xml
generated
3
.idea/deploymentTargetSelector.xml
generated
@@ -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
1
.idea/misc.xml
generated
@@ -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">
|
||||||
|
|||||||
@@ -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.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()
|
||||||
}
|
}*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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