Per-Note Expression

Overview

Read MIDI Processor first for prerequisite information.

Per-note expression is control applied to one sounding note rather than to every note on a channel: the pitch of one note in a chord bent while the others hold, pressure on one key, a change of timbre on one voice. A guitar is the plainest case. A player bends one string while a chord rings on the others, and a pickup that senses each string separately reports six voices, each with a pitch and a dynamic of its own. Expressive keyboard controllers do the same on keys, and a synthesizer that receives it can voice a chord the way a string section does, each part moving on its own.

MIDI 1.0 has no message for it. Pitch bend and channel pressure address a channel, and every note on that channel moves together. Two solutions exist. MIDI Polyphonic Expression, MPE, is a convention over MIDI 1.0: each note is assigned a channel of its own for as long as it sounds, and the bend, pressure and timbre sent on that channel belong to that note alone. MIDI 2.0 addresses the note directly, with per-note messages that carry the note number they apply to.

Q presents both as one vocabulary. Three messages, note_pitch, note_pressure and note_timbre, each carry the channel and key of the note they belong to. mpe_reader produces them from an MPE zone and per_note_reader from MIDI 2.0’s per-note messages, so a synthesizer written against the three receives the same messages from either source, through the same overloads, without knowing which produced them. Both readers are processor proxies; see MIDI for where the stages sit.

An MPE zone and MIDI 2.0 per-note messages, each read into the same three messages for one synth
Figure 1. Two sources, one vocabulary. Each reader produces the same three messages, and a synth written against them takes either source.
  • An MPE zone gives each note a channel of its own. MIDI 2.0 sends per-note messages.

  • The synth is written once, against the three messages.

Use Case

A guitar over MPE, each string on a channel of its own, and the player bends the third string while a chord rings on the others. The synth receives a note_pitch naming that string’s key and bends that voice alone. From a MIDI 2.0 controller sending per-note pitch bend, the same synth receives the same message through per_note_reader.

struct string_synth : midi::processor
{
   using midi::processor::operator();

   void operator()(midi::note_on msg, std::size_t)
   {
      voice(msg.key()).on(msg.velocity());
   }

   void operator()(midi::note_pitch msg, std::size_t)
   {
      voice(msg.key()).bend(msg.semitones());   // this string, and no other
   }
};
string_synth synth;
auto from_mpe   = midi::mpe_reader{synth};       // a guitar over MPE
auto from_midi2 = midi2::per_note_reader{synth}; // or a MIDI 2.0 controller

Include

#include <q/midi/mpe.hpp>        // the three messages, mpe_reader
#include <q/midi/per_note.hpp>   // per_note_reader

The Messages

In namespace midi, since both protocols share them, and named in midi_1_0 and midi_2_0 alike.

struct note_expression : message_base
{
   constexpr note_expression(
      std::uint8_t channel, std::uint8_t key, float value
    , std::uint32_t id = 0);

   constexpr std::uint8_t     channel() const;
   constexpr std::uint8_t     key() const;
   constexpr std::uint32_t    id() const;
};

struct note_pitch : note_expression
{
   using note_expression::note_expression;

   constexpr float            semitones() const;
};

struct note_pressure : note_expression
{
   using note_expression::note_expression;

   constexpr float            value() const;
};

struct note_timbre : note_expression
{
   using note_expression::note_expression;

   constexpr float            value() const;
};
Message Value Meaning

note_pitch

semitones()

How far this note is bent, in semitones, the channel’s bend and the note’s own already combined and scaled by their ranges.

note_pressure

value(), 0 to 1

How hard the key is being held now, rather than how hard it was struck.

note_timbre

value(), 0 to 1

The third dimension, controller 74, brightness. It starts centered.

id() tells apart two notes sounding on the same key at once. MIDI cannot: a note is identified by channel and key alone, so mpe_reader and per_note_reader both leave the field zero. A plugin host can, since CLAP gives every note an identifier of its own, and a host that builds these messages itself puts that identifier here.

struct my_synth : midi::processor
{
   using midi::processor::operator();

   void operator()(midi::note_on msg, std::size_t time);
   void operator()(midi::note_off msg, std::size_t time);
   void operator()(midi::note_pitch msg, std::size_t time);      // bend this voice
   void operator()(midi::note_pressure msg, std::size_t time);
   void operator()(midi::note_timbre msg, std::size_t time);
};

Declaration

namespace cycfi::q::midi_1_0
{
   template <typename P>
   class mpe_reader
   {
   public:

      static constexpr float  default_member_range = 48.0f;
      static constexpr float  default_master_range = 2.0f;
      static constexpr float  center_timbre = 64.0f/127.0f;
      static constexpr std::size_t max_notes_per_channel = 8;

      explicit                mpe_reader(P next);

                              template <typename Message>
      void                    operator()(Message msg, std::size_t time);

      std::uint8_t            lower_members() const;
      std::uint8_t            upper_members() const;
      std::uint8_t            zone_members() const;
   };
}

namespace cycfi::q::midi_2_0
{
   template <typename P>
   class per_note_reader
   {
   public:

      static constexpr float  default_range = 2.0f;
      static constexpr float  center_timbre = 0.5f;
      static constexpr std::uint8_t timbre_controller = 74;

      explicit                per_note_reader(P next);

                              template <typename Message>
      void                    operator()(Message msg, std::size_t time);
   };
}

Expressions

Notation

P

A type that conforms to Processor.

proc

Instance of P.

r

Instance of mpe_reader<P> or per_note_reader<P>.

m

Instance of mpe_reader<P>.

msg

Instance of a MIDI message.

time

A std::size_t time stamp.

Constructors

Expression Semantics

mpe_reader{proc}

A stage that reads MPE for proc. P is deduced: an lvalue is referred to and a temporary is owned.

per_note_reader{proc}

A stage that reads MIDI 2.0’s per-note messages for proc, in the same way.

Function Call

Expression Semantics Return Type

r(msg, time)

Read msg, calling the processor behind it with the per-note messages it gives rise to. Notes, and every message the reader has no use for, pass through.

void

Accessors

Expression Semantics Return Type

m.lower_members()

How many member channels the lower zone has, 0 if it is not declared.

std::uint8_t

m.upper_members()

The same, for the upper zone.

std::uint8_t

m.zone_members()

The two added.

std::uint8_t

mpe_reader

mpe_reader follows MIDI Polyphonic Expression 1.0 (MMA/AMEI RP-053). Notes pass through untouched, so a synth still gets its note_on and note_off. Expression arrives as the three messages instead of channel messages the synth would have to attribute to notes itself, and a note is given its channel’s current values the moment it starts. The MIDI 1.0 messages themselves are on MIDI 1.0 Messages.

namespace midi = cycfi::q::midi_1_0;

auto chain = midi::mpe_reader{my_synth};
midi::dispatch(msg, time, chain);

Until a zone is declared nothing is per-note, and channels outside a zone always pass through, so a plain keyboard plays as it always did.

Zones

An MPE zone is a master channel and the member channels next to it: channel 1 and up for the lower zone, channel 16 and down for the upper. A controller declares one with the MPE Configuration Message, registered parameter 6 sent on the master channel, its value the number of member channels, 0 to 15, and 0 removing the zone. A channel cannot belong to two zones, and the newer message takes the channels it asked for.

Member channels

A member channel’s pitch_bend, channel_pressure and controller 74 are read as the pitch, pressure and timbre of the notes on that channel, and are not passed on. A channel’s values are kept even with nothing sounding, since they are where the next note on it starts. Up to max_notes_per_channel notes share a channel once a zone runs out of channels, and expression then reaches all of them.

Master channel

The same three messages on the master channel apply to every note in the zone, and combine with each note’s own.

Bend range

Registered parameter 0, pitch bend sensitivity, sets the zone’s own range when sent on the master channel and the notes' range when sent on any member channel. They default to 2 semitones for the master and 48 for the members, and return to those with every configuration message.

The specification says three times that the two levels "combine meaningfully" without saying how. Q combines them so:

Dimension Combined as

Pitch

The note’s bend times the member range, plus the zone’s bend times the master range, in semitones.

Pressure

The two added, and clamped at 1.

Timbre

The zone’s value is an offset from center, added to the note’s own and clamped to 0 to 1, because controller 74 rests at its center.

On a member channel a poly_pressure is dropped, since MPE reserves it there, and a program_change is ignored. On a master channel both pass through.

per_note_reader

Where MPE spends a channel per note, MIDI 2.0 addresses the note by its key. per_note_reader reads per_note_pitch_bend as pitch, poly_pressure as pressure, and registered per-note controller 74, brightness, as timbre, the same controller MPE carries timbre on. Those messages are on MIDI 2.0 Messages.

namespace midi2 = cycfi::q::midi_2_0;

auto chain = midi2::per_note_reader{my_synth};
midi2::dispatch(packet, time, chain);

Channel pitch_bend, channel_pressure and channel controller 74 still apply to every sounding note on the channel, and combine with the note’s own as in MPE: pitch is the two bends added and scaled by the range, pressure is added and clamped, and the channel’s timbre is an offset from center.

Notes pass through untouched. A note_on reports the state its key already holds, since per-note messages may come before the note and apply to it.

The bend range is registered controller bank 0 index 0, the MIDI 2.0 form of pitch bend sensitivity, and applies to both channel and per-note bend. It defaults to 2 semitones, since the specification names no other default. A keyboard using a wide per-note range must say so.

One Synth, Either Source

The three messages are the same type whichever reader made them, so one synth serves both. A MIDI 1.0 synth fed by a MIDI 2.0 stream keeps its per-note expression by putting per_note_reader ahead of MIDI Translation, which would otherwise drop the per-note messages:

auto chain = midi2::per_note_reader{midi2::to_midi1{my_midi1_synth}};
midi2::dispatch(packet, time, chain);