# BUILD 10/10 **FFTVisualizer** ```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 visibility = true 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 } Log.i("OnMeasure", "MaxHeight $size $reMeasuredFreeSpace , $visualizerMaxHeight and visibility=$visibility") } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (!this::visualizerHelper.isInitialized) return handleOnFirstRun(canvas, painter) if ((width > 0 && height > 0 && visibility)) { if (isPlaying) { painter.calc(visualizerHelper) painter.draw(canvas, width, height) postInvalidateDelayed(REFRESH_TIME_60FPS) } else { Log.i("DrawStatus", "Drawing w o calc") painter.draw(canvas, width, height) } } else { Log.e("DrawStatus", "Cannot draw because height = $height & width = $width") } } /** * Handle initialize and drawing when the view create */ 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(!painter.isZeroEPointFArray(ParticleAnimation.upperPoints)){ // painter.draw(canvas, width, height, true) } painter.calc(visualizerHelper) } } is StretchAnimation -> { if(painter.isCurveUtilNull()){ 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() { visibility = true invalidate() } fun onPause() { visibility = 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 } } ``` **StretchAnimation** ```kotlin import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.util.Log import androidx.core.graphics.ColorUtils import com.annhienktuit.domain.models.EPointF import com.annhienktuit.muzikv2.R import com.annhienktuit.muzikv2.ui.views.visualizer.nextgen.models.Elastic 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 kotlin.math.PI import kotlin.properties.Delegates /** *Created by Nhien Nguyen on 9/30/2022 */ class StretchAnimation @JvmOverloads constructor( var context: Context, override var paint: Paint = Paint(Paint.ANTI_ALIAS_FLAG), ) : Painter() { private var elasticBands = arrayOf<Elastic>() var elasticCurveUtils = arrayOfNulls<PolynomialSplineFunction>(4) private var polyPathsUtil = PolyBezierPathUtil() private var paintList = Array(numberOfBand) { Paint(Paint.ANTI_ALIAS_FLAG) } private val barWidth = context.resources.getDimensionPixelSize(R.dimen.default_visualizer_bar_width).toFloat() private val radiusR: Float = .4f 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 private var currentRadius by Delegates.notNull<Float>() private var actualBarHeight = 0F private var scale = 1F var defaultBlendColor = Color.parseColor("#707070") init { for (paint in paintList) { paint.apply { style = Paint.Style.STROKE strokeWidth = barWidth / 2 strokeCap = Paint.Cap.ROUND alpha = 150 } } } override fun calc(helper: VisualizerHelper) { if (elasticBands.size < numberOfBand) { initElasticBands(helper) } Log.i(TAG, "empty ${isCurveUtilNull()}") for (idx in elasticBands.indices) { if (firstTimeRunning) { elasticCurveUtils[idx] = elasticBands[idx].calculateCurve(true)!! } else { elasticCurveUtils[idx] = elasticBands[idx].calculateCurve(false)!! } } } 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 } if (firstTimeRunning) { shortest = Integer.min(width, height).toFloat() adjustRadius(shortest) currentWidth = width currentHeight = height } drawHelperCircleLine(canvas, .5f, .5f) { if (!firstTimeRunning) { for (i in elasticBands.indices) { for (j in 0 until numberOfDot) { points[i][j] = toCartesianEpointF( shortest / 2.2f * (radiusR + 0.03F) + elasticCurveUtils[i]?.value(j.toDouble()) !!.toFloat(), angleOffset * j ) } } } } connectSmoothPaths() makeSureViewNotOutOfBound() for (idx in elasticBands.indices) { canvas.drawPath( polyPathsUtil.computePathThroughKnots(points[idx]), paintList[idx] ) } if (firstTimeRunning) firstTimeRunning = false } private fun connectSmoothPaths() { for (idx in elasticBands.indices) { points[idx][0] = points[idx][numberOfDot - 1] points[idx][numberOfDot - 1] = points[idx][0] points[idx][numberOfDot] = points[idx][1] } } private fun makeSureViewNotOutOfBound() { for (i in elasticBands.indices) { for (j in 0 until numberOfDot) { actualBarHeight = (points[i][j].distanceFrom(centerPoint) - discMargin - discRadius - pointRadius * 2) if (actualBarHeight > visualizerMaxHeight) { scale = visualizerMaxHeight / actualBarHeight points[i][j] = points[i][j].scaleBy(scale) } } } } private fun initElasticBands(helper: VisualizerHelper) { elasticBands = arrayOf( Elastic( this, 20, 2000, helper, visualizerMaxHeight, 1.25f, ), Elastic( this, 2000, 4000, helper, visualizerMaxHeight ), Elastic( this, 4000, 6000, helper, visualizerMaxHeight, ), Elastic( this, 6000, 10000, helper, visualizerMaxHeight, ) ) } /** * 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)) currentRadius = (firstPoint.first - secondPoint.first) / 2 val newRadius: Float if (currentRadius < discRadius) { newRadius = discRadius.toFloat() + discMargin val ratio = (newRadius / currentRadius) this.shortest *= ratio } } private fun getShortest(): Float = Integer.min(currentWidth, currentHeight).toFloat() override fun setPaintColor(vararg color: Int) { var initAlpha = 250 var blendRatio = 1f for (painter in paintList) { painter.color = ColorUtils.blendARGB(defaultBlendColor, color[0], blendRatio) painter.alpha = initAlpha initAlpha -= 50 blendRatio -= 0.25f if (initAlpha < 20) initAlpha = 20 if (blendRatio <= 0) blendRatio = 0f } } fun isCurveUtilNull(): Boolean = elasticCurveUtils.any { it == null } companion object { private const val TAG = "StretchAnimation" private const val pointRadius = 4f private val centerPoint = EPointF(0F, 0F) private const val numberOfBand: Int = 4 const val numberOfDot: Int = 100 val points = Array(numberOfBand) { Array(numberOfDot + 1) { EPointF() } } } } ``` Painter ```kotlin import android.graphics.Canvas import android.graphics.Paint import android.util.Log import androidx.annotation.ColorInt 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.Arrays 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(PATCHED_SIZE) private var psf: PolynomialSplineFunction? = null private var nRaw: Int = 0 private var xRaw = DoubleArray(0) private var yRaw = DoubleArray(0) //Fields for normalization private var max = 0F private var min = 0F open fun setPaintColor(@ColorInt vararg color: Int) { if(color.isEmpty()) return this.paint.color = color[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 = Interpolation.SPLINE ): 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 || fft.size != PATCHED_SIZE) { PATCHED_SIZE = fft.size 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() } fun minMaxNormalizeEpointF(points: Array<EPointF>, maxLimit: Int, minLimit: Int = 0){ min = Float.MAX_VALUE max = Float.MIN_VALUE for(point in points){ if(point.x > max) max = point.x if(point.x < min) min = point.x } // We only do normalization if max element data is reached out limit if(max > maxLimit){ for(idx in points.indices){ points[idx].setX( ((points[idx].x - min) / (max - min)) * maxLimit + minLimit ) } } } fun isZeroEPointFArray(points: Array<EPointF>): Boolean{ if (points.isEmpty()) return true points.forEach { if(it.x != 0F || it.y != 0F) return false } return true } /** * 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 private var PATCHED_SIZE = 100 } } ```