Introduce ViewModel and UseCase
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<application
|
||||
android:name=".MyApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
160
app/src/main/java/dev/adriankuta/visualizer/AudioVisualizer.kt
Normal file
160
app/src/main/java/dev/adriankuta/visualizer/AudioVisualizer.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
95
app/src/main/java/dev/adriankuta/visualizer/HSL.kt
Normal file
95
app/src/main/java/dev/adriankuta/visualizer/HSL.kt
Normal 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)
|
||||
}
|
||||
@@ -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<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()
|
||||
}
|
||||
}
|
||||
|
||||
39
app/src/main/java/dev/adriankuta/visualizer/MovingAverage.kt
Normal file
39
app/src/main/java/dev/adriankuta/visualizer/MovingAverage.kt
Normal 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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.adriankuta.visualizer
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class MyApplication: Application() {
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.adriankuta.visualizer.data
|
||||
|
||||
interface AudioVisualizerController {
|
||||
|
||||
fun start()
|
||||
|
||||
fun stop()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package dev.adriankuta.visualizer.view
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
data class VisualizerScreenUiState(
|
||||
val color: Color
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user