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