Compare commits
	
		
			2 Commits
		
	
	
		
			6410477f54
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | ea5fc9e97d | ||
|   | 4bc3a0a096 | 
							
								
								
									
										3
									
								
								.idea/deploymentTargetSelector.xml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								.idea/deploymentTargetSelector.xml
									
									
									
										generated
									
									
									
								
							| @@ -13,6 +13,9 @@ | ||||
|         </DropdownSelection> | ||||
|         <DialogSelection /> | ||||
|       </SelectionState> | ||||
|       <SelectionState runConfigName="ExampleInstrumentedTest"> | ||||
|         <option name="selectionMode" value="DROPDOWN" /> | ||||
|       </SelectionState> | ||||
|     </selectionStates> | ||||
|   </component> | ||||
| </project> | ||||
							
								
								
									
										1
									
								
								.idea/misc.xml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								.idea/misc.xml
									
									
									
										generated
									
									
									
								
							| @@ -1,4 +1,3 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <project version="4"> | ||||
|   <component name="ExternalStorageConfigurationManager" enabled="true" /> | ||||
|   <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> | ||||
|   | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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> | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
| @@ -0,0 +1,26 @@ | ||||
| package dev.adriankuta.visualizer.data | ||||
|  | ||||
| import kotlinx.coroutines.channels.BufferOverflow | ||||
| import kotlinx.coroutines.flow.MutableSharedFlow | ||||
| import kotlinx.coroutines.flow.SharedFlow | ||||
| import javax.inject.Inject | ||||
| import javax.inject.Singleton | ||||
|  | ||||
| @Singleton | ||||
| class RmsValueProvider @Inject constructor() { | ||||
|  | ||||
|     private val currentRms = | ||||
|         MutableSharedFlow<RmsValue>(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) | ||||
|  | ||||
|     fun emit(value: RmsValue) { | ||||
|         currentRms.tryEmit(value) | ||||
|     } | ||||
|  | ||||
|     fun observe(): SharedFlow<RmsValue> = currentRms | ||||
| } | ||||
|  | ||||
| data class RmsValue( | ||||
|     val current: Double, | ||||
|     val max: Double, | ||||
|     val min: Double = 0.0 | ||||
| ) | ||||
| @@ -9,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() | ||||
|         }*/ | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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 don’t 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 | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
| ) | ||||
|   | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -1,17 +0,0 @@ | ||||
| package dev.adriankuta.visualizer | ||||
|  | ||||
| import org.junit.Test | ||||
|  | ||||
| import org.junit.Assert.* | ||||
|  | ||||
| /** | ||||
|  * Example local unit test, which will execute on the development machine (host). | ||||
|  * | ||||
|  * See [testing documentation](http://d.android.com/tools/testing). | ||||
|  */ | ||||
| class ExampleUnitTest { | ||||
|     @Test | ||||
|     fun addition_isCorrect() { | ||||
|         assertEquals(4, 2 + 2) | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user