Skip to content

FM.kt

src/jvmMain/kotlin/afield/audio/synth/FM.kt · 215 lines

@file:afield.runtime.completion.CompletionCategory("Synthesis")
package afield.audio.synth

import kotlin.math.PI
import kotlin.math.floor
import kotlin.math.sin

Frequency-modulation synthesis in two pieces: FMOperator is one modulated sine, and FMPatch wires several of them into the classic algorithms.

An operator is phase modulation rather than true frequency modulation. Its phase advances at freq, and the modulation is added to that phase where the sine is read rather than to the frequency, so it does not accumulate. Operators are connected by assigning to pmInput, and since the modulator is an ordinary Signal the modulation index is a gain in front of it: car.pmInput = mod * Param(3f). feedbackAmount adds an operator's own last sample to its phase, and unlike the rest it is a plain Float fixed at construction rather than a Param.

FMPatch is three builders over that one part, each returning a bare Signal to be enveloped and piped onward like any other source. twoOp is a modulator into a carrier at a frequency ratio, threeOp is a three-operator serial chain, and fourOp takes an algorithm number choosing between a serial chain, two modulators summed into one, two independent stacks mixed, and four carriers added together. Any other number throws.

FMOperator – single FM synthesis operator

class FMOperator

An FM (phase-modulation) operator: sine oscillator with an external phase-modulation input and optional self-feedback.

Usage patterns

2-operator FM:

val mod = FMOperator(freq = Param(220f))
val car = FMOperator(freq = Param(440f))
car.pmInput = mod * Param(3f)          // modulation index = 3
val out = car * envelope

Self-feedback (metallic tones):

val op = FMOperator(freq = Param(440f), feedbackAmount = 0.7f)

FM theory refresher

y(t) = sin(2π·f·t + I·modulator(t)) where I is the modulation index. Higher I → more sidebands. This operator uses phase modulation (mathematically equivalent to FM for sinusoidal modulators, and more stable for feedback).

class FMOperator(
    /** Carrier / operator frequency. */
    val freq: Param = Param(440f),
    /** Amplitude (useful for stacking operators). */
    val amplitude: Param = Param(1f),
    /** Self-feedback amount (0 = none, ~0.7 = metallic, >1 = chaotic). */
    val feedbackAmount: Float = 0f
) : Signal() {

var pmInput: Signal? = null

External phase-modulation input (set to modulator * index).

    private var phase: Double = 0.0
    private var prevOut: Double = 0.0   // for feedback

    private var freqBuf = FloatArray(0)
    private var ampBuf  = FloatArray(0)
    private var pmBuf   = FloatArray(0)

override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)

    override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
        if (freqBuf.size < numFrames) {
            freqBuf = FloatArray(numFrames)
            ampBuf  = FloatArray(numFrames)
            pmBuf   = FloatArray(numFrames)
        }
        freq.render(freqBuf, numFrames, ctx)
        amplitude.render(ampBuf, numFrames, ctx)
        val pm = pmInput
        if (pm != null) pm.render(pmBuf, numFrames, ctx)
        else Buf.clear(pmBuf, 0, numFrames)

        val twoPi = 2.0 * PI
        val invSr = ctx.inverseSampleRate.toDouble()
        val fb = feedbackAmount.toDouble()

        for (i in 0 until numFrames) {
            val modPhase = pmBuf[i].toDouble() + fb * prevOut
            val sample = sin(twoPi * phase + modPhase)
            prevOut = sample
            output[i] = (sample * ampBuf[i]).toFloat()
            phase += freqBuf[i].toDouble() * invSr
            phase -= floor(phase)
        }
    }
}

FMPatch – pre-wired multi-operator FM algorithms

object FMPatch

Convenience builder for classic FM algorithms.

// Classic 2-op bass
val bass = FMPatch.twoOp(
    carrierFreq = 110f,
    ratio = 1f,
    index = Param(2f),
    feedback = 0.3f
)

// DX7-style electric piano (algorithm 5)
val epiano = FMPatch.threeOp(
    freq = 440f,
    ratios = floatArrayOf(1f, 1f, 14f),
    indices = floatArrayOf(2f, 1.5f, 0.5f)
)
object FMPatch {

fun twoOp

2-operator FM: mod → car

@param carrierFreq Carrier frequency in Hz. @param ratio Modulator : carrier frequency ratio. @param index Modulation index (Signal for dynamic control). @param feedback Modulator self-feedback (0…1).

    fun twoOp(
        carrierFreq: Float,
        ratio: Float = 2f,
        index: Param = Param(2f),
        feedback: Float = 0f
    ): Signal {
        val modFreq = Param(carrierFreq * ratio)
        val mod = FMOperator(freq = modFreq, feedbackAmount = feedback)
        val car = FMOperator(freq = Param(carrierFreq))
        car.pmInput = mod * index
        return car
    }

fun threeOp

3-operator serial chain: op3 → op2 → op1 (carrier)

    fun threeOp(
        freq: Float,
        ratios: FloatArray = floatArrayOf(1f, 2f, 3f),
        indices: FloatArray = floatArrayOf(1f, 1f, 0.5f),
        feedback: Float = 0f
    ): Signal {
        val op3 = FMOperator(freq = Param(freq * ratios[2]), feedbackAmount = feedback)
        val op2 = FMOperator(freq = Param(freq * ratios[1]))
        op2.pmInput = op3 * indices[2]
        val op1 = FMOperator(freq = Param(freq * ratios[0]))
        op1.pmInput = op2 * indices[1]
        return op1
    }

fun fourOp

4-operator FM with DX7-style algorithm selection.

Algorithms (simplified DX7 subset): - 1: 4→3→2→1 (serial chain) - 2: (3+4)→2→1 (parallel modulators into carrier chain) - 3: 4→3, 2→1, output = 1+3 (two parallel stacks) - 4: 1+2+3+4 (all carriers, additive)

body — 41 lines, from line 174
    fun fourOp(
        freq: Float,
        ratios: FloatArray = floatArrayOf(1f, 1f, 2f, 3f),
        indices: FloatArray = floatArrayOf(0f, 1f, 1f, 0.5f),
        algorithm: Int = 1,
        feedback: Float = 0f
    ): Signal {
        val ops = Array(4) { i ->
            FMOperator(
                freq = Param(freq * ratios[i]),
                feedbackAmount = if (i == 3) feedback else 0f
            )
        }
        return when (algorithm) {
            1 -> {
                // 4→3→2→1
                ops[2].pmInput = ops[3] * indices[3]
                ops[1].pmInput = ops[2] * indices[2]
                ops[0].pmInput = ops[1] * indices[1]
                ops[0]
            }
            2 -> {
                // (3+4)→2→1
                ops[1].pmInput = (ops[2] + ops[3]) * indices[2]
                ops[0].pmInput = ops[1] * indices[1]
                ops[0]
            }
            3 -> {
                // two stacks: 4→3, 2→1, output = 1+3
                ops[2].pmInput = ops[3] * indices[3]
                ops[0].pmInput = ops[1] * indices[1]
                Mix(ops[0], ops[2])
            }
            4 -> {
                // all additive
                Mix(*ops)
            }
            else -> throw IllegalArgumentException("Unknown FM algorithm: $algorithm")
        }
    }
}