Introduce ViewModel and UseCase
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = JvmTarget.JVM_21
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "11"
|
|
||||||
}
|
}
|
||||||
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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
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(
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxSize()
|
||||||
.weight(1f)
|
.padding(innerPadding)
|
||||||
.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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user