Compare commits

..

2 Commits

Author SHA1 Message Date
Adrian Kuta (DZCQIWG)
ea5fc9e97d RMS Visualizer 2025-10-07 22:13:34 +02:00
Adrian Kuta (DZCQIWG)
4bc3a0a096 Improvements 2025-10-03 08:51:58 +02:00
18 changed files with 580 additions and 352 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

@@ -1,160 +0,0 @@
package dev.adriankuta.visualizer
import android.media.audiofx.Visualizer
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.State
import androidx.compose.ui.graphics.Color
import dev.adriankuta.visualizer.domain.SoundToColorMapper
import dev.adriankuta.visualizer.domain.SoundToColorMapperFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import timber.log.Timber
/**
* Interface for audio visualization functionality.
* Follows Interface Segregation Principle.
*/
interface AudioVisualizerService {
val currentColor: State<Color>
val isActive: State<Boolean>
fun start()
fun stop()
fun release()
}
/**
* Implementation of AudioVisualizerService using Android's Visualizer API.
*
* Follows Single Responsibility Principle by focusing on audio visualization.
* Uses Dependency Injection for the sound-to-color mapper.
*/
class AndroidAudioVisualizer(
private val soundToColorMapper: SoundToColorMapper = SoundToColorMapperFactory.createDefault(),
private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Main)
) : AudioVisualizerService {
companion object {
private const val SAMPLING_RATE = 16000 // Hz
private const val CAPTURE_SIZE = 1024 // Window size for audio capture
}
private var visualizer: Visualizer? = null
private var processingJob: Job? = null
private val _currentColor = mutableStateOf(Color.Black)
override val currentColor: State<Color> = _currentColor
private val _isActive = mutableStateOf(false)
override val isActive: State<Boolean> = _isActive
override fun start() {
try {
// Initialize visualizer with audio session ID 0 (output mix)
visualizer = Visualizer(0).apply {
captureSize = CAPTURE_SIZE
// Set up waveform data listener
setDataCaptureListener(
object : Visualizer.OnDataCaptureListener {
override fun onWaveFormDataCapture(
visualizer: Visualizer?,
waveform: ByteArray?,
samplingRate: Int
) {
waveform?.let { processWaveform(it) }
}
override fun onFftDataCapture(
visualizer: Visualizer?,
fft: ByteArray?,
samplingRate: Int
) {
// Not used in this implementation
}
},
Visualizer.getMaxCaptureRate() / 2, // Capture rate
true, // Waveform
false // FFT
)
enabled = true
}
_isActive.value = true
Timber.d("AudioVisualizer started successfully")
} catch (e: Exception) {
Timber.e(e, "Failed to start AudioVisualizer")
_isActive.value = false
}
}
override fun stop() {
try {
visualizer?.enabled = false
processingJob?.cancel()
_isActive.value = false
Timber.d("AudioVisualizer stopped")
} catch (e: Exception) {
Timber.e(e, "Error stopping AudioVisualizer")
}
}
override fun release() {
stop()
try {
visualizer?.release()
visualizer = null
soundToColorMapper.reset()
Timber.d("AudioVisualizer released")
} catch (e: Exception) {
Timber.e(e, "Error releasing AudioVisualizer")
}
}
private fun processWaveform(waveform: ByteArray) {
processingJob?.cancel()
processingJob = coroutineScope.launch(Dispatchers.Default) {
try {
val color = soundToColorMapper.mapSoundToColor(waveform)
// Update UI on main thread
launch(Dispatchers.Main) {
_currentColor.value = color
}
} catch (e: Exception) {
Timber.e(e, "Error processing waveform")
}
}
}
}
/**
* Factory for creating AudioVisualizerService instances.
* Follows Factory Pattern and makes testing easier.
*/
object AudioVisualizerFactory {
/**
* Creates a default Android audio visualizer.
*/
fun createDefault(coroutineScope: CoroutineScope): AudioVisualizerService {
return AndroidAudioVisualizer(
soundToColorMapper = SoundToColorMapperFactory.createDefault(),
coroutineScope = coroutineScope
)
}
/**
* Creates an Android audio visualizer with custom mapper.
*/
fun create(
soundToColorMapper: SoundToColorMapper,
coroutineScope: CoroutineScope
): AudioVisualizerService {
return AndroidAudioVisualizer(soundToColorMapper, coroutineScope)
}
}

View File

@@ -3,7 +3,6 @@ package dev.adriankuta.visualizer.data
import kotlinx.coroutines.flow.Flow
interface AudioVisualizer: AudioVisualizerController {
fun waveform(): Flow<WaveformFrame>
fun fft(): Flow<FftFrame>
fun visualizerFlow(): Flow<VisualizerFrame>
}

View File

@@ -1,11 +1,12 @@
package dev.adriankuta.visualizer.data
import android.media.audiofx.Visualizer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject
class AudioVisualizerImpl @Inject constructor(
@@ -13,22 +14,14 @@ class AudioVisualizerImpl @Inject constructor(
) : AudioVisualizer {
override fun start() {
visualizer.enabled = true
// visualizer.enabled = true
}
override fun stop() {
visualizer.enabled = false
}
override fun waveform(): Flow<WaveformFrame> =
visualizerFlow()
.filterIsInstance(WaveformFrame::class)
override fun fft(): Flow<FftFrame> =
visualizerFlow()
.filterIsInstance(FftFrame::class)
private fun visualizerFlow(): Flow<VisualizerFrame> = callbackFlow {
override fun visualizerFlow(): Flow<VisualizerFrame> = callbackFlow {
val listener = object : Visualizer.OnDataCaptureListener {
override fun onFftDataCapture(
visualizer: Visualizer?,
@@ -47,13 +40,15 @@ class AudioVisualizerImpl @Inject constructor(
}
}
visualizer.setDataCaptureListener(
val result = visualizer.setDataCaptureListener(
listener,
Visualizer.getMaxCaptureRate(),
true,
true
)
visualizer.enabled = true
awaitClose {
runCatching {
visualizer.enabled = false
@@ -63,13 +58,17 @@ class AudioVisualizerImpl @Inject constructor(
}
}
.conflate()
.flowOn(Dispatchers.Default)
}
sealed interface VisualizerFrame {
val data: ByteArray
}
sealed interface VisualizerFrame
data class WaveformFrame(
val data: ByteArray,
override val data: ByteArray,
) : VisualizerFrame
data class FftFrame(
val data: ByteArray,
override val data: ByteArray,
) : VisualizerFrame

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,6 +9,8 @@ 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.HSLSoundToColorMapper
import dev.adriankuta.visualizer.domain.processors.VisualizerProcessor
import javax.inject.Singleton
@Module
@@ -25,6 +27,11 @@ abstract class VisualizerModule {
audioVisualizerImpl: AudioVisualizerImpl
): AudioVisualizerController
@Binds
abstract fun bindProcessor(
fftToColor: HSLSoundToColorMapper
): VisualizerProcessor
companion object {
@Provides
@Singleton
@@ -33,5 +40,11 @@ abstract class VisualizerModule {
captureSize = 1024
}
}
/*@Provides
@Singleton
fun provideProcessor(): VisualizerProcessor {
return HSLSoundToColorMapper()
}*/
}
}

View File

@@ -2,22 +2,28 @@ package dev.adriankuta.visualizer.domain
import androidx.compose.ui.graphics.Color
import dev.adriankuta.visualizer.data.AudioVisualizer
import dev.adriankuta.visualizer.data.FftFrame
import dev.adriankuta.visualizer.data.VisualizerFrame
import dev.adriankuta.visualizer.data.WaveformFrame
import dev.adriankuta.visualizer.domain.processors.VisualizerProcessor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import javax.inject.Inject
class ObserveVisualizerColorUseCase @Inject constructor(
private val audioVisualizer: AudioVisualizer
private val audioVisualizer: AudioVisualizer,
private val processor: VisualizerProcessor,
) {
private val colorMapper = HSLSoundToColorMapper()
operator fun invoke(): Flow<Color> =
audioVisualizer.waveform()
.map { processColor(it) }
private fun processColor(waveformFrame: WaveformFrame): Color {
return colorMapper.mapSoundToColor(waveformFrame.data)
}
audioVisualizer.visualizerFlow()
.process(processor)
.onStart { emit(Color.Black) }
}
fun Flow<VisualizerFrame>.process(processor: VisualizerProcessor) =
this.filterIsInstance(WaveformFrame::class)
.map(processor::invoke)

View File

@@ -1,97 +0,0 @@
package dev.adriankuta.visualizer.domain
import androidx.compose.ui.graphics.Color
import dev.adriankuta.visualizer.HSL
import dev.adriankuta.visualizer.MovingAverage
import dev.adriankuta.visualizer.calculateRms
/**
* Interface for mapping sound data to colors.
* Follows the Interface Segregation Principle by defining a focused contract.
*/
interface SoundToColorMapper {
/**
* Maps audio waveform data to a color.
*
* @param waveform Audio waveform data as byte array
* @return Compose Color representing the mapped sound
*/
fun mapSoundToColor(waveform: ByteArray): Color
/**
* Resets the internal state of the mapper.
*/
fun reset()
}
/**
* Default implementation of SoundToColorMapper that uses HSL color space
* and moving average for smooth color transitions.
*
* 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 = 1
) : SoundToColorMapper {
private val movingAverage = MovingAverage(movingAverageWindowSize)
private var rmsMax = 0.0
override fun mapSoundToColor(waveform: ByteArray): Color {
// Calculate RMS value for current frame
val currentRms = calculateRms(waveform)
// Update maximum RMS value for normalization
if (currentRms > rmsMax) {
rmsMax = currentRms
}
// Calculate energy ratio (0-1)
val energyRatio = if (rmsMax > 0) currentRms / rmsMax else 0.0
// Apply moving average for smooth transitions
val adjustedLightness = movingAverage.process(energyRatio)
// Create HSL color and convert to Compose Color
val hslColor = HSL(
hue = baseHue,
saturation = saturation,
lightness = adjustedLightness.toFloat().coerceIn(0f, 1f)
)
return hslColor.toComposeColor()
}
override fun reset() {
movingAverage.reset()
rmsMax = 0.0
}
}
/**
* Factory for creating SoundToColorMapper instances.
* Follows the Factory Pattern and Open/Closed Principle.
*/
object SoundToColorMapperFactory {
/**
* Creates a default HSL-based sound to color mapper.
*/
fun createDefault(): SoundToColorMapper = HSLSoundToColorMapper()
/**
* Creates an HSL-based sound to color mapper with custom parameters.
*
* @param baseHue The base hue for the color (0-360)
* @param saturation The saturation level (0-1)
* @param movingAverageWindowSize Window size for moving average smoothing
*/
fun createHSLMapper(
baseHue: Float = 240f,
saturation: Float = 1f,
movingAverageWindowSize: Int = 7
): SoundToColorMapper = HSLSoundToColorMapper(baseHue, saturation, movingAverageWindowSize)
}

View File

@@ -0,0 +1,142 @@
package dev.adriankuta.visualizer.domain.processors
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.colorspace.ColorSpaces
import dev.adriankuta.visualizer.data.FftFrame
import dev.adriankuta.visualizer.data.VisualizerFrame
import dev.adriankuta.visualizer.data.WaveformFrame
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.sqrt
import kotlin.reflect.KClass
/**
* Shared utilities for brightness mapping.
*/
class BrightnessMapper(
private val hue: Float = 210f, // blue-ish
private val saturation: Float = 0.9f, // vivid color
private val gain: Float = 8f, // log mapping strength (higher -> more sensitive)
private val attack: Float = 0.35f, // 0..1, higher = faster rise
private val release: Float = 0.08f, // 0..1, lower = slower fall
private val peakDecay: Float = 0.995f // 0..1, how fast auto-gain reference decays
) {
private var smoothed = 0f
private var peak = 0.1f
/**
* @param rawEnergy input in [0, +inf) (e.g., RMS or avg magnitude)
* @return Color with brightness (V) in [0,1]
*/
fun toColor(rawEnergy: Float): Color {
// 1) Log loudness mapping: map [0, +inf) → [0, 1]
// normalizedLog = ln(1 + gain * x) / ln(1 + gain)
val loud = if (rawEnergy <= 0f) 0f
else (ln(1f + gain * rawEnergy) / ln(1f + gain))
// 2) Peak/auto-gain tracking
peak = max(loud, peak * peakDecay)
val norm = if (peak > 1e-6f) loud / peak else loud
val clamped = norm.coerceIn(0f, 1f)
// 3) Attack/Release smoothing
val a = if (clamped > smoothed) attack else release
smoothed = smoothed + a * (clamped - smoothed)
// 4) HSV → Color (fixed hue & saturation; dynamic value)
return Color.hsv(
hue = hue.coerceIn(0f, 360f),
saturation = saturation.coerceIn(0f, 1f),
value = smoothed.coerceIn(0f, 1f),
colorSpace = ColorSpaces.Srgb
)
}
}
/**
* Visualizer waveform → brightness.
* Visualizer's waveform bytes are unsigned 8-bit: 0..255 with 128 ≈ 0.
*/
class WaveformToColorProcessor @Inject constructor(
) : VisualizerProcessor {
private val brightness: BrightnessMapper = BrightnessMapper(
hue = 210f,
saturation = 0.9f,
gain = 8f,
attack = 0.35f,
release = 0.08f,
peakDecay = 0.995f
)
override suspend fun invoke(frame: VisualizerFrame): Color = withContext(Dispatchers.Default) {
val bytes = frame.data
if (bytes.isEmpty()) {
return@withContext Color.Black
}
// Convert to [-1, 1] floats, compute RMS.
var sumSq = 0.0
val inv = 1f / 128f
for (b in bytes) {
// unsigned -> center at 128 -> normalize
val s = ((b.toInt() and 0xFF) - 128) * inv
sumSq += (s * s)
}
val rms = sqrt(sumSq / bytes.size).toFloat() // 0..~1
brightness.toColor(rms)
}
}
/**
* Visualizer FFT → brightness.
* Visualizer's FFT bytes are 8-bit signed fixed-point: interleaved (re, im).
* We compute mean magnitude over bins. You can bias ranges by adding weights.
*/
class FftToColorProcessor @Inject constructor(
) : VisualizerProcessor {
private val brightness: BrightnessMapper = BrightnessMapper(
hue = 210f,
saturation = 0.9f,
gain = 10f,
attack = 0.30f,
release = 0.10f,
peakDecay = 0.996f
)
private val binWeighting: ((binIndex: Int, totalBins: Int) -> Float)? = null
override suspend fun invoke(frame: VisualizerFrame): Color = withContext(Dispatchers.Default) {
val bytes = frame.data
if (bytes.size < 2) {
return@withContext Color.Black
}
// Each bin: (re, im) as signed bytes. Skip DC/nyquist if you like; here we include all valid pairs.
val pairCount = bytes.size / 2
var sumMag = 0.0
var weightSum = 0.0
var bin = 0
var i = 0
while (i + 1 < bytes.size) {
val re = bytes[i].toInt().toByte().toInt() // ensure sign
val im = bytes[i + 1].toInt().toByte().toInt()
val mag = sqrt((re * re + im * im).toDouble()) // magnitude in byte units
val w = binWeighting?.invoke(bin, pairCount)?.coerceAtLeast(0f)?.toDouble() ?: 1.0
sumMag += mag * w
weightSum += w
bin += 1
i += 2
}
// Average magnitude → normalize to something close to [0,1].
// 8-bit range ≈ [-128,127], so typical magnitude can be up to ~181; average is far lower.
val avgMag = if (weightSum > 0.0) sumMag / weightSum else 0.0
val norm = (avgMag / 128.0).toFloat().coerceAtLeast(0f) // rough normalization
brightness.toColor(norm)
}
}

View File

@@ -0,0 +1,58 @@
package dev.adriankuta.visualizer.domain.processors
import androidx.compose.ui.graphics.Color
import dev.adriankuta.visualizer.data.FftFrame
import dev.adriankuta.visualizer.data.VisualizerFrame
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.math.sqrt
import kotlin.reflect.KClass
class FftToColor @Inject constructor() : VisualizerProcessor {
override suspend fun invoke(frame: VisualizerFrame): Color = withContext(Dispatchers.Default) {
// --- Extract raw FFT bytes from your frame ---
// Adjust the property name if your FftFrame differs (e.g., `frame.data`, `frame.bytes`, etc.)
val fft: ByteArray = frame.data
if (fft.isEmpty()) return@withContext Color.Black
// Visualizer FFT format: interleaved [re0, im0, re1, im1, ...]
// Skip DC (bin 0: re0, im0) as it often behaves like noise/offset.
var sumMagnitude = 0.0
var bins = 0
var i = 2 // start from bin 1 (skip DC)
while (i + 1 < fft.size) {
val re = fft[i].toInt()
val im = fft[i + 1].toInt()
val mag = sqrt((re * re + im * im).toDouble())
sumMagnitude += mag
bins++
i += 2
}
val avgMagnitude = if (bins > 0) sumMagnitude / bins else 0.0
// Normalize ~[0..1]. Each component is in [-128..127], so magnitude upper bound ~181.
// 128 is a practical normalization for stable visuals; then clamp.
val normalized = (avgMagnitude / 128.0).coerceIn(0.0, 1.0)
// Perceptual easing to avoid flicker and give more dynamic range at lower levels.
val eased = sqrt(normalized).toFloat()
// Simple noise gate so tiny ambient levels dont glow.
val value = if (eased < NOISE_FLOOR) 0f else eased
// Single, stable color only brightness changes with the music.
Color.hsv(BASE_HUE, BASE_SATURATION, value)
}
private companion object {
// Tune these to your taste (degrees 0..360, 0..1).
const val BASE_HUE = 0f // cool blue
const val BASE_SATURATION = 0.85f // vivid
const val NOISE_FLOOR = 0.05f // ignore very low energy
}
}

View File

@@ -0,0 +1,59 @@
package dev.adriankuta.visualizer.domain.processors
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.RmsValue
import dev.adriankuta.visualizer.data.RmsValueProvider
import dev.adriankuta.visualizer.data.VisualizerFrame
import timber.log.Timber
import javax.inject.Inject
/**
* Default implementation of SoundToColorMapper that uses HSL color space
* and moving average for smooth color transitions.
*
* Follows Single Responsibility Principle by focusing on sound-to-color mapping.
* Follows Dependency Inversion Principle by depending on abstractions.
*/
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
override suspend fun invoke(frame: VisualizerFrame): Color {
val waveform = frame.data
// Calculate RMS value for current frame
val currentRms = calculateRms(waveform)
rmsMax -= 0.01
// Update maximum RMS value for normalization
if (currentRms > rmsMax) {
rmsMax = currentRms
}
rmsValueProvider.emit(RmsValue(currentRms, rmsMax))
Timber.d("Max RMS: %s, current %s", rmsMax, currentRms)
// Calculate energy ratio (0-1)
val energyRatio = if (rmsMax > 0) currentRms / rmsMax else 0.0
// Apply moving average for smooth transitions
val adjustedLightness = movingAverage.process(energyRatio)
// Create HSL color and convert to Compose Color
val hslColor = HSL(
hue = baseHue,
saturation = saturation,
lightness = adjustedLightness.toFloat().coerceIn(0f, 1f)
)
return hslColor.toComposeColor()
}
}

View File

@@ -0,0 +1,10 @@
package dev.adriankuta.visualizer.domain.processors
import androidx.compose.ui.graphics.Color
import dev.adriankuta.visualizer.data.VisualizerFrame
import kotlin.reflect.KClass
interface VisualizerProcessor {
suspend operator fun invoke(frame: VisualizerFrame): Color
}

View File

@@ -0,0 +1,20 @@
package dev.adriankuta.visualizer.ui.components
import android.view.WindowManager
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@Composable
fun KeepScreenOn() {
val activity = LocalActivity.current
DisposableEffect(Unit) {
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
onDispose {
// Clear the flag when the composable is disposed
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}

View File

@@ -4,14 +4,40 @@ import android.Manifest
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.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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
@@ -24,35 +50,21 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.adriankuta.visualizer.AudioVisualizerFactory
import dev.adriankuta.visualizer.AudioVisualizerService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import dev.adriankuta.visualizer.data.RmsValue
import dev.adriankuta.visualizer.ui.components.KeepScreenOn
import timber.log.Timber
@Composable
fun VisualizerScreen(
viewModel: VisualizerScreenViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
}
@Composable
fun VisualizerScreen(
modifier: Modifier = Modifier,
audioVisualizer: AudioVisualizerService = remember {
AudioVisualizerFactory.createDefault(
CoroutineScope(Dispatchers.Main + SupervisorJob())
)
}
viewModel: VisualizerScreenViewModel = hiltViewModel()
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentColor by audioVisualizer.currentColor
val isActive by audioVisualizer.isActive
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
KeepScreenOn()
// Permission state management
var hasAudioPermission by remember {
@@ -73,7 +85,7 @@ fun VisualizerScreen(
hasAudioPermission = isGranted
if (isGranted) {
Timber.d("Audio permission granted, starting visualizer")
audioVisualizer.start()
viewModel.start()
} else {
Timber.w("Audio permission denied")
showPermissionRationale = true
@@ -84,8 +96,9 @@ fun VisualizerScreen(
fun startVisualizerWithPermissionCheck() {
when {
hasAudioPermission -> {
audioVisualizer.start()
viewModel.start()
}
else -> {
Timber.d("Requesting audio permission")
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
@@ -98,7 +111,7 @@ fun VisualizerScreen(
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> startVisualizerWithPermissionCheck()
Lifecycle.Event.ON_STOP -> audioVisualizer.stop()
Lifecycle.Event.ON_STOP -> viewModel.stop()
else -> {}
}
}
@@ -107,7 +120,7 @@ fun VisualizerScreen(
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
audioVisualizer.release()
viewModel.stop()
}
}
@@ -127,19 +140,15 @@ fun VisualizerScreen(
modifier = Modifier.padding(bottom = 32.dp)
)
// Color display area
Box(
modifier = Modifier
.size(300.dp)
.clip(RoundedCornerShape(16.dp))
.background(currentColor)
.padding(16.dp),
contentAlignment = Alignment.Center
// Color display and RMS progress bar area
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Show color information
ColorInfoCard(
color = currentColor,
isActive = isActive
ColorDisplay(uiState)
RmsProgressBar(
rmsValue = uiState.rmsValue,
modifier = Modifier
)
}
@@ -147,7 +156,7 @@ fun VisualizerScreen(
// Status indicator
StatusIndicator(
isActive = isActive,
isActive = uiState.isActive,
hasPermission = hasAudioPermission
)
@@ -157,7 +166,7 @@ fun VisualizerScreen(
Text(
text = when {
!hasAudioPermission -> "Audio permission is required to visualize sound"
isActive -> "Play some music or make sounds to see the colors change!"
uiState.isActive -> "Play some music or make sounds to see the colors change!"
else -> "Starting audio visualizer..."
},
fontSize = 16.sp,
@@ -211,13 +220,35 @@ fun VisualizerScreen(
}
}
@Composable
private fun ColorDisplay(uiState: VisualizerScreenUiState) {
val animatedColor by animateColorAsState(
uiState.color,
animationSpec = tween(50),
)
Box(
modifier = Modifier
.size(300.dp)
.clip(RoundedCornerShape(16.dp))
.drawBehind {
drawRect(animatedColor)
}
.padding(16.dp),
contentAlignment = Alignment.Center
) {
// Show color information
ColorInfoCard(
color = uiState.color,
)
}
}
/**
* Card displaying color information.
*/
@Composable
private fun ColorInfoCard(
color: Color,
isActive: Boolean,
modifier: Modifier = Modifier
) {
Card(
@@ -298,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,7 +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 color: Color,
val isActive: Boolean = false,
val rmsValue: RmsValue? = null
)

View File

@@ -1,23 +1,59 @@
@file:OptIn(FlowPreview::class)
package dev.adriankuta.visualizer.view
import androidx.compose.ui.graphics.Color
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
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel
class VisualizerScreenViewModel @Inject constructor(
private val observeVisualizerColorUseCase: ObserveVisualizerColorUseCase
observeVisualizerColorUseCase: ObserveVisualizerColorUseCase,
private val audioVisualizerController: AudioVisualizerController,
private val rmsValueProvider: RmsValueProvider
) : ViewModel() {
val uiState = observeVisualizerColorUseCase()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = Color.Black
private val _isActive = MutableStateFlow(false)
val uiState = combine(
observeVisualizerColorUseCase().sample(50.milliseconds),
_isActive,
rmsValueProvider.observe().sample(50.milliseconds)
) { color, isActive, rmsValue ->
VisualizerScreenUiState(
color = color,
isActive = isActive,
rmsValue = rmsValue
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = VisualizerScreenUiState(
color = Color.Black,
isActive = false,
rmsValue = null
)
)
fun start() {
audioVisualizerController.start()
_isActive.value = true
}
fun stop() {
audioVisualizerController.stop()
_isActive.value = false
}
}

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