# BUILD 14/9 **Hope this build will resolve the fucking cannot play(0,-1,null) problem 😢** **FFTAudioProcessor.java** ```java /** * Created by Nhien Nguyen on 5/5/2022 * <p> * An audio processor which forwards the input to the output, * but also takes the input and executes a Fast-Fourier Transformation (FFT) on it. * The results of this transformation is an array of frequencies with their amplitudes, * which will be forwarded to the listener<br> * The sequence diagram is apply in Exoplayer 2.16.1 * <p> * <p> * <center><object style="width: 480px; height: 190px;" type="image/jpeg" * <img * src="https://i.imgur.com/Bjcr9dy.jpg" style="width: 480px; height: 190px" * alt="FFTAudioProcessor Sequence diagram"></object></center> * <p> */ @SuppressWarnings("FieldCanBeLocal") public class FFTAudioProcessor implements AudioProcessor { public static final String TAG = "FFTAudioProcessor"; public static int SAMPLE_SIZE = 4096; //Default 4096 -> The higher SAMPLE_SIZE value, the slower data return public final long EXO_MIN_BUFFER_DURATION_US = 250000L; public final long EXO_MAX_BUFFER_DURATION_US = 750000L; public final long EXO_BUFFER_MULTIPLICATION_FACTOR = 4; private final boolean isDebugging = false; public int BUFFER_EXTRA_SIZE = SAMPLE_SIZE * 8; public FFTAudioProcessor.FFTListener listener; private byte[] mTempByteArray = new byte[SAMPLE_SIZE * 2]; private float[] mSrc = new float[SAMPLE_SIZE]; private float[] mDst = new float[SAMPLE_SIZE + 2]; private float[] fft; private byte[] tempBufferArray; private Noise mNoise; private ByteBuffer mProcessBuffer; private ByteBuffer mFftBuffer; private ByteBuffer mOutputBuffer; private ByteBuffer mSrcBuffer; private int mSrcBufferPosition = 0; private int mAudioTrackBufferSize = 0; private int mChannelCount; private int mSampleRateHz; private int mEncoding = 0; private int mBytesToProcess = SAMPLE_SIZE * 2; private int position; private int limit; private int frameCount; private int singleChannelOutputSize; //UNUSED YET private int outputSize; private boolean mIsActive; private boolean wasActive; private boolean mInputEnded; private long mFFTProcessTimeMs; public FFTAudioProcessor() { mProcessBuffer = AudioProcessor.EMPTY_BUFFER; mFftBuffer = AudioProcessor.EMPTY_BUFFER; mOutputBuffer = AudioProcessor.EMPTY_BUFFER; mChannelCount = Format.NO_VALUE; mSampleRateHz = Format.NO_VALUE; } private int getDefaultBufferSizeInBytes() { if (isDebugging) Log.i(TAG, "getDefaultBufferSizeInBytes"); int outputPcmFrameSize = Util.getPcmFrameSize(mEncoding, mChannelCount); int minBufferSize = AudioTrack.getMinBufferSize(mSampleRateHz, Util.getAudioTrackChannelConfig(mChannelCount), mEncoding); Assertions.checkState(minBufferSize != ERROR_BAD_VALUE); int multipliedBufferSize = (int) (minBufferSize * EXO_BUFFER_MULTIPLICATION_FACTOR); int minAppBufferSize = (int) durationUsToFrames(EXO_MIN_BUFFER_DURATION_US) * outputPcmFrameSize; long maxAppBufferSize = Math.max(minBufferSize, (durationUsToFrames(EXO_MAX_BUFFER_DURATION_US) * outputPcmFrameSize)); int bufferSizeInFrames = (int) (Util.constrainValue(multipliedBufferSize, minAppBufferSize, maxAppBufferSize) / outputPcmFrameSize); return bufferSizeInFrames * outputPcmFrameSize; } private long durationUsToFrames(long durationUs) { return durationUs * mSampleRateHz / C.MICROS_PER_SECOND; } @Override public boolean configure(int sampleRateHz, int channelCount, int encoding) throws UnhandledFormatException { if (isDebugging) Log.i(TAG, "Configure"); if (encoding != C.ENCODING_PCM_16BIT) { throw new UnhandledFormatException(sampleRateHz, channelCount, encoding); } mSampleRateHz = sampleRateHz; mChannelCount = channelCount; mEncoding = encoding; wasActive = mIsActive; mIsActive = true; mNoise = Noise.real(SAMPLE_SIZE); mAudioTrackBufferSize = getDefaultBufferSizeInBytes(); mSrcBuffer = ByteBuffer.allocate(mAudioTrackBufferSize + BUFFER_EXTRA_SIZE); Log.i("FFTProcessorDetail", toString()); return !wasActive; } @Override public boolean isActive() { return mIsActive; } @Override public int getOutputChannelCount() { return mChannelCount; } @Override public int getOutputEncoding() { return mEncoding; } @Override public int getOutputSampleRateHz() { return mSampleRateHz; } @Override public void queueInput(ByteBuffer inputBuffer) { if (isDebugging) Log.i(TAG, "queueInput"); int remaining = inputBuffer.remaining(); if (remaining == 0) return; position = inputBuffer.position(); limit = inputBuffer.limit(); frameCount = (limit - position) / (2 * mChannelCount); singleChannelOutputSize = frameCount * 2; outputSize = frameCount * mChannelCount * 2; if (mProcessBuffer.capacity() < outputSize) { mProcessBuffer = ByteBuffer.allocateDirect(outputSize).order(ByteOrder.nativeOrder()); } else { mProcessBuffer.clear(); } if (mFftBuffer.capacity() < remaining) { mFftBuffer = ByteBuffer.allocateDirect(remaining).order(ByteOrder.nativeOrder()); } else { mFftBuffer.clear(); } while (position < limit) { int summedUp = 0; for (int channelIndex = 0; channelIndex < mChannelCount; channelIndex++) { short current = inputBuffer.getShort(position + 2 * channelIndex); try { mProcessBuffer.putShort(current); } catch (BufferOverflowException e) { Log.e(TAG, e.getLocalizedMessage()); } summedUp += current; } try { mFftBuffer.putShort((short) (summedUp / mChannelCount)); } catch (BufferOverflowException e) { Log.e(TAG, e.getLocalizedMessage()); } position += mChannelCount * 2; } inputBuffer.position(limit); processFFT(mFftBuffer); mProcessBuffer.flip(); mOutputBuffer = this.mProcessBuffer; } private void processFFT(ByteBuffer buffer) { if (isDebugging) Log.i(TAG, "processFFT"); if (listener == null) { return; } try { mSrcBuffer.put(buffer.array()); mSrcBufferPosition += buffer.array().length; mBytesToProcess = SAMPLE_SIZE * 2; Byte currentByte = null; while (mSrcBufferPosition > mAudioTrackBufferSize) { mSrcBuffer.position(0); mSrcBuffer.get(mTempByteArray, 0, mBytesToProcess); for (int idx = 0; idx < mTempByteArray.length; idx++) { if (currentByte == null) { currentByte = mTempByteArray[idx]; } else { mSrc[idx / 2] = ((float) currentByte * Byte.MAX_VALUE + mTempByteArray[idx]) / (Byte.MAX_VALUE * Byte.MAX_VALUE); mDst[idx / 2] = 0f; currentByte = null; } } tempBufferArray = new byte[mSrcBuffer.remaining()]; Arrays.fill(tempBufferArray, (byte) 0); mSrcBuffer.get(tempBufferArray); mSrcBuffer.position(mBytesToProcess); mSrcBuffer.compact(); mSrcBufferPosition -= mBytesToProcess; mSrcBuffer.position(mSrcBufferPosition); fft = mNoise.fft(mSrc, mDst); listener.onFFTReady(mSampleRateHz, mChannelCount, fft); if (isDebugging) Log.i("FFTProcessTime", "Process in " + (System.currentTimeMillis() - mFFTProcessTimeMs) + " ms"); mFFTProcessTimeMs = System.currentTimeMillis(); } } catch (Exception e) { e.printStackTrace(); } } @Override public void queueEndOfStream() { mInputEnded = true; mProcessBuffer = AudioProcessor.EMPTY_BUFFER; } @Override public ByteBuffer getOutput() { ByteBuffer outputBuffer = this.mOutputBuffer; this.mOutputBuffer = AudioProcessor.EMPTY_BUFFER; return outputBuffer; } @Override public boolean isEnded() { return mInputEnded && mProcessBuffer == AudioProcessor.EMPTY_BUFFER; } @Override public void flush() { mOutputBuffer = AudioProcessor.EMPTY_BUFFER; mInputEnded = false; } @Override public void reset() { if (isDebugging) Log.i(TAG, "reset"); flush(); mProcessBuffer = AudioProcessor.EMPTY_BUFFER; mFftBuffer = AudioProcessor.EMPTY_BUFFER; mOutputBuffer = AudioProcessor.EMPTY_BUFFER; mEncoding = Format.NO_VALUE; mChannelCount = Format.NO_VALUE; mSampleRateHz = Format.NO_VALUE; } public void closeInstance() { mNoise.close(); } public void showToast(Context context, String msg) { Toast.makeText(context, msg, Toast.LENGTH_SHORT).show(); } @NonNull @Override public String toString() { return "FFTAudioProcessor{" + "SAMPLE_SIZE=" + SAMPLE_SIZE + ", BUFFER_EXTRA_SIZE=" + BUFFER_EXTRA_SIZE + ", mAudioTrackBufferSize=" + mAudioTrackBufferSize + ", mChannelCount=" + mChannelCount + ", mSampleRateHz=" + mSampleRateHz + ", mEncoding=" + mEncoding + '}'; } public interface FFTListener { void onFFTReady(int sampleRateHz, int channelCount, float[] FFTArray); } } ``` **FFTCircleLine.kt** ```kotlin /** *Created by Nhien Nguyen on 8/19/2022 */ 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) { fft = helper.getFftMagnitudeRange(startHz, endHz) fft = getPowerFft(fft) fft = if (firstTimeRunning) { getCircleFft(fft, true) } else { getCircleFft(fft) } 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, skipCalc: Boolean) { if (skipCalc) 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 } } } 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) } } ``` **Painter.kt** ```kotlin abstract class Painter { 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: String ): 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) { "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? { 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 } /** * 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 } /** * 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() } open fun saveCurrentState() {} /** * 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]" } } } ```