# PARTICLE ANIMATION
**ParticleAnimation.kt**
```kotlin
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import com.annhienktuit.domain.models.EPointF
import com.annhienktuit.muzikv2.R
import com.annhienktuit.muzikv2.enums.Interpolation
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 java.util.*
import kotlin.math.PI
/**
*Created by Nhien Nguyen on 9/26/2022
*/
class ParticleAnimation(
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,
) : Painter() {
private var upperGravityPoints = Array(0) { GravityModel(0f) }
private val barWidth =
context.resources.getDimensionPixelSize(R.dimen.default_visualizer_bar_width).toFloat()
private val radiusR: Float = .4f
private val ampR: Float = 1f //The rate of amplification
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 dotCoordinate = Pair(0F, 0F)
private var currentHeight = 0
private var currentWidth = 0
lateinit var upperFFT: FloatArray
var upperCurveUtil: PolynomialSplineFunction? = null
// 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 bottom animator
private var bottomLinePaint = Paint(Paint.ANTI_ALIAS_FLAG)
lateinit var bottomFFT: FloatArray
private var polyPathsUtil = PolyBezierPathUtil()
var bottomCurveUtil: PolynomialSplineFunction? = null
private var bottomStartHZ = 8000
private var bottomEndHz = 10000
private var bottomGravityPoints = Array(0) { GravityModel(0f) }
//Fields for spears
private var spearPaint = Paint(Paint.ANTI_ALIAS_FLAG)
init {
paint.apply {
color = Color.WHITE
style = Paint.Style.FILL
strokeWidth = barWidth
strokeCap = Paint.Cap.ROUND
}
bottomLinePaint.apply {
color = Color.WHITE
style = Paint.Style.STROKE
strokeWidth = barWidth / 2
strokeCap = Paint.Cap.ROUND
}
spearPaint.apply {
color = Color.WHITE
style = Paint.Style.FILL
strokeWidth = barWidth / 2
strokeCap = Paint.Cap.ROUND
alpha = 20
}
}
override fun calc(helper: VisualizerHelper) {
calcForUpperAnimator(helper)
calcForBottomAnimator(helper)
}
private fun calcForBottomAnimator(helper: VisualizerHelper) {
bottomFFT = (helper.getFftMagnitudeRange(bottomStartHZ, bottomEndHz))
bottomFFT = getCircleFft(bottomFFT)
if (bottomGravityPoints.size != bottomFFT.size) {
bottomGravityPoints = Array(bottomFFT.size) {
GravityModel(0F)
}
}
bottomGravityPoints.forEachIndexed { index, bar ->
bar.update(bottomFFT[index] * ampR)
}
bottomCurveUtil = interpolateFft(bottomGravityPoints, numberOfDot / 5, interpolator)
}
private fun calcForUpperAnimator(helper: VisualizerHelper) {
upperFFT = helper.getFftMagnitudeRange(startHz, endHz)
upperFFT = getPowerFft(upperFFT)
upperFFT = if (firstTimeRunning) {
getCircleFft(upperFFT, true)
} else {
getCircleFft(upperFFT)
}
minMaxNormalize(upperFFT, visualizerMaxHeight, 0)
if (upperGravityPoints.size != upperFFT.size) upperGravityPoints = Array(upperFFT.size) {
GravityModel(
0f
)
}
upperGravityPoints.forEachIndexed { index, bar -> bar.update(upperFFT[index] * ampR) }
upperCurveUtil = interpolateFftCircle(upperGravityPoints, numberOfDot, interpolator)
}
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 = Integer.min(width, height).toFloat()
adjustRadius(shortest)
}
drawHelperCircleLine(canvas, .5f, .5f) {
if (!firstTimeRunning || !drawWithoutCalc) {
for (idx in 0 until numberOfDot) {
//TODO: Replace these magic numbers
upperPoints[idx] = toCartesianEpointF(
shortest / 2f * (radiusR + 0.03F) + upperCurveUtil?.value(idx.toDouble())!!
.toFloat(), angleOffset * idx
)
bottomPoints[idx] = toCartesianEpointF(
shortest / 2.4f * (radiusR + 0.03F) + bottomCurveUtil?.value(idx.toDouble())!!
.toFloat(), angleOffset * idx
)
}
}
}
upperPoints[numberOfDot] = upperPoints[0]
bottomPoints[numberOfDot] = bottomPoints[0] // we do this to connect the begin-end of line
for (idx in 0 until upperPoints.size) {
canvas.drawCircle(upperPoints[idx].x, upperPoints[idx].y, 4f, paint)
}
canvas.drawPath(
polyPathsUtil.computePathThroughKnots(bottomPoints.asList()), bottomLinePaint
)
drawSpears(canvas)
if (firstTimeRunning) firstTimeRunning = false
}
private fun drawSpears(canvas: Canvas) {
for (idx in bottomPoints.indices) {
if (upperPoints[idx].distanceFrom(bottomPoints[idx]) > drawSpearThreshold) {
canvas.drawLine(
upperPoints[idx].x,
upperPoints[idx].y,
(bottomPoints[idx].x + upperPoints[idx].x) / 2,
(bottomPoints[idx].y + upperPoints[idx].y) / 2,
spearPaint
)
}
}
}
/**
* 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))
val currentRadius = (firstPoint.first - secondPoint.first) / 2
val newRadius: Float
if (currentRadius < discRadius) {
newRadius = discRadius.toFloat() + discMargin
val ratio = (newRadius / currentRadius)
this.shortest *= ratio
}
}
/**
* 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)
}
}
}
}
/**
* Handle high frequency value to avoid low variability
* If that data is in range [[visualizerMaxHeight] - [highFreqDelta] , [visualizerMaxHeight]]
*/
private fun handleHighFreqValue(idx: Int) {
try {
if (frequencyMap.containsKey(idx)) {
frequencyMap[idx] = frequencyMap[idx]!!.plus(1)
} else {
frequencyMap[idx] = 1
}
if (frequencyMap[idx]!! > 3) {
frequencyMap[idx] = 0
upperFFT[idx] =
(random.nextInt((visualizerMaxHeight - highFreqDelta) - highFreqDelta + 1) + highFreqDelta).toFloat()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 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
}
}
private fun Float.isExclusive(start: Int, stop: Int): Boolean {
return (this >= start && this <= stop)
}
private fun getShortest(): Float = Integer.min(currentWidth, currentHeight).toFloat()
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()
}
companion object {
private const val TAG = "ParticleAnimation"
private const val numberOfDot: Int = 100
private const val drawSpearThreshold = 20F
val upperPoints = Array(numberOfDot + 1) { EPointF() }
val bottomPoints = Array(numberOfDot + 1) { EPointF() }
}
}
```
**EPointF.java**
```java
import androidx.annotation.NonNull;
/**
* Created by Nhien Nguyen on 5/11/2022
*/
public class EPointF {
public float x;
public float y;
public EPointF(){};
public EPointF(final float x, final float y) {
this.x = x;
this.y = y;
}
public void setX(float x) {
this.x = x;
}
public void setY(float y) {
this.y = y;
}
public void setCoordinate(float x, float y){
this.x = x;
this.y = y;
}
public float getX() {
return x;
}
public float getY() {
return y;
}
public EPointF plus(float factor, EPointF ePointF) {
return new EPointF(x + factor * ePointF.x, y + factor * ePointF.y);
}
public EPointF plus(EPointF ePointF) {
return plus(1.0f, ePointF);
}
public EPointF minus(float factor, EPointF ePointF) {
return new EPointF(x - factor * ePointF.x, y - factor * ePointF.y);
}
public EPointF minus(EPointF ePointF) {
return minus(1.0f, ePointF);
}
public EPointF scaleBy(float factor) {
return new EPointF(factor * x, factor * y);
}
public float distanceFrom(EPointF target){
float distanceX = this.x - target.x;
float distanceY = this.y - target.y;
return (float) Math.hypot(distanceX, distanceY);
}
@NonNull
@Override
public String toString() {
return "(" + x +", " + y + ")";
}
}
```
**Change FFTCircleLine.kt to CircleLineAnimation.kt**
```kotlin
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import com.annhienktuit.muzikv2.R
import com.annhienktuit.muzikv2.enums.Interpolation
import com.annhienktuit.muzikv2.ui.views.visualizer.nextgen.painters.Painter
import com.annhienktuit.muzikv2.utils.VisualizerHelper
import com.annhienktuit.muzikv2.utils.maths.polynomials.PolynomialSplineFunction
import java.lang.Integer.min
import java.util.*
import kotlin.math.PI
/**
*Created by Nhien Nguyen on 8/19/2022
*/
class CircleLineAnimation(
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,
) : Painter() {
private var gravityPoints = Array(0) { GravityModel(0f) }
private val barWidth =
context.resources.getDimensionPixelSize(R.dimen.default_visualizer_bar_width).toFloat()
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
override var discRadius = 0
set(value) {
field = value
shortest = getShortest()
adjustRadius(shortest)
}
override var isPlaying = false
var firstTimeRunning = true
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
}
}
/**
* Retrieve the [fft] data from [VisualizerHelper]
* Then do get [getCircleFft] to connect the data
* Then normalize the data using min-max formula
* Put the [fft] to [Painter.GravityModel]
* Finally, get [PolynomialSplineFunction] using [interpolateFftCircle]
*/
override fun calc(helper: VisualizerHelper) {
fft = helper.getFftMagnitudeRange(startHz, endHz)
fft = getPowerFft(fft)
fft = if (firstTimeRunning) {
getCircleFft(fft, true)
} else {
getCircleFft(fft)
}
minMaxNormalize(fft, visualizerMaxHeight, 0)
//Need to be tested more
// if (isSilence(fft)) {
// suppressLowValue(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)
}
/**
* 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) {
try {
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()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
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 || !drawWithoutCalc) {
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
)
if (i < 4) {
handleSomeBarsException()
}
points[4 * i] = start.first
points[4 * i + 1] = start.second
points[4 * i + 2] = stop.first
points[4 * i + 3] = stop.second
}
}
}
canvas.drawLines(points, paint)
if (firstTimeRunning) firstTimeRunning = false
}
private fun handleSomeBarsException() {
if (stop.first < start.first) stop = start
}
/**
* 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 getShortest(): Float = min(currentWidth, currentHeight).toFloat()
companion object {
private const val numberOfBar: Int = 100
val points = FloatArray(4 * numberOfBar)
}
}
```
**FFTVisualizer.kt**
```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 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.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
handleOnFirstRun(canvas, painter)
if ((width > 0 && height > 0 && visible)) {
if (isPlaying) {
painter.calc(visualizerHelper)
painter.draw(canvas, width, height)
postInvalidateDelayed(REFRESH_TIME_60FPS)
} else {
painter.draw(canvas, width, height)
}
} else {
Log.e("DrawStatus", "Cannot draw because height = $height & width = $width")
}
}
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 (!ParticleAnimation.upperPoints.isZeroArray()) {
// painter.draw(canvas, width, height, true)
// }
//We must run this function on 1st time to initialize curve utils
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() {
visible()
invalidate()
}
fun onPause() {
invisible()
}
/**
* Show the visualizer
*/
fun visible() {
visible = true
}
/**
* Hide the visualizer
*/
fun invisible() {
visible = 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
}
}
```
**Painter.kt**
```kotlin
/**
*Created by Nhien Nguyen on 8/19/2022
*/
import android.graphics.Canvas
import android.graphics.Paint
import android.util.Log
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.*
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(0)
private var psf: PolynomialSplineFunction? = null
private var nRaw: Int = 0
private var xRaw = DoubleArray(0)
private var yRaw = DoubleArray(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
): 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) {
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()
}
/**
* 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
}
}
```
**VisualierHelper.kt**
```kotlin
import com.annhienktuit.muzikv2.processors.FFTAudioProcessor
import kotlin.math.hypot
/**
*Created by Nhien Nguyen on 8/19/2022
*/
class VisualizerHelper {
private var fftM: FloatArray
private var fftBuffer: FloatArray
private var listener: FFTAudioProcessor.FFTListener
private var magnitudeIndex: Int = 0
private lateinit var audioProcessor: FFTAudioProcessor
init {
fftBuffer = FloatArray(FFTAudioProcessor.SAMPLE_SIZE + 2)
fftM = FloatArray(fftBuffer.size / 2 - 1)
listener =
FFTAudioProcessor.FFTListener { sampleRateHz, channelCount, fft -> fftBuffer = fft }
}
fun setAudioProcessor(processor: FFTAudioProcessor) {
this.audioProcessor = processor
processor.listener = listener
}
/**
* Get the FFT Magnitude
*/
private fun getFftMagnitude(): FloatArray {
for (k in fftM.indices) {
magnitudeIndex = (k + 1) * 2
fftM[k] = hypot(fftBuffer[magnitudeIndex], fftBuffer[magnitudeIndex + 1])
}
return fftM
}
/**
* Get Fft values from startHz to endHz
*/
fun getFftMagnitudeRange(startHz: Int, endHz: Int): FloatArray {
return getFftMagnitude().copyOfRange(hzToFftIndex(startHz), hzToFftIndex(endHz))
}
/**
* Equation from documentation, kth frequency = k*Fs/(n/2)
*/
private fun hzToFftIndex(Hz: Int): Int {
return (Hz * 1024 / (44100 * 2)).coerceAtLeast(0).coerceAtMost(255)
}
/**
* Release visualizer when not using anymore
*/
fun release() {
fftM = emptyArray<Float>().toFloatArray()
fftBuffer = emptyArray<Float>().toFloatArray()
}
}
```
**PolyBezierPathUtil.java**
```java
import android.graphics.Path;
import com.annhienktuit.domain.models.EPointF;
import java.util.Collection;
import java.util.List;
public class PolyBezierPathUtil {
/**
* Computes a Poly-Bezier curve passing through a given list of knots.
* The curve will be twice-differentiable everywhere and satisfy natural
* boundary conditions at both ends.
*
* @param knots a list of knots
* @return a Path representing the twice-differentiable curve
* passing through all the given knots
*/
public Path computePathThroughKnots(List<EPointF> knots) {
throwExceptionIfInputIsInvalid(knots);
final Path polyBezierPath = new Path();
final EPointF firstKnot = knots.get(0);
polyBezierPath.moveTo(firstKnot.getX(), firstKnot.getY());
/*
* variable representing the number of Bezier curves we will join
* together
*/
final int n = knots.size() - 1;
if (n == 1) {
final EPointF lastKnot = knots.get(1);
polyBezierPath.lineTo(lastKnot.getX(), lastKnot.getY());
} else {
final EPointF[] controlPoints = computeControlPoints(n, knots);
for (int i = 0; i < n; i++) {
final EPointF targetKnot = knots.get(i + 1);
appendCurveToPath(polyBezierPath, controlPoints[i], controlPoints[n + i], targetKnot);
}
}
return polyBezierPath;
}
private EPointF[] computeControlPoints(int n, List<EPointF> knots) {
final EPointF[] result = new EPointF[2 * n];
final EPointF[] target = constructTargetVector(n, knots);
final Float[] lowerDiag = constructLowerDiagonalVector(n - 1);
final Float[] mainDiag = constructMainDiagonalVector(n);
final Float[] upperDiag = constructUpperDiagonalVector(n - 1);
final EPointF[] newTarget = new EPointF[n];
final Float[] newUpperDiag = new Float[n - 1];
// forward sweep for control points c_i,0:
newUpperDiag[0] = upperDiag[0] / mainDiag[0];
newTarget[0] = target[0].scaleBy(1 / mainDiag[0]);
for (int i = 1; i < n - 1; i++) {
newUpperDiag[i] = upperDiag[i] /
(mainDiag[i] - lowerDiag[i - 1] * newUpperDiag[i - 1]);
}
for (int i = 1; i < n; i++) {
final float targetScale = 1 /
(mainDiag[i] - lowerDiag[i - 1] * newUpperDiag[i - 1]);
newTarget[i] =
(target[i].minus(newTarget[i - 1].scaleBy(lowerDiag[i - 1]))).scaleBy(targetScale);
}
// backward sweep for control points c_i,0:
result[n - 1] = newTarget[n - 1];
for (int i = n - 2; i >= 0; i--) {
result[i] = newTarget[i].minus(newUpperDiag[i], result[i + 1]);
}
// calculate remaining control points c_i,1 directly:
for (int i = 0; i < n - 1; i++) {
result[n + i] = knots.get(i + 1).scaleBy(2).minus(result[i + 1]);
}
result[2 * n - 1] = knots.get(n).plus(result[n - 1]).scaleBy(0.5f);
return result;
}
private EPointF[] constructTargetVector(int n, List<EPointF> knots) {
final EPointF[] result = new EPointF[n];
result[0] = knots.get(0).plus(2, knots.get(1));
for (int i = 1; i < n - 1; i++) {
result[i] = (knots.get(i).scaleBy(2).plus(knots.get(i + 1))).scaleBy(2);
}
result[result.length - 1] = knots.get(n - 1).scaleBy(8).plus(knots.get(n));
return result;
}
private Float[] constructLowerDiagonalVector(int length) {
final Float[] result = new Float[length];
for (int i = 0; i < result.length - 1; i++) {
result[i] = 1f;
}
result[result.length - 1] = 2f;
return result;
}
private Float[] constructMainDiagonalVector(int n) {
final Float[] result = new Float[n];
result[0] = 2f;
for (int i = 1; i < result.length - 1; i++) {
result[i] = 4f;
}
result[result.length - 1] = 7f;
return result;
}
private Float[] constructUpperDiagonalVector(int length) {
final Float[] result = new Float[length];
for (int i = 0; i < result.length; i++) {
result[i] = 1f;
}
return result;
}
private void appendCurveToPath(Path path, EPointF control1, EPointF control2, EPointF targetKnot) {
path.cubicTo(
control1.getX(),
control1.getY(),
control2.getX(),
control2.getY(),
targetKnot.getX(),
targetKnot.getY()
);
}
private void throwExceptionIfInputIsInvalid(Collection<EPointF> knots) {
if (knots.size() < 2) {
throw new IllegalArgumentException(
"Collection must contain at least two knots"
);
}
}
}
```