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.

What the voice owns: the operators, the algorithm, the LFO and the pitch envelope, with the pitch and the note coming from outside
Figure 1. What the voice owns, inside the dashed line: the operators, the algorithm that runs them, the LFO and the pitch envelope. The pitch and the note come from the caller. The LFO reaches both the pitch and each operator’s gain, which is why an operator with tremolo that modulates another changes its timbre.
  • master is the caller’s phase_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_operator types, 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. a DX 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

basic_fm_voice<Op…​>

A voice of those operators, in order.

fm_voice

Six sine operators with the DX7 envelope.

v_type::size

How many operators it has.

v_type::note

What attack takes.

Constructors

Expression Semantics

v_type()

A voice with default settings at 44.1 kHz.

v_type(cfg, sps)

Construct from a configuration.

v_type(patch)

Construct from a patcher’s voice settings and sample rate.

Note Lifetime

Expression Semantics

v.set(cfg, sps)

Take new settings, for the next note.

v.update(cfg, n)

Give the sounding note new settings and a new note’s ratios and envelopes, each part going on from where it is.

v.update(patch, key, velocity)

The same, from what a patcher compiles for that key and velocity.

v.attack(n)

Start a note: ratios and envelopes per operator.

v.attack(patch, key, velocity)

Start the note a patcher compiles for that key and velocity.

v.release()

Release every envelope and the pitch envelope.

v.active()

true until every operator is idle, which is when a pool may reuse it.

While It Sounds

Expression Semantics

v.mod_wheel(w)

Set the mod wheel, 0 to 1.

v.mod_wheel()

The mod wheel.

v.enable(m)

Switch the operators on and off: those whose bit is clear neither sound nor modulate. All are on to begin with.

v.enabled()

Which operators are on.

Function Call

Expression Semantics

v(master)

Generate one sample at the master’s pitch. The caller advances master.

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.