commit 2a46c6143bf13d28399463d1cade24ef7d33e093 Author: GitHub Actions Bot Date: Fri Aug 29 11:47:23 2025 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..371f2e2 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7061a0d --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..f4211b3 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,1167 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0842300 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Visualizer (Android) + +A tiny Jetpack Compose demo that visualizes the device audio output using the Android `Visualizer` API. + +It attaches to audio session 0 (global output mix) and renders: +- Waveform view (time-domain samples) +- FFT magnitude bars (frequency-domain snapshot) +- Simple metrics (sample count, RMS, peak, first bytes) + +The UI is written with Jetpack Compose and organized into small composables. + +## Features +- VisualizerController wrapper around `android.media.audiofx.Visualizer` +- Global output (session 0) capture for convenience +- Waveform and FFT visualizations +- Permission handling (RECORD_AUDIO) +- Basic controls to Start/Stop capture + +## Requirements +- Android 7.0 (API 24) or later +- Microphone permission (RECORD_AUDIO) +- Audio playing on the device (e.g., music app) to see the visualization + +## How it works +- `VisualizerController` sets up the `Visualizer` with the maximum supported capture size and a reasonable capture rate (half of the device max). +- It registers `OnDataCaptureListener` to receive waveform and FFT byte arrays. +- The app processes those bytes in Compose to draw a waveform path and magnitude bars for FFT bins. + +Key parts of the UI: +- `PermissionSection` asks for RECORD_AUDIO when missing. +- `WaveformView` draws the latest waveform as a continuous path. +- `FftBarsView` computes magnitude per bin and draws vertical bars. +- `MetricsSection` shows simple stats and a short hex-ish dump of the first bytes. +- `ControlButtons` exposes Start/Stop buttons. + +## Build & Run +1. Open the project in Android Studio (Giraffe or newer recommended) and let it sync. +2. Select a connected device or emulator running API 24+. +3. Run the app. + +Or via command line: + +```bash +./gradlew :app:assembleDebug +``` + +Then install the generated APK on a device. + +## Permissions +This app requests `android.permission.RECORD_AUDIO` at runtime. + +Note: The `Visualizer` API reads audio data for visualization — it does not record or store audio. + +## Limitations & Notes +- Some OEMs/devices restrict or alter access to the global output mix (session 0). On such devices the visualizer may return zeros or fail to initialize. +- You need active audio playback from another app to see non-zero data. +- Bluetooth/headphones/audio effects may influence sampling output. +- The FFT values are raw magnitudes derived from interleaved real/imag bytes and are not calibrated to physical units. + +## KDocs +Public classes and composables are documented with KDoc in the source: +- `VisualizerController` (class, start/stop) +- `MainActivity` and `VisualizerScreen` +- Composables: `WaveformView`, `FftBarsView`, `PermissionSection`, `ControlButtons`, `MetricsSection` +- `AudioSessionsTracker` (placeholder) + +You can browse KDocs directly in code. There is no generated site output configured for this sample. + +## Project Info +- Min SDK: 24 +- Target/Compile SDK: 36 +- Kotlin/JVM target: 11 +- UI: Jetpack Compose Material 3 + +This is a learning/demo project — feel free to adapt it to your needs. \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..d0ebe6b --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,59 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "dev.adriankuta.visualizer" + compileSdk = 36 + + defaultConfig { + applicationId = "dev.adriankuta.visualizer" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/dev/adriankuta/visualizer/ExampleInstrumentedTest.kt b/app/src/androidTest/java/dev/adriankuta/visualizer/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..8c58c0a --- /dev/null +++ b/app/src/androidTest/java/dev/adriankuta/visualizer/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package dev.adriankuta.visualizer + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("dev.adriankuta.visualizer", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bd32108 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/dev/adriankuta/visualizer/AudioSessionsTracker.kt b/app/src/main/java/dev/adriankuta/visualizer/AudioSessionsTracker.kt new file mode 100644 index 0000000..7144eef --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/AudioSessionsTracker.kt @@ -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() {} +} diff --git a/app/src/main/java/dev/adriankuta/visualizer/MainActivity.kt b/app/src/main/java/dev/adriankuta/visualizer/MainActivity.kt new file mode 100644 index 0000000..c1a4972 --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/MainActivity.kt @@ -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(null) } + var fft by remember { mutableStateOf(null) } + + val controller = remember(permissionGranted) { + VisualizerController( + audioSessionId = 0, + onWaveform = { data -> waveform = data.clone() }, + onFft = { data -> fft = data.clone() } + ) + } + + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner, permissionGranted, controller) { + val observer = LifecycleEventObserver { _, event -> + if (!permissionGranted) return@LifecycleEventObserver + when (event) { + Lifecycle.Event.ON_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() + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/adriankuta/visualizer/VisualizerController.kt b/app/src/main/java/dev/adriankuta/visualizer/VisualizerController.kt new file mode 100644 index 0000000..956da72 --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/VisualizerController.kt @@ -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 + } +} diff --git a/app/src/main/java/dev/adriankuta/visualizer/components/ControlButtons.kt b/app/src/main/java/dev/adriankuta/visualizer/components/ControlButtons.kt new file mode 100644 index 0000000..6c13aee --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/components/ControlButtons.kt @@ -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") } + } +} diff --git a/app/src/main/java/dev/adriankuta/visualizer/components/FftBarsView.kt b/app/src/main/java/dev/adriankuta/visualizer/components/FftBarsView.kt new file mode 100644 index 0000000..553ae01 --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/components/FftBarsView.kt @@ -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(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 + ) + } + } +} diff --git a/app/src/main/java/dev/adriankuta/visualizer/components/MetricsSection.kt b/app/src/main/java/dev/adriankuta/visualizer/components/MetricsSection.kt new file mode 100644 index 0000000..4b9994c --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/components/MetricsSection.kt @@ -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) } ?: "") + } + } +} + +private fun firstBytesString(bytes: ByteArray, count: Int): String = + bytes.take(count).joinToString(prefix = "[", postfix = "]") { it.toUByte().toString() } + +private fun rms(bytes: ByteArray): Double { + if (bytes.isEmpty()) return 0.0 + var sum = 0.0 + for (b in bytes) { + val centered = (b.toInt() and 0xFF) - 128 + sum += centered * centered + } + return kotlin.math.sqrt(sum / bytes.size) +} + +private fun peak(bytes: ByteArray): Double { + var p = 0 + for (b in bytes) { + val centered = abs(((b.toInt() and 0xFF) - 128)) + if (centered > p) p = centered + } + return p.toDouble() +} diff --git a/app/src/main/java/dev/adriankuta/visualizer/components/PermissionSection.kt b/app/src/main/java/dev/adriankuta/visualizer/components/PermissionSection.kt new file mode 100644 index 0000000..6ac7f96 --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/components/PermissionSection.kt @@ -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, + modifier: Modifier = Modifier, +) { + if (permissionGranted) return + Text("This app needs microphone permission to access the audio output for visualization.", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(8.dp)) + Button(onClick = { requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) }) { + Text("Grant Microphone Permission") + } +} diff --git a/app/src/main/java/dev/adriankuta/visualizer/components/WaveformView.kt b/app/src/main/java/dev/adriankuta/visualizer/components/WaveformView.kt new file mode 100644 index 0000000..a370209 --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/components/WaveformView.kt @@ -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(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 + ) + } + } +} diff --git a/app/src/main/java/dev/adriankuta/visualizer/ui/theme/Color.kt b/app/src/main/java/dev/adriankuta/visualizer/ui/theme/Color.kt new file mode 100644 index 0000000..6fa7b23 --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/ui/theme/Color.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/dev/adriankuta/visualizer/ui/theme/Theme.kt b/app/src/main/java/dev/adriankuta/visualizer/ui/theme/Theme.kt new file mode 100644 index 0000000..5124d2f --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/ui/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/dev/adriankuta/visualizer/ui/theme/Type.kt b/app/src/main/java/dev/adriankuta/visualizer/ui/theme/Type.kt new file mode 100644 index 0000000..661dd9f --- /dev/null +++ b/app/src/main/java/dev/adriankuta/visualizer/ui/theme/Type.kt @@ -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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7630995 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Visualizer + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..f187821 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +