# PARTICLE ANIMATION - 29/09 **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 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() private var actualBarHeight = 0F private var scale = 1F //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.parseColor(visualizerColor) style = Paint.Style.FILL strokeWidth = barWidth strokeCap = Paint.Cap.ROUND alpha = 200 } bottomLinePaint.apply { color = Color.parseColor(visualizerColor) style = Paint.Style.STROKE strokeWidth = barWidth / 2 strokeCap = Paint.Cap.ROUND alpha = 200 } spearPaint.apply { color = Color.parseColor(visualizerColor) style = Paint.Style.FILL strokeWidth = barWidth / 2 strokeCap = Paint.Cap.ROUND alpha = 30 } } 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 / 1.5F) } 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 } 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.35f * (radiusR + 0.03F) + bottomCurveUtil?.value(idx.toDouble())!! .toFloat(), angleOffset * idx ) } } canvas.drawCircle(0f, 0f, 10f, paint) } upperPoints[numberOfDot] = upperPoints[0] bottomPoints[numberOfDot] = bottomPoints[0] // we do this to connect the begin-end of line makeSureViewNotOutOfBound() for (idx in upperPoints.indices) { 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 ) } } } /** * Check whether the current bar is over [visualizerMaxHeight] * If true, scale tp fit [visualizerMaxHeight] */ private fun makeSureViewNotOutOfBound() { for (idx in upperPoints.indices) { actualBarHeight = (upperPoints[idx].distanceFrom(centerPoint) - discMargin - discRadius) if (actualBarHeight > visualizerMaxHeight) { scale = visualizerMaxHeight / actualBarHeight upperPoints[idx] = upperPoints[idx].scaleBy(scale) } } } /** * 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() companion object { private const val TAG = "ParticleAnimation" private const val numberOfDot: Int = 100 private const val drawSpearThreshold = 20F private const val visualizerColor = "#FFFFFF" val upperPoints = Array(numberOfDot + 1) { EPointF() } val bottomPoints = Array(numberOfDot + 1) { EPointF() } val centerPoint = EPointF(0F, 0F) } } ```