[orx-fft] Add orx-fft for a simple fast fourier transform routine
This commit is contained in:
5
orx-fft/README.md
Normal file
5
orx-fft/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# orx-fft
|
||||
|
||||
Simple forward and inverse FFT routine
|
||||
|
||||
The FFT routine found in `orx-fft` is a Kotlin port of Minim's FFT routine.
|
||||
23
orx-fft/build.gradle.kts
Normal file
23
orx-fft/build.gradle.kts
Normal file
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
org.openrndr.extra.convention.`kotlin-multiplatform`
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val jvmDemo by getting {
|
||||
dependencies {
|
||||
implementation(project(":orx-shapes"))
|
||||
implementation(project(":orx-noise"))
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
225
orx-fft/src/commonMain/kotlin/FFT.kt
Normal file
225
orx-fft/src/commonMain/kotlin/FFT.kt
Normal file
@@ -0,0 +1,225 @@
|
||||
package org.openrndr.extra.fft
|
||||
|
||||
import kotlin.math.*
|
||||
|
||||
/*
|
||||
Based on https://github.com/ddf/Minim/blob/e294e2881a20340603ee0156cb9188c15b5915c2/src/main/java/ddf/minim/analysis/FFT.java
|
||||
I (EJ) stripped away spectrum and averages.
|
||||
|
||||
This is the original license (GPLv2). I am not sure if my low-effort Kotlin port falls under the same license.
|
||||
|
||||
* Copyright (c) 2007 - 2008 by Damien Di Fede <ddf@compartmental.net>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Library General Public License as published
|
||||
* by the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Library General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Library General Public
|
||||
* License along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
|
||||
*/
|
||||
|
||||
class FFT(val size: Int, private val windowFunction: WindowFunction = IdentityWindow()) {
|
||||
|
||||
var real = FloatArray(size)
|
||||
var imag = FloatArray(size)
|
||||
|
||||
private fun setComplex(r: FloatArray, i: FloatArray) {
|
||||
if (real.size != r.size && imag.size != i.size) {
|
||||
error("FourierTransform.setComplex: the two arrays must be the same length as their member counterparts.")
|
||||
} else {
|
||||
r.copyInto(real)
|
||||
i.copyInto(imag)
|
||||
}
|
||||
}
|
||||
|
||||
fun magnitudeSum(includeDC: Boolean = false): Double {
|
||||
var sum = 0.0
|
||||
for (i in (if (includeDC) 0 else 1)..size / 2) {
|
||||
sum += magnitude(i)
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
fun scaleAll(sr: Float, includeDC: Boolean = false) {
|
||||
for (i in (if (includeDC) 0 else 1)..size / 2) {
|
||||
scaleBand(i, sr)
|
||||
}
|
||||
}
|
||||
|
||||
fun magnitude(i: Int): Float {
|
||||
return sqrt(real[i] * real[i] + imag[i] * imag[i])
|
||||
}
|
||||
|
||||
fun phase(i: Int): Float {
|
||||
return atan2(imag[i], real[i])
|
||||
}
|
||||
|
||||
fun shiftPhase(i: Int, shift: Double) {
|
||||
val m = magnitude(i)
|
||||
val phase = phase(i)
|
||||
real[i] = (cos(phase + shift) * m).toFloat()
|
||||
imag[i] = (sin(phase + shift) * m).toFloat()
|
||||
|
||||
if (i != 0 && i != size / 2) {
|
||||
real[(size - i)] = real[i]
|
||||
imag[(size - i)] = -imag[i]
|
||||
}
|
||||
}
|
||||
|
||||
fun scaleBand(i: Int, sr: Float) {
|
||||
if (sr < 0) {
|
||||
error("Can't scale a frequency band by a negative value.")
|
||||
}
|
||||
|
||||
real[i] *= sr
|
||||
imag[i] *= sr
|
||||
|
||||
if (i != 0 && i != size / 2) {
|
||||
real[(size - i)] = real[i]
|
||||
imag[(size - i)] = -imag[i]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// performs an in-place fft on the data in the real and imag arrays
|
||||
// bit reversing is not necessary as the data will already be bit reversed
|
||||
private fun fft() {
|
||||
var halfSize = 1
|
||||
while (halfSize < real.size) {
|
||||
// float k = -(float)Math.PI/halfSize;
|
||||
// phase shift step
|
||||
// float phaseShiftStepR = (float)Math.cos(k);
|
||||
// float phaseShiftStepI = (float)Math.sin(k);
|
||||
// using lookup table
|
||||
val phaseShiftStepR = cos[halfSize]
|
||||
val phaseShiftStepI = sin[halfSize]
|
||||
// current phase shift
|
||||
var currentPhaseShiftR = 1.0f
|
||||
var currentPhaseShiftI = 0.0f
|
||||
for (fftStep in 0 until halfSize) {
|
||||
var i = fftStep
|
||||
while (i < real.size) {
|
||||
val off = i + halfSize
|
||||
val tr = (currentPhaseShiftR * real[off]) - (currentPhaseShiftI * imag[off])
|
||||
val ti = (currentPhaseShiftR * imag[off]) + (currentPhaseShiftI * real[off])
|
||||
real[off] = real[i] - tr
|
||||
imag[off] = imag[i] - ti
|
||||
real[i] += tr
|
||||
imag[i] += ti
|
||||
i += 2 * halfSize
|
||||
}
|
||||
val tmpR = currentPhaseShiftR
|
||||
currentPhaseShiftR = (tmpR * phaseShiftStepR) - (currentPhaseShiftI * phaseShiftStepI)
|
||||
currentPhaseShiftI = (tmpR * phaseShiftStepI) + (currentPhaseShiftI * phaseShiftStepR)
|
||||
}
|
||||
halfSize *= 2
|
||||
}
|
||||
}
|
||||
|
||||
private fun doWindow(samples: FloatArray) {
|
||||
windowFunction.apply(samples)
|
||||
}
|
||||
|
||||
|
||||
fun forward(buffer: FloatArray) {
|
||||
if (buffer.size != size) {
|
||||
error("FFT.forward: The length of the passed sample buffer must be equal to timeSize().")
|
||||
}
|
||||
doWindow(buffer)
|
||||
// copy samples to real/imag in bit-reversed order
|
||||
bitReverseSamples(buffer, 0)
|
||||
// perform the fft
|
||||
fft()
|
||||
}
|
||||
|
||||
fun forward(buffer: FloatArray, startAt: Int) {
|
||||
if (buffer.size - startAt < size) {
|
||||
error(
|
||||
"FourierTransform.forward: not enough samples in the buffer between " +
|
||||
startAt + " and " + buffer.size + " to perform a transform."
|
||||
)
|
||||
}
|
||||
windowFunction.apply(buffer, startAt, size)
|
||||
bitReverseSamples(buffer, startAt)
|
||||
fft()
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a forward transform on the passed buffers.
|
||||
*
|
||||
* @param buffReal the real part of the time domain signal to transform
|
||||
* @param buffImag the imaginary part of the time domain signal to transform
|
||||
*/
|
||||
fun forward(buffReal: FloatArray, buffImag: FloatArray) {
|
||||
if (buffReal.size != size || buffImag.size != size) {
|
||||
error("FFT.forward: The length of the passed buffers must be equal to timeSize().")
|
||||
}
|
||||
setComplex(buffReal, buffImag)
|
||||
bitReverseComplex()
|
||||
fft()
|
||||
}
|
||||
|
||||
fun inverse(buffer: FloatArray) {
|
||||
if (buffer.size > real.size) {
|
||||
error("FFT.inverse: the passed array's length must equal FFT.timeSize().")
|
||||
}
|
||||
// conjugate
|
||||
for (i in 0 until size) {
|
||||
imag[i] *= -1.0f
|
||||
}
|
||||
bitReverseComplex()
|
||||
fft()
|
||||
// copy the result in real into buffer, scaling as we do
|
||||
for (i in buffer.indices) {
|
||||
buffer[i] = real[i] / real.size
|
||||
}
|
||||
}
|
||||
|
||||
private val reverse by lazy { buildReverseTable() }
|
||||
|
||||
private fun buildReverseTable(): IntArray {
|
||||
val reverse = IntArray(size)
|
||||
// set up the bit reversing table
|
||||
reverse[0] = 0
|
||||
var limit = 1
|
||||
var bit = size / 2
|
||||
while (limit < size) {
|
||||
for (i in 0 until limit) reverse[i + limit] = reverse[i] + bit
|
||||
limit = limit shl 1
|
||||
bit = bit shr 1
|
||||
}
|
||||
return reverse
|
||||
}
|
||||
|
||||
// copies the values in the samples array into the real array
|
||||
// in bit reversed order. the imag array is filled with zeros.
|
||||
private fun bitReverseSamples(samples: FloatArray, startAt: Int) {
|
||||
for (i in 0 until size) {
|
||||
real[i] = samples[startAt + reverse[i]]
|
||||
imag[i] = 0.0f
|
||||
}
|
||||
}
|
||||
|
||||
// bit reverse real[] and imag[]
|
||||
private fun bitReverseComplex() {
|
||||
val revReal = FloatArray(real.size)
|
||||
val revImag = FloatArray(imag.size)
|
||||
for (i in real.indices) {
|
||||
revReal[i] = real[reverse[i]]
|
||||
revImag[i] = imag[reverse[i]]
|
||||
}
|
||||
real = revReal
|
||||
imag = revImag
|
||||
}
|
||||
|
||||
// lookup tables
|
||||
private val sin by lazy { FloatArray(size) { i -> sin((-PI.toFloat() / i).toDouble()).toFloat() } }
|
||||
private val cos by lazy { FloatArray(size) { i -> cos((-PI.toFloat() / i).toDouble()).toFloat() } }
|
||||
}
|
||||
9
orx-fft/src/commonMain/kotlin/HannWindow.kt
Normal file
9
orx-fft/src/commonMain/kotlin/HannWindow.kt
Normal file
@@ -0,0 +1,9 @@
|
||||
package org.openrndr.extra.fft
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
|
||||
class HannWindow : WindowFunction() {
|
||||
override fun value(length: Int, index: Int): Float = 0.5f * (1f - cos((PI * 2.0 * index / (length - 1f)))
|
||||
.toFloat())
|
||||
}
|
||||
6
orx-fft/src/commonMain/kotlin/IdentityWindow.kt
Normal file
6
orx-fft/src/commonMain/kotlin/IdentityWindow.kt
Normal file
@@ -0,0 +1,6 @@
|
||||
package org.openrndr.extra.fft
|
||||
|
||||
class IdentityWindow
|
||||
: WindowFunction() {
|
||||
override fun value(length: Int, index: Int): Float = 1.0f
|
||||
}
|
||||
39
orx-fft/src/commonMain/kotlin/WindowFunction.kt
Normal file
39
orx-fft/src/commonMain/kotlin/WindowFunction.kt
Normal file
@@ -0,0 +1,39 @@
|
||||
package org.openrndr.extra.fft
|
||||
|
||||
abstract class WindowFunction {
|
||||
private var length: Int = 0
|
||||
|
||||
/**
|
||||
* Apply the window function to a sample buffer.
|
||||
*
|
||||
* @param samples a sample buffer
|
||||
*/
|
||||
fun apply(samples: FloatArray) {
|
||||
this.length = samples.size
|
||||
|
||||
for (n in samples.indices) {
|
||||
samples[n] *= value(samples.size, n)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the window to a portion of this sample buffer,
|
||||
* given an offset from the beginning of the buffer
|
||||
* and the number of samples to be windowed.
|
||||
*
|
||||
* @param samples
|
||||
* float[]: the array of samples to apply the window to
|
||||
* @param offset
|
||||
* int: the index in the array to begin windowing
|
||||
* @param length
|
||||
* int: how many samples to apply the window to
|
||||
*/
|
||||
fun apply(samples: FloatArray, offset: Int, length: Int) {
|
||||
this.length = length
|
||||
|
||||
for (n in offset until offset + length) {
|
||||
samples[n] *= value(length, n - offset)
|
||||
}
|
||||
}
|
||||
protected abstract fun value(length: Int, index: Int): Float
|
||||
}
|
||||
109
orx-fft/src/jvmDemo/kotlin/DemoFFTShape01.kt
Normal file
109
orx-fft/src/jvmDemo/kotlin/DemoFFTShape01.kt
Normal file
@@ -0,0 +1,109 @@
|
||||
import org.openrndr.application
|
||||
|
||||
import org.openrndr.color.ColorRGBa
|
||||
import org.openrndr.extra.fft.FFT
|
||||
import org.openrndr.extra.noise.scatter
|
||||
import org.openrndr.extra.shapes.hobbycurve.hobbyCurve
|
||||
import org.openrndr.math.Vector2
|
||||
import org.openrndr.extra.shapes.splines.catmullRom
|
||||
import org.openrndr.extra.shapes.splines.toContour
|
||||
import org.openrndr.math.smoothstep
|
||||
import org.openrndr.math.transforms.buildTransform
|
||||
import kotlin.math.*
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Demonstration of using FFT to filter a two-dimensional shape. Mouse xy-position is mapped
|
||||
* to lowpass and highpass settings of the filter.
|
||||
*/
|
||||
fun main() {
|
||||
application {
|
||||
configure {
|
||||
width = 720
|
||||
height = 720
|
||||
}
|
||||
program {
|
||||
val fftSize = 512
|
||||
val fft = FFT(fftSize)
|
||||
fun List<Vector2>.toFloatArrays(x: FloatArray, y: FloatArray) {
|
||||
for ((index, segment) in this.withIndex()) {
|
||||
x[index] = segment.x.toFloat()
|
||||
y[index] = segment.y.toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
fun vectorsFromFloatArrays(x: FloatArray, y: FloatArray): List<Vector2> {
|
||||
val n = x.size
|
||||
val result = mutableListOf<Vector2>()
|
||||
for (i in 0 until n) {
|
||||
result.add(Vector2(x[i].toDouble(), y[i].toDouble()))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun lp(t: Double, c: Double): Double {
|
||||
return smoothstep(c, c - 0.1, t)
|
||||
}
|
||||
|
||||
fun hp(t: Double, c: Double): Double {
|
||||
return smoothstep(c, c + 0.1, t)
|
||||
}
|
||||
|
||||
val c = hobbyCurve(
|
||||
drawer.bounds.scatter(40.0, distanceToEdge = 100.0, random = Random(0)),
|
||||
true
|
||||
).transform(buildTransform { translate(-drawer.bounds.center) })
|
||||
|
||||
val x = FloatArray(fftSize)
|
||||
val y = FloatArray(fftSize)
|
||||
|
||||
val xFiltered = FloatArray(fftSize)
|
||||
val yFiltered = FloatArray(fftSize)
|
||||
|
||||
extend {
|
||||
c.equidistantPositions(fftSize).take(fftSize).toFloatArrays(x, y)
|
||||
|
||||
// process x-component
|
||||
fft.forward(x)
|
||||
val xpower = fft.magnitudeSum()
|
||||
|
||||
val lpc = mouse.position.x / width
|
||||
val hpc = mouse.position.y / height
|
||||
|
||||
for (i in 1..fftSize / 2) {
|
||||
val t = i.toDouble() / (fftSize / 2 - 1)
|
||||
val f = max(lp(t, lpc), hp(t, hpc))
|
||||
fft.scaleBand(i, f.toFloat())
|
||||
}
|
||||
val xfpower = fft.magnitudeSum().coerceAtLeast(1.0)
|
||||
|
||||
fft.scaleAll((xpower / xfpower).toFloat())
|
||||
fft.inverse(xFiltered)
|
||||
|
||||
// process y-component
|
||||
fft.forward(y)
|
||||
val ypower = fft.magnitudeSum()
|
||||
|
||||
for (i in 1..fftSize / 2) {
|
||||
val t = i.toDouble() / (fftSize / 2 - 1)
|
||||
val f = max(lp(t, lpc), hp(t, hpc))
|
||||
fft.scaleBand(i, f.toFloat())
|
||||
}
|
||||
val yfpower = fft.magnitudeSum().coerceAtLeast(1.0)
|
||||
|
||||
fft.scaleAll((ypower / yfpower).toFloat())
|
||||
fft.inverse(yFiltered)
|
||||
|
||||
val cr = vectorsFromFloatArrays(xFiltered, yFiltered).catmullRom(closed = true).toContour()
|
||||
//val cr = ShapeContour.fromPoints(vectorsFromFloatArrays(xr, yr), closed=true)
|
||||
|
||||
val recenteredShape = cr.transform(buildTransform {
|
||||
translate(drawer.bounds.center)
|
||||
})
|
||||
drawer.fill = null
|
||||
drawer.stroke = ColorRGBa.WHITE
|
||||
drawer.contour(recenteredShape)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user