Introduce ViewModel and UseCase

This commit is contained in:
Adrian Kuta (DZCQIWG)
2025-10-01 12:30:31 +02:00
parent f459482ddd
commit 6410477f54
27 changed files with 1111 additions and 576 deletions

View File

@@ -1,7 +1,11 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.google.ksp)
alias(libs.plugins.hilt.android)
} }
android { android {
@@ -28,11 +32,13 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_21
} }
kotlinOptions { kotlin {
jvmTarget = "11" compilerOptions {
jvmTarget = JvmTarget.JVM_21
}
} }
buildFeatures { buildFeatures {
compose = true compose = true
@@ -51,6 +57,9 @@ dependencies {
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.timber) implementation(libs.timber)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose)
ksp(libs.hilt.android.compiler)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application <application
android:name=".MyApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"

View File

@@ -1,17 +0,0 @@
package dev.adriankuta.visualizer
/**
* Placeholder for potential future audio session tracking.
* Currently not used to avoid API-level constraints on older devices.
*/
class AudioSessionsTracker {
/**
* Starts tracking audio sessions. No-op in the current placeholder implementation.
*/
fun start() {}
/**
* Stops tracking audio sessions. No-op in the current placeholder implementation.
*/
fun stop() {}
}

View File

@@ -0,0 +1,160 @@
package dev.adriankuta.visualizer
import android.media.audiofx.Visualizer
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.State
import androidx.compose.ui.graphics.Color
import dev.adriankuta.visualizer.domain.SoundToColorMapper
import dev.adriankuta.visualizer.domain.SoundToColorMapperFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import timber.log.Timber
/**
* Interface for audio visualization functionality.
* Follows Interface Segregation Principle.
*/
interface AudioVisualizerService {
val currentColor: State<Color>
val isActive: State<Boolean>
fun start()
fun stop()
fun release()
}
/**
* Implementation of AudioVisualizerService using Android's Visualizer API.
*
* Follows Single Responsibility Principle by focusing on audio visualization.
* Uses Dependency Injection for the sound-to-color mapper.
*/
class AndroidAudioVisualizer(
private val soundToColorMapper: SoundToColorMapper = SoundToColorMapperFactory.createDefault(),
private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Main)
) : AudioVisualizerService {
companion object {
private const val SAMPLING_RATE = 16000 // Hz
private const val CAPTURE_SIZE = 1024 // Window size for audio capture
}
private var visualizer: Visualizer? = null
private var processingJob: Job? = null
private val _currentColor = mutableStateOf(Color.Black)
override val currentColor: State<Color> = _currentColor
private val _isActive = mutableStateOf(false)
override val isActive: State<Boolean> = _isActive
override fun start() {
try {
// Initialize visualizer with audio session ID 0 (output mix)
visualizer = Visualizer(0).apply {
captureSize = CAPTURE_SIZE
// Set up waveform data listener
setDataCaptureListener(
object : Visualizer.OnDataCaptureListener {
override fun onWaveFormDataCapture(
visualizer: Visualizer?,
waveform: ByteArray?,
samplingRate: Int
) {
waveform?.let { processWaveform(it) }
}
override fun onFftDataCapture(
visualizer: Visualizer?,
fft: ByteArray?,
samplingRate: Int
) {
// Not used in this implementation
}
},
Visualizer.getMaxCaptureRate() / 2, // Capture rate
true, // Waveform
false // FFT
)
enabled = true
}
_isActive.value = true
Timber.d("AudioVisualizer started successfully")
} catch (e: Exception) {
Timber.e(e, "Failed to start AudioVisualizer")
_isActive.value = false
}
}
override fun stop() {
try {
visualizer?.enabled = false
processingJob?.cancel()
_isActive.value = false
Timber.d("AudioVisualizer stopped")
} catch (e: Exception) {
Timber.e(e, "Error stopping AudioVisualizer")
}
}
override fun release() {
stop()
try {
visualizer?.release()
visualizer = null
soundToColorMapper.reset()
Timber.d("AudioVisualizer released")
} catch (e: Exception) {
Timber.e(e, "Error releasing AudioVisualizer")
}
}
private fun processWaveform(waveform: ByteArray) {
processingJob?.cancel()
processingJob = coroutineScope.launch(Dispatchers.Default) {
try {
val color = soundToColorMapper.mapSoundToColor(waveform)
// Update UI on main thread
launch(Dispatchers.Main) {
_currentColor.value = color
}
} catch (e: Exception) {
Timber.e(e, "Error processing waveform")
}
}
}
}
/**
* Factory for creating AudioVisualizerService instances.
* Follows Factory Pattern and makes testing easier.
*/
object AudioVisualizerFactory {
/**
* Creates a default Android audio visualizer.
*/
fun createDefault(coroutineScope: CoroutineScope): AudioVisualizerService {
return AndroidAudioVisualizer(
soundToColorMapper = SoundToColorMapperFactory.createDefault(),
coroutineScope = coroutineScope
)
}
/**
* Creates an Android audio visualizer with custom mapper.
*/
fun create(
soundToColorMapper: SoundToColorMapper,
coroutineScope: CoroutineScope
): AudioVisualizerService {
return AndroidAudioVisualizer(soundToColorMapper, coroutineScope)
}
}

View File

@@ -0,0 +1,95 @@
package dev.adriankuta.visualizer
import androidx.compose.ui.graphics.Color
import kotlin.math.sqrt
/**
* Represents a color in HSL (Hue, Saturation, Lightness) color space.
*
* @param hue The hue component (0-360 degrees)
* @param saturation The saturation component (0-1)
* @param lightness The lightness component (0-1)
*/
data class HSL(
val hue: Float,
val saturation: Float,
val lightness: Float
) {
/**
* Converts HSL color to RGB values.
*
* @return Triple containing RGB values (0-255)
*/
fun toRgb(): Triple<Int, Int, Int> {
val (r, g, b) = hlsToRgb(hue / 360f, lightness, saturation)
return Triple((r * 255).toInt(), (g * 255).toInt(), (b * 255).toInt())
}
/**
* Converts HSL color to Compose Color.
*
* @return Compose Color object
*/
fun toComposeColor(): Color {
val (r, g, b) = toRgb()
return Color(r, g, b)
}
/**
* Creates a new HSL color with modified lightness.
*
* @param newLightness The new lightness value (0-1)
* @return New HSL color with updated lightness
*/
fun withLightness(newLightness: Float): HSL = copy(lightness = newLightness.coerceIn(0f, 1f))
private fun hlsToRgb(h: Float, l: Float, s: Float): Triple<Float, Float, Float> {
val r: Float
val g: Float
val b: Float
if (s == 0f) {
// Achromatic (gray)
r = l
g = l
b = l
} else {
val q = if (l < 0.5f) l * (1f + s) else l + s - l * s
val p = 2f * l - q
r = hueToRgb(p, q, h + 1f / 3f)
g = hueToRgb(p, q, h)
b = hueToRgb(p, q, h - 1f / 3f)
}
return Triple(r, g, b)
}
private fun hueToRgb(p: Float, q: Float, t: Float): Float {
var tTemp = t
if (tTemp < 0f) tTemp += 1f
if (tTemp > 1f) tTemp -= 1f
return when {
tTemp < 1f / 6f -> p + (q - p) * 6f * tTemp
tTemp < 1f / 2f -> q
tTemp < 2f / 3f -> p + (q - p) * (2f / 3f - tTemp) * 6f
else -> p
}
}
}
/**
* Calculates the Root Mean Square (RMS) value of audio data.
*
* @param bytes Audio data as byte array
* @return RMS value as Double
*/
fun calculateRms(bytes: ByteArray): Double {
if (bytes.isEmpty()) return 0.0
var sum = 0.0
for (b in bytes) {
val centered = (b.toInt() and 0xFF) - 128
sum += centered * centered
}
return sqrt(sum / bytes.size)
}

View File

@@ -1,54 +1,19 @@
package dev.adriankuta.visualizer package dev.adriankuta.visualizer
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.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.Modifier
import androidx.compose.ui.platform.LocalContext import dagger.hilt.android.AndroidEntryPoint
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 dev.adriankuta.visualizer.ui.theme.VisualizerTheme import dev.adriankuta.visualizer.ui.theme.VisualizerTheme
import dev.adriankuta.visualizer.view.VisualizerScreen
import timber.log.Timber import timber.log.Timber
/** @AndroidEntryPoint
* Entry point activity that hosts the Compose UI for the audio visualizer demo.
*
* Sets up the app theme, window insets, and renders [VisualizerScreen].
*/
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -60,128 +25,13 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
VisualizerTheme { VisualizerTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
VisualizerScreen(modifier = Modifier.padding(innerPadding)) VisualizerScreen(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
)
} }
} }
} }
} }
} }
/**
* Top-level screen that manages permission requests and lifecycle of [VisualizerController],
* and renders the waveform, FFT bars, and simple metrics with control buttons.
*
* @param modifier Modifier applied to the screen container.
*/
@Composable
private fun VisualizerScreen(modifier: Modifier = Modifier) {
val context = LocalContext.current
var permissionGranted by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
)
}
val requestPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted ->
permissionGranted = granted
}
var waveform by remember { mutableStateOf<ByteArray?>(null) }
var fft by remember { mutableStateOf<ByteArray?>(null) }
val controller = remember(permissionGranted) {
VisualizerController(
audioSessionId = 0,
onWaveform = { data -> waveform = data.clone() },
onFft = { data -> fft = data.clone() }
)
}
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, permissionGranted, controller) {
val observer = LifecycleEventObserver { _, event ->
if (!permissionGranted) return@LifecycleEventObserver
when (event) {
Lifecycle.Event.ON_CREATE -> runCatching { controller.start() }
Lifecycle.Event.ON_DESTROY -> runCatching { controller.stop() }
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
// If permission has just been granted while we are already STARTED/RESUMED,
// ensure the visualizer starts immediately (ON_START won't be called again).
if (permissionGranted && lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
runCatching { controller.start() }
}
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
runCatching { controller.stop() }
}
}
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.Start
) {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState())
) {
Text("Android Visualizer Demo", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(8.dp))
if (!permissionGranted) {
PermissionSection(
permissionGranted = false,
requestPermissionLauncher = requestPermissionLauncher
)
return@Column
}
Text("Listening to global output. Play music in another app to see data.")
Spacer(Modifier.height(8.dp))
WaveformView(data = waveform)
Spacer(Modifier.height(8.dp))
FftBarsView(data = fft)
Spacer(Modifier.height(12.dp))
HorizontalDivider()
Spacer(Modifier.height(12.dp))
MetricsSection(
waveform = waveform,
fft = fft
)
}
ControlButtons(
onStart = { runCatching { controller.start() } },
onStop = { runCatching { controller.stop() } },
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
}
/**
* Preview of [VisualizerScreen] for Android Studio without runtime data.
*/
@Preview(showBackground = true)
@Composable
fun VisualizerPreview() {
VisualizerTheme {
VisualizerScreen()
}
}

View File

@@ -0,0 +1,39 @@
package dev.adriankuta.visualizer
/**
* A utility class for calculating moving averages over a sliding window.
* Follows the Single Responsibility Principle by focusing solely on moving average calculations.
*/
class MovingAverage(private val windowSize: Int) {
private val values = mutableListOf<Double>()
/**
* Processes a new value and returns the current moving average.
*
* @param newValue The new value to add to the moving average calculation
* @return The current moving average
*/
fun process(newValue: Double): Double {
values.add(newValue)
// Remove oldest value if window size exceeded
if (values.size > windowSize) {
values.removeAt(0)
}
return values.average()
}
/**
* Resets the moving average calculation.
*/
fun reset() {
values.clear()
}
/**
* Returns the current number of values in the window.
*/
val currentSize: Int
get() = values.size
}

View File

@@ -0,0 +1,8 @@
package dev.adriankuta.visualizer
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MyApplication: Application() {
}

View File

@@ -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")
}
}
}
}
}

View File

@@ -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") }
}
}

View File

@@ -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
)
}
}
}

View File

@@ -1,74 +0,0 @@
package dev.adriankuta.visualizer.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlin.math.abs
/**
* Displays simple metrics derived from the current waveform and a preview of raw bytes.
*
* - Waveform metrics include sample count, RMS and peak (centered around 128).
* - Raw bytes shows the first N bytes from waveform or FFT, whichever is available.
*
* @param waveform Latest waveform bytes (0..255 per sample) or null if not available.
* @param fft Latest FFT interleaved real/imag bytes or null.
* @param modifier Compose modifier for layout.
*/
@Composable
fun MetricsSection(
waveform: ByteArray?,
fft: ByteArray?,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(modifier = Modifier.weight(1f)) {
Text("Waveform metrics", style = MaterialTheme.typography.titleSmall)
val wf = waveform
if (wf != null) {
val rms = rms(wf)
val peak = peak(wf)
Text("Samples: ${wf.size}")
Text("RMS: ${String.format("%.3f", rms)}")
Text("Peak: ${String.format("%.3f", peak)}")
} else {
Text("No waveform yet")
}
}
Column(modifier = Modifier.weight(1f)) {
Text("Raw bytes (first 32)", style = MaterialTheme.typography.titleSmall)
val arr = waveform ?: fft
Text(arr?.let { firstBytesString(it, 32) } ?: "<no data>")
}
}
}
private fun firstBytesString(bytes: ByteArray, count: Int): String =
bytes.take(count).joinToString(prefix = "[", postfix = "]") { it.toUByte().toString() }
private fun rms(bytes: ByteArray): Double {
if (bytes.isEmpty()) return 0.0
var sum = 0.0
for (b in bytes) {
val centered = (b.toInt() and 0xFF) - 128
sum += centered * centered
}
return kotlin.math.sqrt(sum / bytes.size)
}
private fun peak(bytes: ByteArray): Double {
var p = 0
for (b in bytes) {
val centered = abs(((b.toInt() and 0xFF) - 128))
if (centered > p) p = centered
}
return p.toDouble()
}

View File

@@ -1,33 +0,0 @@
package dev.adriankuta.visualizer.components
import android.Manifest
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.activity.compose.ManagedActivityResultLauncher
/**
* Shows rationale and a button to request RECORD_AUDIO permission when not granted.
*
* @param permissionGranted Whether microphone permission is already granted.
* @param requestPermissionLauncher Launcher configured for ActivityResultContracts.RequestPermission().
* @param modifier Compose modifier for layout.
*/
@Composable
fun PermissionSection(
permissionGranted: Boolean,
requestPermissionLauncher: ManagedActivityResultLauncher<String, Boolean>,
modifier: Modifier = Modifier,
) {
if (permissionGranted) return
Text("This app needs microphone permission to access the audio output for visualization.", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(8.dp))
Button(onClick = { requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) }) {
Text("Grant Microphone Permission")
}
}

View File

@@ -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
)
}
}
}

View File

@@ -0,0 +1,9 @@
package dev.adriankuta.visualizer.data
import kotlinx.coroutines.flow.Flow
interface AudioVisualizer: AudioVisualizerController {
fun waveform(): Flow<WaveformFrame>
fun fft(): Flow<FftFrame>
}

View File

@@ -0,0 +1,8 @@
package dev.adriankuta.visualizer.data
interface AudioVisualizerController {
fun start()
fun stop()
}

View File

@@ -0,0 +1,75 @@
package dev.adriankuta.visualizer.data
import android.media.audiofx.Visualizer
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.filterIsInstance
import javax.inject.Inject
class AudioVisualizerImpl @Inject constructor(
private val visualizer: Visualizer
) : AudioVisualizer {
override fun start() {
visualizer.enabled = true
}
override fun stop() {
visualizer.enabled = false
}
override fun waveform(): Flow<WaveformFrame> =
visualizerFlow()
.filterIsInstance(WaveformFrame::class)
override fun fft(): Flow<FftFrame> =
visualizerFlow()
.filterIsInstance(FftFrame::class)
private fun visualizerFlow(): Flow<VisualizerFrame> = callbackFlow {
val listener = object : Visualizer.OnDataCaptureListener {
override fun onFftDataCapture(
visualizer: Visualizer?,
fft: ByteArray,
samplingRate: Int
) {
trySend(FftFrame(fft.copyOf()))
}
override fun onWaveFormDataCapture(
visualizer: Visualizer?,
waveform: ByteArray,
samplingRate: Int
) {
trySend(WaveformFrame(waveform.copyOf()))
}
}
visualizer.setDataCaptureListener(
listener,
Visualizer.getMaxCaptureRate(),
true,
true
)
awaitClose {
runCatching {
visualizer.enabled = false
visualizer.setDataCaptureListener(null, 0, false, false)
visualizer.release()
}
}
}
.conflate()
}
sealed interface VisualizerFrame
data class WaveformFrame(
val data: ByteArray,
) : VisualizerFrame
data class FftFrame(
val data: ByteArray,
) : VisualizerFrame

View File

@@ -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
}
}
}
}

View File

@@ -0,0 +1,23 @@
package dev.adriankuta.visualizer.domain
import androidx.compose.ui.graphics.Color
import dev.adriankuta.visualizer.data.AudioVisualizer
import dev.adriankuta.visualizer.data.WaveformFrame
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class ObserveVisualizerColorUseCase @Inject constructor(
private val audioVisualizer: AudioVisualizer
) {
private val colorMapper = HSLSoundToColorMapper()
operator fun invoke(): Flow<Color> =
audioVisualizer.waveform()
.map { processColor(it) }
private fun processColor(waveformFrame: WaveformFrame): Color {
return colorMapper.mapSoundToColor(waveformFrame.data)
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -0,0 +1,99 @@
import kotlin.math.sqrt
data class HSL(
// in Python code, the HSL values were in range 0 - 1
val hue: Float,
val saturation: Float,
val lightness: Float
) {
fun toRgb(): Triple<Int, Int, Int> {
val (r, g, b) = hlsToRgb(hue / 360, lightness, saturation) // Convert HSL to RGB
return Triple((r * 255).toInt(), (g * 255).toInt(), (b * 255).toInt())
}
private fun hlsToRgb(h: Float, l: Float, s: Float): Triple<Float, Float, Float> {
val r: Float
val g: Float
val b: Float
if (s == 0f) {
// Achromatic (gray)
r = l
g = l
b = l
} else {
val q = if (l < 0.5) l * (1 + s) else l + s - l * s
val p = 2 * l - q
r = hueToRgb(p, q, h + 1 / 3)
g = hueToRgb(p, q, h)
b = hueToRgb(p, q, h - 1 / 3)
}
return Triple(r, g, b)
}
private fun hueToRgb(p: Float, q: Float, t: Float): Float {
var tTemp = t
if (tTemp < 0) tTemp += 1
if (tTemp > 1) tTemp -= 1
return when {
tTemp < 1 / 6 -> p + (q - p) * 6 * tTemp
tTemp < 1 / 2 -> q
tTemp < 2 / 3 -> p + (q - p) * (2 / 3 - tTemp) * 6
else -> p
}
}
}
// copied from Adrian's code, so this probably redundant here
private fun rms(bytes: ByteArray): Double {
if (bytes.isEmpty()) return 0.0
var sum = 0.0
for (b in bytes) {
val centered = (b.toInt() and 0xFF) - 128
sum += centered * centered
}
return kotlin.math.sqrt(sum / bytes.size)
}
// not sure whether the rmsMax, movingAverage, and hslColor should be passed as parameter, therefore I used "...", in Python they were all: self.rms_max, ... so I could access them directly
fun soundToColor( ... ) {
// Time domain processing, adjustable maximum of energy (we need ratio, as we do not have given maximum)
// calculate RMS value for one current frame, actualize value of RMS maximum
val tmpRms = rms(wf) // variable "wf" - waveform, I saw it Adrian's code
if (tmpRms > rmsMax) {
rmsMax = tmpRms
}
energyRatio = tmpRms / rmsMax
adjLightness = movingAverage.process(energyRatio)
hslColor.lightness = adjLightness
val rgbColor = hslColor.toRgb()
return rgbColor
}
// predefine values for soundToColor method
val movingAverage = MovingAverage(windowSize = 7) // object for calculation of moving average, this should stay the same during the run of application
rmsMax = 0.0 // to store the value of current maximum of RMS, this might be changed in every call of method (every loop)
val hslColor = HSL(hue = 0f, saturation = 1f, value = 0f) // to store the values for current color, value will change every loop
// I can only gues, but I can imagine that the definitioin of variables (movingAverage, rmsMax, hslColor) can be defined
// somewhere at higher level of application, maybe VisualizerScreen.
// Somewhere, where the loop that takes data for waveform, where the data can be passed to soundToColor.
//
// And as I do not know Kotlin, I am not able to tell how it should be there.
// Then, the method soundToColor might be defined (implemented) in MetricsSection, with the rest of the code too.
// Or is it for separate file?
//
// I can only gues how it will work. In Python, the settigns of audio processing was:
// sampling_rate = 16000 # Hz, sampling frequency
// window_size = 1024
// moving averate size = 7
//
// I think that, when the window size (or sampling rate) is different we will need to find new value for moving average

View File

@@ -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
)
}
}

View File

@@ -0,0 +1,7 @@
package dev.adriankuta.visualizer.view
import androidx.compose.ui.graphics.Color
data class VisualizerScreenUiState(
val color: Color
)

View File

@@ -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
)
}

View File

@@ -3,4 +3,6 @@ plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.hilt.android) apply false
alias(libs.plugins.google.ksp) apply false
} }

View File

@@ -1,18 +1,24 @@
[versions] [versions]
agp = "8.11.1" activityCompose = "1.11.0"
kotlin = "2.2.10" agp = "8.11.2"
composeBom = "2025.09.01"
coreKtx = "1.17.0" coreKtx = "1.17.0"
coroutines = "1.10.2"
espressoCore = "3.7.0"
hilt = "2.57.2"
hiltNavigationCompose = "1.2.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
espressoCore = "3.7.0" kotlin = "2.2.20"
lifecycleRuntimeKtx = "2.9.3" ksp = "2.2.20-2.0.3"
activityCompose = "1.10.1" lifecycleRuntimeKtx = "2.9.4"
composeBom = "2025.08.01"
timber = "5.0.1" timber = "5.0.1"
coroutines = "1.8.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } 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" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", 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" }