From 4bc3a0a0965152d95340628dfc9b475bbe56ee05 Mon Sep 17 00:00:00 2001 From: "Adrian Kuta (DZCQIWG)" Date: Fri, 3 Oct 2025 08:51:58 +0200 Subject: [PATCH] Improvements --- .../adriankuta/visualizer/AudioVisualizer.kt | 160 ------------------ .../visualizer/data/AudioVisualizer.kt | 3 +- .../visualizer/data/AudioVisualizerImpl.kt | 29 ++-- .../visualizer/di/VisualizerModule.kt | 14 ++ .../domain/ObserveVisualizerColorUseCase.kt | 24 ++- .../visualizer/domain/SoundToColorMapper.kt | 97 ----------- .../domain/processors/BrightnessMapper.kt | 142 ++++++++++++++++ .../domain/processors/FftToColor.kt | 58 +++++++ .../domain/processors/SoundToColorMapper.kt | 57 +++++++ .../domain/processors/VisualizerProcessor.kt | 10 ++ .../visualizer/ui/components/KeepScreenOn.kt | 20 +++ .../visualizer/view/VisualizerScreen.kt | 108 +++++++----- .../view/VisualizerScreenUiState.kt | 3 +- .../view/VisualizerScreenViewModel.kt | 45 ++++- 14 files changed, 434 insertions(+), 336 deletions(-) delete mode 100644 app/src/main/java/dev/adriankuta/visualizer/AudioVisualizer.kt delete mode 100644 app/src/main/java/dev/adriankuta/visualizer/domain/SoundToColorMapper.kt create mode 100644 app/src/main/java/dev/adriankuta/visualizer/domain/processors/BrightnessMapper.kt create mode 100644 app/src/main/java/dev/adriankuta/visualizer/domain/processors/FftToColor.kt create mode 100644 app/src/main/java/dev/adriankuta/visualizer/domain/processors/SoundToColorMapper.kt create mode 100644 app/src/main/java/dev/adriankuta/visualizer/domain/processors/VisualizerProcessor.kt create mode 100644 app/src/main/java/dev/adriankuta/visualizer/ui/components/KeepScreenOn.kt diff --git a/app/src/main/java/dev/adriankuta/visualizer/AudioVisualizer.kt b/app/src/main/java/dev/adriankuta/visualizer/AudioVisualizer.kt deleted file mode 100644 index 25ed28e..0000000 --- a/app/src/main/java/dev/adriankuta/visualizer/AudioVisualizer.kt +++ /dev/null @@ -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 - val isActive: State - - 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 = _currentColor - - private val _isActive = mutableStateOf(false) - override val isActive: State = _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) - } -} \ No newline at end of file diff --git a/app/src/main/java/dev/adriankuta/visualizer/data/AudioVisualizer.kt b/app/src/main/java/dev/adriankuta/visualizer/data/AudioVisualizer.kt index f7a2e73..07875dc 100644 --- a/app/src/main/java/dev/adriankuta/visualizer/data/AudioVisualizer.kt +++ b/app/src/main/java/dev/adriankuta/visualizer/data/AudioVisualizer.kt @@ -3,7 +3,6 @@ package dev.adriankuta.visualizer.data import kotlinx.coroutines.flow.Flow interface AudioVisualizer: AudioVisualizerController { - fun waveform(): Flow - fun fft(): Flow + fun visualizerFlow(): Flow } diff --git a/app/src/main/java/dev/adriankuta/visualizer/data/AudioVisualizerImpl.kt b/app/src/main/java/dev/adriankuta/visualizer/data/AudioVisualizerImpl.kt index 817c0d5..a4e9754 100644 --- a/app/src/main/java/dev/adriankuta/visualizer/data/AudioVisualizerImpl.kt +++ b/app/src/main/java/dev/adriankuta/visualizer/data/AudioVisualizerImpl.kt @@ -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 = - visualizerFlow() - .filterIsInstance(WaveformFrame::class) - - override fun fft(): Flow = - visualizerFlow() - .filterIsInstance(FftFrame::class) - - private fun visualizerFlow(): Flow = callbackFlow { + override fun visualizerFlow(): Flow = 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 \ No newline at end of file diff --git a/app/src/main/java/dev/adriankuta/visualizer/di/VisualizerModule.kt b/app/src/main/java/dev/adriankuta/visualizer/di/VisualizerModule.kt index a6a2efd..5fb3561 100644 --- a/app/src/main/java/dev/adriankuta/visualizer/di/VisualizerModule.kt +++ b/app/src/main/java/dev/adriankuta/visualizer/di/VisualizerModule.kt @@ -9,6 +9,9 @@ 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.FftToColor +import dev.adriankuta.visualizer.domain.processors.HSLSoundToColorMapper +import dev.adriankuta.visualizer.domain.processors.VisualizerProcessor import javax.inject.Singleton @Module @@ -25,6 +28,11 @@ abstract class VisualizerModule { audioVisualizerImpl: AudioVisualizerImpl ): AudioVisualizerController + /*@Binds + abstract fun bindFftProcessor( + fftToColor: WaveformToColorProcessor + ): VisualizerProcessor*/ + companion object { @Provides @Singleton @@ -33,5 +41,11 @@ abstract class VisualizerModule { captureSize = 1024 } } + + @Provides + @Singleton + fun provideProcessor(): VisualizerProcessor { + return HSLSoundToColorMapper() + } } } \ No newline at end of file diff --git a/app/src/main/java/dev/adriankuta/visualizer/domain/ObserveVisualizerColorUseCase.kt b/app/src/main/java/dev/adriankuta/visualizer/domain/ObserveVisualizerColorUseCase.kt index 46628fc..9432dc6 100644 --- a/app/src/main/java/dev/adriankuta/visualizer/domain/ObserveVisualizerColorUseCase.kt +++ b/app/src/main/java/dev/adriankuta/visualizer/domain/ObserveVisualizerColorUseCase.kt @@ -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 = - audioVisualizer.waveform() - .map { processColor(it) } + audioVisualizer.visualizerFlow() + .process(processor) + .onStart { emit(Color.Black) } +} + +fun Flow.process(processor: VisualizerProcessor) = + this.filterIsInstance(WaveformFrame::class) + .map(processor::invoke) - private fun processColor(waveformFrame: WaveformFrame): Color { - return colorMapper.mapSoundToColor(waveformFrame.data) - } -} \ No newline at end of file diff --git a/app/src/main/java/dev/adriankuta/visualizer/domain/SoundToColorMapper.kt b/app/src/main/java/dev/adriankuta/visualizer/domain/SoundToColorMapper.kt deleted file mode 100644 index 0435bf3..0000000 --- a/app/src/main/java/dev/adriankuta/visualizer/domain/SoundToColorMapper.kt +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/app/src/main/java/dev/adriankuta/visualizer/domain/processors/BrightnessMapper.kt b/app/src/main/java/dev/adriankuta/visualizer/domain/processors/BrightnessMapper.kt new file mode 100644 index 0000000..eefe6db --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/domain/processors/BrightnessMapper.kt @@ -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) + } +} diff --git a/app/src/main/java/dev/adriankuta/visualizer/domain/processors/FftToColor.kt b/app/src/main/java/dev/adriankuta/visualizer/domain/processors/FftToColor.kt new file mode 100644 index 0000000..b4ccd77 --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/domain/processors/FftToColor.kt @@ -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 + } +} diff --git a/app/src/main/java/dev/adriankuta/visualizer/domain/processors/SoundToColorMapper.kt b/app/src/main/java/dev/adriankuta/visualizer/domain/processors/SoundToColorMapper.kt new file mode 100644 index 0000000..3ef121c --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/domain/processors/SoundToColorMapper.kt @@ -0,0 +1,57 @@ +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.FftFrame +import dev.adriankuta.visualizer.data.VisualizerFrame +import dev.adriankuta.visualizer.data.WaveformFrame +import timber.log.Timber +import kotlin.reflect.KClass + +/** + * 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 = 2 +) : VisualizerProcessor { + + 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 + } + + 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() + } +} diff --git a/app/src/main/java/dev/adriankuta/visualizer/domain/processors/VisualizerProcessor.kt b/app/src/main/java/dev/adriankuta/visualizer/domain/processors/VisualizerProcessor.kt new file mode 100644 index 0000000..5ebad1e --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/domain/processors/VisualizerProcessor.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/dev/adriankuta/visualizer/ui/components/KeepScreenOn.kt b/app/src/main/java/dev/adriankuta/visualizer/ui/components/KeepScreenOn.kt new file mode 100644 index 0000000..f3d49e1 --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/ui/components/KeepScreenOn.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreen.kt b/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreen.kt index b56b347..7f0a0bb 100644 --- a/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreen.kt +++ b/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreen.kt @@ -4,14 +4,37 @@ 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.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.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight @@ -24,35 +47,20 @@ 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.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 +81,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 +92,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 +107,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 +116,7 @@ fun VisualizerScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) - audioVisualizer.release() + viewModel.stop() } } @@ -128,26 +137,13 @@ fun VisualizerScreen( ) // Color display area - Box( - modifier = Modifier - .size(300.dp) - .clip(RoundedCornerShape(16.dp)) - .background(currentColor) - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - // Show color information - ColorInfoCard( - color = currentColor, - isActive = isActive - ) - } + ColorDisplay(uiState) Spacer(modifier = Modifier.height(32.dp)) // Status indicator StatusIndicator( - isActive = isActive, + isActive = uiState.isActive, hasPermission = hasAudioPermission ) @@ -157,7 +153,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, @@ -187,7 +183,7 @@ fun VisualizerScreen( AlertDialog( onDismissRequest = { showPermissionRationale = false }, title = { Text("Audio Permission Required") }, - text = { + text = { Text("This app needs access to your microphone to visualize audio and create colors based on sound. Please grant the audio recording permission to continue.") }, confirmButton = { @@ -211,13 +207,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( diff --git a/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenUiState.kt b/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenUiState.kt index aa61861..0fb15db 100644 --- a/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenUiState.kt +++ b/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenUiState.kt @@ -3,5 +3,6 @@ package dev.adriankuta.visualizer.view import androidx.compose.ui.graphics.Color data class VisualizerScreenUiState( - val color: Color + val color: Color, + val isActive: Boolean = false ) diff --git a/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenViewModel.kt b/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenViewModel.kt index 53e942b..ae2e596 100644 --- a/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenViewModel.kt +++ b/app/src/main/java/dev/adriankuta/visualizer/view/VisualizerScreenViewModel.kt @@ -1,23 +1,54 @@ +@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.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 ) : 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 + ) { color, isActive -> + VisualizerScreenUiState( + color = color, + isActive = isActive ) -} \ No newline at end of file + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = VisualizerScreenUiState( + color = Color.Black, + isActive = false + ) + ) + + fun start() { + audioVisualizerController.start() + _isActive.value = true + } + + fun stop() { + audioVisualizerController.stop() + _isActive.value = false + } +}