Writing a custom dspVoice#

Goal: implement your own synthesiser voice by subclassing YSE::SYNTH::dspVoice.

The built-in voices (sine, virtual-analog, sampler, FM) cover a lot of ground, but the whole point of the voice model is that you can define what a note sounds like while the engine keeps owning polyphony, allocation and lifecycle. This tutorial dissects the reference voice, YSE::SYNTH::sineVoice — one sine oscillator shaped by an ADSR envelope. It is the smallest legal voice, and every synth test builds on it.

Source: sineVoice.hpp and sineVoice.cpp.

The contract#

A dspVoice is a DSP::dspSourceObject — it already owns the samples output buffers — extended with the two methods you must implement:

      // ---- dspVoice contract -------------------------------------------------

      /** @brief Render one block, honouring and settling ``intent``. Audio-thread only. */
      void process(SOUND_STATUS& intent) override;

      /** @brief Return a new, independently-allocated copy of this voice. Setup-thread only. */
      dspVoice* clone() override;

    protected:
      /** @brief Copy-construct an independent voice (rebuilds its own envelope). */
      sineVoice(const sineVoice& other);
  • process(SOUND_STATUS& intent) fills one block of samples on the audio thread. It must be allocation-free, lock-free and non-blocking.

  • clone() returns a fresh, fully-allocated heap copy of your voice. It runs only on the setup thread, so allocation there is fine — and necessary, because everything process() touches must already exist.

Reading note state#

The engine delivers the current note’s parameters as atomics you read inside process(): getFrequency() (Hz), getVelocity() ([0, 1]), getAftertouch() and getPitchWheel() ([-1, 1]). You never write them.

Allocate everything up front#

The voice builds its oscillator and envelope in the constructor and in the (off-thread) setter methods — never in process():

    sineVoice::sineVoice(int outputChannels)
      : dspVoice(outputChannels),
        _attack(0.01f),
        _decay(0.05f),
        _sustainLevel(0.7f),
        _release(0.1f),
        phase(IDLE) {
      buildEnvelope();
    }

clone() is then a one-liner that copy-constructs, and the copy constructor rebuilds independent state so a clone shares nothing mutable with its prototype:

    dspVoice* sineVoice::clone() {
      return new sineVoice(*this);
    }
    sineVoice::sineVoice(const sineVoice& other)
      : dspVoice(other),
        _attack(other._attack),
        _decay(other._decay),
        _sustainLevel(other._sustainLevel),
        _release(other._release),
        phase(IDLE) {
      // Build a fresh envelope rather than copy the prototype's: ADSRenvelope
      // holds raw pointers into its own storage, so a copy would alias the
      // original. Rebuilding keeps a clone's state fully independent.
      buildEnvelope();
    }

Honouring the intent#

process()’s intent argument is this voice’s SOUND_STATUS. You read it to drive your envelope and — crucially — you write it back to tell the engine what the voice is doing:

    void sineVoice::process(SOUND_STATUS& intent) {
      DSP::ADSRenvelope::STATE estate;

      if (intent == SS_WANTSTOPLAY || intent == SS_WANTSTORESTART) {
        // Note start (or retrigger): restart the oscillator and the envelope.
        osc.reset();
        estate = DSP::ADSRenvelope::ATTACK;
        phase = PLAYING;
        intent = SS_PLAYING;
      } else if (intent == SS_WANTSTOSTOP || intent == SS_WANTSTOPAUSE) {
        if (phase == IDLE) {
          // Released before it ever attacked — the note-on and note-off drained
          // in the same audio block, or a pedal / all-notes-off release landed
          // before the first render. Nothing is sounding, so settle at once:
          // driving the envelope's RELEASE here would read its phase pointer
          // before ATTACK ever primed it.
          intent = (intent == SS_WANTSTOPAUSE) ? SS_PAUSED : SS_STOPPED;
          for (UInt i = 0; i < samples.size(); i++)
            samples[i] = 0.f;
          return;
        }
        // Note off: take the release transition once, then let it tail out.
        if (phase != RELEASING) {
          estate = DSP::ADSRenvelope::RELEASE;
          phase = RELEASING;
        } else {
          estate = DSP::ADSRenvelope::RESUME;
        }
      } else if (intent == SS_PLAYING || intent == SS_PLAYING_FULL_VOLUME) {
        // Sustaining: RESUME loops the envelope's sustain plateau.
        estate = DSP::ADSRenvelope::RESUME;
      } else {
        // SS_STOPPED / SS_PAUSED: emit silence.
        for (UInt i = 0; i < samples.size(); i++)
          samples[i] = 0.f;
        return;
      }

      const Flt vel = getVelocity();

      // Apply the channel pitch wheel (delivered by the keyboard state machine,
      // §5) as an exponential frequency ratio: value 0 leaves the note in tune,
      // ±1 shifts it ±kBendRangeSemitones. Read once per block — cheap and
      // allocation-free.
      const Flt wheel = getPitchWheel();
      Flt freq = getFrequency();
      if (wheel != 0.f) {
        freq *= std::exp2(wheel * kBendRangeSemitones / 12.f);
      }

      DSP::buffer& sig = osc(freq);
      DSP::buffer& amp = (*env)(estate);

      for (UInt i = 0; i < samples.size(); i++) {
        samples[i] = sig;
        samples[i] *= amp;
        samples[i] *= vel;
      }

      // The release tail has fully decayed — hand the slot back to the engine.
      if (phase == RELEASING && env->isAtEnd()) {
        intent = SS_STOPPED;
        phase = IDLE;
      }
    }

The lifecycle in that block:

  • SS_WANTSTOPLAY (note start) → restart the oscillator and envelope attack, then settle the intent to SS_PLAYING.

  • SS_PLAYING → hold the sustain plateau.

  • SS_WANTSTOSTOP (note off) → take the release transition, then tail out.

  • When the release tail reaches zero, set intent = SS_STOPPED so the allocator can free the slot. If you never settle to ``SS_STOPPED`` the voice is never reclaimed.

Using it#

A custom voice is used exactly like a built-in one — it is a dspVoice:

MyVoice proto;
proto.attack(0.01f).release(0.2f);   // your own setters

YSE::synth syn;
syn.create().addVoices(proto, 8);

YSE::sound snd;
snd.create(syn);
snd.play();
syn.noteOn(1, 60, 0.9f);

What you learned#

  • Subclass dspVoice and implement process() (audio thread) and clone() (setup thread).

  • Allocate in the constructor / setters, never in process().

  • Read note state with getFrequency() / getVelocity() / etc.

  • Drive the envelope off intent and settle it to SS_STOPPED when the release tail ends.

Next#