FM Voice
Overview
basic_fm_voice is one sounding note: a set of FM Operator through an
FM Algorithm, with an LFO Generator for vibrato and tremolo and a pitch
envelope. fm_voice is the DX7’s shape, six sine operators with the DX7
envelope, and a voice of any other size or make is a matter of listing the
operator types:
using fm_voice = basic_fm_voice<
fm_operator, fm_operator, fm_operator
, fm_operator, fm_operator, fm_operator>;
// Two operators, a saw modulating a sine
q::basic_fm_voice<
q::fm_operator,
q::basic_fm_operator<q::basic_saw_osc, q::dx_envelope_gen>
> voice;
The pitch is the caller’s. Each sample the voice takes a phase_iterator,
bends it by the LFO and the pitch envelope, and hands it to the operators,
which follow it by their ratios. A keyboard, a pitch detector, a glide or a
bend can drive it, and nothing in the voice needs to know which.
A note is data
attack takes a note: per operator, a frequency ratio (or a fixed step)
and an envelope configuration. Everything that varies with the key and the
velocity is in there, which is what lets a patch compiler precompute it. A
Patcher does exactly that, and a voice takes one directly:
voice.attack(patch, key, velocity); // the patcher fills the note
Every operator of the voice needs its own entry, since all of them sound unless the routing has them modulating something.
What the voice owns
The LFO runs once per sample and feeds two things: the pitch, by
pitch_mod semitones at its peak, and each operator’s gain, cut by
amp_mod[i] decibels at its trough. Tremolo is applied per operator rather
than to the sum, because an operator with tremolo that modulates another
wobbles its timbre, not its loudness.
The pitch envelope ramps in semitones between its four levels, at its four rates, and rests at the fourth.
-
masteris the caller’sphase_iterator. -
attack(note)gives every operator its ratio and its envelope. -
The pitch envelope bends the pitch in semitones, and the LFO adds vibrato to it.
-
The LFO also applies tremolo to each operator’s gain.
-
The algorithm holds the routing, the index and the feedback, and each operator has its own phase and envelope.
While the note sounds
Three things reach a note that is already sounding. The mod wheel, 0 to
1, deepens the vibrato and the tremolo toward the configuration’s wheel
depths, pitch_mod_wheel and amp_mod_wheel[i]: each depth is the deeper
of the LFO’s own and the wheel’s share of its full, as the DX7 takes them.
A switch per operator, a bit each in `enable’s mask, takes an operator
out: switched off, it neither sounds nor modulates, which is how an editor
lets the player hear one operator alone, or a voice without it.
And update gives the note a new patch. Each part takes its new settings
and goes on from where it is, the way a DX7’s sounding notes follow a
voice change or an edit: the envelopes keep their place and move toward
the new levels at the new rates, and the pitch envelope, the LFO and the
phases carry on. A note that a release level holds after its key is up
fades when the new level is lower. A new note is different: set and
attack start it from the beginning.
Include
#include <q/synth/fm/fm_voice.hpp>
The examples below also use <q/synth/fm/dx_patcher.hpp> for the patcher and
<q/midi/messages.hpp> for note_frequency.
Declaration
struct fm_voice_config
{
fm_algorithm::config algorithm;
lfo_gen::config lfo;
float pitch_mod = 0.0f; // semitones at the LFO's peak
// a cut in decibels at the LFO's trough, one per operator
float amp_mod[fm_max_operators] = {};
// The depths the mod wheel reaches at full: it takes each from
// the one above toward these, whichever is the deeper.
float pitch_mod_wheel = 0.0f; // semitones
float amp_mod_wheel[fm_max_operators] = {}; // dB
std::array<float, 4> pitch_env_level = {}; // semitones
std::array<float, 4> pitch_env_rate = {}; // semitones per second
bool key_sync = true; // phases restart at note-on
};
template <typename Env>
struct basic_fm_note
{
struct op_params
{
double ratio = 1.0;
phase fixed_step = {};
typename Env::config env;
};
op_params op[fm_max_operators];
};
using fm_note = basic_fm_note<dx_envelope_gen>;
template <typename Op, typename... RestOps>
struct basic_fm_voice
{
static constexpr std::size_t size = 1 + sizeof...(RestOps);
using envelope_type = typename Op::envelope_type;
using config = fm_voice_config;
using note = basic_fm_note<envelope_type>;
basic_fm_voice();
basic_fm_voice(config const& cfg, float sps);
template <concepts::Patcher<note> P>
basic_fm_voice(P const& patch);
void set(config const& cfg, float sps);
void update(config const& cfg, note const& n);
template <concepts::Patcher<note> P>
void update(P const& patch, std::uint8_t key, float velocity);
void attack(note const& n);
template <concepts::Patcher<note> P>
void attack(P const& patch, std::uint8_t key, float velocity);
void release();
bool active() const;
void mod_wheel(float w);
float mod_wheel() const;
void enable(fm_routing::mask m);
fm_routing::mask
enabled() const;
float operator()(phase_iterator master);
};
using fm_voice = basic_fm_voice<
fm_operator, fm_operator, fm_operator
, fm_operator, fm_operator, fm_operator>;
Expressions
Notation
Op,RestOps-
basic_fm_operatortypes, all with the same envelope. v_type-
A
basic_fm_voice<Op, RestOps…>type. v-
Object of type
v_type. cfg-
A
fm_voice_config. n-
A
v_type::note. patch-
A
Patcher, e.g. aDX Patcher. key-
A MIDI key number, 0 to 127. A
std::uint8_t. velocity-
Velocity, 0 to 1. A
float. master-
A
phase_iterator, the pitch to play. w-
The mod wheel, 0 to 1. A
float. m-
A
FM Routing::mask: a bit per operator, bit 0 the first. sps-
Floating point value representing samples per second.
Type Construction
| Expression | Semantics |
|---|---|
|
A voice of those operators, in order. |
|
Six sine operators with the DX7 envelope. |
|
How many operators it has. |
|
What |
Constructors
| Expression | Semantics |
|---|---|
|
A voice with default settings at 44.1 kHz. |
|
Construct from a configuration. |
|
Construct from a patcher’s voice settings and sample rate. |
Note Lifetime
| Expression | Semantics |
|---|---|
|
Take new settings, for the next note. |
|
Give the sounding note new settings and a new note’s ratios and envelopes, each part going on from where it is. |
|
The same, from what a patcher compiles for that key and velocity. |
|
Start a note: ratios and envelopes per operator. |
|
Start the note a patcher compiles for that key and velocity. |
|
Release every envelope and the pitch envelope. |
|
|
Example
A pool of voices, as in the poly synth example, playing DX7 patches:
q::dx_patcher patch{cfg, sps}; // compiled once, shared by the pool
std::vector<q::fm_voice> pool(16, q::fm_voice{patch});
std::vector<q::phase_iterator> pitch(16);
// A note on: take a free voice, give it the key's pitch, and start it
auto free = std::find_if(pool.begin(), pool.end(),
[](auto& v) { return !v.active(); });
auto i = free - pool.begin();
pitch[i].set(q::midi::note_frequency(key), sps);
free->attack(patch, key, velocity);
// Per sample: every voice that is still sounding, at its own pitch
float out = 0;
for (std::size_t i = 0; i != pool.size(); ++i)
if (pool[i].active())
out += pool[i](pitch[i]++);
Each voice has its own master iterator, so a pitch bend or a per-string detector can move one note without touching the others.