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:
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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),
|
||||||
|
@@ -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))
|
||||||
|
Reference in New Issue
Block a user