Compare commits
	
		
			2 Commits
		
	
	
		
			6410477f54
			...
			ea5fc9e97d
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | ea5fc9e97d | ||
|   | 4bc3a0a096 | 
							
								
								
									
										3
									
								
								.idea/deploymentTargetSelector.xml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								.idea/deploymentTargetSelector.xml
									
									
									
										generated
									
									
									
								
							| @@ -13,6 +13,9 @@ | |||||||
|         </DropdownSelection> |         </DropdownSelection> | ||||||
|         <DialogSelection /> |         <DialogSelection /> | ||||||
|       </SelectionState> |       </SelectionState> | ||||||
|  |       <SelectionState runConfigName="ExampleInstrumentedTest"> | ||||||
|  |         <option name="selectionMode" value="DROPDOWN" /> | ||||||
|  |       </SelectionState> | ||||||
|     </selectionStates> |     </selectionStates> | ||||||
|   </component> |   </component> | ||||||
| </project> | </project> | ||||||
							
								
								
									
										1
									
								
								.idea/misc.xml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								.idea/misc.xml
									
									
									
										generated
									
									
									
								
							| @@ -1,4 +1,3 @@ | |||||||
| <?xml version="1.0" encoding="UTF-8"?> |  | ||||||
| <project version="4"> | <project version="4"> | ||||||
|   <component name="ExternalStorageConfigurationManager" enabled="true" /> |   <component name="ExternalStorageConfigurationManager" enabled="true" /> | ||||||
|   <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> |   <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> | ||||||
|   | |||||||
| @@ -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 | import kotlinx.coroutines.flow.Flow | ||||||
|  |  | ||||||
| interface AudioVisualizer: AudioVisualizerController { | interface AudioVisualizer: AudioVisualizerController { | ||||||
|     fun waveform(): Flow<WaveformFrame> |  | ||||||
|  |  | ||||||
|     fun fft(): Flow<FftFrame> |     fun visualizerFlow(): Flow<VisualizerFrame> | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,11 +1,12 @@ | |||||||
| package dev.adriankuta.visualizer.data | package dev.adriankuta.visualizer.data | ||||||
|  |  | ||||||
| import android.media.audiofx.Visualizer | import android.media.audiofx.Visualizer | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
| import kotlinx.coroutines.channels.awaitClose | import kotlinx.coroutines.channels.awaitClose | ||||||
| import kotlinx.coroutines.flow.Flow | import kotlinx.coroutines.flow.Flow | ||||||
| import kotlinx.coroutines.flow.callbackFlow | import kotlinx.coroutines.flow.callbackFlow | ||||||
| import kotlinx.coroutines.flow.conflate | import kotlinx.coroutines.flow.conflate | ||||||
| import kotlinx.coroutines.flow.filterIsInstance | import kotlinx.coroutines.flow.flowOn | ||||||
| import javax.inject.Inject | import javax.inject.Inject | ||||||
|  |  | ||||||
| class AudioVisualizerImpl @Inject constructor( | class AudioVisualizerImpl @Inject constructor( | ||||||
| @@ -13,22 +14,14 @@ class AudioVisualizerImpl @Inject constructor( | |||||||
| ) : AudioVisualizer { | ) : AudioVisualizer { | ||||||
|  |  | ||||||
|     override fun start() { |     override fun start() { | ||||||
|         visualizer.enabled = true |        // visualizer.enabled = true | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     override fun stop() { |     override fun stop() { | ||||||
|         visualizer.enabled = false |         visualizer.enabled = false | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     override fun waveform(): Flow<WaveformFrame> = |     override fun visualizerFlow(): Flow<VisualizerFrame> = callbackFlow { | ||||||
|         visualizerFlow() |  | ||||||
|             .filterIsInstance(WaveformFrame::class) |  | ||||||
|  |  | ||||||
|     override fun fft(): Flow<FftFrame> = |  | ||||||
|         visualizerFlow() |  | ||||||
|             .filterIsInstance(FftFrame::class) |  | ||||||
|  |  | ||||||
|     private fun visualizerFlow(): Flow<VisualizerFrame> = callbackFlow { |  | ||||||
|         val listener = object : Visualizer.OnDataCaptureListener { |         val listener = object : Visualizer.OnDataCaptureListener { | ||||||
|             override fun onFftDataCapture( |             override fun onFftDataCapture( | ||||||
|                 visualizer: Visualizer?, |                 visualizer: Visualizer?, | ||||||
| @@ -47,13 +40,15 @@ class AudioVisualizerImpl @Inject constructor( | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         visualizer.setDataCaptureListener( |         val result = visualizer.setDataCaptureListener( | ||||||
|             listener, |             listener, | ||||||
|             Visualizer.getMaxCaptureRate(), |             Visualizer.getMaxCaptureRate(), | ||||||
|             true, |             true, | ||||||
|             true |             true | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |         visualizer.enabled = true | ||||||
|  |  | ||||||
|         awaitClose { |         awaitClose { | ||||||
|             runCatching { |             runCatching { | ||||||
|                 visualizer.enabled = false |                 visualizer.enabled = false | ||||||
| @@ -63,13 +58,17 @@ class AudioVisualizerImpl @Inject constructor( | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|         .conflate() |         .conflate() | ||||||
|  |         .flowOn(Dispatchers.Default) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | sealed interface VisualizerFrame { | ||||||
|  |     val data: ByteArray | ||||||
| } | } | ||||||
|  |  | ||||||
| sealed interface VisualizerFrame |  | ||||||
| data class WaveformFrame( | data class WaveformFrame( | ||||||
|     val data: ByteArray, |     override val data: ByteArray, | ||||||
| ) : VisualizerFrame | ) : VisualizerFrame | ||||||
|  |  | ||||||
| data class FftFrame( | data class FftFrame( | ||||||
|     val data: ByteArray, |     override val data: ByteArray, | ||||||
| ) : VisualizerFrame | ) : 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.AudioVisualizer | ||||||
| import dev.adriankuta.visualizer.data.AudioVisualizerController | import dev.adriankuta.visualizer.data.AudioVisualizerController | ||||||
| import dev.adriankuta.visualizer.data.AudioVisualizerImpl | import dev.adriankuta.visualizer.data.AudioVisualizerImpl | ||||||
|  | import dev.adriankuta.visualizer.domain.processors.HSLSoundToColorMapper | ||||||
|  | import dev.adriankuta.visualizer.domain.processors.VisualizerProcessor | ||||||
| import javax.inject.Singleton | import javax.inject.Singleton | ||||||
|  |  | ||||||
| @Module | @Module | ||||||
| @@ -25,6 +27,11 @@ abstract class VisualizerModule { | |||||||
|         audioVisualizerImpl: AudioVisualizerImpl |         audioVisualizerImpl: AudioVisualizerImpl | ||||||
|     ): AudioVisualizerController |     ): AudioVisualizerController | ||||||
|  |  | ||||||
|  |     @Binds | ||||||
|  |     abstract fun bindProcessor( | ||||||
|  |         fftToColor: HSLSoundToColorMapper | ||||||
|  |     ): VisualizerProcessor | ||||||
|  |  | ||||||
|     companion object { |     companion object { | ||||||
|         @Provides |         @Provides | ||||||
|         @Singleton |         @Singleton | ||||||
| @@ -33,5 +40,11 @@ abstract class VisualizerModule { | |||||||
|                 captureSize = 1024 |                 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 androidx.compose.ui.graphics.Color | ||||||
| import dev.adriankuta.visualizer.data.AudioVisualizer | 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.data.WaveformFrame | ||||||
|  | import dev.adriankuta.visualizer.domain.processors.VisualizerProcessor | ||||||
| import kotlinx.coroutines.flow.Flow | import kotlinx.coroutines.flow.Flow | ||||||
|  | import kotlinx.coroutines.flow.filterIsInstance | ||||||
| import kotlinx.coroutines.flow.map | import kotlinx.coroutines.flow.map | ||||||
|  | import kotlinx.coroutines.flow.onStart | ||||||
| import javax.inject.Inject | import javax.inject.Inject | ||||||
|  |  | ||||||
| class ObserveVisualizerColorUseCase @Inject constructor( | 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> = |     operator fun invoke(): Flow<Color> = | ||||||
|         audioVisualizer.waveform() |         audioVisualizer.visualizerFlow() | ||||||
|             .map { processColor(it) } |             .process(processor) | ||||||
|  |             .onStart { emit(Color.Black) } | ||||||
|     private fun processColor(waveformFrame: WaveformFrame): Color { |  | ||||||
|         return colorMapper.mapSoundToColor(waveformFrame.data) |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | 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 android.content.pm.PackageManager | ||||||
| import androidx.activity.compose.rememberLauncherForActivityResult | import androidx.activity.compose.rememberLauncherForActivityResult | ||||||
| import androidx.activity.result.contract.ActivityResultContracts | import androidx.activity.result.contract.ActivityResultContracts | ||||||
|  | import androidx.compose.animation.animateColorAsState | ||||||
|  | import androidx.compose.animation.core.animateFloatAsState | ||||||
|  | import androidx.compose.animation.core.tween | ||||||
| import androidx.compose.foundation.background | 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.foundation.shape.RoundedCornerShape | ||||||
| import androidx.compose.material3.* | import androidx.compose.material3.AlertDialog | ||||||
| import androidx.compose.runtime.* | 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.Alignment | ||||||
| import androidx.compose.ui.Modifier | import androidx.compose.ui.Modifier | ||||||
| import androidx.compose.ui.draw.clip | import androidx.compose.ui.draw.clip | ||||||
|  | import androidx.compose.ui.draw.drawBehind | ||||||
|  | import androidx.compose.ui.geometry.Offset | ||||||
|  | import androidx.compose.ui.geometry.Size | ||||||
| import androidx.compose.ui.graphics.Color | import androidx.compose.ui.graphics.Color | ||||||
| import androidx.compose.ui.platform.LocalContext | import androidx.compose.ui.platform.LocalContext | ||||||
| import androidx.compose.ui.text.font.FontWeight | import androidx.compose.ui.text.font.FontWeight | ||||||
| @@ -24,35 +50,21 @@ import androidx.lifecycle.Lifecycle | |||||||
| import androidx.lifecycle.LifecycleEventObserver | import androidx.lifecycle.LifecycleEventObserver | ||||||
| import androidx.lifecycle.compose.LocalLifecycleOwner | import androidx.lifecycle.compose.LocalLifecycleOwner | ||||||
| import androidx.lifecycle.compose.collectAsStateWithLifecycle | import androidx.lifecycle.compose.collectAsStateWithLifecycle | ||||||
| import dev.adriankuta.visualizer.AudioVisualizerFactory | import dev.adriankuta.visualizer.data.RmsValue | ||||||
| import dev.adriankuta.visualizer.AudioVisualizerService | import dev.adriankuta.visualizer.ui.components.KeepScreenOn | ||||||
| import kotlinx.coroutines.CoroutineScope |  | ||||||
| import kotlinx.coroutines.Dispatchers |  | ||||||
| import kotlinx.coroutines.SupervisorJob |  | ||||||
| import timber.log.Timber | import timber.log.Timber | ||||||
|  |  | ||||||
|  |  | ||||||
| @Composable |  | ||||||
| fun VisualizerScreen( |  | ||||||
|     viewModel: VisualizerScreenViewModel = hiltViewModel() |  | ||||||
| ) { |  | ||||||
|  |  | ||||||
|     val uiState by viewModel.uiState.collectAsStateWithLifecycle() |  | ||||||
|  |  | ||||||
| } |  | ||||||
| @Composable | @Composable | ||||||
| fun VisualizerScreen( | fun VisualizerScreen( | ||||||
|     modifier: Modifier = Modifier, |     modifier: Modifier = Modifier, | ||||||
|     audioVisualizer: AudioVisualizerService = remember { |     viewModel: VisualizerScreenViewModel = hiltViewModel() | ||||||
|         AudioVisualizerFactory.createDefault( |  | ||||||
|             CoroutineScope(Dispatchers.Main + SupervisorJob()) |  | ||||||
|         ) |  | ||||||
|     } |  | ||||||
| ) { | ) { | ||||||
|     val context = LocalContext.current |     val context = LocalContext.current | ||||||
|     val lifecycleOwner = LocalLifecycleOwner.current |     val lifecycleOwner = LocalLifecycleOwner.current | ||||||
|     val currentColor by audioVisualizer.currentColor |     val uiState by viewModel.uiState.collectAsStateWithLifecycle() | ||||||
|     val isActive by audioVisualizer.isActive |  | ||||||
|  |     KeepScreenOn() | ||||||
|  |  | ||||||
|     // Permission state management |     // Permission state management | ||||||
|     var hasAudioPermission by remember { |     var hasAudioPermission by remember { | ||||||
| @@ -73,7 +85,7 @@ fun VisualizerScreen( | |||||||
|         hasAudioPermission = isGranted |         hasAudioPermission = isGranted | ||||||
|         if (isGranted) { |         if (isGranted) { | ||||||
|             Timber.d("Audio permission granted, starting visualizer") |             Timber.d("Audio permission granted, starting visualizer") | ||||||
|             audioVisualizer.start() |             viewModel.start() | ||||||
|         } else { |         } else { | ||||||
|             Timber.w("Audio permission denied") |             Timber.w("Audio permission denied") | ||||||
|             showPermissionRationale = true |             showPermissionRationale = true | ||||||
| @@ -84,8 +96,9 @@ fun VisualizerScreen( | |||||||
|     fun startVisualizerWithPermissionCheck() { |     fun startVisualizerWithPermissionCheck() { | ||||||
|         when { |         when { | ||||||
|             hasAudioPermission -> { |             hasAudioPermission -> { | ||||||
|                 audioVisualizer.start() |                 viewModel.start() | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             else -> { |             else -> { | ||||||
|                 Timber.d("Requesting audio permission") |                 Timber.d("Requesting audio permission") | ||||||
|                 permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) |                 permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) | ||||||
| @@ -98,7 +111,7 @@ fun VisualizerScreen( | |||||||
|         val observer = LifecycleEventObserver { _, event -> |         val observer = LifecycleEventObserver { _, event -> | ||||||
|             when (event) { |             when (event) { | ||||||
|                 Lifecycle.Event.ON_START -> startVisualizerWithPermissionCheck() |                 Lifecycle.Event.ON_START -> startVisualizerWithPermissionCheck() | ||||||
|                 Lifecycle.Event.ON_STOP -> audioVisualizer.stop() |                 Lifecycle.Event.ON_STOP -> viewModel.stop() | ||||||
|                 else -> {} |                 else -> {} | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| @@ -107,7 +120,7 @@ fun VisualizerScreen( | |||||||
|  |  | ||||||
|         onDispose { |         onDispose { | ||||||
|             lifecycleOwner.lifecycle.removeObserver(observer) |             lifecycleOwner.lifecycle.removeObserver(observer) | ||||||
|             audioVisualizer.release() |             viewModel.stop() | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -127,19 +140,15 @@ fun VisualizerScreen( | |||||||
|             modifier = Modifier.padding(bottom = 32.dp) |             modifier = Modifier.padding(bottom = 32.dp) | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         // Color display area |         // Color display and RMS progress bar area | ||||||
|         Box( |         Row( | ||||||
|             modifier = Modifier |             horizontalArrangement = Arrangement.spacedBy(16.dp), | ||||||
|                 .size(300.dp) |             verticalAlignment = Alignment.CenterVertically | ||||||
|                 .clip(RoundedCornerShape(16.dp)) |  | ||||||
|                 .background(currentColor) |  | ||||||
|                 .padding(16.dp), |  | ||||||
|             contentAlignment = Alignment.Center |  | ||||||
|         ) { |         ) { | ||||||
|             // Show color information |             ColorDisplay(uiState) | ||||||
|             ColorInfoCard( |             RmsProgressBar( | ||||||
|                 color = currentColor, |                 rmsValue = uiState.rmsValue, | ||||||
|                 isActive = isActive |                 modifier = Modifier | ||||||
|             ) |             ) | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -147,7 +156,7 @@ fun VisualizerScreen( | |||||||
|  |  | ||||||
|         // Status indicator |         // Status indicator | ||||||
|         StatusIndicator( |         StatusIndicator( | ||||||
|             isActive = isActive, |             isActive = uiState.isActive, | ||||||
|             hasPermission = hasAudioPermission |             hasPermission = hasAudioPermission | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @@ -157,7 +166,7 @@ fun VisualizerScreen( | |||||||
|         Text( |         Text( | ||||||
|             text = when { |             text = when { | ||||||
|                 !hasAudioPermission -> "Audio permission is required to visualize sound" |                 !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..." |                 else -> "Starting audio visualizer..." | ||||||
|             }, |             }, | ||||||
|             fontSize = 16.sp, |             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. |  * Card displaying color information. | ||||||
|  */ |  */ | ||||||
| @Composable | @Composable | ||||||
| private fun ColorInfoCard( | private fun ColorInfoCard( | ||||||
|     color: Color, |     color: Color, | ||||||
|     isActive: Boolean, |  | ||||||
|     modifier: Modifier = Modifier |     modifier: Modifier = Modifier | ||||||
| ) { | ) { | ||||||
|     Card( |     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 | package dev.adriankuta.visualizer.view | ||||||
|  |  | ||||||
| import androidx.compose.ui.graphics.Color | import androidx.compose.ui.graphics.Color | ||||||
|  | import dev.adriankuta.visualizer.data.RmsValue | ||||||
|  |  | ||||||
| data class VisualizerScreenUiState( | data class VisualizerScreenUiState( | ||||||
|     val color: Color |     val color: Color, | ||||||
|  |     val isActive: Boolean = false, | ||||||
|  |     val rmsValue: RmsValue? = null | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -1,23 +1,59 @@ | |||||||
|  | @file:OptIn(FlowPreview::class) | ||||||
|  |  | ||||||
| package dev.adriankuta.visualizer.view | package dev.adriankuta.visualizer.view | ||||||
|  |  | ||||||
| import androidx.compose.ui.graphics.Color | import androidx.compose.ui.graphics.Color | ||||||
| import androidx.lifecycle.ViewModel | import androidx.lifecycle.ViewModel | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
| import dagger.hilt.android.lifecycle.HiltViewModel | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
|  | import dev.adriankuta.visualizer.data.AudioVisualizerController | ||||||
|  | import dev.adriankuta.visualizer.data.RmsValueProvider | ||||||
| import dev.adriankuta.visualizer.domain.ObserveVisualizerColorUseCase | import dev.adriankuta.visualizer.domain.ObserveVisualizerColorUseCase | ||||||
|  | import kotlinx.coroutines.FlowPreview | ||||||
|  | import kotlinx.coroutines.flow.MutableStateFlow | ||||||
| import kotlinx.coroutines.flow.SharingStarted | import kotlinx.coroutines.flow.SharingStarted | ||||||
|  | import kotlinx.coroutines.flow.combine | ||||||
|  | import kotlinx.coroutines.flow.sample | ||||||
| import kotlinx.coroutines.flow.stateIn | import kotlinx.coroutines.flow.stateIn | ||||||
| import javax.inject.Inject | import javax.inject.Inject | ||||||
|  | import kotlin.time.Duration.Companion.milliseconds | ||||||
|  |  | ||||||
| @HiltViewModel | @HiltViewModel | ||||||
| class VisualizerScreenViewModel @Inject constructor( | class VisualizerScreenViewModel @Inject constructor( | ||||||
|     private val observeVisualizerColorUseCase: ObserveVisualizerColorUseCase |     observeVisualizerColorUseCase: ObserveVisualizerColorUseCase, | ||||||
|  |     private val audioVisualizerController: AudioVisualizerController, | ||||||
|  |     private val rmsValueProvider: RmsValueProvider | ||||||
| ) : ViewModel() { | ) : ViewModel() { | ||||||
|  |  | ||||||
|     val uiState = observeVisualizerColorUseCase() |     private val _isActive = MutableStateFlow(false) | ||||||
|         .stateIn( |  | ||||||
|  |     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, |         scope = viewModelScope, | ||||||
|         started = SharingStarted.WhileSubscribed(5_000), |         started = SharingStarted.WhileSubscribed(5_000), | ||||||
|             initialValue = Color.Black |         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