Envelopes.kt
src/jvmMain/kotlin/afield/audio/synth/Envelopes.kt · 420 lines
@file:afield.runtime.completion.CompletionCategory("Synthesis")
package afield.audio.synth
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.pow
import kotlin.math.sqrt
Control shapes: Gate, ADSR, AR, Line and BreakpointEnvelope generate one, Slew limits how fast one can move, and EnvelopeFollower recovers one from a signal that already exists.
Everything here is a Signal rather than a SignalProcessor, so / does not reach it. An
envelope is applied by multiplying, SawOsc(220.p) * env, and the two that read a signal take it
as a constructor argument instead of through an input. What comes out is a stream of samples like
any other, so an envelope can be handed to a filter cutoff or a delay time as readily as to an
amplitude.
Times are in seconds throughout, and the gated envelopes watch a signal rather than wait for a
method call: ADSR follows the level of its gate and holds at sustain while it is high, AR fires
on the rising edge and runs to the end regardless. Every curve argument is an exponent applied to
a normalised ramp, so 1 is linear, below 1 starts fast and above 1 starts slow.
Gate – simple on/off trigger signal
class Gate : Signal()
A gate signal: outputs 1.0 while open, 0.0 while closed. Thread-safe – call open / close from any thread.
@Volatile var isOpen: Boolean = false
fun open() { isOpen = true }
fun close() { isOpen = false }
fun trigger() { isOpen = true }
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
Buf.fill(output, if (isOpen) 1f else 0f, 0, numFrames)
}
}
ADSR – attack / decay / sustain / release envelope
class ADSR
Classic ADSR envelope generator. Driven by a gate signal: - Gate > 0 → begin attack → decay → sustain (hold) - Gate ≤ 0 → begin release from current level
All times are in seconds. Curvature parameters let you shape each segment: 1.0 = linear, <1 = exponential (fast start),
1 = logarithmic (slow start).
val env = ADSR(gate = myGate, attack = 0.01f, decay = 0.1f,
sustain = 0.7f, release = 0.3f)
val output = oscillator * env
class ADSR(
val gate: Signal = Gate(),
val attack: Param = Param(0.01f),
val decay: Param = Param(0.1f),
val sustain: Param = Param(0.7f),
val release: Param = Param(0.5f),
/** Shape curve: 1 = linear, 0.5 = fast-exponential, 2 = slow-log. */
val curve: Float = 1f
) : Signal() {
private enum class Stage { IDLE, ATTACK, DECAY, SUSTAIN, RELEASE }
private var stage = Stage.IDLE
private var level = 0f
private var stageTime = 0f // seconds elapsed in current stage
private var releaseStart = 0f // level when release began
private var prevGate = 0f
internal val vizLevelTrace = ScalarTrace(capacity = 4096, stride = 8)
internal val vizGateTrace = ScalarTrace(capacity = 4096, stride = 8)
@Volatile internal var vizStageLabel: String = Stage.IDLE.name
@Volatile internal var vizCurrentLevel: Float = 0f
private var gateBuf = FloatArray(0)
private var aBuf = FloatArray(0)
private var dBuf = FloatArray(0)
private var sBuf = FloatArray(0)
private var rBuf = FloatArray(0)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
body — 52 lines, from line 92
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
if (gateBuf.size < numFrames) {
gateBuf = FloatArray(numFrames)
aBuf = FloatArray(numFrames); dBuf = FloatArray(numFrames)
sBuf = FloatArray(numFrames); rBuf = FloatArray(numFrames)
}
gate.render(gateBuf, numFrames, ctx)
attack.render(aBuf, numFrames, ctx)
decay.render(dBuf, numFrames, ctx)
sustain.render(sBuf, numFrames, ctx)
release.render(rBuf, numFrames, ctx)
val dt = ctx.inverseSampleRate
for (i in 0 until numFrames) {
val g = gateBuf[i]
val a = aBuf[i].coerceAtLeast(0.001f)
val d = dBuf[i].coerceAtLeast(0.001f)
val s = sBuf[i].coerceIn(0f, 1f)
val r = rBuf[i].coerceAtLeast(0.001f)
// Gate edge detection
if (g > 0f && prevGate <= 0f) {
stage = Stage.ATTACK; stageTime = 0f
} else if (g <= 0f && prevGate > 0f) {
stage = Stage.RELEASE; stageTime = 0f; releaseStart = level
}
prevGate = g
when (stage) {
Stage.IDLE -> level = 0f
Stage.ATTACK -> {
level = shapedRamp(stageTime / a, curve)
if (stageTime >= a) { stage = Stage.DECAY; stageTime = 0f }
}
Stage.DECAY -> {
level = 1f - (1f - s) * shapedRamp(stageTime / d, curve)
if (stageTime >= d) { stage = Stage.SUSTAIN; stageTime = 0f }
}
Stage.SUSTAIN -> level = s
Stage.RELEASE -> {
level = releaseStart * (1f - shapedRamp(stageTime / r, curve))
if (stageTime >= r) { stage = Stage.IDLE; stageTime = 0f; level = 0f }
}
}
output[i] = level
vizCurrentLevel = level
vizStageLabel = stage.name
vizLevelTrace.record(level)
vizGateTrace.record(if (g > 0f) 1f else 0f)
stageTime += dt
}
}
fun retrigger() { stage = Stage.ATTACK; stageTime = 0f }
Retrigger: reset to attack stage (callable from audio thread).
Attempt shaped ramp: t^curve, clamped to [0,1].
private fun shapedRamp(t: Float, curve: Float): Float {
val tc = t.coerceIn(0f, 1f)
return if (curve == 1f) tc else tc.toDouble().pow(curve.toDouble()).toFloat()
}
AR – attack / release one-shot envelope
class AR
Simplified two-stage envelope. Triggers on rising edge of gate. Runs attack→release once, then stays at zero until re-triggered.
class AR(
val gate: Signal = Gate(),
val attack: Param = Param(0.01f),
val release: Param = Param(0.3f),
val curve: Float = 1f
) : Signal() {
private enum class Stage { IDLE, ATTACK, RELEASE }
private var stage = Stage.IDLE
private var level = 0f
private var stageTime = 0f
private var prevGate = 0f
internal val vizLevelTrace = ScalarTrace(capacity = 4096, stride = 8)
internal val vizGateTrace = ScalarTrace(capacity = 4096, stride = 8)
@Volatile internal var vizStageLabel: String = Stage.IDLE.name
@Volatile internal var vizCurrentLevel: Float = 0f
private var gateBuf = FloatArray(0)
private var aBuf = FloatArray(0)
private var rBuf = FloatArray(0)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
if (gateBuf.size < numFrames) {
gateBuf = FloatArray(numFrames)
aBuf = FloatArray(numFrames); rBuf = FloatArray(numFrames)
}
gate.render(gateBuf, numFrames, ctx)
attack.render(aBuf, numFrames, ctx)
release.render(rBuf, numFrames, ctx)
val dt = ctx.inverseSampleRate
for (i in 0 until numFrames) {
val g = gateBuf[i]
if (g > 0f && prevGate <= 0f) { stage = Stage.ATTACK; stageTime = 0f }
prevGate = g
val a = aBuf[i].coerceAtLeast(0.001f)
val r = rBuf[i].coerceAtLeast(0.001f)
when (stage) {
Stage.IDLE -> level = 0f
Stage.ATTACK -> {
level = shapedRamp(stageTime / a, curve)
if (stageTime >= a) { stage = Stage.RELEASE; stageTime = 0f }
}
Stage.RELEASE -> {
level = 1f - shapedRamp(stageTime / r, curve)
if (stageTime >= r) { stage = Stage.IDLE; stageTime = 0f; level = 0f }
}
}
output[i] = level
vizCurrentLevel = level
vizStageLabel = stage.name
vizLevelTrace.record(level)
vizGateTrace.record(if (g > 0f) 1f else 0f)
stageTime += dt
}
}
}
Line – linear ramp from A to B over duration
class Line
Generates a linear ramp from start to end over duration seconds,
then holds at end. Call trigger to restart.
class Line(
val start: Float = 0f,
val end: Float = 1f,
val duration: Float = 1f
) : Signal() {
private var time = 0f
@Volatile var running = true
fun trigger() { time = 0f; running = true }
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
val dt = ctx.inverseSampleRate
for (i in 0 until numFrames) {
val t = (time / duration).coerceIn(0f, 1f)
output[i] = start + (end - start) * t
if (running) time += dt
}
}
}
BreakpointEnvelope – multi-segment envelope
data class Breakpoint(val time: Float, val value: Float, val curve: Float = 1f)
One corner of a BreakpointEnvelope: reach value at time seconds.
curve shapes the segment arriving at this breakpoint rather than the one leaving it, so the
exponent that makes an attack snap belongs on the point at the top of it.
class BreakpointEnvelope
An envelope of as many segments as you care to give it, each one a Breakpoint naming a time and a level to arrive at.
Where ADSR has a shape with parameters, this has a shape you write out. Points are sorted by time, so they need not arrive in order; before the first the envelope sits at its value and after the last it holds there for good. There is no sustain stage: a level you want held is two points at the same value.
val env = BreakpointEnvelope(
Breakpoint(0f, 0f), // start at 0
Breakpoint(0.01f, 1f), // attack to 1 in 10ms
Breakpoint(0.1f, 0.6f), // decay to 0.6 over 90ms
Breakpoint(1.0f, 0.6f), // sustain at 0.6 for 900ms
Breakpoint(1.5f, 0f) // release to 0 over 500ms
)
The clock runs from construction and stops at the last point. trigger restarts it, and clearing running freezes the envelope where it stands without resetting it.
class BreakpointEnvelope(
vararg points: Breakpoint
) : Signal() {
private val bp = points.sortedBy { it.time }.toTypedArray()
private var time = 0f
@Volatile var running = true
internal val vizLevelTrace = ScalarTrace(capacity = 4096, stride = 8)
@Volatile internal var vizCurrentTimeSec: Float = 0f
@Volatile internal var vizRunning: Boolean = true
fun trigger() { time = 0f; running = true }
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
val dt = ctx.inverseSampleRate
for (i in 0 until numFrames) {
output[i] = evaluate(time)
vizCurrentTimeSec = time
vizRunning = running
vizLevelTrace.record(output[i])
if (running && time < (bp.lastOrNull()?.time ?: 0f)) time += dt
}
}
private fun evaluate(t: Float): Float {
if (bp.isEmpty()) return 0f
if (t <= bp.first().time) return bp.first().value
if (t >= bp.last().time) return bp.last().value
// find segment
for (j in 0 until bp.size - 1) {
if (t >= bp[j].time && t < bp[j + 1].time) {
val seg = bp[j + 1].time - bp[j].time
val frac = if (seg > 0f) (t - bp[j].time) / seg else 1f
val shaped = shapedRamp(frac, bp[j + 1].curve)
return bp[j].value + (bp[j + 1].value - bp[j].value) * shaped
}
}
return bp.last().value
}
}
EnvelopeFollower – amplitude follower
class EnvelopeFollower
Tracks the amplitude envelope of an input signal. Uses separate attack and release smoothing coefficients.
val follower = EnvelopeFollower(input = micSignal,
attackTime = Param(0.005f), releaseTime = Param(0.05f))
class EnvelopeFollower(
val input: Signal,
val attackTime: Param = Param(0.005f),
val releaseTime: Param = Param(0.05f),
val mode: Mode = Mode.PEAK
) : Signal() {
enum class Mode { PEAK, RMS }
var env = 0f
internal val vizEnvelopeTrace = ScalarTrace(capacity = 4096, stride = 8)
@Volatile internal var vizCurrentLevel: Float = 0f
@Volatile internal var vizModeLabel: String = mode.name
private var inBuf = FloatArray(0)
private var aBuf = FloatArray(0)
private var rBuf = FloatArray(0)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
if (inBuf.size < numFrames) {
inBuf = FloatArray(numFrames)
aBuf = FloatArray(numFrames)
rBuf = FloatArray(numFrames)
}
input.render(inBuf, numFrames, ctx)
attackTime.render(aBuf, numFrames, ctx)
releaseTime.render(rBuf, numFrames, ctx)
val invSr = ctx.inverseSampleRate.toDouble()
for (i in 0 until numFrames) {
val sample = when (mode) {
Mode.PEAK -> abs(inBuf[i])
Mode.RMS -> inBuf[i] * inBuf[i]
}
val at = aBuf[i].coerceAtLeast(0.0001f).toDouble()
val rt = rBuf[i].coerceAtLeast(0.0001f).toDouble()
val attackCoeff = exp(-invSr / at).toFloat()
val releaseCoeff = exp(-invSr / rt).toFloat()
val coeff = if (sample > env) attackCoeff else releaseCoeff
env = coeff * env + (1f - coeff) * sample
output[i] = if (mode == Mode.RMS) sqrt(env) else env
vizCurrentLevel = output[i]
vizModeLabel = mode.name
vizEnvelopeTrace.record(output[i])
}
}
}
Slew – slew rate limiter (portamento / glide)
class Slew
Limits how fast a signal can change.
riseTime and fallTime are in seconds (time to traverse full range).
class Slew(
val input: Signal,
val riseTime: Param = Param(0.01f),
val fallTime: Param = Param(0.01f)
) : Signal() {
private var current = 0f
private var inBuf = FloatArray(0)
private var riseBuf = FloatArray(0)
private var fallBuf = FloatArray(0)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
if (inBuf.size < numFrames) {
inBuf = FloatArray(numFrames); riseBuf = FloatArray(numFrames); fallBuf = FloatArray(numFrames)
}
input.render(inBuf, numFrames, ctx)
riseTime.render(riseBuf, numFrames, ctx)
fallTime.render(fallBuf, numFrames, ctx)
val invSr = ctx.inverseSampleRate
for (i in 0 until numFrames) {
val target = inBuf[i]
val rise = riseBuf[i].coerceAtLeast(0.0001f)
val fall = fallBuf[i].coerceAtLeast(0.0001f)
val maxUp = invSr / rise
val maxDown = invSr / fall
val diff = target - current
current += diff.coerceIn(-maxDown, maxUp)
output[i] = current
}
}
}