427.org.uk

Random musings and out of date things


In C, in C - Part II

23rd August 2020

In Part I I set about writing a program in C to perform Terry Riley’s piece “In C”. At the end of it I mentioned the need to change volume over time so I can play distinct notes, so that’s exactly what I’ve focussed on in the latest bit of coding I’ve done.

Envelopes

In the synthesiser realm we use envelopes to change volume over time. There’s generally four parts of the envelope, the Attack, Decay, Sustain and Release. When you play a note on a keyboard for example, the envelope is triggered, which starts with the attack phase. This takes the volume from 0 up to it’s maximum level, and will either do this quickly or slowly depending on how it’s been set up. You could even skip the attack entirely and start the note at full volume. Next is decay, which decreases the volume over time, until it reaches the sustain level.

The note then stays at the sustain level until the key is released, which starts the release phase, which in turn reduces the volume over time. By tweaking these four parameters we can change the feel of the sound fairly dramatically.

So far I’ve simplified this a bit by skipping the decay phase, so I’m creating what’s known as an ASR envelope, or Attack/Sustain/Release.

Extending the notes struct

In order to do this I’ve added some fields to the notes struct. Here’s how it was in the previous post:

struct notes {
  int total;
  struct note *notes;
};

And here it is now:

enum note_state {wait, attack, sustain, release, finished};

struct notes {
  int total;
  struct note *notes;
  float freq;
  enum note_state state;
  float sustain_level;
  int sustain_time;
  float current_amp;
  float (*attack)(float, int, const ao_sample_format *);
  float (*release)(float, int, const ao_sample_format *);
};

Quite a lot more stuff added in there. I’ve also added an enum, note_state, which we’ll use to reference what phase the note is in, and the corresponding field state will be used to keep track of this.

Frequency was previously set on a per-note (badly named struct!) basis, I’ve changed this now to be set at this higher level, and have the note struct instead use a multiple of the frequency set in this expanded notes struct. It also used to be an int, but I’ve changed it to be a float now.

sustain_level is the maximum amplitude this note will reach, with 1.0 being the loudest. sustain_time is how long the note will be held after the attack phase is complete. At the moment it’s in samples which isn’t that friendly to set, but makes it pretty easy to handle in the code.

current_amp is for tracking the current volume of the note, so we know when to change to the next phase.

attack and release are pointers to functions which are called in the attack and release phases respectively.

I’ve thrown together some quick attack and release functions to use for this proof of concept:

float attack_linear(float current, int pos, const ao_sample_format *format) {
  if (current == 0.0) {
    current = 0.0001;
  }

  return current * 1.001;
}

float release_linear(float current, int pos, const ao_sample_format *format) {
  return current / 1.0001;
}

It’s pretty basic stuff right now and don’t use the pos or format variables that are available to them - I imagine they will be useful in more complex functions though.

How it works

It’s pretty simple really, though I did get it wrong a few times before getting it working. When we render the buffer that will be played we check the status of the note. If it’s wait we change it to attack, then call the attack function and update the current amplitude, which we multiply the value we derive for that sample by. When the amplitude hits the sustain level we move to the sustain state, and start to decrement sustain_level. When that reaches 0 we go in to the release state and set the amplitude with the release function until the amplitude reaches 0 (or close enough to 0) when we move to finished. We then set the flag on the buffer to tell the play_buffers function that all the data has been processed and it can stop running after its current loop. In the finished program it will play more than one note, so will only set this flag when all notes have reached the finished state.

I’ve also added the ability to skip the attack or release phase by setting the function pointers to NULL.

The main change is in the render_buffer function, where I’ve added a switch statement to handle the changing of states, and to multiply the result of render_notes by the amplitude value:

sample = render_notes(notes, i, format);
switch (notes->state) {
case wait:
  notes->state = attack;
  /* fall through */
case attack:
  if (notes->attack == NULL) {
      notes->state = sustain;
      break;
  }
  notes->current_amp = notes->attack(notes->current_amp, i, format);
  if (notes->current_amp >= notes->sustain_level) {
    /* compensate for overshoot */
    notes->current_amp = notes->sustain_level;
    notes->state = sustain;
  }
  break;
case sustain:
  if (notes->sustain_time-- <= 0) {
    notes->state = release;
  }
  break;
case release:
  if (notes->release == NULL) {
    notes->state = finished;
    break;
  }
  notes->current_amp = notes->release(notes->current_amp, i, format);
  if (notes->current_amp <= 0.01) {
    notes->state = finished;
  }
  break;
case finished:
  break;
}

sample *= notes->current_amp;

Another change is that the main loop in handle_buffers will now keep going until all the notes have finished rather than the arbitrary four iterations it did before:

  for (;;) {
    pthread_mutex_lock(&mutex);
    state->active_buffer ^= 1;
    render_buffer(&(state->buffers[state->active_buffer]), &(state->notes), &(state->format));
    if (state->notes.state == finished) {
      state->buffers[state->active_buffer].last = true;
      if (send_ready) pthread_cond_broadcast(&data_ready);
      pthread_mutex_unlock(&mutex);
      return NULL;
    }
    if (send_ready) {
      pthread_cond_broadcast(&data_ready);
      send_ready = false;
    }
    pthread_cond_wait(&read_ready, &mutex);
    pthread_mutex_unlock(&mutex);
  }

Next steps

Now that I’ve figured out playing individual notes my next step will be sequencing them somehow. Rests should be easy, after all they’ll just be notes with a 0 sustain level, though I should probably do something to skip calling the note generation function in this instance to avoid wasting CPU cycles. Overall I’m more confident that I’ll get this finished than I was after the previous post, and I’ve enjoyed figuring this stuff out so far.

Update - See Part III