# PARTICLE ANIMATION **ParticleAnimation.kt** ```kotlin import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import com.annhienktuit.domain.models.EPointF import com.annhienktuit.muzikv2.R import com.annhienktuit.muzikv2.enums.Interpolation import com.annhienktuit.muzikv2.ui.views.visualizer.nextgen.painters.Painter import com.annhienktuit.muzikv2.utils.VisualizerHelper import com.annhienktuit.muzikv2.utils.audiovisualizer.PolyBezierPathUtil import com.annhienktuit.muzikv2.utils.maths.polynomials.PolynomialSplineFunction import java.util.* import kotlin.math.PI /** *Created by Nhien Nguyen on 9/26/2022 */ class ParticleAnimation( var context: Context, override var paint: Paint = Paint(Paint.ANTI_ALIAS_FLAG), private var startHz: Int = 20, private var endHz: Int = 2000, private var interpolator: Interpolation = Interpolation.SPLINE, ) : Painter() { private var upperGravityPoints = Array(0) { GravityModel(0f) } private val barWidth = context.resources.getDimensionPixelSize(R.dimen.default_visualizer_bar_width).toFloat() private val radiusR: Float = .4f private val ampR: Float = 1f //The rate of amplification private val angleOffset = 2 * PI.toFloat() / numberOfDot private var shortest = 0F override var discRadius = 0 set(value) { field = value shortest = getShortest() adjustRadius(shortest) } override var isPlaying = false var firstTimeRunning = true private var dotCoordinate = Pair(0F, 0F) private var currentHeight = 0 private var currentWidth = 0 lateinit var upperFFT: FloatArray var upperCurveUtil: PolynomialSplineFunction? = null // Fields for normalization private var min = 0f private var max = 0f private val highFreqDelta = 20 private var frequencyMap = HashMap<Int, Int>() private var random = Random() //Fields for bottom animator private var bottomLinePaint = Paint(Paint.ANTI_ALIAS_FLAG) lateinit var bottomFFT: FloatArray private var polyPathsUtil = PolyBezierPathUtil() var bottomCurveUtil: PolynomialSplineFunction? = null private var bottomStartHZ = 8000 private var bottomEndHz = 10000 private var bottomGravityPoints = Array(0) { GravityModel(0f) } //Fields for spears private var spearPaint = Paint(Paint.ANTI_ALIAS_FLAG) init { paint.apply { color = Color.WHITE style = Paint.Style.FILL strokeWidth = barWidth strokeCap = Paint.Cap.ROUND } bottomLinePaint.apply { color = Color.WHITE style = Paint.Style.STROKE strokeWidth = barWidth / 2 strokeCap = Paint.Cap.ROUND } spearPaint.apply { color = Color.WHITE style = Paint.Style.FILL strokeWidth = barWidth / 2 strokeCap = Paint.Cap.ROUND alpha = 20 } } override fun calc(helper: VisualizerHelper) { calcForUpperAnimator(helper) calcForBottomAnimator(helper) } private fun calcForBottomAnimator(helper: VisualizerHelper) { bottomFFT = (helper.getFftMagnitudeRange(bottomStartHZ, bottomEndHz)) bottomFFT = getCircleFft(bottomFFT) if (bottomGravityPoints.size != bottomFFT.size) { bottomGravityPoints = Array(bottomFFT.size) { GravityModel(0F) } } bottomGravityPoints.forEachIndexed { index, bar -> bar.update(bottomFFT[index] * ampR) } bottomCurveUtil = interpolateFft(bottomGravityPoints, numberOfDot / 5, interpolator) } private fun calcForUpperAnimator(helper: VisualizerHelper) { upperFFT = helper.getFftMagnitudeRange(startHz, endHz) upperFFT = getPowerFft(upperFFT) upperFFT = if (firstTimeRunning) { getCircleFft(upperFFT, true) } else { getCircleFft(upperFFT) } minMaxNormalize(upperFFT, visualizerMaxHeight, 0) if (upperGravityPoints.size != upperFFT.size) upperGravityPoints = Array(upperFFT.size) { GravityModel( 0f ) } upperGravityPoints.forEachIndexed { index, bar -> bar.update(upperFFT[index] * ampR) } upperCurveUtil = interpolateFftCircle(upperGravityPoints, numberOfDot, interpolator) } override fun draw(canvas: Canvas, width: Int, height: Int, drawWithoutCalc: Boolean) { if (drawWithoutCalc) { drawHelperCircleLine(canvas, .5f, .5f) { // Still need to run this function to do the translate, but we don't update data on the first time // Otherwise, it will draw in somewhere psychically //canvas.drawLines(points, paint) } return } currentWidth = width currentHeight = height if (firstTimeRunning) { shortest = Integer.min(width, height).toFloat() adjustRadius(shortest) } drawHelperCircleLine(canvas, .5f, .5f) { if (!firstTimeRunning || !drawWithoutCalc) { for (idx in 0 until numberOfDot) { //TODO: Replace these magic numbers upperPoints[idx] = toCartesianEpointF( shortest / 2f * (radiusR + 0.03F) + upperCurveUtil?.value(idx.toDouble())!! .toFloat(), angleOffset * idx ) bottomPoints[idx] = toCartesianEpointF( shortest / 2.4f * (radiusR + 0.03F) + bottomCurveUtil?.value(idx.toDouble())!! .toFloat(), angleOffset * idx ) } } } upperPoints[numberOfDot] = upperPoints[0] bottomPoints[numberOfDot] = bottomPoints[0] // we do this to connect the begin-end of line for (idx in 0 until upperPoints.size) { canvas.drawCircle(upperPoints[idx].x, upperPoints[idx].y, 4f, paint) } canvas.drawPath( polyPathsUtil.computePathThroughKnots(bottomPoints.asList()), bottomLinePaint ) drawSpears(canvas) if (firstTimeRunning) firstTimeRunning = false } private fun drawSpears(canvas: Canvas) { for (idx in bottomPoints.indices) { if (upperPoints[idx].distanceFrom(bottomPoints[idx]) > drawSpearThreshold) { canvas.drawLine( upperPoints[idx].x, upperPoints[idx].y, (bottomPoints[idx].x + upperPoints[idx].x) / 2, (bottomPoints[idx].y + upperPoints[idx].y) / 2, spearPaint ) } } } /** * Calculate the new radius between 2 opposite point in circle * Then scale [shortest] based on ratio between new radius and old radius */ private fun adjustRadius(minValue: Float) { val firstPoint = toCartesian(minValue / 2f * radiusR, angleOffset * 0) val secondPoint = toCartesian(minValue / 2f * radiusR, angleOffset * (numberOfDot / 2)) val currentRadius = (firstPoint.first - secondPoint.first) / 2 val newRadius: Float if (currentRadius < discRadius) { newRadius = discRadius.toFloat() + discMargin val ratio = (newRadius / currentRadius) this.shortest *= ratio } } /** * Apply min-max normalization then handle if its value is "high frequency" continuously * @see <a href="https://towardsdatascience.com/everything-you-need-to-know-about-min-max-normalization-in-python-b79592732b79">Min-Max Normalization </a> <br> * <p> A data is defined to be "high frequency" if its value is in range <br> [[visualizerMaxHeight] - [highFreqDelta] , [visualizerMaxHeight]] </p> */ private fun minMaxNormalize(fftData: FloatArray, maxLimit: Int, minLimit: Int = 0) { min = Float.MAX_VALUE max = Float.MIN_VALUE for (value in fftData) { if (value > max) max = value if (value < min) min = value } if (max > visualizerMaxHeight) { for (idx in fftData.indices) { fftData[idx] = ((fftData[idx] - min) / (max - min)) * maxLimit + minLimit if (fftData[idx].isExclusive( visualizerMaxHeight - highFreqDelta, visualizerMaxHeight ) ) { handleHighFreqValue(idx) } else { resetMapValue(idx) } } } } /** * Handle high frequency value to avoid low variability * If that data is in range [[visualizerMaxHeight] - [highFreqDelta] , [visualizerMaxHeight]] */ private fun handleHighFreqValue(idx: Int) { try { if (frequencyMap.containsKey(idx)) { frequencyMap[idx] = frequencyMap[idx]!!.plus(1) } else { frequencyMap[idx] = 1 } if (frequencyMap[idx]!! > 3) { frequencyMap[idx] = 0 upperFFT[idx] = (random.nextInt((visualizerMaxHeight - highFreqDelta) - highFreqDelta + 1) + highFreqDelta).toFloat() } } catch (e: Exception) { e.printStackTrace() } } /** * If the frequency value is not continue to return high frequency, reset it counter in [frequencyMap] */ private fun resetMapValue(idx: Int) { if (frequencyMap.containsKey(idx) && frequencyMap[idx]!! > 0) { frequencyMap[idx] = 0 } } private fun Float.isExclusive(start: Int, stop: Int): Boolean { return (this >= start && this <= stop) } private fun getShortest(): Float = Integer.min(currentWidth, currentHeight).toFloat() private fun FloatArray.contentToStringWithIndex(): String { val strBuilder = StringBuilder() strBuilder.append("[") for (index in 0 until this.size) { strBuilder.append(" $index: ${this[index]},") } strBuilder.append("]") return strBuilder.toString() } companion object { private const val TAG = "ParticleAnimation" private const val numberOfDot: Int = 100 private const val drawSpearThreshold = 20F val upperPoints = Array(numberOfDot + 1) { EPointF() } val bottomPoints = Array(numberOfDot + 1) { EPointF() } } } ``` **EPointF.java** ```java import androidx.annotation.NonNull; /** * Created by Nhien Nguyen on 5/11/2022 */ public class EPointF { public float x; public float y; public EPointF(){}; public EPointF(final float x, final float y) { this.x = x; this.y = y; } public void setX(float x) { this.x = x; } public void setY(float y) { this.y = y; } public void setCoordinate(float x, float y){ this.x = x; this.y = y; } public float getX() { return x; } public float getY() { return y; } public EPointF plus(float factor, EPointF ePointF) { return new EPointF(x + factor * ePointF.x, y + factor * ePointF.y); } public EPointF plus(EPointF ePointF) { return plus(1.0f, ePointF); } public EPointF minus(float factor, EPointF ePointF) { return new EPointF(x - factor * ePointF.x, y - factor * ePointF.y); } public EPointF minus(EPointF ePointF) { return minus(1.0f, ePointF); } public EPointF scaleBy(float factor) { return new EPointF(factor * x, factor * y); } public float distanceFrom(EPointF target){ float distanceX = this.x - target.x; float distanceY = this.y - target.y; return (float) Math.hypot(distanceX, distanceY); } @NonNull @Override public String toString() { return "(" + x +", " + y + ")"; } } ``` **Change FFTCircleLine.kt to CircleLineAnimation.kt** ```kotlin import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import com.annhienktuit.muzikv2.R import com.annhienktuit.muzikv2.enums.Interpolation import com.annhienktuit.muzikv2.ui.views.visualizer.nextgen.painters.Painter import com.annhienktuit.muzikv2.utils.VisualizerHelper import com.annhienktuit.muzikv2.utils.maths.polynomials.PolynomialSplineFunction import java.lang.Integer.min import java.util.* import kotlin.math.PI /** *Created by Nhien Nguyen on 8/19/2022 */ class CircleLineAnimation( var context: Context, override var paint: Paint = Paint(Paint.ANTI_ALIAS_FLAG), private var startHz: Int = 20, private var endHz: Int = 2000, private var interpolator: Interpolation = Interpolation.SPLINE, ) : Painter() { private var gravityPoints = Array(0) { GravityModel(0f) } private val barWidth = context.resources.getDimensionPixelSize(R.dimen.default_visualizer_bar_width).toFloat() private val radiusR: Float = .4f private val ampR: Float = 1f //The rate of amplification private val angleOffset = 2 * PI.toFloat() / numberOfBar private var shortest = 0F override var discRadius = 0 set(value) { field = value shortest = getShortest() adjustRadius(shortest) } override var isPlaying = false var firstTimeRunning = true var curveUtil: PolynomialSplineFunction? = null lateinit var fft: FloatArray // Fields for normalization private var min = 0f private var max = 0f private val highFreqDelta = 20 private var frequencyMap = HashMap<Int, Int>() private var random = Random() // Fields for draw private var start = Pair(0F, 0F) private var stop = Pair(0F, 0F) private var currentHeight: Int = 0 private var currentWidth: Int = 0 init { paint.apply { color = Color.WHITE style = Paint.Style.FILL strokeWidth = barWidth strokeCap = Paint.Cap.ROUND } } /** * Retrieve the [fft] data from [VisualizerHelper] * Then do get [getCircleFft] to connect the data * Then normalize the data using min-max formula * Put the [fft] to [Painter.GravityModel] * Finally, get [PolynomialSplineFunction] using [interpolateFftCircle] */ override fun calc(helper: VisualizerHelper) { fft = helper.getFftMagnitudeRange(startHz, endHz) fft = getPowerFft(fft) fft = if (firstTimeRunning) { getCircleFft(fft, true) } else { getCircleFft(fft) } minMaxNormalize(fft, visualizerMaxHeight, 0) //Need to be tested more // if (isSilence(fft)) { // suppressLowValue(fft) // } if (gravityPoints.size != fft.size) gravityPoints = Array(fft.size) { GravityModel(0f) } gravityPoints.forEachIndexed { index, bar -> bar.update(fft[index] * ampR) } curveUtil = interpolateFftCircle(gravityPoints, numberOfBar, interpolator) } /** * Apply min-max normalization then handle if its value is "high frequency" continuously * @see <a href="https://towardsdatascience.com/everything-you-need-to-know-about-min-max-normalization-in-python-b79592732b79">Min-Max Normalization </a> <br> * <p> A data is defined to be "high frequency" if its value is in range <br> [[visualizerMaxHeight] - [highFreqDelta] , [visualizerMaxHeight]] </p> */ private fun minMaxNormalize(fftData: FloatArray, maxLimit: Int, minLimit: Int = 0) { min = Float.MAX_VALUE max = Float.MIN_VALUE for (value in fftData) { if (value > max) max = value if (value < min) min = value } if (max > visualizerMaxHeight) { for (idx in fftData.indices) { fftData[idx] = ((fftData[idx] - min) / (max - min)) * maxLimit + minLimit if (fftData[idx].isExclusive( visualizerMaxHeight - highFreqDelta, visualizerMaxHeight ) ) { handleHighFreqValue(idx) } else { resetMapValue(idx) } } } } /** * If the frequency value is not continue to return high frequency, reset it counter in [frequencyMap] */ private fun resetMapValue(idx: Int) { if (frequencyMap.containsKey(idx) && frequencyMap[idx]!! > 0) { frequencyMap[idx] = 0 } } /** * Handle high frequency value to avoid low variability * If that data is in range [[visualizerMaxHeight] - [highFreqDelta] , [visualizerMaxHeight]] */ private fun handleHighFreqValue(idx: Int) { try { if (frequencyMap.containsKey(idx)) { frequencyMap[idx] = frequencyMap[idx]!!.plus(1) } else { frequencyMap[idx] = 1 } if (frequencyMap[idx]!! > 3) { frequencyMap[idx] = 0 fft[idx] = (random.nextInt((visualizerMaxHeight - highFreqDelta) - highFreqDelta + 1) + highFreqDelta).toFloat() } } catch (e: Exception) { e.printStackTrace() } } override fun draw(canvas: Canvas, width: Int, height: Int, drawWithoutCalc: Boolean) { if (drawWithoutCalc) { drawHelperCircleLine(canvas, .5f, .5f) { // Still need to run this function to do the translate, but we don't update data on the first time // Otherwise, it will draw in somewhere psychically //canvas.drawLines(points, paint) } return } currentWidth = width currentHeight = height if (firstTimeRunning) { shortest = min(width, height).toFloat() adjustRadius(shortest) } drawHelperCircleLine(canvas, .5f, .5f) { if (!firstTimeRunning || !drawWithoutCalc) { for (i in 0 until numberOfBar) { start = toCartesian(shortest / 2f * radiusR, angleOffset * i) stop = toCartesian( shortest / 2f * radiusR + curveUtil?.value(i.toDouble())!!.toFloat(), angleOffset * i ) if (i < 4) { handleSomeBarsException() } points[4 * i] = start.first points[4 * i + 1] = start.second points[4 * i + 2] = stop.first points[4 * i + 3] = stop.second } } } canvas.drawLines(points, paint) if (firstTimeRunning) firstTimeRunning = false } private fun handleSomeBarsException() { if (stop.first < start.first) stop = start } /** * Calculate the new radius between 2 opposite point in circle * Then scale [shortest] based on ratio between new radius and old radius */ private fun adjustRadius(minValue: Float) { val firstPoint = toCartesian(minValue / 2f * radiusR, angleOffset * 0) val secondPoint = toCartesian(minValue / 2f * radiusR, angleOffset * (numberOfBar / 2)) val currentRadius = (firstPoint.first - secondPoint.first) / 2 val newRadius: Float if (currentRadius < discRadius) { newRadius = discRadius.toFloat() + discMargin val ratio = (newRadius / currentRadius) this.shortest *= ratio } } /** * @return if number is in range [[start], [stop]] */ private fun Float.isExclusive(start: Int, stop: Int): Boolean { return (this >= start && this <= stop) } private fun getShortest(): Float = min(currentWidth, currentHeight).toFloat() companion object { private const val numberOfBar: Int = 100 val points = FloatArray(4 * numberOfBar) } } ``` **FFTVisualizer.kt** ```kotlin import android.content.Context import android.graphics.Canvas import android.util.AttributeSet import android.util.Log import android.view.View import com.annhienktuit.muzikv2.R import com.annhienktuit.muzikv2.ui.views.visualizer.nextgen.painters.Painter import com.annhienktuit.muzikv2.utils.VisualizerHelper /** *Created by Nhien Nguyen on 8/19/2022 */ class FFTVisualizer : View { private val discMargin = context.resources.getDimensionPixelSize(R.dimen.default_disc_margin) private val barWidth = context.resources.getDimensionPixelSize(R.dimen.default_visualizer_bar_width) private var visualizerMaxHeight: Int = context.resources.getDimensionPixelSize(R.dimen.default_visualizer_max_height) private lateinit var painter: Painter private lateinit var visualizerHelper: VisualizerHelper private var reMeasuredFreeSpace = 0 var visible = false var isPlaying = false var discRadius: Int = 0 constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( context, attrs, defStyleAttr ) fun setup(visualizerHelper: VisualizerHelper, painter: Painter) { this.visualizerHelper = visualizerHelper this.painter = painter.also { it.visualizerMaxHeight = this.visualizerMaxHeight } this.onResume() } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) val size = measuredWidth.coerceAtMost(measuredHeight) setMeasuredDimension(size, size) reMeasuredFreeSpace = ((size - discRadius * 2 - discMargin * 2 - barWidth * 4) / 2) if (reMeasuredFreeSpace < visualizerMaxHeight) visualizerMaxHeight = reMeasuredFreeSpace } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (!this::visualizerHelper.isInitialized) return handleOnFirstRun(canvas, painter) if ((width > 0 && height > 0 && visible)) { if (isPlaying) { painter.calc(visualizerHelper) painter.draw(canvas, width, height) postInvalidateDelayed(REFRESH_TIME_60FPS) } else { painter.draw(canvas, width, height) } } else { Log.e("DrawStatus", "Cannot draw because height = $height & width = $width") } } private fun handleOnFirstRun(canvas: Canvas, painter: Painter) { when (painter) { is CircleLineAnimation -> { if (painter.curveUtil == null) { //If saved data exist, draw it first if (!CircleLineAnimation.points.isZeroArray()) { painter.draw(canvas, width, height, true) } //We must run this function on 1st time to initialize curve utils painter.calc(visualizerHelper) } } is ParticleAnimation -> { if (painter.upperCurveUtil == null || painter.bottomCurveUtil == null ) { //If saved data exist, draw it first // if (!ParticleAnimation.upperPoints.isZeroArray()) { // painter.draw(canvas, width, height, true) // } //We must run this function on 1st time to initialize curve utils painter.calc(visualizerHelper) } } } } fun setPlayingStatus(isPlaying: Boolean) { this.isPlaying = isPlaying if (isPlaying) { invalidate() } updatePainterPlayStatus(this.isPlaying) } /** * Notify new playing status to [CircleLineAnimation] */ private fun updatePainterPlayStatus(playing: Boolean) { painter.isPlaying = playing } fun onResume() { visible() invalidate() } fun onPause() { invisible() } /** * Show the visualizer */ fun visible() { visible = true } /** * Hide the visualizer */ fun invisible() { visible = false } private fun FloatArray.isZeroArray(): Boolean { for (idx in 0 until this.size) { if (this[idx] != 0F) return false } return true } companion object { private const val REFRESH_TIME_60FPS: Long = 1000 / 60 } } ``` **Painter.kt** ```kotlin /** *Created by Nhien Nguyen on 8/19/2022 */ import android.graphics.Canvas import android.graphics.Paint import android.util.Log import com.annhienktuit.domain.models.EPointF import com.annhienktuit.muzikv2.enums.Interpolation import com.annhienktuit.muzikv2.utils.VisualizerHelper import com.annhienktuit.muzikv2.utils.maths.interpolation.AkimaSplineInterpolator import com.annhienktuit.muzikv2.utils.maths.interpolation.LinearInterpolator import com.annhienktuit.muzikv2.utils.maths.polynomials.PolynomialSplineFunction import java.util.* import kotlin.math.cos import kotlin.math.sin abstract class Painter { open var visualizerMaxHeight = 0 open var discRadius = 0 open var discMargin = 0 open var isPlaying = false abstract var paint: Paint private val li = LinearInterpolator() private val sp = AkimaSplineInterpolator() private val quietThreshold = 5f private var patched: FloatArray = FloatArray(0) private var psf: PolynomialSplineFunction? = null private var nRaw: Int = 0 private var xRaw = DoubleArray(0) private var yRaw = DoubleArray(0) /** * An abstract function that every painters must implement and do their calculation there. * * @param helper the visualizerHelper from VisualizerView */ abstract fun calc(helper: VisualizerHelper) /** * An abstract function that every painters must implement and do their drawing there. * * @param canvas the canvas from VisualizerView */ abstract fun draw(canvas: Canvas, width: Int = 0, height: Int = 0, drawWithoutCalc: Boolean = false) /** * Interpolate FFT spectrum * * Android don't capture a high resolution spectrum, and we want the number of bands be adjustable, * so we do an interpolation here. * * (For example, to show 64 bands at frequencies from 0Hz to 1200Hz, Android only return ~10 FFT values. * So we need to interpolate those ~10 values into 64 values in order to fit it into our bands) * * @param gravityModels Array of gravityModel * @param sliceNum Number of Slice * @param interpolator Which interpolator to use, `li` for Linear, `sp` for Spline * * @return a `PolynomialSplineFunction` (psf). To get the value, use * `psf.value(x)`, where `x` must be a Double value from 0 to `num` */ fun interpolateFft( gravityModels: Array<GravityModel>, sliceNum: Int, interpolator: Interpolation ): PolynomialSplineFunction? { if (gravityModels.size != nRaw) { nRaw = gravityModels.size xRaw = DoubleArray(nRaw) { (it * sliceNum).toDouble() / (nRaw - 1) } yRaw = DoubleArray(nRaw) } else { xRaw.forEach { (it * sliceNum) / (nRaw - 1) } } gravityModels.forEachIndexed { index, bar -> yRaw[index] = bar.height.toDouble() } psf = when (interpolator) { Interpolation.LINEAR -> li.interpolate(xRaw, yRaw) Interpolation.SPLINE -> sp.interpolate(xRaw, yRaw) } return psf } /** * Interpolate FFT spectrum (Circle) * * Similar to `interpolateFft()`. However this is meant for Fft from `getCircleFft()` * * @param gravityModels Array of gravityModel * @param sliceNum Number of Slice * @param interpolator Which interpolator to use, `li` for Linear, `sp` for Spline * * @return a `PolynomialSplineFunction` (psf). To get the value, use * `psf.value(x)`, where `x` must be a Double value from 0 to `num` */ fun interpolateFftCircle( gravityModels: Array<GravityModel>, sliceNum: Int, interpolator: Interpolation ): PolynomialSplineFunction? { if (gravityModels.size != nRaw) { nRaw = gravityModels.size xRaw = DoubleArray(nRaw) { ((it - 1) * sliceNum).toDouble() / (nRaw - 1 - 2) } yRaw = DoubleArray(nRaw) } else { xRaw.forEach { ((it - 1) * sliceNum) / (nRaw - 1 - 2) } } gravityModels.forEachIndexed { index, bar -> yRaw[index] = bar.height.toDouble() } psf = when (interpolator) { Interpolation.LINEAR -> li.interpolate(xRaw, yRaw) Interpolation.SPLINE -> sp.interpolate(xRaw, yRaw) } return psf } /** * Check if it's quiet enough such that we can skip the drawing * @param fft Fft * @return true if it's quiet, false otherwise */ fun isQuiet(fft: FloatArray): Boolean { fft.forEach { if (it > quietThreshold) return false } Log.i("IsQuiet", "True") return true } /** * Check if it's silence enough? * If true, we suppress all to 0 for easier visualizer */ fun isSilence(fft: FloatArray): Boolean { if(fft.isEmpty()) return false if(getAverage(fft) > LOW_VALUE_THRESHOLD) return false return true } private fun getAverage(fft: FloatArray):Float { var sum = 0F for(idx in fft.indices){ sum += fft[idx] } return sum / fft.size } fun suppressLowValue(fft: FloatArray){ Arrays.fill(fft, 0F) } /** * Convert Polar to Cartesian * @param radius Radius * @param theta Theta * @return Pair of (x,y) of Cartesian */ fun toCartesian(radius: Float, theta: Float): Pair<Float, Float> { return Pair(radius * cos(theta), radius * sin(theta)) } /** * Convert Polar to Cartesian * @param radius Radius * @param theta Theta * @return [EPointF] of (x,y) of Cartesian */ fun toCartesianEpointF(radius: Float, theta: Float): EPointF{ return EPointF(radius * cos(theta), radius * sin(theta)) } /** * Patch the Fft so that the start and the end connect perfectly. Use this with `interpolateFftCircle()` * * `[0, 1, ..., n] -> [n-1, 0, 1, ..., n-1, 0, 1]` * * @param fft Fft * @return CircleFft */ fun getCircleFft(fft: FloatArray, updateArraySize: Boolean = false): FloatArray { if (updateArraySize) { patched = FloatArray(fft.size + 2) } fft.forEachIndexed { index, item -> patched[index + 1] = item } patched[0] = fft[fft.lastIndex - 1] patched[patched.lastIndex - 1] = fft[0] patched[patched.lastIndex] = fft[1] return patched } /** * Boost high values while suppress low values, generally give a powerful feeling * @param fft Fft * @param power Parameter, adjust to fit your liking * @return PowerFft */ fun getPowerFft(fft: FloatArray, power: Float = 100.0F): FloatArray { return fft.map { it * it / power }.toFloatArray() } /** * Draw the circle visualizer * @param xR The ratio of X position to the canvas.width (= the width of the visualizer's view) * For example, 0f mean the start/left, while 1f mean the end/right of the screen. * @param yR The ratio of Y position to the canvas.height (= the height of the visualizer's view) * For example, 0f mean the top, while 1f mean the bottom of the screen. */ fun drawHelperCircleLine( canvas: Canvas, xR: Float, yR: Float, drawCircleLine: () -> Unit ) { canvas.save() canvas.translate(canvas.width * xR, canvas.height * yR) drawCircleLine() } /** * A model with gravity. Useful to smooth raw Fft values. */ class GravityModel( var height: Float = 0f, var dy: Float = 0f, var ay: Float = 2f ) { fun update(h: Float) { if (h > height) { height = h dy = 0f } height -= dy dy += ay if (height < 0) { height = 0f dy = 0f } } override fun toString(): String { return "@GravityModel[Height: $height, dy: $dy, ay: $ay]" } } companion object{ private const val LOW_VALUE_THRESHOLD = 7F } } ``` **VisualierHelper.kt** ```kotlin import com.annhienktuit.muzikv2.processors.FFTAudioProcessor import kotlin.math.hypot /** *Created by Nhien Nguyen on 8/19/2022 */ class VisualizerHelper { private var fftM: FloatArray private var fftBuffer: FloatArray private var listener: FFTAudioProcessor.FFTListener private var magnitudeIndex: Int = 0 private lateinit var audioProcessor: FFTAudioProcessor init { fftBuffer = FloatArray(FFTAudioProcessor.SAMPLE_SIZE + 2) fftM = FloatArray(fftBuffer.size / 2 - 1) listener = FFTAudioProcessor.FFTListener { sampleRateHz, channelCount, fft -> fftBuffer = fft } } fun setAudioProcessor(processor: FFTAudioProcessor) { this.audioProcessor = processor processor.listener = listener } /** * Get the FFT Magnitude */ private fun getFftMagnitude(): FloatArray { for (k in fftM.indices) { magnitudeIndex = (k + 1) * 2 fftM[k] = hypot(fftBuffer[magnitudeIndex], fftBuffer[magnitudeIndex + 1]) } return fftM } /** * Get Fft values from startHz to endHz */ fun getFftMagnitudeRange(startHz: Int, endHz: Int): FloatArray { return getFftMagnitude().copyOfRange(hzToFftIndex(startHz), hzToFftIndex(endHz)) } /** * Equation from documentation, kth frequency = k*Fs/(n/2) */ private fun hzToFftIndex(Hz: Int): Int { return (Hz * 1024 / (44100 * 2)).coerceAtLeast(0).coerceAtMost(255) } /** * Release visualizer when not using anymore */ fun release() { fftM = emptyArray<Float>().toFloatArray() fftBuffer = emptyArray<Float>().toFloatArray() } } ``` **PolyBezierPathUtil.java** ```java import android.graphics.Path; import com.annhienktuit.domain.models.EPointF; import java.util.Collection; import java.util.List; public class PolyBezierPathUtil { /** * Computes a Poly-Bezier curve passing through a given list of knots. * The curve will be twice-differentiable everywhere and satisfy natural * boundary conditions at both ends. * * @param knots a list of knots * @return a Path representing the twice-differentiable curve * passing through all the given knots */ public Path computePathThroughKnots(List<EPointF> knots) { throwExceptionIfInputIsInvalid(knots); final Path polyBezierPath = new Path(); final EPointF firstKnot = knots.get(0); polyBezierPath.moveTo(firstKnot.getX(), firstKnot.getY()); /* * variable representing the number of Bezier curves we will join * together */ final int n = knots.size() - 1; if (n == 1) { final EPointF lastKnot = knots.get(1); polyBezierPath.lineTo(lastKnot.getX(), lastKnot.getY()); } else { final EPointF[] controlPoints = computeControlPoints(n, knots); for (int i = 0; i < n; i++) { final EPointF targetKnot = knots.get(i + 1); appendCurveToPath(polyBezierPath, controlPoints[i], controlPoints[n + i], targetKnot); } } return polyBezierPath; } private EPointF[] computeControlPoints(int n, List<EPointF> knots) { final EPointF[] result = new EPointF[2 * n]; final EPointF[] target = constructTargetVector(n, knots); final Float[] lowerDiag = constructLowerDiagonalVector(n - 1); final Float[] mainDiag = constructMainDiagonalVector(n); final Float[] upperDiag = constructUpperDiagonalVector(n - 1); final EPointF[] newTarget = new EPointF[n]; final Float[] newUpperDiag = new Float[n - 1]; // forward sweep for control points c_i,0: newUpperDiag[0] = upperDiag[0] / mainDiag[0]; newTarget[0] = target[0].scaleBy(1 / mainDiag[0]); for (int i = 1; i < n - 1; i++) { newUpperDiag[i] = upperDiag[i] / (mainDiag[i] - lowerDiag[i - 1] * newUpperDiag[i - 1]); } for (int i = 1; i < n; i++) { final float targetScale = 1 / (mainDiag[i] - lowerDiag[i - 1] * newUpperDiag[i - 1]); newTarget[i] = (target[i].minus(newTarget[i - 1].scaleBy(lowerDiag[i - 1]))).scaleBy(targetScale); } // backward sweep for control points c_i,0: result[n - 1] = newTarget[n - 1]; for (int i = n - 2; i >= 0; i--) { result[i] = newTarget[i].minus(newUpperDiag[i], result[i + 1]); } // calculate remaining control points c_i,1 directly: for (int i = 0; i < n - 1; i++) { result[n + i] = knots.get(i + 1).scaleBy(2).minus(result[i + 1]); } result[2 * n - 1] = knots.get(n).plus(result[n - 1]).scaleBy(0.5f); return result; } private EPointF[] constructTargetVector(int n, List<EPointF> knots) { final EPointF[] result = new EPointF[n]; result[0] = knots.get(0).plus(2, knots.get(1)); for (int i = 1; i < n - 1; i++) { result[i] = (knots.get(i).scaleBy(2).plus(knots.get(i + 1))).scaleBy(2); } result[result.length - 1] = knots.get(n - 1).scaleBy(8).plus(knots.get(n)); return result; } private Float[] constructLowerDiagonalVector(int length) { final Float[] result = new Float[length]; for (int i = 0; i < result.length - 1; i++) { result[i] = 1f; } result[result.length - 1] = 2f; return result; } private Float[] constructMainDiagonalVector(int n) { final Float[] result = new Float[n]; result[0] = 2f; for (int i = 1; i < result.length - 1; i++) { result[i] = 4f; } result[result.length - 1] = 7f; return result; } private Float[] constructUpperDiagonalVector(int length) { final Float[] result = new Float[length]; for (int i = 0; i < result.length; i++) { result[i] = 1f; } return result; } private void appendCurveToPath(Path path, EPointF control1, EPointF control2, EPointF targetKnot) { path.cubicTo( control1.getX(), control1.getY(), control2.getX(), control2.getY(), targetKnot.getX(), targetKnot.getY() ); } private void throwExceptionIfInputIsInvalid(Collection<EPointF> knots) { if (knots.size() < 2) { throw new IllegalArgumentException( "Collection must contain at least two knots" ); } } } ```