Tabula Sonora 0.1.0
A native C++20 implementation of the Roland Sound Canvas VA synth voice
Loading...
Searching...
No Matches
ts::ControlMatrix Struct Reference

The GS controller assignment matrix (40 2x): six sources, each with eleven depths. More...

#include <tabulasonora/control_matrix.hpp>

Classes

struct  Modulation
 What one source contributes, once its depths have been scaled by its current amount. More...
struct  Law
 What turns a summed destination into its own unit — part_mod_depth_recalc, once per destination. More...

Public Types

enum class  Source {
  modulation = 0 , bend = 1 , channel_pressure , poly_pressure ,
  cc1 , cc2
}
 Where a modulation comes from, in the order 40 2x addresses them. More...
enum class  Destination {
  pitch = 0 , tvf_cutoff , amplitude , lfo1_rate ,
  lfo1_pitch , lfo1_tvf , lfo1_tva , lfo2_rate ,
  lfo2_pitch , lfo2_tvf , lfo2_tva
}
 What a modulation reaches. The index is the low nibble of the SysEx address. More...

Public Member Functions

 ControlMatrix () noexcept
void reset () noexcept
 Returns every route to power-on.
int & at (Source source, Destination destination) noexcept
int at (Source source, Destination destination) const noexcept
void store (Source source, Destination destination, int value) noexcept
 Stores one depth the way sysex_part_control_matrix does, which is not a plain assignment.
Modulation applied_linear (Source source, int amount) const noexcept
 Scales this source's depths by a controller amount — modmatrix_apply_linear.
Modulation applied_bipolar (Source source, int amount, int pitch_depth=neutral) const noexcept
 Scales this source's depths by a signed amount — modmatrix_apply_bipolar.

Static Public Member Functions

static constexpr Law law (Destination destination) noexcept
 The three constants for one destination.
static constexpr int scaled (Destination destination, int sum) noexcept
 Clamps and scales a destination's summed sources into that destination's unit.
static constexpr Modulation scaled (const Modulation &sums) noexcept
 Every destination's summed sources, each scaled by its own law.

Public Attributes

std::array< std::array< int, destination_count >, source_countdepth {}
 [source][destination].

Static Public Attributes

static constexpr int source_count = 6
static constexpr int destination_count = 11
static constexpr int neutral = 0x40
 The value every depth centres on, and the value of an unassigned route.
static constexpr int default_modulation_lfo1_pitch = 0x0A
 The mod wheel's LFO1 pitch depth at power-on — the one destination that is not zero or centred, and the reason a GM file's mod wheel produces vibrato without being told to.
static constexpr bool bend_pitch_lives_in_bend_range = true
 Bend's pitch depth is not stored here, and this says so out loud.

Detailed Description

The GS controller assignment matrix (40 2x): six sources, each with eleven depths.

This is the module's modulation routing — how far the mod wheel bends the pitch, how much aftertouch opens the filter, how deep CC1 drives LFO2, and so on. The engine keeps one eleven-byte block per source starting at part+0x3fc and hands the block to modmatrix_apply_linear or modmatrix_apply_bipolar with the controller's current amount; the result is eleven signed modulation values the voice adds to its own.

The depths are 0x40-centred, so a source assigned nothing anywhere leaves the voice alone.

Member Enumeration Documentation

◆ Source

enum class ts::ControlMatrix::Source
strong

Where a modulation comes from, in the order 40 2x addresses them.

Enumerator
modulation 

CC#1, 40 2x 00-0A.

bend 

pitch bend, 40 2x 10-1A

channel_pressure 

channel aftertouch, 40 2x 20-2A

poly_pressure 

polyphonic aftertouch, 40 2x 30-3A

cc1 

the part's CC1, 40 2x 40-4A

cc2 

the part's CC2, 40 2x 50-5A

◆ Destination

enum class ts::ControlMatrix::Destination
strong

What a modulation reaches. The index is the low nibble of the SysEx address.

Enumerator
pitch 

-24…+24 semitones

tvf_cutoff 

-9600…+9600 cents

amplitude 

-100…+100 %

lfo1_rate 

-10…+10 Hz

lfo1_pitch 

0…600 cents

lfo1_tvf 

0…2400 cents

lfo1_tva 

0…100 %

lfo2_rate 

-10…+10 Hz

lfo2_pitch 

0…600 cents

lfo2_tvf 

0…2400 cents

lfo2_tva 

0…100 %

Constructor & Destructor Documentation

◆ ControlMatrix()

ts::ControlMatrix::ControlMatrix ( )
inlinenoexcept

Member Function Documentation

◆ reset()

void ts::ControlMatrix::reset ( )
inlinenoexcept

Returns every route to power-on.

◆ at() [1/2]

int & ts::ControlMatrix::at ( Source source,
Destination destination )
inlinenodiscardnoexcept

◆ at() [2/2]

int ts::ControlMatrix::at ( Source source,
Destination destination ) const
inlinenodiscardnoexcept

◆ store()

void ts::ControlMatrix::store ( Source source,
Destination destination,
int value )
inlinenoexcept

Stores one depth the way sysex_part_control_matrix does, which is not a plain assignment.

Ten of the eleven destinations take the byte as it arrives. Pitch does not: it is clamped to 0x28-0x58, which is ±24 semitones, and that range is the reason the pitch law's 0xbe8 clamp is the top of the scale rather than a rail a real stream can reach — 24 × 127 is exactly 3048. Storing the byte raw instead lets a depth of 0x7f reach that clamp at a third of the wheel's travel, so the pitch ramps steeply and then stops dead half way up. That is what it did here until the module was asked: against 40 21 00 at 0x7f the module ramps evenly to 24 semitones across the whole wheel, and this engine had already railed by 64.

Bend's pitch depth never reaches this function — see bend_pitch_lives_in_bend_range. The engine clamps that one to 0x40-0x58 instead, one-sided, which is the 0-24 range Part::bend_range already holds it to.

◆ applied_linear()

Modulation ts::ControlMatrix::applied_linear ( Source source,
int amount ) const
inlinenodiscardnoexcept

Scales this source's depths by a controller amount — modmatrix_apply_linear.

Used by every source except bend, which has its own law (modmatrix_apply_bipolar, a per-destination 16-bit scale rather than a shift) and is not this function.

This reads more simply than the engine's version, and the reason is worth knowing rather than trusting. The engine permutes twice: sysex_part_control_matrix writes SysEx destination 3 to block byte 4 and destination 10 to block byte 11, skipping byte 3 altogether; then modmatrix_apply_linear reads block byte 5 into output 6 and byte 7 into output 4, running each LFO group backwards. Composed, the two cancel — storing by SysEx index and naming the outputs makes both disappear. The check that this is a real cancellation and not a coincidence is that the laws then line up with the published ranges: every destination that comes out bipolar is one the manual documents as ±something, and every quartered one is documented as a 0-upward amount.

Three scalings, not one. Pitch takes the product whole; the two continuous destinations and the LFO rates take it halved; the six LFO depths take it quartered and are unipolar — they are amounts rather than offsets, so they are not measured from 0x40. The halving is written as a magnitude shift with the sign reapplied, because an arithmetic shift of a negative rounds toward minus infinity and the engine's does not.

◆ applied_bipolar()

Modulation ts::ControlMatrix::applied_bipolar ( Source source,
int amount,
int pitch_depth = neutral ) const
inlinenodiscardnoexcept

Scales this source's depths by a signed amount — modmatrix_apply_bipolar.

Bend's law, and only bend's. It is not a variant of the linear apply: where that one shifts, this one multiplies by a per-destination 16-bit constant and takes the high word, and its amount is a signed 14-bit deflection rather than a 7-bit controller.

The three constants turn out to be the same three-way split the linear apply makes. Against the << 2 the magnitude already carries, 0xfe16 is ×1, 0x7f00 is ×½ and 0x3f81 is ×¼ — whole for pitch, halved for the continuous destinations and the LFO rates, quartered for the six LFO depths. That the two functions agree on which destination gets which treatment, by completely different arithmetic, is the check that neither has been misread.

The sign is the product of two: the depth's side of centre and the amount's. A downward bend through a negative depth pushes pitch up, which is the point of an inverted assignment. pitch_depth supplies the cell this matrix does not store — bend's, which lives in Part::bend_range. Passing it in keeps that one store rather than mirroring it here.

◆ law()

constexpr Law ts::ControlMatrix::law ( Destination destination)
inlinestaticnodiscardconstexprnoexcept

The three constants for one destination.

Two things are worth reading off this table rather than trusting. The clamps are exactly the largest sum each destination can reach: 0xbe8 is 127 x 24 for a whole product, 4000 is 127 x 63 / 2 for a halved one, and 0xfc0 is 127 x 127 / 4 for a quartered one — so the clamp is not a safety rail that a real stream might hit, it is the top of the scale. And each clamp << shift lands just inside 16 bits (64512 is the largest, for the LFO amplitude depths), which is what keeps the engine's unsigned-short intermediate from wrapping. Both facts fall out of the constants; neither was put there by hand.

The full-scale results are the published ranges, exactly: +-24000 milli-semitones of pitch, +-24576 cutoff units (9600 cents at 2.56 a cent), +-32512 of 0x7f00 amplitude, +-6553 of LFO increment (10 Hz, since 65536 increments a tick at 100 Hz is 100 Hz), and for the LFO depths 32512, 6144 and 6000 — 100 %, 2400 cents and 600 cents. Eleven constants reproducing seven documented figures is the check that the table has been read off correctly.

◆ scaled() [1/2]

constexpr int ts::ControlMatrix::scaled ( Destination destination,
int sum )
inlinestaticnodiscardconstexprnoexcept

Clamps and scales a destination's summed sources into that destination's unit.

Sign-magnitude, like everything else here: the clamp and the multiply see the magnitude and the sign is reapplied, which is not the same as clamping a signed value and shifting it.

◆ scaled() [2/2]

constexpr Modulation ts::ControlMatrix::scaled ( const Modulation & sums)
inlinestaticnodiscardconstexprnoexcept

Every destination's summed sources, each scaled by its own law.

Member Data Documentation

◆ source_count

int ts::ControlMatrix::source_count = 6
staticconstexpr

◆ destination_count

int ts::ControlMatrix::destination_count = 11
staticconstexpr

◆ neutral

int ts::ControlMatrix::neutral = 0x40
staticconstexpr

The value every depth centres on, and the value of an unassigned route.

◆ default_modulation_lfo1_pitch

int ts::ControlMatrix::default_modulation_lfo1_pitch = 0x0A
staticconstexpr

The mod wheel's LFO1 pitch depth at power-on — the one destination that is not zero or centred, and the reason a GM file's mod wheel produces vibrato without being told to.

◆ depth

std::array<std::array<int, destination_count>, source_count> ts::ControlMatrix::depth {}

[source][destination].

◆ bend_pitch_lives_in_bend_range

bool ts::ControlMatrix::bend_pitch_lives_in_bend_range = true
staticconstexpr

Bend's pitch depth is not stored here, and this says so out loud.

40 2x 10 (BEND PITCH CONTROL) and RPN 00/00 (pitch bend sensitivity) are not two parameters that happen to agree — they are one byte, part+0x408, written by both handlers with the same 0–24 semitone clamp. The engine's bend range is this matrix cell. Keeping a second copy here would be a second source of truth for one value, so Part::bend_range owns it and both messages write there.


The documentation for this struct was generated from the following file: