# 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
}
}
```