# Build 24/8 **FFTVisualizer.kt** ```kotlin /** *Created by Nhien Nguyen on 8/19/2022 */ class FFTVisualizer : View { private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val discMargin = context.resources.getDimensionPixelSize(R.dimen.default_disc_margin) private var visualizerMaxHeight: Int = context.resources.getDimensionPixelSize(R.dimen.default_visualizer_max_height).toInt() private lateinit var painter: Painter private lateinit var visualizerHelper: VisualizerHelper private var reMeasuredMaxHeight: Int = 0 var enable = false var discRadius: Int = 0 var time: Long = System.currentTimeMillis() 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 (it as? FFTCircleLine)?.visualizerMaxHeight = visualizerMaxHeight } this.onResume() } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) val size = measuredWidth.coerceAtMost(measuredHeight) setMeasuredDimension(size, size) reMeasuredMaxHeight = ((size - discRadius * 2 - discMargin * 2) / 2) if (reMeasuredMaxHeight < visualizerMaxHeight) visualizerMaxHeight = reMeasuredMaxHeight } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (!this::visualizerHelper.isInitialized) return if(enable){ setLayerType(LAYER_TYPE_HARDWARE, paint) painter.calc(visualizerHelper) } if (width > 0 && height > 0) { painter.draw(canvas, width, height) } postInvalidateDelayed(1000 / 60) } fun onResume() { enable = true invalidate() } fun onPause() { enable = false } } ``` **FFTCircleLine** ```kotlin /** *Created by Nhien Nguyen on 8/19/2022 */ class FFTCircleLine( override var paint: Paint = Paint(Paint.ANTI_ALIAS_FLAG), // var startHz: Int = 20, var endHz: Int = 2000, // var interpolator: Interpolation = Interpolation.SPLINE, // var power: Boolean = true, // var discMargin: Int = 0 ) : Painter() { private var gravityPoints = Array(hzToFftIndex(endHz) - hzToFftIndex(startHz)) { GravityModel(0f) } private var skipFrame = false private val barWidth = 8f private val numberOfBar: Int = 100 private val debugPaint = Paint() private val radiusR: Float = .4f private val ampR: Float = 1f private val points = FloatArray(4 * numberOfBar) private val angleOffset = 2 * PI.toFloat() / numberOfBar private var shortest = 0F var discRadius = 0 var isFirstTimeRunning = true var visualizerMaxHeight = 0 lateinit var fft: FloatArray lateinit var curveUtil: PolynomialSplineFunction // Fields for normalization private var min = 0f private var max = 0f // Fields for draw private var start = Pair(0F, 0F) private var stop = Pair(0F, 0F) // var defaultCurveUtil: PolynomialSplineFunction init { paint.apply { color = Color.WHITE style = Paint.Style.FILL strokeWidth = barWidth } debugPaint.apply { color = Color.RED style = Paint.Style.FILL strokeWidth = barWidth } // defaultCurveUtil = interpolateFftCircle(gravityPoints, numberOfBar, interpolator) } override fun calc(helper: VisualizerHelper) { fft = helper.getFftMagnitudeRange(startHz, endHz) minMaxNormalize(fft, visualizerMaxHeight, 0) if (isQuiet(fft)) { skipFrame = true return } else skipFrame = false if (power) fft = getPowerFft(fft) fft = if (isFirstTimeRunning) { getCircleFft(fft, true) } else { getCircleFft(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) } 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 } } } override fun draw(canvas: Canvas, width: Int, height: Int) { if (skipFrame) { drawBottomBar(canvas, start, stop) return } if (isFirstTimeRunning) { shortest = min(width, height).toFloat() adjustRadius(shortest) isFirstTimeRunning = false } drawHelperCircleLine(canvas, .5f, .5f) { 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 drawBottomBar(canvas, start, stop) } canvas.drawLines(points, paint) } } private fun drawBottomBar(canvas: Canvas, start: Pair<Float, Float>, stop: Pair<Float, Float>){ canvas.drawCircle(start.first, start.second, barWidth / 2, paint) //draw floor canvas.drawCircle(stop.first, stop.second, barWidth / 2, paint) // draw ceil } 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 } } } ``` **VisualizerHelper.kt** ```kotlin ** *Created by Nhien Nguyen on 8/19/2022 */ class VisualizerHelper { private var fftM: FloatArray private var fftBuffer: FloatArray private var listener: FFTAudioProcessor.FFTListener private lateinit var audioProcessor: FFTAudioProcessor init { fftBuffer = FloatArray(FFTAudioProcessor.SAMPLE_SIZE + 2) fftM = FloatArray(fftBuffer.size / 2 - 1) listener = object : FFTAudioProcessor.FFTListener{ override fun onFFTReady(sampleRateHz: Int, channelCount: Int, fft: FloatArray) { fftBuffer = fft } override fun onBitrateChange(bitrateFactor: Int) { // Will be implemented soon or maybe delete if unnecessary } } } fun setAudioProcessor(processor: FFTAudioProcessor){ this.audioProcessor = processor processor.listener = listener } fun getFftBuffer(): FloatArray { return fftBuffer } private fun getFftMagnitude(): FloatArray { for(k in fftM.indices){ val i = (k+1) * 2 fftM[k] = hypot(fftBuffer[i], fftBuffer[i+1]) } return fftM } /** * Get Fft values from startHz to endHz */ fun getFftMagnitudeRange(startHz: Int, endHz: Int): FloatArray { return getFftMagnitude().copyOfRange(hzToFftIndex(startHz), hzToFftIndex(endHz)) } /** * Release visualizer when not using anymore */ fun release() { fftM = emptyArray<Float>().toFloatArray() fftBuffer = emptyArray<Float>().toFloatArray() } companion object{ /** * Equation from documentation, kth frequency = k*Fs/(n/2) */ fun hzToFftIndex(Hz: Int): Int { return Math.min(Math.max(Hz * 1024 / (44100 * 2), 0), 255) } } } ``` **Painter.kt** ```kotlin abstract class Painter { private val li = LinearInterpolator() private val sp = AkimaSplineInterpolator() private var patched: FloatArray = FloatArray(0) abstract var paint: Paint /** * 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) /** * 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: String ): PolynomialSplineFunction { val nRaw = gravityModels.size val xRaw = DoubleArray(nRaw) { (it * sliceNum).toDouble() / (nRaw - 1) } val yRaw = DoubleArray(nRaw) gravityModels.forEachIndexed { index, bar -> yRaw[index] = bar.height.toDouble() } val psf: PolynomialSplineFunction psf = when (interpolator) { "li", "linear" -> li.interpolate(xRaw, yRaw) "sp", "spline" -> sp.interpolate(xRaw, yRaw) else -> li.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 { val nRaw = gravityModels.size val xRaw = DoubleArray(nRaw) { ((it - 1) * sliceNum).toDouble() / (nRaw - 1 - 2) } val yRaw = DoubleArray(nRaw) gravityModels.forEachIndexed { index, bar -> yRaw[index] = bar.height.toDouble() } val psf: PolynomialSplineFunction = 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 { val threshold = 5f fft.forEach { if (it > threshold) return false } Log.i("IsQuiet", "True") return true } /** * 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)) } /** * 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 } /** * Patch the Fft to a MirrorFft * * @param fft Fft * @param mode when 0 -> do nothing * when 1 -> * `[0, 1, ..., n] -> [n, ..., 1, 0, 0, 1, ..., n]` * when 2 -> * `[0, 1, ..., n] -> [0, 1, ..., n, n, ..., 1, 0]` * when 3 -> * `[0, 1, ..., n] -> [n/2, ..., 1, 0, 0, 1, ..., n/2]` * when 4 -> * `[0, 1, ..., n] -> [0, 1, ..., n/2, n/2, ..., 1, 0]` * @return MirrorFft */ fun getMirrorFft(fft: FloatArray, mode: Int = 1): FloatArray { return when (mode) { 1 -> { fft.sliceArray(0..fft.lastIndex).reversedArray() + fft.sliceArray(0..fft.lastIndex) } 2 -> { fft.sliceArray(0..fft.lastIndex) + fft.sliceArray(0..fft.lastIndex).reversedArray() } 3 -> { fft.sliceArray(0..fft.lastIndex / 2) .reversedArray() + fft.sliceArray(0..fft.lastIndex / 2) } 4 -> { fft.sliceArray(0..fft.lastIndex / 2) + fft.sliceArray(0..fft.lastIndex / 2) .reversedArray() } else -> fft } } /** * Boost high values while suppress low values, generally give a powerful feeling * @param fft Fft * @param param Parameter, adjust to fit your liking * @return PowerFft */ fun getPowerFft(fft: FloatArray, param: Float = 100.0F): FloatArray { return fft.map { it * it / param }.toFloatArray() } 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 } } } } ``` **dimens.xml** ```xml <?xml version="1.0" encoding="utf-8"?> <resources> <dimen name="default_disc_margin">12dp</dimen> <dimen name="default_visualizer_max_height">36dp</dimen> </resources> ```