Refactor: Remove smoothing and throttling from visualizer

The visualizer components (FftBarsView and WaveformView) now directly render the raw data received from the `VisualizerController`.

- Removed the smoothing logic (using `LaunchedEffect` and `mutableStateOf`) from both `FftBarsView` and `WaveformView`.
- The `Canvas` drawing logic in both views now directly processes the input `data` byte array.
- In `VisualizerController`, removed the manual throttling mechanism (using `Handler`, `minIntervalMs`, `lastWaveformDispatch`, `lastFftDispatch`).
- The `Visualizer.OnDataCaptureListener` now directly invokes the `onWaveform` and `onFft` callbacks with the received data.
- The capture rate for the Visualizer is now set to half of the maximum rate.
This commit is contained in:
GitHub Actions Bot
2025-08-29 11:49:04 +02:00
parent 2a46c6143b
commit ba634095d9
3 changed files with 18 additions and 101 deletions

View File

@@ -1,9 +1,6 @@
package dev.adriankuta.visualizer package dev.adriankuta.visualizer
import android.media.audiofx.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). * Simple wrapper over Android Visualizer to capture global output mix (audio session 0).
@@ -15,12 +12,6 @@ class VisualizerController(
) { ) {
private var visualizer: Visualizer? = null 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]. * Starts capturing audio data via [Visualizer].
* *
@@ -36,20 +27,14 @@ class VisualizerController(
// Use the maximum supported capture size for better resolution // Use the maximum supported capture size for better resolution
val captureSize = Visualizer.getCaptureSizeRange().let { it[1] } val captureSize = Visualizer.getCaptureSizeRange().let { it[1] }
v.captureSize = captureSize v.captureSize = captureSize
val rate = Visualizer.getMaxCaptureRate() // allow max rate; we'll throttle ourselves val rate = Visualizer.getMaxCaptureRate() / 2 // decent rate
v.setDataCaptureListener(object : Visualizer.OnDataCaptureListener { v.setDataCaptureListener(object : Visualizer.OnDataCaptureListener {
override fun onWaveFormDataCapture( override fun onWaveFormDataCapture(
visualizer: Visualizer?, visualizer: Visualizer?,
waveform: ByteArray?, waveform: ByteArray?,
samplingRate: Int samplingRate: Int
) { ) {
if (waveform == null) return if (waveform != null) onWaveform(waveform)
val now = SystemClock.uptimeMillis()
if (now - lastWaveformDispatch >= minIntervalMs) {
lastWaveformDispatch = now
val copy = waveform.copyOf()
mainHandler.post { onWaveform(copy) }
}
} }
override fun onFftDataCapture( override fun onFftDataCapture(
@@ -57,13 +42,7 @@ class VisualizerController(
fft: ByteArray?, fft: ByteArray?,
samplingRate: Int samplingRate: Int
) { ) {
if (fft == null) return if (fft != null) onFft(fft)
val now = SystemClock.uptimeMillis()
if (now - lastFftDispatch >= minIntervalMs) {
lastFftDispatch = now
val copy = fft.copyOf()
mainHandler.post { onFft(copy) }
}
} }
}, rate, true, true) }, rate, true, true)
v.enabled = true v.enabled = true
@@ -84,7 +63,5 @@ class VisualizerController(
visualizer?.enabled = false visualizer?.enabled = false
visualizer?.release() visualizer?.release()
visualizer = null visualizer = null
lastWaveformDispatch = 0L
lastFftDispatch = 0L
} }
} }

View File

@@ -7,11 +7,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -19,7 +14,7 @@ import kotlin.math.hypot
import kotlin.math.min import kotlin.math.min
/** /**
* Renders FFT magnitude as vertical bars with smoothing for natural transitions. * Renders FFT magnitude as vertical bars.
* *
* The input is the raw FFT byte array from Visualizer where values are interleaved * 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 * real and imaginary parts: [re0, im0, re1, im1, ...]. This composable computes the
@@ -34,37 +29,6 @@ fun FftBarsView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Text("FFT (magnitude bars)", style = MaterialTheme.typography.titleMedium) 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( Canvas(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -73,13 +37,16 @@ fun FftBarsView(
) { ) {
val w = size.width val w = size.width
val h = size.height val h = size.height
val values = smoothed if (data != null && data.size >= 2) {
if (values != null && values.isNotEmpty()) { val n = data.size / 2 // real/imag pairs count
val barCount = values.size val barCount = min(64, n) // limit bars for readability
val barWidth = w / barCount val barWidth = w / barCount
for (i in 0 until barCount) { for (i in 0 until barCount) {
val v = values[i].coerceIn(0f, 1f) val re = data[2 * i].toInt()
val barHeight = h * v 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 val left = i * barWidth
drawRect( drawRect(
color = Color(0xFF8BC34A), color = Color(0xFF8BC34A),

View File

@@ -7,11 +7,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
@@ -19,7 +14,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
* Displays the most recent audio waveform as a continuous path with smoothing to reduce jitter. * 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. * @param data Waveform bytes where each value is 0..255 (unsigned), as provided by Visualizer.
* Pass null to render an idle baseline. * Pass null to render an idle baseline.
@@ -31,28 +26,6 @@ fun WaveformView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Text("Waveform", style = MaterialTheme.typography.titleMedium) 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( Canvas(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -61,8 +34,7 @@ fun WaveformView(
) { ) {
val w = size.width val w = size.width
val h = size.height val h = size.height
val values = smoothed if (data == null || data.isEmpty()) {
if (values == null || values.isEmpty()) {
drawLine( drawLine(
color = Color.Gray, color = Color.Gray,
start = androidx.compose.ui.geometry.Offset(0f, h / 2), start = androidx.compose.ui.geometry.Offset(0f, h / 2),
@@ -71,10 +43,11 @@ fun WaveformView(
) )
} else { } else {
val path = Path() val path = Path()
val stepX = w / (values.size - 1).coerceAtLeast(1) val stepX = w / (data.size - 1).coerceAtLeast(1)
for (i in values.indices) { for (i in data.indices) {
val x = i * stepX val x = i * stepX
val y = h * (1f - values[i]) 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) if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
} }
drawPath(path = path, color = Color.Cyan, style = Stroke(width = 2f)) drawPath(path = path, color = Color.Cyan, style = Stroke(width = 2f))