# BUILD 12/9 **FFTCircleLine.kt** ```kotlin class FFTCircleLine( 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, var discMargin: Int = 0 ) : Painter() { private var gravityPoints = Array(0) { GravityModel(0f) } private val barWidth = context.resources.getDimensionPixelSize(R.dimen.default_visualizer_bar_width).toFloat() private val debugPaint = Paint() 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 var discRadius = 0 set(value) { field = value shortest = getShortest() adjustRadius(shortest) } var firstTimeRunning = true var isPlaying = false var visualizerMaxHeight = 0 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 } debugPaint.apply { isAntiAlias = true color = Color.RED style = Paint.Style.FILL strokeWidth = barWidth } } override fun calc(helper: VisualizerHelper) { Log.i("CalcStatus", "Calculating on ${Thread.currentThread().id}") fft = helper.getFftMagnitudeRange(startHz, endHz) Log.i("FFTArray_Raw", fft.contentToStringWithIndex()) fft = getPowerFft(fft) fft = if (firstTimeRunning) { getCircleFft(fft, true) } else { getCircleFft(fft) } Log.i("FFTArray", fft.contentToStringWithIndex()) minMaxNormalize(fft, visualizerMaxHeight, 0) 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) { 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() } } 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) { 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 ) points[4 * i] = start.first points[4 * i + 1] = start.second points[4 * i + 2] = stop.first points[4 * i + 3] = stop.second } } } if (!drawWithoutCalc) canvas.drawLines(points, paint) if (firstTimeRunning) firstTimeRunning = false } /** * 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 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() } private fun getShortest(): Float = min(currentWidth, currentHeight).toFloat() companion object { private const val numberOfBar: Int = 100 val points = FloatArray(4 * numberOfBar) } } ``` **FFTVisualizer.kt** ```kotlin class FFTVisualizer : View { private val paint = Paint(Paint.ANTI_ALIAS_FLAG) 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 as? FFTCircleLine)?.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 if ((painter as? FFTCircleLine)?.curveUtil == null) { //If saved data exist, draw it first if (!FFTCircleLine.points.isZeroArray() && !isPlaying){ painter.draw(canvas, width, height, true) } //We must run this function on 1st time to initialize curve utils painter.calc(visualizerHelper) } if ((width > 0 && height > 0 && visible)) { if (isPlaying) { painter.calc(visualizerHelper) painter.draw(canvas, width, height) postInvalidateDelayed(REFRESH_TIME_60FPS) } else { Log.i("DrawStatus", "Skip Calculating") painter.draw(canvas, width, height) } } else { Log.e("DrawStatus", "Cannot draw because height = $height & width = $width") } } fun onResume() { visible = true setLayerType(LAYER_TYPE_HARDWARE, paint) invalidate() } fun onPause() { visible = false setLayerType(LAYER_TYPE_SOFTWARE, null) } /** * Show the visualizer */ fun visible() { visible = true } /** * Hide the visualizer */ fun invisible() { visible = false } fun setPlayingStatus(isPlaying: Boolean) { this.isPlaying = isPlaying if (isPlaying) { invalidate() } updatePainterPlayStatus(this.isPlaying) } /** * Notify new playing status to [FFTCircleLine] */ private fun updatePainterPlayStatus(playing: Boolean) { (painter as? FFTCircleLine)?.let { it.isPlaying = playing } } 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 } } ```