Introduce ViewModel and UseCase
This commit is contained in:
		| @@ -1,17 +0,0 @@ | ||||
| package dev.adriankuta.visualizer | ||||
|  | ||||
| /** | ||||
|  * Placeholder for potential future audio session tracking. | ||||
|  * Currently not used to avoid API-level constraints on older devices. | ||||
|  */ | ||||
| class AudioSessionsTracker { | ||||
|     /** | ||||
|      * Starts tracking audio sessions. No-op in the current placeholder implementation. | ||||
|      */ | ||||
|     fun start() {} | ||||
|  | ||||
|     /** | ||||
|      * Stops tracking audio sessions. No-op in the current placeholder implementation. | ||||
|      */ | ||||
|     fun stop() {} | ||||
| } | ||||
							
								
								
									
										160
									
								
								app/src/main/java/dev/adriankuta/visualizer/AudioVisualizer.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								app/src/main/java/dev/adriankuta/visualizer/AudioVisualizer.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,160 @@ | ||||
| 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) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										95
									
								
								app/src/main/java/dev/adriankuta/visualizer/HSL.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								app/src/main/java/dev/adriankuta/visualizer/HSL.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | ||||
| package dev.adriankuta.visualizer | ||||
|  | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import kotlin.math.sqrt | ||||
|  | ||||
| /** | ||||
|  * Represents a color in HSL (Hue, Saturation, Lightness) color space. | ||||
|  *  | ||||
|  * @param hue The hue component (0-360 degrees) | ||||
|  * @param saturation The saturation component (0-1) | ||||
|  * @param lightness The lightness component (0-1) | ||||
|  */ | ||||
| data class HSL( | ||||
|     val hue: Float, | ||||
|     val saturation: Float, | ||||
|     val lightness: Float | ||||
| ) { | ||||
|     /** | ||||
|      * Converts HSL color to RGB values. | ||||
|      *  | ||||
|      * @return Triple containing RGB values (0-255) | ||||
|      */ | ||||
|     fun toRgb(): Triple<Int, Int, Int> { | ||||
|         val (r, g, b) = hlsToRgb(hue / 360f, lightness, saturation) | ||||
|         return Triple((r * 255).toInt(), (g * 255).toInt(), (b * 255).toInt()) | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Converts HSL color to Compose Color. | ||||
|      *  | ||||
|      * @return Compose Color object | ||||
|      */ | ||||
|     fun toComposeColor(): Color { | ||||
|         val (r, g, b) = toRgb() | ||||
|         return Color(r, g, b) | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Creates a new HSL color with modified lightness. | ||||
|      *  | ||||
|      * @param newLightness The new lightness value (0-1) | ||||
|      * @return New HSL color with updated lightness | ||||
|      */ | ||||
|     fun withLightness(newLightness: Float): HSL = copy(lightness = newLightness.coerceIn(0f, 1f)) | ||||
|      | ||||
|     private fun hlsToRgb(h: Float, l: Float, s: Float): Triple<Float, Float, Float> { | ||||
|         val r: Float | ||||
|         val g: Float | ||||
|         val b: Float | ||||
|  | ||||
|         if (s == 0f) { | ||||
|             // Achromatic (gray) | ||||
|             r = l | ||||
|             g = l | ||||
|             b = l | ||||
|         } else { | ||||
|             val q = if (l < 0.5f) l * (1f + s) else l + s - l * s | ||||
|             val p = 2f * l - q | ||||
|  | ||||
|             r = hueToRgb(p, q, h + 1f / 3f) | ||||
|             g = hueToRgb(p, q, h) | ||||
|             b = hueToRgb(p, q, h - 1f / 3f) | ||||
|         } | ||||
|  | ||||
|         return Triple(r, g, b) | ||||
|     } | ||||
|  | ||||
|     private fun hueToRgb(p: Float, q: Float, t: Float): Float { | ||||
|         var tTemp = t | ||||
|         if (tTemp < 0f) tTemp += 1f | ||||
|         if (tTemp > 1f) tTemp -= 1f | ||||
|         return when { | ||||
|             tTemp < 1f / 6f -> p + (q - p) * 6f * tTemp | ||||
|             tTemp < 1f / 2f -> q | ||||
|             tTemp < 2f / 3f -> p + (q - p) * (2f / 3f - tTemp) * 6f | ||||
|             else -> p | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Calculates the Root Mean Square (RMS) value of audio data. | ||||
|  *  | ||||
|  * @param bytes Audio data as byte array | ||||
|  * @return RMS value as Double | ||||
|  */ | ||||
| fun calculateRms(bytes: ByteArray): Double { | ||||
|     if (bytes.isEmpty()) return 0.0 | ||||
|     var sum = 0.0 | ||||
|     for (b in bytes) { | ||||
|         val centered = (b.toInt() and 0xFF) - 128 | ||||
|         sum += centered * centered | ||||
|     } | ||||
|     return sqrt(sum / bytes.size) | ||||
| } | ||||
| @@ -1,54 +1,19 @@ | ||||
| package dev.adriankuta.visualizer | ||||
|  | ||||
| import android.Manifest | ||||
| import android.content.pm.PackageManager | ||||
| import android.os.Bundle | ||||
| import androidx.activity.ComponentActivity | ||||
| import androidx.activity.compose.rememberLauncherForActivityResult | ||||
| import androidx.activity.compose.setContent | ||||
| import androidx.activity.enableEdgeToEdge | ||||
| import androidx.activity.result.contract.ActivityResultContracts | ||||
| import androidx.compose.foundation.layout.Arrangement | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.Spacer | ||||
| import androidx.compose.foundation.layout.fillMaxSize | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.rememberScrollState | ||||
| import androidx.compose.foundation.verticalScroll | ||||
| import androidx.compose.material3.HorizontalDivider | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Scaffold | ||||
| import androidx.compose.material3.Text | ||||
| 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.platform.LocalContext | ||||
| import androidx.compose.ui.platform.LocalLifecycleOwner | ||||
| import androidx.compose.ui.tooling.preview.Preview | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.core.content.ContextCompat | ||||
| import androidx.lifecycle.Lifecycle | ||||
| import androidx.lifecycle.LifecycleEventObserver | ||||
| import dev.adriankuta.visualizer.components.ControlButtons | ||||
| import dev.adriankuta.visualizer.components.FftBarsView | ||||
| import dev.adriankuta.visualizer.components.MetricsSection | ||||
| import dev.adriankuta.visualizer.components.PermissionSection | ||||
| import dev.adriankuta.visualizer.components.WaveformView | ||||
| import dagger.hilt.android.AndroidEntryPoint | ||||
| import dev.adriankuta.visualizer.ui.theme.VisualizerTheme | ||||
| import dev.adriankuta.visualizer.view.VisualizerScreen | ||||
| import timber.log.Timber | ||||
|  | ||||
| /** | ||||
|  * Entry point activity that hosts the Compose UI for the audio visualizer demo. | ||||
|  * | ||||
|  * Sets up the app theme, window insets, and renders [VisualizerScreen]. | ||||
|  */ | ||||
| @AndroidEntryPoint | ||||
| class MainActivity : ComponentActivity() { | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
| @@ -60,128 +25,13 @@ class MainActivity : ComponentActivity() { | ||||
|         setContent { | ||||
|             VisualizerTheme { | ||||
|                 Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> | ||||
|                     VisualizerScreen(modifier = Modifier.padding(innerPadding)) | ||||
|                     VisualizerScreen( | ||||
|                         modifier = Modifier | ||||
|                             .fillMaxSize() | ||||
|                             .padding(innerPadding) | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Top-level screen that manages permission requests and lifecycle of [VisualizerController], | ||||
|  * and renders the waveform, FFT bars, and simple metrics with control buttons. | ||||
|  * | ||||
|  * @param modifier Modifier applied to the screen container. | ||||
|  */ | ||||
| @Composable | ||||
| private fun VisualizerScreen(modifier: Modifier = Modifier) { | ||||
|     val context = LocalContext.current | ||||
|     var permissionGranted by remember { | ||||
|         mutableStateOf( | ||||
|             ContextCompat.checkSelfPermission( | ||||
|                 context, | ||||
|                 Manifest.permission.RECORD_AUDIO | ||||
|             ) == PackageManager.PERMISSION_GRANTED | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     val requestPermissionLauncher = rememberLauncherForActivityResult( | ||||
|         contract = ActivityResultContracts.RequestPermission() | ||||
|     ) { granted -> | ||||
|         permissionGranted = granted | ||||
|     } | ||||
|  | ||||
|     var waveform by remember { mutableStateOf<ByteArray?>(null) } | ||||
|     var fft by remember { mutableStateOf<ByteArray?>(null) } | ||||
|  | ||||
|     val controller = remember(permissionGranted) { | ||||
|         VisualizerController( | ||||
|             audioSessionId = 0, | ||||
|             onWaveform = { data -> waveform = data.clone() }, | ||||
|             onFft = { data -> fft = data.clone() } | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     val lifecycleOwner = LocalLifecycleOwner.current | ||||
|  | ||||
|     DisposableEffect(lifecycleOwner, permissionGranted, controller) { | ||||
|         val observer = LifecycleEventObserver { _, event -> | ||||
|             if (!permissionGranted) return@LifecycleEventObserver | ||||
|             when (event) { | ||||
|                 Lifecycle.Event.ON_CREATE -> runCatching { controller.start() } | ||||
|                 Lifecycle.Event.ON_DESTROY -> runCatching { controller.stop() } | ||||
|                 else -> {} | ||||
|             } | ||||
|         } | ||||
|         lifecycleOwner.lifecycle.addObserver(observer) | ||||
|         // If permission has just been granted while we are already STARTED/RESUMED, | ||||
|         // ensure the visualizer starts immediately (ON_START won't be called again). | ||||
|         if (permissionGranted && lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { | ||||
|             runCatching { controller.start() } | ||||
|         } | ||||
|         onDispose { | ||||
|             lifecycleOwner.lifecycle.removeObserver(observer) | ||||
|             runCatching { controller.stop() } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     Column( | ||||
|         modifier = modifier | ||||
|             .fillMaxSize() | ||||
|             .padding(16.dp), | ||||
|         verticalArrangement = Arrangement.Top, | ||||
|         horizontalAlignment = Alignment.Start | ||||
|     ) { | ||||
|         Column( | ||||
|             modifier = Modifier | ||||
|                 .fillMaxWidth() | ||||
|                 .weight(1f) | ||||
|                 .verticalScroll(rememberScrollState()) | ||||
|         ) { | ||||
|             Text("Android Visualizer Demo", style = MaterialTheme.typography.headlineSmall) | ||||
|             Spacer(Modifier.height(8.dp)) | ||||
|             if (!permissionGranted) { | ||||
|                 PermissionSection( | ||||
|                     permissionGranted = false, | ||||
|                     requestPermissionLauncher = requestPermissionLauncher | ||||
|                 ) | ||||
|                 return@Column | ||||
|             } | ||||
|  | ||||
|             Text("Listening to global output. Play music in another app to see data.") | ||||
|             Spacer(Modifier.height(8.dp)) | ||||
|  | ||||
|             WaveformView(data = waveform) | ||||
|  | ||||
|             Spacer(Modifier.height(8.dp)) | ||||
|  | ||||
|             FftBarsView(data = fft) | ||||
|  | ||||
|             Spacer(Modifier.height(12.dp)) | ||||
|             HorizontalDivider() | ||||
|             Spacer(Modifier.height(12.dp)) | ||||
|  | ||||
|             MetricsSection( | ||||
|                 waveform = waveform, | ||||
|                 fft = fft | ||||
|             ) | ||||
|         } | ||||
|         ControlButtons( | ||||
|             onStart = { runCatching { controller.start() } }, | ||||
|             onStop = { runCatching { controller.stop() } }, | ||||
|             modifier = Modifier.align(Alignment.CenterHorizontally) | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| /** | ||||
|  * Preview of [VisualizerScreen] for Android Studio without runtime data. | ||||
|  */ | ||||
| @Preview(showBackground = true) | ||||
| @Composable | ||||
| fun VisualizerPreview() { | ||||
|     VisualizerTheme { | ||||
|         VisualizerScreen() | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										39
									
								
								app/src/main/java/dev/adriankuta/visualizer/MovingAverage.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/src/main/java/dev/adriankuta/visualizer/MovingAverage.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| package dev.adriankuta.visualizer | ||||
|  | ||||
| /** | ||||
|  * A utility class for calculating moving averages over a sliding window. | ||||
|  * Follows the Single Responsibility Principle by focusing solely on moving average calculations. | ||||
|  */ | ||||
| class MovingAverage(private val windowSize: Int) { | ||||
|     private val values = mutableListOf<Double>() | ||||
|  | ||||
|     /** | ||||
|      * Processes a new value and returns the current moving average. | ||||
|      *  | ||||
|      * @param newValue The new value to add to the moving average calculation | ||||
|      * @return The current moving average | ||||
|      */ | ||||
|     fun process(newValue: Double): Double { | ||||
|         values.add(newValue) | ||||
|  | ||||
|         // Remove oldest value if window size exceeded | ||||
|         if (values.size > windowSize) { | ||||
|             values.removeAt(0) | ||||
|         } | ||||
|  | ||||
|         return values.average() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Resets the moving average calculation. | ||||
|      */ | ||||
|     fun reset() { | ||||
|         values.clear() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the current number of values in the window. | ||||
|      */ | ||||
|     val currentSize: Int | ||||
|         get() = values.size | ||||
| } | ||||
| @@ -0,0 +1,8 @@ | ||||
| package dev.adriankuta.visualizer | ||||
|  | ||||
| import android.app.Application | ||||
| import dagger.hilt.android.HiltAndroidApp | ||||
|  | ||||
| @HiltAndroidApp | ||||
| class MyApplication: Application() { | ||||
| } | ||||
| @@ -1,123 +0,0 @@ | ||||
| package dev.adriankuta.visualizer | ||||
|  | ||||
| import android.media.audiofx.Visualizer | ||||
| import kotlinx.coroutines.* | ||||
| import timber.log.Timber | ||||
|  | ||||
| /** | ||||
|  * Simple wrapper over Android Visualizer to capture global output mix (audio session 0). | ||||
|  */ | ||||
| class VisualizerController( | ||||
|     private val audioSessionId: Int = 0, | ||||
|     private val onWaveform: (ByteArray) -> Unit, | ||||
|     private val onFft: (ByteArray) -> Unit, | ||||
| ) { | ||||
|     private var visualizer: Visualizer? = null | ||||
|     private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) | ||||
|     private var loggingJob: Job? = null | ||||
|     private var latestWaveform: ByteArray? = null | ||||
|     private var latestFft: ByteArray? = null | ||||
|  | ||||
|     /** | ||||
|      * Starts capturing audio data via [Visualizer]. | ||||
|      * | ||||
|      * Safe to call multiple times; subsequent calls are no-ops if already started. | ||||
|      * If initialization fails (e.g., missing microphone permission or device restriction), | ||||
|      * the controller performs cleanup and rethrows the underlying exception. | ||||
|      */ | ||||
|     @Synchronized | ||||
|     fun start() { | ||||
|         if (visualizer != null) return | ||||
|         try { | ||||
|             Timber.d("Starting audio visualizer listening (audioSessionId: $audioSessionId)") | ||||
|             val v = Visualizer(audioSessionId) | ||||
|             // Use the maximum supported capture size for better resolution | ||||
|             val captureSize = Visualizer.getCaptureSizeRange().let { it[1] } | ||||
|             v.captureSize = captureSize | ||||
|             val rate = Visualizer.getMaxCaptureRate() / 2 // decent rate | ||||
|             v.setDataCaptureListener(object : Visualizer.OnDataCaptureListener { | ||||
|                 override fun onWaveFormDataCapture( | ||||
|                     visualizer: Visualizer?, | ||||
|                     waveform: ByteArray?, | ||||
|                     samplingRate: Int | ||||
|                 ) { | ||||
|                     if (waveform != null) { | ||||
|                         latestWaveform = waveform.clone() | ||||
|                         onWaveform(waveform) | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 override fun onFftDataCapture( | ||||
|                     visualizer: Visualizer?, | ||||
|                     fft: ByteArray?, | ||||
|                     samplingRate: Int | ||||
|                 ) { | ||||
|                     if (fft != null) { | ||||
|                         latestFft = fft.clone() | ||||
|                         onFft(fft) | ||||
|                     } | ||||
|                 } | ||||
|             }, rate, true, true) | ||||
|             v.enabled = true | ||||
|             visualizer = v | ||||
|  | ||||
|             // Start coroutine-based logging for raw data samples every 1 second | ||||
|             startDataLogging() | ||||
|  | ||||
|             Timber.i("Audio visualizer listening started successfully") | ||||
|         } catch (e: Throwable) { | ||||
|             // If Visualizer cannot be initialized (e.g. permission or device restriction), ensure cleanup | ||||
|             Timber.e(e, "Failed to start audio visualizer listening") | ||||
|             stop() | ||||
|             throw e | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Stops capturing audio data and releases the underlying [Visualizer]. | ||||
|      * Safe to call multiple times. | ||||
|      */ | ||||
|     @Synchronized | ||||
|     fun stop() { | ||||
|         Timber.d("Stopping audio visualizer listening") | ||||
|  | ||||
|         // Stop the logging coroutine | ||||
|         loggingJob?.cancel() | ||||
|         loggingJob = null | ||||
|  | ||||
|         visualizer?.enabled = false | ||||
|         visualizer?.release() | ||||
|         visualizer = null | ||||
|  | ||||
|         // Clear latest data | ||||
|         latestWaveform = null | ||||
|         latestFft = null | ||||
|  | ||||
|         Timber.i("Audio visualizer listening stopped") | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Starts a coroutine that logs raw data samples every 1 second. | ||||
|      */ | ||||
|     private fun startDataLogging() { | ||||
|         loggingJob?.cancel() // Cancel any existing logging job | ||||
|         loggingJob = coroutineScope.launch { | ||||
|             while (isActive) { | ||||
|                 delay(1000) // Wait for 1 second | ||||
|  | ||||
|                 val waveform = latestWaveform | ||||
|                 val fft = latestFft | ||||
|  | ||||
|                 if (waveform != null && fft != null) { | ||||
|                     // Log sample of raw data (first few bytes to avoid too much log spam) | ||||
|                     val waveformSample = waveform.take(8).joinToString(", ") { it.toString() } | ||||
|                     val fftSample = fft.take(8).joinToString(", ") { it.toString() } | ||||
|  | ||||
|                     Timber.d("Raw data sample - Waveform {size: ${waveform.size}, sample: [$waveformSample...]}, FFT {size: {${fft.size}}, sample: [$fftSample...]}") | ||||
|                 } else { | ||||
|                     Timber.d("Raw data sample - No data available yet") | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,30 +0,0 @@ | ||||
| package dev.adriankuta.visualizer.components | ||||
|  | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.Spacer | ||||
| import androidx.compose.foundation.layout.width | ||||
| import androidx.compose.material3.Button | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.unit.dp | ||||
|  | ||||
| /** | ||||
|  * Simple control row with Start/Stop buttons for the visualizer. | ||||
|  * | ||||
|  * @param onStart Invoked when the Start button is pressed. | ||||
|  * @param onStop Invoked when the Stop button is pressed. | ||||
|  * @param modifier Compose modifier for layout. | ||||
|  */ | ||||
| @Composable | ||||
| fun ControlButtons( | ||||
|     onStart: () -> Unit, | ||||
|     onStop: () -> Unit, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     Row(modifier = modifier) { | ||||
|         Button(onClick = onStart) { Text("Start") } | ||||
|         Spacer(Modifier.width(8.dp)) | ||||
|         Button(onClick = onStop) { Text("Stop") } | ||||
|     } | ||||
| } | ||||
| @@ -1,66 +0,0 @@ | ||||
| package dev.adriankuta.visualizer.components | ||||
|  | ||||
| import androidx.compose.foundation.Canvas | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.unit.dp | ||||
| import kotlin.math.hypot | ||||
| import kotlin.math.min | ||||
|  | ||||
| /** | ||||
|  * Renders FFT magnitude as vertical bars. | ||||
|  * | ||||
|  * The input is the raw FFT byte array from Visualizer where values are interleaved | ||||
|  * real and imaginary parts: [re0, im0, re1, im1, ...]. This composable computes the | ||||
|  * magnitude per bin and draws a limited number of bars for readability. | ||||
|  * | ||||
|  * @param data Interleaved real/imag FFT bytes; null shows a baseline. | ||||
|  * @param modifier Compose modifier for sizing and padding. | ||||
|  */ | ||||
| @Composable | ||||
| fun FftBarsView( | ||||
|     data: ByteArray?, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     Text("FFT (magnitude bars)", style = MaterialTheme.typography.titleMedium) | ||||
|     Canvas( | ||||
|         modifier = modifier | ||||
|             .fillMaxWidth() | ||||
|             .height(160.dp) | ||||
|             .padding(vertical = 4.dp) | ||||
|     ) { | ||||
|         val w = size.width | ||||
|         val h = size.height | ||||
|         if (data != null && data.size >= 2) { | ||||
|             val n = data.size / 2 // real/imag pairs count | ||||
|             val barCount = min(64, n) // limit bars for readability | ||||
|             val barWidth = w / barCount | ||||
|             for (i in 0 until barCount) { | ||||
|                 val re = data[2 * i].toInt() | ||||
|                 val im = data[2 * i + 1].toInt() | ||||
|                 val mag = hypot(re.toDouble(), im.toDouble()).toFloat() | ||||
|                 val scaled = (mag / 128f).coerceIn(0f, 1.5f) | ||||
|                 val barHeight = h * (scaled.coerceAtMost(1f)) | ||||
|                 val left = i * barWidth | ||||
|                 drawRect( | ||||
|                     color = Color(0xFF8BC34A), | ||||
|                     topLeft = androidx.compose.ui.geometry.Offset(left, h - barHeight), | ||||
|                     size = androidx.compose.ui.geometry.Size(barWidth * 0.9f, barHeight) | ||||
|                 ) | ||||
|             } | ||||
|         } else { | ||||
|             drawLine( | ||||
|                 color = Color.Gray, | ||||
|                 start = androidx.compose.ui.geometry.Offset(0f, h - 1f), | ||||
|                 end = androidx.compose.ui.geometry.Offset(w, h - 1f), | ||||
|                 strokeWidth = 1f | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,74 +0,0 @@ | ||||
| package dev.adriankuta.visualizer.components | ||||
|  | ||||
| import androidx.compose.foundation.layout.Arrangement | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.unit.dp | ||||
| import kotlin.math.abs | ||||
|  | ||||
| /** | ||||
|  * Displays simple metrics derived from the current waveform and a preview of raw bytes. | ||||
|  * | ||||
|  * - Waveform metrics include sample count, RMS and peak (centered around 128). | ||||
|  * - Raw bytes shows the first N bytes from waveform or FFT, whichever is available. | ||||
|  * | ||||
|  * @param waveform Latest waveform bytes (0..255 per sample) or null if not available. | ||||
|  * @param fft Latest FFT interleaved real/imag bytes or null. | ||||
|  * @param modifier Compose modifier for layout. | ||||
|  */ | ||||
| @Composable | ||||
| fun MetricsSection( | ||||
|     waveform: ByteArray?, | ||||
|     fft: ByteArray?, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { | ||||
|         Column(modifier = Modifier.weight(1f)) { | ||||
|             Text("Waveform metrics", style = MaterialTheme.typography.titleSmall) | ||||
|             val wf = waveform | ||||
|             if (wf != null) { | ||||
|                 val rms = rms(wf) | ||||
|                 val peak = peak(wf) | ||||
|                 Text("Samples: ${wf.size}") | ||||
|                 Text("RMS: ${String.format("%.3f", rms)}") | ||||
|                 Text("Peak: ${String.format("%.3f", peak)}") | ||||
|             } else { | ||||
|                 Text("No waveform yet") | ||||
|             } | ||||
|         } | ||||
|         Column(modifier = Modifier.weight(1f)) { | ||||
|             Text("Raw bytes (first 32)", style = MaterialTheme.typography.titleSmall) | ||||
|             val arr = waveform ?: fft | ||||
|             Text(arr?.let { firstBytesString(it, 32) } ?: "<no data>") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| private fun firstBytesString(bytes: ByteArray, count: Int): String = | ||||
|     bytes.take(count).joinToString(prefix = "[", postfix = "]") { it.toUByte().toString() } | ||||
|  | ||||
| private fun rms(bytes: ByteArray): Double { | ||||
|     if (bytes.isEmpty()) return 0.0 | ||||
|     var sum = 0.0 | ||||
|     for (b in bytes) { | ||||
|         val centered = (b.toInt() and 0xFF) - 128 | ||||
|         sum += centered * centered | ||||
|     } | ||||
|     return kotlin.math.sqrt(sum / bytes.size) | ||||
| } | ||||
|  | ||||
| private fun peak(bytes: ByteArray): Double { | ||||
|     var p = 0 | ||||
|     for (b in bytes) { | ||||
|         val centered = abs(((b.toInt() and 0xFF) - 128)) | ||||
|         if (centered > p) p = centered | ||||
|     } | ||||
|     return p.toDouble() | ||||
| } | ||||
| @@ -1,33 +0,0 @@ | ||||
| package dev.adriankuta.visualizer.components | ||||
|  | ||||
| import android.Manifest | ||||
| import androidx.compose.foundation.layout.Spacer | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.material3.Button | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.activity.compose.ManagedActivityResultLauncher | ||||
|  | ||||
| /** | ||||
|  * Shows rationale and a button to request RECORD_AUDIO permission when not granted. | ||||
|  * | ||||
|  * @param permissionGranted Whether microphone permission is already granted. | ||||
|  * @param requestPermissionLauncher Launcher configured for ActivityResultContracts.RequestPermission(). | ||||
|  * @param modifier Compose modifier for layout. | ||||
|  */ | ||||
| @Composable | ||||
| fun PermissionSection( | ||||
|     permissionGranted: Boolean, | ||||
|     requestPermissionLauncher: ManagedActivityResultLauncher<String, Boolean>, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     if (permissionGranted) return | ||||
|     Text("This app needs microphone permission to access the audio output for visualization.", style = MaterialTheme.typography.bodyMedium) | ||||
|     Spacer(Modifier.height(8.dp)) | ||||
|     Button(onClick = { requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) }) { | ||||
|         Text("Grant Microphone Permission") | ||||
|     } | ||||
| } | ||||
| @@ -1,62 +0,0 @@ | ||||
| package dev.adriankuta.visualizer.components | ||||
|  | ||||
| import androidx.compose.foundation.Canvas | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.graphics.Path | ||||
| import androidx.compose.ui.graphics.drawscope.Stroke | ||||
| import androidx.compose.ui.unit.dp | ||||
|  | ||||
| /** | ||||
|  * Displays the most recent audio waveform as a continuous path. | ||||
|  * | ||||
|  * @param data Waveform bytes where each value is 0..255 (unsigned), as provided by Visualizer. | ||||
|  *             Pass null to render an idle baseline. | ||||
|  * @param modifier Compose modifier for sizing and padding. | ||||
|  */ | ||||
| @Composable | ||||
| fun WaveformView( | ||||
|     data: ByteArray?, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     Text("Waveform", style = MaterialTheme.typography.titleMedium) | ||||
|     Canvas( | ||||
|         modifier = modifier | ||||
|             .fillMaxWidth() | ||||
|             .height(120.dp) | ||||
|             .padding(vertical = 4.dp) | ||||
|     ) { | ||||
|         val w = size.width | ||||
|         val h = size.height | ||||
|         if (data == null || data.isEmpty()) { | ||||
|             drawLine( | ||||
|                 color = Color.Gray, | ||||
|                 start = androidx.compose.ui.geometry.Offset(0f, h / 2), | ||||
|                 end = androidx.compose.ui.geometry.Offset(w, h / 2), | ||||
|                 strokeWidth = 1f | ||||
|             ) | ||||
|         } else { | ||||
|             val path = Path() | ||||
|             val stepX = w / (data.size - 1).coerceAtLeast(1) | ||||
|             for (i in data.indices) { | ||||
|                 val x = i * stepX | ||||
|                 val normalized = (data[i].toInt() and 0xFF) / 255f | ||||
|                 val y = h * (1f - normalized) | ||||
|                 if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) | ||||
|             } | ||||
|             drawPath(path = path, color = Color.Cyan, style = Stroke(width = 2f)) | ||||
|             drawLine( | ||||
|                 color = Color.Gray, | ||||
|                 start = androidx.compose.ui.geometry.Offset(0f, h / 2), | ||||
|                 end = androidx.compose.ui.geometry.Offset(w, h / 2), | ||||
|                 strokeWidth = 1f | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| package dev.adriankuta.visualizer.data | ||||
|  | ||||
| import kotlinx.coroutines.flow.Flow | ||||
|  | ||||
| interface AudioVisualizer: AudioVisualizerController { | ||||
|     fun waveform(): Flow<WaveformFrame> | ||||
|  | ||||
|     fun fft(): Flow<FftFrame> | ||||
| } | ||||
| @@ -0,0 +1,8 @@ | ||||
| package dev.adriankuta.visualizer.data | ||||
|  | ||||
| interface AudioVisualizerController { | ||||
|  | ||||
|     fun start() | ||||
|  | ||||
|     fun stop() | ||||
| } | ||||
| @@ -0,0 +1,75 @@ | ||||
| package dev.adriankuta.visualizer.data | ||||
|  | ||||
| import android.media.audiofx.Visualizer | ||||
| 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 javax.inject.Inject | ||||
|  | ||||
| class AudioVisualizerImpl @Inject constructor( | ||||
|     private val visualizer: Visualizer | ||||
| ) : AudioVisualizer { | ||||
|  | ||||
|     override fun start() { | ||||
|         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 { | ||||
|         val listener = object : Visualizer.OnDataCaptureListener { | ||||
|             override fun onFftDataCapture( | ||||
|                 visualizer: Visualizer?, | ||||
|                 fft: ByteArray, | ||||
|                 samplingRate: Int | ||||
|             ) { | ||||
|                 trySend(FftFrame(fft.copyOf())) | ||||
|             } | ||||
|  | ||||
|             override fun onWaveFormDataCapture( | ||||
|                 visualizer: Visualizer?, | ||||
|                 waveform: ByteArray, | ||||
|                 samplingRate: Int | ||||
|             ) { | ||||
|                 trySend(WaveformFrame(waveform.copyOf())) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         visualizer.setDataCaptureListener( | ||||
|             listener, | ||||
|             Visualizer.getMaxCaptureRate(), | ||||
|             true, | ||||
|             true | ||||
|         ) | ||||
|  | ||||
|         awaitClose { | ||||
|             runCatching { | ||||
|                 visualizer.enabled = false | ||||
|                 visualizer.setDataCaptureListener(null, 0, false, false) | ||||
|                 visualizer.release() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|         .conflate() | ||||
| } | ||||
|  | ||||
| sealed interface VisualizerFrame | ||||
| data class WaveformFrame( | ||||
|     val data: ByteArray, | ||||
| ) : VisualizerFrame | ||||
|  | ||||
| data class FftFrame( | ||||
|     val data: ByteArray, | ||||
| ) : VisualizerFrame | ||||
| @@ -0,0 +1,37 @@ | ||||
| package dev.adriankuta.visualizer.di | ||||
|  | ||||
| import android.media.audiofx.Visualizer | ||||
| import dagger.Binds | ||||
| import dagger.Module | ||||
| import dagger.Provides | ||||
| import dagger.hilt.InstallIn | ||||
| import dagger.hilt.components.SingletonComponent | ||||
| import dev.adriankuta.visualizer.data.AudioVisualizer | ||||
| import dev.adriankuta.visualizer.data.AudioVisualizerController | ||||
| import dev.adriankuta.visualizer.data.AudioVisualizerImpl | ||||
| import javax.inject.Singleton | ||||
|  | ||||
| @Module | ||||
| @InstallIn(SingletonComponent::class) | ||||
| abstract class VisualizerModule { | ||||
|  | ||||
|     @Binds | ||||
|     abstract fun bindVisualizer( | ||||
|         audioVisualizerImpl: AudioVisualizerImpl | ||||
|     ): AudioVisualizer | ||||
|  | ||||
|     @Binds | ||||
|     abstract fun bindVisualizerToController( | ||||
|         audioVisualizerImpl: AudioVisualizerImpl | ||||
|     ): AudioVisualizerController | ||||
|  | ||||
|     companion object { | ||||
|         @Provides | ||||
|         @Singleton | ||||
|         fun provideVisualizer(): Visualizer { | ||||
|             return Visualizer(0).apply { | ||||
|                 captureSize = 1024 | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,23 @@ | ||||
| package dev.adriankuta.visualizer.domain | ||||
|  | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import dev.adriankuta.visualizer.data.AudioVisualizer | ||||
| import dev.adriankuta.visualizer.data.WaveformFrame | ||||
| import kotlinx.coroutines.flow.Flow | ||||
| import kotlinx.coroutines.flow.map | ||||
| import javax.inject.Inject | ||||
|  | ||||
| class ObserveVisualizerColorUseCase @Inject constructor( | ||||
|     private val audioVisualizer: AudioVisualizer | ||||
| ) { | ||||
|  | ||||
|     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) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,97 @@ | ||||
| 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) | ||||
| } | ||||
| @@ -0,0 +1,91 @@ | ||||
| package dev.adriankuta.visualizer.domain | ||||
|  | ||||
| import android.graphics.Color | ||||
| import kotlin.math.* | ||||
|  | ||||
| // Keep this across frames (e.g., one per audio stream/visualizer) | ||||
| data class VisualizerState( | ||||
|     var prevMag: FloatArray? = null, | ||||
|     var maxEnergy: Double = 1e-6,  // running max of energy | ||||
|     var maxFlux: Double = 1e-6     // running max of flux | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * Convert one FFT magnitude spectrum to a color. | ||||
|  * | ||||
|  * @param mags        Magnitude of the *half* spectrum (bins 0..Nyquist), non-negative. | ||||
|  * @param sampleRate  Audio sample rate in Hz (e.g., 44100). | ||||
|  * @param state       Persistent state for normalization & flux (keep between calls). | ||||
|  * @return            ARGB color Int (Android). | ||||
|  */ | ||||
| fun fftToColor(mags: FloatArray, sampleRate: Int, state: VisualizerState): Int { | ||||
|     require(mags.isNotEmpty()) { "mags must not be empty" } | ||||
|     val nBins = mags.size | ||||
|     val nyquist = sampleRate * 0.5 | ||||
|  | ||||
|     // --- Basic spectrum stats --- | ||||
|     var sumMag = 0.0 | ||||
|     var weightedFreqSum = 0.0 | ||||
|     var energy = 0.0 | ||||
|  | ||||
|     // Frequency step per bin (covers 0..Nyquist across nBins bins) | ||||
|     val binHz = if (nBins > 1) nyquist / (nBins - 1) else 0.0 | ||||
|  | ||||
|     for (k in 0 until nBins) { | ||||
|         val m = mags[k].toDouble().coerceAtLeast(0.0) | ||||
|         sumMag += m | ||||
|         energy += m * m | ||||
|         val fk = k * binHz | ||||
|         weightedFreqSum += fk * m | ||||
|     } | ||||
|  | ||||
|     // Spectral centroid in Hz (fallback to 0 when silent) | ||||
|     val centroidHz = if (sumMag > 0.0) weightedFreqSum / sumMag else 0.0 | ||||
|  | ||||
|     // Log-scaled centroid -> [0,1] (more perceptual) | ||||
|     val hue01 = run { | ||||
|         val num = ln(1.0 + centroidHz) | ||||
|         val den = ln(1.0 + nyquist) | ||||
|         if (den > 0.0) (num / den).coerceIn(0.0, 1.0) else 0.0 | ||||
|     } | ||||
|  | ||||
|     // --- Spectral flux (vs. previous frame) for saturation --- | ||||
|     val prev = state.prevMag | ||||
|     var flux = 0.0 | ||||
|     if (prev != null && prev.size == nBins) { | ||||
|         for (k in 0 until nBins) { | ||||
|             val d = mags[k].toDouble() - prev[k].toDouble() | ||||
|             if (d > 0.0) flux += d | ||||
|         } | ||||
|     } | ||||
|     // Update prev spectrum (copy to avoid aliasing) | ||||
|     state.prevMag = mags.copyOf() | ||||
|  | ||||
|     // --- Adaptive normalization (with slow decay) --- | ||||
|     // Update running maxima with slight decay so the visual doesn't "stick" | ||||
|     fun decay(x: Double, factor: Double) = x * factor | ||||
|  | ||||
|     state.maxEnergy = max(decay(state.maxEnergy, 0.995), energy) | ||||
|     state.maxFlux   = max(decay(state.maxFlux,   0.995), flux) | ||||
|  | ||||
|     // --- Map to HSV --- | ||||
|     // V: log-compress energy; normalize by running max | ||||
|     val v = run { | ||||
|         val eNorm = if (state.maxEnergy > 1e-12) energy / state.maxEnergy else 0.0 | ||||
|         val logCompressed = ln(1.0 + 9.0 * eNorm) / ln(10.0) // [0,1], gentle curve | ||||
|         logCompressed.coerceIn(0.0, 1.0) | ||||
|     } | ||||
|  | ||||
|     // S: flux normalized by running max, then soft knee | ||||
|     val s = run { | ||||
|         val fNorm = if (state.maxFlux > 1e-12) flux / state.maxFlux else 0.0 | ||||
|         val soft = sqrt(fNorm.coerceIn(0.0, 1.0)) // emphasizes small changes less | ||||
|         (0.15 + 0.85 * soft).coerceIn(0.0, 1.0)   // keep some color even when still | ||||
|     } | ||||
|  | ||||
|     // H: map [0,1] over a 300° range so it wraps nicely (0=red, 0.5≈cyan, 1≈purple) | ||||
|     val h = (hue01 * 300.0).toFloat() | ||||
|  | ||||
|     val hsv = floatArrayOf(h, s.toFloat(), v.toFloat()) | ||||
|     return Color.HSVToColor(hsv) // ARGB Int | ||||
| } | ||||
| @@ -0,0 +1,99 @@ | ||||
| import kotlin.math.sqrt | ||||
|  | ||||
| data class HSL( | ||||
| // in Python code, the HSL values were in range 0 - 1 | ||||
|  | ||||
|     val hue: Float, | ||||
|     val saturation: Float, | ||||
|     val lightness: Float | ||||
| ) { | ||||
|     fun toRgb(): Triple<Int, Int, Int> { | ||||
|         val (r, g, b) = hlsToRgb(hue / 360, lightness, saturation) // Convert HSL to RGB | ||||
|         return Triple((r * 255).toInt(), (g * 255).toInt(), (b * 255).toInt()) | ||||
|     } | ||||
|  | ||||
|     private fun hlsToRgb(h: Float, l: Float, s: Float): Triple<Float, Float, Float> { | ||||
|         val r: Float | ||||
|         val g: Float | ||||
|         val b: Float | ||||
|  | ||||
|         if (s == 0f) { | ||||
|             // Achromatic (gray) | ||||
|             r = l | ||||
|             g = l | ||||
|             b = l | ||||
|         } else { | ||||
|             val q = if (l < 0.5) l * (1 + s) else l + s - l * s | ||||
|             val p = 2 * l - q | ||||
|  | ||||
|             r = hueToRgb(p, q, h + 1 / 3) | ||||
|             g = hueToRgb(p, q, h) | ||||
|             b = hueToRgb(p, q, h - 1 / 3) | ||||
|         } | ||||
|  | ||||
|         return Triple(r, g, b) | ||||
|     } | ||||
|  | ||||
|     private fun hueToRgb(p: Float, q: Float, t: Float): Float { | ||||
|         var tTemp = t | ||||
|         if (tTemp < 0) tTemp += 1 | ||||
|         if (tTemp > 1) tTemp -= 1 | ||||
|         return when { | ||||
|             tTemp < 1 / 6 -> p + (q - p) * 6 * tTemp | ||||
|             tTemp < 1 / 2 -> q | ||||
|             tTemp < 2 / 3 -> p + (q - p) * (2 / 3 - tTemp) * 6 | ||||
|             else -> p | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| // copied from Adrian's code, so this probably redundant here | ||||
| private fun rms(bytes: ByteArray): Double { | ||||
|     if (bytes.isEmpty()) return 0.0 | ||||
|     var sum = 0.0 | ||||
|     for (b in bytes) { | ||||
|         val centered = (b.toInt() and 0xFF) - 128 | ||||
|         sum += centered * centered | ||||
|     } | ||||
|     return kotlin.math.sqrt(sum / bytes.size) | ||||
| } | ||||
|  | ||||
|  | ||||
| // not sure whether the rmsMax, movingAverage, and hslColor should be passed as parameter, therefore I used  "...", in Python they were all: self.rms_max, ... so I could access them directly | ||||
| fun soundToColor( ... ) { | ||||
| 	// Time domain processing, adjustable maximum of energy (we need ratio, as we do not have given maximum) | ||||
|     	 | ||||
| 	// calculate RMS value for one current frame, actualize value of RMS maximum | ||||
| 	val tmpRms = rms(wf)		// variable "wf" - waveform, I saw it Adrian's code | ||||
| 	if (tmpRms > rmsMax) { | ||||
| 		rmsMax = tmpRms | ||||
| 	} | ||||
| 	energyRatio = tmpRms / rmsMax | ||||
|     adjLightness = movingAverage.process(energyRatio) | ||||
| 	hslColor.lightness = adjLightness | ||||
| 	val rgbColor = hslColor.toRgb() | ||||
| 	return rgbColor | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| // predefine values for soundToColor method | ||||
| val movingAverage = MovingAverage(windowSize = 7)	// object for calculation of moving average, this should stay the same during the run of application | ||||
| rmsMax = 0.0		// to store the value of current maximum of RMS, this might be changed in every call of method (every loop) | ||||
| val hslColor = HSL(hue = 0f, saturation = 1f, value = 0f)	// to store the values for current color, value will change every loop | ||||
|  | ||||
|  | ||||
| // I can only gues, but I can imagine that the definitioin of variables (movingAverage, rmsMax, hslColor) can be defined | ||||
| // somewhere at higher level of application, maybe VisualizerScreen. | ||||
| // Somewhere, where the loop that takes data for waveform, where the data can be passed to soundToColor. | ||||
| // | ||||
| // And as I do not know Kotlin, I am not able to tell how it should be there. | ||||
| // Then, the method soundToColor might be defined (implemented) in MetricsSection, with the rest of the code too. | ||||
| // Or is it for separate file? | ||||
| // | ||||
| // I can only gues how it will work. In Python, the settigns of audio processing was: | ||||
| // sampling_rate = 16000 # Hz, sampling frequency | ||||
| // window_size = 1024 | ||||
| // moving averate size = 7 | ||||
| // | ||||
| // I think that, when the window size (or sampling rate) is different we will need to find new value for moving average | ||||
| @@ -0,0 +1,300 @@ | ||||
| package dev.adriankuta.visualizer.view | ||||
|  | ||||
| import android.Manifest | ||||
| import android.content.pm.PackageManager | ||||
| import androidx.activity.compose.rememberLauncherForActivityResult | ||||
| import androidx.activity.result.contract.ActivityResultContracts | ||||
| import androidx.compose.foundation.background | ||||
| import androidx.compose.foundation.layout.* | ||||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||||
| import androidx.compose.material3.* | ||||
| import androidx.compose.runtime.* | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.draw.clip | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.text.font.FontWeight | ||||
| import androidx.compose.ui.text.style.TextAlign | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.compose.ui.unit.sp | ||||
| import androidx.core.content.ContextCompat | ||||
| import androidx.hilt.navigation.compose.hiltViewModel | ||||
| 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 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()) | ||||
|         ) | ||||
|     } | ||||
| ) { | ||||
|     val context = LocalContext.current | ||||
|     val lifecycleOwner = LocalLifecycleOwner.current | ||||
|     val currentColor by audioVisualizer.currentColor | ||||
|     val isActive by audioVisualizer.isActive | ||||
|  | ||||
|     // Permission state management | ||||
|     var hasAudioPermission by remember { | ||||
|         mutableStateOf( | ||||
|             ContextCompat.checkSelfPermission( | ||||
|                 context, | ||||
|                 Manifest.permission.RECORD_AUDIO | ||||
|             ) == PackageManager.PERMISSION_GRANTED | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     var showPermissionRationale by remember { mutableStateOf(false) } | ||||
|  | ||||
|     // Permission request launcher | ||||
|     val permissionLauncher = rememberLauncherForActivityResult( | ||||
|         contract = ActivityResultContracts.RequestPermission() | ||||
|     ) { isGranted -> | ||||
|         hasAudioPermission = isGranted | ||||
|         if (isGranted) { | ||||
|             Timber.d("Audio permission granted, starting visualizer") | ||||
|             audioVisualizer.start() | ||||
|         } else { | ||||
|             Timber.w("Audio permission denied") | ||||
|             showPermissionRationale = true | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Function to start audio visualizer with permission check | ||||
|     fun startVisualizerWithPermissionCheck() { | ||||
|         when { | ||||
|             hasAudioPermission -> { | ||||
|                 audioVisualizer.start() | ||||
|             } | ||||
|             else -> { | ||||
|                 Timber.d("Requesting audio permission") | ||||
|                 permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Handle lifecycle events | ||||
|     DisposableEffect(lifecycleOwner) { | ||||
|         val observer = LifecycleEventObserver { _, event -> | ||||
|             when (event) { | ||||
|                 Lifecycle.Event.ON_START -> startVisualizerWithPermissionCheck() | ||||
|                 Lifecycle.Event.ON_STOP -> audioVisualizer.stop() | ||||
|                 else -> {} | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         lifecycleOwner.lifecycle.addObserver(observer) | ||||
|  | ||||
|         onDispose { | ||||
|             lifecycleOwner.lifecycle.removeObserver(observer) | ||||
|             audioVisualizer.release() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     Column( | ||||
|         modifier = modifier | ||||
|             .fillMaxSize() | ||||
|             .padding(16.dp), | ||||
|         horizontalAlignment = Alignment.CenterHorizontally, | ||||
|         verticalArrangement = Arrangement.Center | ||||
|     ) { | ||||
|         // Title | ||||
|         Text( | ||||
|             text = "Audio Color Visualizer", | ||||
|             fontSize = 24.sp, | ||||
|             fontWeight = FontWeight.Bold, | ||||
|             textAlign = TextAlign.Center, | ||||
|             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 | ||||
|         ) { | ||||
|             // Show color information | ||||
|             ColorInfoCard( | ||||
|                 color = currentColor, | ||||
|                 isActive = isActive | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         Spacer(modifier = Modifier.height(32.dp)) | ||||
|  | ||||
|         // Status indicator | ||||
|         StatusIndicator( | ||||
|             isActive = isActive, | ||||
|             hasPermission = hasAudioPermission | ||||
|         ) | ||||
|  | ||||
|         Spacer(modifier = Modifier.height(16.dp)) | ||||
|  | ||||
|         // Instructions | ||||
|         Text( | ||||
|             text = when { | ||||
|                 !hasAudioPermission -> "Audio permission is required to visualize sound" | ||||
|                 isActive -> "Play some music or make sounds to see the colors change!" | ||||
|                 else -> "Starting audio visualizer..." | ||||
|             }, | ||||
|             fontSize = 16.sp, | ||||
|             textAlign = TextAlign.Center, | ||||
|             color = MaterialTheme.colorScheme.onSurfaceVariant, | ||||
|             modifier = Modifier.padding(horizontal = 32.dp) | ||||
|         ) | ||||
|  | ||||
|         // Permission request button (shown when permission is denied) | ||||
|         if (!hasAudioPermission) { | ||||
|             Spacer(modifier = Modifier.height(16.dp)) | ||||
|  | ||||
|             Button( | ||||
|                 onClick = { | ||||
|                     showPermissionRationale = false | ||||
|                     permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) | ||||
|                 }, | ||||
|                 modifier = Modifier.padding(horizontal = 32.dp) | ||||
|             ) { | ||||
|                 Text("Grant Audio Permission") | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Permission rationale dialog | ||||
|     if (showPermissionRationale) { | ||||
|         AlertDialog( | ||||
|             onDismissRequest = { showPermissionRationale = false }, | ||||
|             title = { Text("Audio Permission Required") }, | ||||
|             text = {  | ||||
|                 Text("This app needs access to your microphone to visualize audio and create colors based on sound. Please grant the audio recording permission to continue.") | ||||
|             }, | ||||
|             confirmButton = { | ||||
|                 TextButton( | ||||
|                     onClick = { | ||||
|                         showPermissionRationale = false | ||||
|                         permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) | ||||
|                     } | ||||
|                 ) { | ||||
|                     Text("Grant Permission") | ||||
|                 } | ||||
|             }, | ||||
|             dismissButton = { | ||||
|                 TextButton( | ||||
|                     onClick = { showPermissionRationale = false } | ||||
|                 ) { | ||||
|                     Text("Cancel") | ||||
|                 } | ||||
|             } | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Card displaying color information. | ||||
|  */ | ||||
| @Composable | ||||
| private fun ColorInfoCard( | ||||
|     color: Color, | ||||
|     isActive: Boolean, | ||||
|     modifier: Modifier = Modifier | ||||
| ) { | ||||
|     Card( | ||||
|         modifier = modifier, | ||||
|         colors = CardDefaults.cardColors( | ||||
|             containerColor = Color.White.copy(alpha = 0.9f) | ||||
|         ), | ||||
|         elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) | ||||
|     ) { | ||||
|         Column( | ||||
|             modifier = Modifier.padding(16.dp), | ||||
|             horizontalAlignment = Alignment.CenterHorizontally | ||||
|         ) { | ||||
|             Text( | ||||
|                 text = "Current Color", | ||||
|                 fontSize = 14.sp, | ||||
|                 fontWeight = FontWeight.Medium, | ||||
|                 color = Color.Black | ||||
|             ) | ||||
|  | ||||
|             Spacer(modifier = Modifier.height(8.dp)) | ||||
|  | ||||
|             // RGB values | ||||
|             val (r, g, b) = with(color) { | ||||
|                 Triple( | ||||
|                     (red * 255).toInt(), | ||||
|                     (green * 255).toInt(), | ||||
|                     (blue * 255).toInt() | ||||
|                 ) | ||||
|             } | ||||
|  | ||||
|             Text( | ||||
|                 text = "RGB($r, $g, $b)", | ||||
|                 fontSize = 12.sp, | ||||
|                 color = Color.Black.copy(alpha = 0.7f), | ||||
|                 fontWeight = FontWeight.Light | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Status indicator showing whether the visualizer is active and permission status. | ||||
|  */ | ||||
| @Composable | ||||
| private fun StatusIndicator( | ||||
|     isActive: Boolean, | ||||
|     hasPermission: Boolean, | ||||
|     modifier: Modifier = Modifier | ||||
| ) { | ||||
|     Row( | ||||
|         modifier = modifier, | ||||
|         verticalAlignment = Alignment.CenterVertically | ||||
|     ) { | ||||
|         Box( | ||||
|             modifier = Modifier | ||||
|                 .size(12.dp) | ||||
|                 .clip(RoundedCornerShape(6.dp)) | ||||
|                 .background( | ||||
|                     when { | ||||
|                         !hasPermission -> Color.Gray | ||||
|                         isActive -> Color.Green | ||||
|                         else -> Color.Red | ||||
|                     } | ||||
|                 ) | ||||
|         ) | ||||
|  | ||||
|         Spacer(modifier = Modifier.width(8.dp)) | ||||
|  | ||||
|         Text( | ||||
|             text = when { | ||||
|                 !hasPermission -> "Permission Required" | ||||
|                 isActive -> "Active" | ||||
|                 else -> "Inactive" | ||||
|             }, | ||||
|             fontSize = 14.sp, | ||||
|             color = MaterialTheme.colorScheme.onSurface | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,7 @@ | ||||
| package dev.adriankuta.visualizer.view | ||||
|  | ||||
| import androidx.compose.ui.graphics.Color | ||||
|  | ||||
| data class VisualizerScreenUiState( | ||||
|     val color: Color | ||||
| ) | ||||
| @@ -0,0 +1,23 @@ | ||||
| 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.domain.ObserveVisualizerColorUseCase | ||||
| import kotlinx.coroutines.flow.SharingStarted | ||||
| import kotlinx.coroutines.flow.stateIn | ||||
| import javax.inject.Inject | ||||
|  | ||||
| @HiltViewModel | ||||
| class VisualizerScreenViewModel @Inject constructor( | ||||
|     private val observeVisualizerColorUseCase: ObserveVisualizerColorUseCase | ||||
| ) : ViewModel() { | ||||
|  | ||||
|     val uiState = observeVisualizerColorUseCase() | ||||
|         .stateIn( | ||||
|             scope = viewModelScope, | ||||
|             started = SharingStarted.WhileSubscribed(5_000), | ||||
|             initialValue = Color.Black | ||||
|         ) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user
	 Adrian Kuta (DZCQIWG)
					Adrian Kuta (DZCQIWG)