first commit
This commit is contained in:
		| @@ -0,0 +1,17 @@ | ||||
| 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() {} | ||||
| } | ||||
							
								
								
									
										177
									
								
								app/src/main/java/dev/adriankuta/visualizer/MainActivity.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								app/src/main/java/dev/adriankuta/visualizer/MainActivity.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,177 @@ | ||||
| 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.ui.theme.VisualizerTheme | ||||
|  | ||||
| /** | ||||
|  * 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() { | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
|         enableEdgeToEdge() | ||||
|         setContent { | ||||
|             VisualizerTheme { | ||||
|                 Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> | ||||
|                     VisualizerScreen(modifier = Modifier.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_START -> runCatching { controller.start() } | ||||
|                 Lifecycle.Event.ON_STOP -> 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) { | ||||
|                 dev.adriankuta.visualizer.components.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)) | ||||
|  | ||||
|             dev.adriankuta.visualizer.components.WaveformView(data = waveform) | ||||
|  | ||||
|             Spacer(Modifier.height(8.dp)) | ||||
|  | ||||
|             dev.adriankuta.visualizer.components.FftBarsView(data = fft) | ||||
|  | ||||
|             Spacer(Modifier.height(12.dp)) | ||||
|             HorizontalDivider() | ||||
|             Spacer(Modifier.height(12.dp)) | ||||
|  | ||||
|             dev.adriankuta.visualizer.components.MetricsSection( | ||||
|                 waveform = waveform, | ||||
|                 fft = fft | ||||
|             ) | ||||
|         } | ||||
|         dev.adriankuta.visualizer.components.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() | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,90 @@ | ||||
| package dev.adriankuta.visualizer | ||||
|  | ||||
| import android.media.audiofx.Visualizer | ||||
| import android.os.Handler | ||||
| import android.os.Looper | ||||
| import android.os.SystemClock | ||||
|  | ||||
| /** | ||||
|  * 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 | ||||
|  | ||||
|     // Ensure UI updates happen on the main thread and at a reasonable frame rate | ||||
|     private val mainHandler = Handler(Looper.getMainLooper()) | ||||
|     private val minIntervalMs = 16L // ~60 FPS | ||||
|     @Volatile private var lastWaveformDispatch = 0L | ||||
|     @Volatile private var lastFftDispatch = 0L | ||||
|  | ||||
|     /** | ||||
|      * 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 { | ||||
|             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() // allow max rate; we'll throttle ourselves | ||||
|             v.setDataCaptureListener(object : Visualizer.OnDataCaptureListener { | ||||
|                 override fun onWaveFormDataCapture( | ||||
|                     visualizer: Visualizer?, | ||||
|                     waveform: ByteArray?, | ||||
|                     samplingRate: Int | ||||
|                 ) { | ||||
|                     if (waveform == null) return | ||||
|                     val now = SystemClock.uptimeMillis() | ||||
|                     if (now - lastWaveformDispatch >= minIntervalMs) { | ||||
|                         lastWaveformDispatch = now | ||||
|                         val copy = waveform.copyOf() | ||||
|                         mainHandler.post { onWaveform(copy) } | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 override fun onFftDataCapture( | ||||
|                     visualizer: Visualizer?, | ||||
|                     fft: ByteArray?, | ||||
|                     samplingRate: Int | ||||
|                 ) { | ||||
|                     if (fft == null) return | ||||
|                     val now = SystemClock.uptimeMillis() | ||||
|                     if (now - lastFftDispatch >= minIntervalMs) { | ||||
|                         lastFftDispatch = now | ||||
|                         val copy = fft.copyOf() | ||||
|                         mainHandler.post { onFft(copy) } | ||||
|                     } | ||||
|                 } | ||||
|             }, rate, true, true) | ||||
|             v.enabled = true | ||||
|             visualizer = v | ||||
|         } catch (e: Throwable) { | ||||
|             // If Visualizer cannot be initialized (e.g. permission or device restriction), ensure cleanup | ||||
|             stop() | ||||
|             throw e | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Stops capturing audio data and releases the underlying [Visualizer]. | ||||
|      * Safe to call multiple times. | ||||
|      */ | ||||
|     @Synchronized | ||||
|     fun stop() { | ||||
|         visualizer?.enabled = false | ||||
|         visualizer?.release() | ||||
|         visualizer = null | ||||
|         lastWaveformDispatch = 0L | ||||
|         lastFftDispatch = 0L | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,30 @@ | ||||
| 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") } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,99 @@ | ||||
| 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.runtime.LaunchedEffect | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.runtime.setValue | ||||
| 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 with smoothing for natural transitions. | ||||
|  * | ||||
|  * 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) | ||||
|  | ||||
|     var smoothed by remember { mutableStateOf<FloatArray?>(null) } | ||||
|  | ||||
|     // Update smoothed magnitudes whenever new FFT data arrives | ||||
|     LaunchedEffect(data) { | ||||
|         val bytes = data ?: return@LaunchedEffect | ||||
|         if (bytes.size < 2) return@LaunchedEffect | ||||
|         val n = bytes.size / 2 | ||||
|         val barCount = min(64, n) | ||||
|         val target = FloatArray(barCount) { i -> | ||||
|             val re = bytes[2 * i].toInt() | ||||
|             val im = bytes[2 * i + 1].toInt() | ||||
|             val mag = hypot(re.toDouble(), im.toDouble()).toFloat() | ||||
|             // Scale to 0..1 range (with a bit of headroom capped later in draw) | ||||
|             (mag / 128f).coerceIn(0f, 1.5f).coerceAtMost(1f) | ||||
|         } | ||||
|         val prev = smoothed | ||||
|         val rise = 0.35f | ||||
|         val decay = 0.12f | ||||
|         smoothed = if (prev == null || prev.size != target.size) { | ||||
|             target | ||||
|         } else { | ||||
|             val out = FloatArray(target.size) | ||||
|             for (i in target.indices) { | ||||
|                 val a = if (target[i] > prev[i]) rise else decay | ||||
|                 out[i] = prev[i] + (target[i] - prev[i]) * a | ||||
|             } | ||||
|             out | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     Canvas( | ||||
|         modifier = modifier | ||||
|             .fillMaxWidth() | ||||
|             .height(160.dp) | ||||
|             .padding(vertical = 4.dp) | ||||
|     ) { | ||||
|         val w = size.width | ||||
|         val h = size.height | ||||
|         val values = smoothed | ||||
|         if (values != null && values.isNotEmpty()) { | ||||
|             val barCount = values.size | ||||
|             val barWidth = w / barCount | ||||
|             for (i in 0 until barCount) { | ||||
|                 val v = values[i].coerceIn(0f, 1f) | ||||
|                 val barHeight = h * v | ||||
|                 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 | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,74 @@ | ||||
| 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() | ||||
| } | ||||
| @@ -0,0 +1,33 @@ | ||||
| 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") | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,89 @@ | ||||
| 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.runtime.LaunchedEffect | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.setValue | ||||
| 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 with smoothing to reduce jitter. | ||||
|  * | ||||
|  * @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) | ||||
|  | ||||
|     // Keep a smoothed copy of the latest waveform for softer transitions | ||||
|     var smoothed by remember { mutableStateOf<FloatArray?>(null) } | ||||
|  | ||||
|     // Update smoothed values whenever new data arrives using exponential smoothing | ||||
|     LaunchedEffect(data) { | ||||
|         val bytes = data ?: return@LaunchedEffect | ||||
|         if (bytes.isEmpty()) return@LaunchedEffect | ||||
|         val target = FloatArray(bytes.size) { i -> ((bytes[i].toInt() and 0xFF) / 255f) } | ||||
|         val prev = smoothed | ||||
|         val alpha = 0.22f // smoothing factor; lower = smoother | ||||
|         smoothed = if (prev == null || prev.size != target.size) { | ||||
|             target | ||||
|         } else { | ||||
|             val out = FloatArray(target.size) | ||||
|             for (i in target.indices) { | ||||
|                 out[i] = prev[i] + (target[i] - prev[i]) * alpha | ||||
|             } | ||||
|             out | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     Canvas( | ||||
|         modifier = modifier | ||||
|             .fillMaxWidth() | ||||
|             .height(120.dp) | ||||
|             .padding(vertical = 4.dp) | ||||
|     ) { | ||||
|         val w = size.width | ||||
|         val h = size.height | ||||
|         val values = smoothed | ||||
|         if (values == null || values.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 / (values.size - 1).coerceAtLeast(1) | ||||
|             for (i in values.indices) { | ||||
|                 val x = i * stepX | ||||
|                 val y = h * (1f - values[i]) | ||||
|                 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,11 @@ | ||||
| package dev.adriankuta.visualizer.ui.theme | ||||
|  | ||||
| import androidx.compose.ui.graphics.Color | ||||
|  | ||||
| val Purple80 = Color(0xFFD0BCFF) | ||||
| val PurpleGrey80 = Color(0xFFCCC2DC) | ||||
| val Pink80 = Color(0xFFEFB8C8) | ||||
|  | ||||
| val Purple40 = Color(0xFF6650a4) | ||||
| val PurpleGrey40 = Color(0xFF625b71) | ||||
| val Pink40 = Color(0xFF7D5260) | ||||
| @@ -0,0 +1,58 @@ | ||||
| package dev.adriankuta.visualizer.ui.theme | ||||
|  | ||||
| import android.app.Activity | ||||
| import android.os.Build | ||||
| import androidx.compose.foundation.isSystemInDarkTheme | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.darkColorScheme | ||||
| import androidx.compose.material3.dynamicDarkColorScheme | ||||
| import androidx.compose.material3.dynamicLightColorScheme | ||||
| import androidx.compose.material3.lightColorScheme | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
|  | ||||
| private val DarkColorScheme = darkColorScheme( | ||||
|     primary = Purple80, | ||||
|     secondary = PurpleGrey80, | ||||
|     tertiary = Pink80 | ||||
| ) | ||||
|  | ||||
| private val LightColorScheme = lightColorScheme( | ||||
|     primary = Purple40, | ||||
|     secondary = PurpleGrey40, | ||||
|     tertiary = Pink40 | ||||
|  | ||||
|     /* Other default colors to override | ||||
|     background = Color(0xFFFFFBFE), | ||||
|     surface = Color(0xFFFFFBFE), | ||||
|     onPrimary = Color.White, | ||||
|     onSecondary = Color.White, | ||||
|     onTertiary = Color.White, | ||||
|     onBackground = Color(0xFF1C1B1F), | ||||
|     onSurface = Color(0xFF1C1B1F), | ||||
|     */ | ||||
| ) | ||||
|  | ||||
| @Composable | ||||
| fun VisualizerTheme( | ||||
|     darkTheme: Boolean = isSystemInDarkTheme(), | ||||
|     // Dynamic color is available on Android 12+ | ||||
|     dynamicColor: Boolean = true, | ||||
|     content: @Composable () -> Unit | ||||
| ) { | ||||
|     val colorScheme = when { | ||||
|         dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { | ||||
|             val context = LocalContext.current | ||||
|             if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) | ||||
|         } | ||||
|  | ||||
|         darkTheme -> DarkColorScheme | ||||
|         else -> LightColorScheme | ||||
|     } | ||||
|  | ||||
|     MaterialTheme( | ||||
|         colorScheme = colorScheme, | ||||
|         typography = Typography, | ||||
|         content = content | ||||
|     ) | ||||
| } | ||||
							
								
								
									
										34
									
								
								app/src/main/java/dev/adriankuta/visualizer/ui/theme/Type.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								app/src/main/java/dev/adriankuta/visualizer/ui/theme/Type.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| package dev.adriankuta.visualizer.ui.theme | ||||
|  | ||||
| import androidx.compose.material3.Typography | ||||
| import androidx.compose.ui.text.TextStyle | ||||
| import androidx.compose.ui.text.font.FontFamily | ||||
| import androidx.compose.ui.text.font.FontWeight | ||||
| import androidx.compose.ui.unit.sp | ||||
|  | ||||
| // Set of Material typography styles to start with | ||||
| val Typography = Typography( | ||||
|     bodyLarge = TextStyle( | ||||
|         fontFamily = FontFamily.Default, | ||||
|         fontWeight = FontWeight.Normal, | ||||
|         fontSize = 16.sp, | ||||
|         lineHeight = 24.sp, | ||||
|         letterSpacing = 0.5.sp | ||||
|     ) | ||||
|     /* Other default text styles to override | ||||
|     titleLarge = TextStyle( | ||||
|         fontFamily = FontFamily.Default, | ||||
|         fontWeight = FontWeight.Normal, | ||||
|         fontSize = 22.sp, | ||||
|         lineHeight = 28.sp, | ||||
|         letterSpacing = 0.sp | ||||
|     ), | ||||
|     labelSmall = TextStyle( | ||||
|         fontFamily = FontFamily.Default, | ||||
|         fontWeight = FontWeight.Medium, | ||||
|         fontSize = 11.sp, | ||||
|         lineHeight = 16.sp, | ||||
|         letterSpacing = 0.5.sp | ||||
|     ) | ||||
|     */ | ||||
| ) | ||||
		Reference in New Issue
	
	Block a user
	![github-actions[bot]@users.noreply.github.com](/assets/img/avatar_default.png) GitHub Actions Bot
					GitHub Actions Bot