Provide a way to set a channel's volume without creating popping artifacts

(Forgive me for posting yet another audio related thread as I continue to poke and prod all over the Playdate's C audio API!)

I've been playing around with setting a channel's volume or assigning it a volume modulator as part of my in-progress music engine. When audio is playing, making small adjustments to volume doesn't produce much in the way of audio artifacts. But large changes, especially changing a channel's volume between 0 and 1, generally results in a pop.

Here's some code to put in your update callback that demonstrates the issue by flipping a channel's volume between 0 and 1 every second:

static uint32_t lastTime = 0;
static SoundChannel *channel = NULL;
uint32_t currentTime = pd->sound->getCurrentTime();

if (!channel) {
    channel = pd->sound->channel->newChannel();
    PDSynth *synth = pd->sound->synth->newSynth();
    pd->sound->synth->setWaveform(synth, kWaveformSine);
    pd->sound->channel->addSource(channel, (SoundSource *)synth);
    pd->sound->synth->playMIDINote(synth, NOTE_C4, 0.5, -1.0, 0);
}

if ((currentTime / 44100) != (lastTime / 44100)) {
    pd->sound->channel->setVolume(channel, 1.0f - pd->sound->channel->getVolume(channel));
}

lastTime = currentTime;

I gather this happens because volume changes apply instantaneously, creating sharp discontinuities in the audio waveform. A better method would be to have the adjustment happen gradually over a very short interval. Just a basic linear adjustment over thirty samples (or around 0.7 milliseconds) is enough to avoid a pop. Because volume modulators only have a resolution of 256 samples that doesn't provide the ability to make large volume changes without creating popping artifacts.

I figure this counts as a feature request rather than a bug report because this part of the API is working as intended, and what I'm requesting would be an enhancement. That said, I think a case could be made for having what I'm suggesting simply be the way setVolume / volume modulators work, since generally speaking popping artifacts like that are always going to sound bad.

This thread is probably related: Clicks and pops when using amplitude LFO on a synth

2 Likes

can you advise what format the audio is, whether you are using playSound or playMusic, and what the filesize is?

The issue happens regardless of format or method of playing the audio. The code I provided demonstrates the issue with just a basic sine wave.

I need to bump this to the top as this is something I really want to have happen in my life.

While I think this should probably be addressed in the Playdate API, here's a custom SoundEffect that implements pop-free volume changes, for whoever wants to use it. I tried to write it efficiently, though it will almost certainly use more CPU cycles than just setting a channel's volume. The following code has both the implementation for the effect, plus an updated version of my example code above that shows how to use it.

typedef struct SmoothVolumeEffect_ {
    SoundEffect *effect;
    _Atomic float volume;
    int32_t lastVolumeFixed;
    int32_t fadeSampleCount;
} SmoothVolumeEffect;

static inline int32_t multiplyQ8_24(int32_t a, int32_t b) {
    return (int32_t)(((int64_t)a * (int64_t)b) >> 24);
}

static inline float floatToQ8_24(float f) {
    if (f > 127.999999f) {
        return INT32_MAX;
    }
    
    if (f < -128.0f) {
        return INT32_MIN;
    }
    
    return (int32_t)roundf(f * (1 << 24));
}

static inline int32_t lerpQ8_24(int32_t a, int32_t b, int32_t x, int32_t y) {
    int64_t aa = (int64_t)a * (y - x);
    int64_t bb = (int64_t)b * x;
    return (int32_t)((aa + bb) / y);
}

int SmoothVolumeEffectCallback(SoundEffect *effect, int32_t *left, int32_t *right, int nsamples, int bufactive)
{
    if (bufactive == 0) {
        return 0;
    }
    
    void *userData = pd->sound->effect->getUserdata(effect);
    
    if (!userData) {
        return 0;
    }
    
    SmoothVolumeEffect *data = (SmoothVolumeEffect *)userData;
    int32_t volumeFixed = floatToQ8_24(data->volume);
    int transitionSamples;
    
    if (volumeFixed == data->lastVolumeFixed) {
        transitionSamples = 0;
    } else {
        transitionSamples = (nsamples >= data->fadeSampleCount) ? data->fadeSampleCount : nsamples;
    }
    
    for(int i = 0; i < transitionSamples; ++i) {
        int32_t vol = lerpQ8_24(data->lastVolumeFixed, volumeFixed, i, transitionSamples);
        
        if (left) {
            left[i] = multiplyQ8_24(left[i], vol);
        }
        
        if (right) {
            right[i] = multiplyQ8_24(left[i], vol);
        }
    }
    
    if (left) {
        for(int i = transitionSamples; i < nsamples; ++i) {
            left[i] = multiplyQ8_24(left[i], volumeFixed);
        }
    }
    
    if (right) {
        for(int i = transitionSamples; i < nsamples; ++i) {
            right[i] = multiplyQ8_24(left[i], volumeFixed);
        }
    }
    
    data->lastVolumeFixed = volumeFixed;
    
    return 1;
}


SmoothVolumeEffect * newSmoothVolumeEffect(void)
{
    SmoothVolumeEffect *result = pd->system->realloc(NULL, sizeof(SmoothVolumeEffect));
    
    if (!result) {
        return NULL;
    }
    
    result->effect = pd->sound->effect->newEffect(SmoothVolumeEffectCallback, result);
    result->fadeSampleCount = 64;
    result->volume = 1.0f;
    result->lastVolumeFixed = floatToQ8_24(result->volume);
    
    return result;
}

void freeSmoothVolumeEffect(SmoothVolumeEffect *smoothVolumeEffect)
{
    pd->sound->effect->freeEffect(smoothVolumeEffect->effect);
    pd->system->realloc(smoothVolumeEffect, 0);
}

int update(void* ud)
{
    static uint32_t lastTime = 0;
    static SoundChannel *channel = NULL;
    static SmoothVolumeEffect *smoothVolumeEffect;
    uint32_t currentTime = pd->sound->getCurrentTime();

    if (!channel) {
        channel = pd->sound->channel->newChannel();
        PDSynth *synth = pd->sound->synth->newSynth();
        pd->sound->synth->setWaveform(synth, kWaveformSine);
        pd->sound->channel->addSource(channel, (SoundSource *)synth);
        pd->sound->synth->playMIDINote(synth, NOTE_C4, 0.5, -1.0, 0);
        
        smoothVolumeEffect = newSmoothVolumeEffect();
        pd->sound->channel->addEffect(channel, smoothVolumeEffect->effect);
        pd->sound->effect->setMix(smoothVolumeEffect->effect, 1.0f);
    }

    if ((currentTime / 44100) != (lastTime / 44100)) {
        smoothVolumeEffect->volume = (smoothVolumeEffect->volume == 1.0f) ? 0.0f : 1.0f;
    }

    lastTime = currentTime;    
    
    return 1;
}