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