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 ofsampleson 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 everythingprocess()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 toSS_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_STOPPEDso 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
dspVoiceand implementprocess()(audio thread) andclone()(setup thread).Allocate in the constructor / setters, never in
process().Read note state with
getFrequency()/getVelocity()/ etc.Drive the envelope off
intentand settle it toSS_STOPPEDwhen the release tail ends.
Next#
Your first synth — the synth basics this builds on.
Synthesis — the
dspVoiceand built-in-voice reference.