Fix: Audio Artifacts When Updating Oscillator Parameters During ADSR Envelope in AudioKit

Fix: Audio Artifacts When Updating Oscillator Parameters During ADSR Envelope in AudioKit

Written by

in

Quick answer: AudioKit parameter assignments take effect immediately, interrupting any running ADSR envelope and causing audible clicks or chunks. Fix this by either queuing parameter changes to apply at the next envelope trigger boundary, or by using a dual-voice (ping-pong) architecture where the new parameters start on a fresh voice while the old voice finishes its release.

Why this happens

AudioKit’s OperationGenerator parameters are live AUValue properties. When you write $0.parameters[...].value = newValue, the change propagates to the audio engine on the next render cycle — there is no built-in “wait for envelope” flag. If an ADSR envelope is in its attack, hold, or decay phase, the sudden parameter jump creates a discontinuity in the waveform, which you hear as a click, pop, or “chunk.”

The portamento() helper in your Operation.fmOscillator call only smooths the target value over time; it does not gate the update to an envelope boundary. Because your reconfigOscillatorsForInput runs every ~50 ms, you are effectively retriggering parameter ramps in the middle of the previous envelope’s life, guaranteeing overlap artifacts.

How to fix it

1. Queue changes and apply at the next trigger boundary (single-voice)

Keep one OperationGenerator per voice but defer parameter writes until the envelope restarts. The simplest way is to store pending values and swap them inside the operation graph at the moment the trigger fires.

import SoundpipeAudioKit
import SporthAudioKit

final class SyncedOscillatorBank {
    private var generators: [OperationGenerator] = []
    private var pendingFreq: [Double] = []
    private var pendingCyclic: [Double] = []
    private var pendingHold: [Double] = []
    private let parameterLock = NSLock()

    func createVoice(baseFrequency: Double) -> OperationGenerator {
        let gen = OperationGenerator { _ in
            let trig = Operation.periodicTrigger(period: 0.05) // your 50 ms cycle
            let env = Operation.adsr(
                trigger: trig,
                attack: 0.01,
                hold: 0.05,
                release: 0.01
            )
            // Read the *current* parameter values at trigger time
            let freq = Operation.parameters(0)
            let cyc  = Operation.parameters(1)
            let hold = Operation.parameters(2)
            let osc = Operation.fmOscillator(
                baseFrequency: freq,
                carrierMultiplier: 2,
                modulatingMultiplier: 0.8,
                modulationIndex: 1,
                amplitude: env
            )
            return osc
        }
        // Seed initial values
        gen.parameters[0].value = AUValue(baseFrequency)
        gen.parameters[1].value = 0.05
        gen.parameters[2].value = 0.025
        generators.append(gen)
        pendingFreq.append(baseFrequency)
        pendingCyclic.append(0.05)
        pendingHold.append(0.025)
        return gen
    }

    /// Call from your 50 ms timer / UI callback — thread-safe.
    func scheduleUpdate(voiceIndex: Int, frequency: Double, cyclic: Double, hold: Double) {
        parameterLock.lock()
        pendingFreq[voiceIndex] = frequency
        pendingCyclic[voiceIndex] = cyclic
        pendingHold[voiceIndex] = hold
        parameterLock.unlock()
    }

    /// Call once per audio render block (e.g. in a `AKCallbackInstrument` or `AVAudioEngine` render callback).
    func commitPendingUpdates() {
        parameterLock.lock()
        for (i, gen) in generators.enumerated() {
            gen.parameters[0].value = AUValue(pendingFreq[i])
            gen.parameters[1].value = AUValue(pendingCyclic[i])
            gen.parameters[2].value = AUValue(pendingHold[i])
        }
        parameterLock.unlock()
    }
}

Key points
– The operation graph reads Operation.parameters(n) at trigger time, so the envelope always sees a consistent set of values.
– scheduleUpdate can be called from any thread; the actual AUParameter write happens on the audio thread in commitPendingUpdates.
– No voice stealing, no extra polyphony — just one parameter swap per voice per cycle.

2. Dual-voice ping-pong (zero-latency, no queue needed)

If you cannot guarantee a single commit point (e.g. you’re driving parameters from a MIDI callback), allocate two OperationGenerator instances per logical voice and alternate them. While voice A is in its release tail, voice B starts with the new parameters.

final class PingPongVoice {
    private var active = 0
    private let voices: [OperationGenerator] = [
        makeGenerator(),
        makeGenerator()
    ]

    private static func makeGenerator() -> OperationGenerator {
        OperationGenerator { _ in
            let trig = Operation.periodicTrigger(period: 0.05)
            let env = Operation.adsr(trigger: trig, attack: 0.01, hold: 0.05, release: 0.01)
            let freq = Operation.parameters(0)
            let cyc  = Operation.parameters(1)
            let hold = Operation.parameters(2)
            return Operation.fmOscillator(
                baseFrequency: freq,
                carrierMultiplier: 2,
                modulatingMultiplier: 0.8,
                modulationIndex: 1,
                amplitude: env
            )
        }
    }

    func update(frequency: Double, cyclic: Double, hold: Double) {
        let next = 1 - active
        let gen = voices[next]
        gen.parameters[0].value = AUValue(frequency)
        gen.parameters[1].value = AUValue(cyclic)
        gen.parameters[2].value = AUValue(hold)
        gen.start()          // triggers its own envelope
        voices[active].stop() // lets current voice finish release
        active = next
    }

    func start() { voices[0].start() }
    func stop() { voices.forEach { $0.stop() } }
}

Trade-offs
– Double the voice count (still trivial for FM oscillators).
– Zero parameter-update latency — the new sound starts immediately.
– Requires that stop() allows the release segment to complete; AudioKit’s OperationGenerator.stop() does this correctly.

How to prevent it

  • Never write AUParameter values directly from a UI or timer callback if an envelope may be running. Always funnel writes through a single audio-thread commit point or a voice-switching layer.
  • Design the operation graph to read parameters at trigger time (Operation.parameters(n)) rather than baking them into the graph at creation.
  • Profile with Instruments → Audio Latency to verify no glitches appear when parameters change at your target rate (50 ms, 100 ms, etc.).
  • Unit-test the parameter path by driving a sine wave through the same graph and checking for spectral splatter in a test render.

FAQ

Can I just increase the portamento time to hide the artifact?

Longer portamento smears the transition but does not eliminate the discontinuity; you will still hear a timbral shift mid-envelope. The fixes above remove the root cause.

Does this apply to AKOscillator / AKOperation (non-generator) nodes?

Yes. Any AUParameter write on a running node takes effect at the next render block. The same queue-or-voice-switch patterns work with AKOscillator, AKSampler, or custom AVAudioUnit subclasses.

What if I need per-sample parameter automation (e.g. vibrato)?

Use an Operation-level modulator (e.g. Operation.sineWave(frequency: 5).scale(minimum: -1, maximum: 1).add(frequency)) inside the graph instead of updating the parameter from Swift. That keeps automation on the audio thread and perfectly synchronized.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.