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

The filter side of a voice: the cutoff chain that turns static bytes into the two coefficients the state-variable filter needs, and the envelope that moves the cutoff over time. More...

#include <tabulasonora/tvf_chain.hpp>

Classes

struct  Offsets
 The envelope's peak and its five stage offsets, in cutoff units relative to that peak. More...
struct  Envelope
 A cutoff envelope and the base cutoff its offsets are added to. More...
struct  Coefficients
 The pair of coefficients the filter runs on for one control block. More...

Public Member Functions

 TvfChain (const TableSet &tables, const EnvelopeMachine &envelope)
 Creates the chain over a loaded table set and the shared machine, both of which must outlive it.
FilterTap tap (int filter_type) const noexcept
 Which filter response a partial takes, or FilterTap::bypass.
int cutoff_units (double cutoff15, int resonance_byte) const noexcept
 Warps a 15-bit cutoff sum and clamps it to the resonance-dependent ceiling — stage C.
double frequency_coefficient (int units) const noexcept
 The filter's frequency coefficient f.
double damping_coefficient (int units, int resonance_byte, int filter_type) const noexcept
 The filter's damping coefficient q — stage D.
Coefficients coefficients (int units, int resonance_byte, int filter_type) const noexcept
 Both coefficients at once, with f clamped to the stability ceiling q selects.
std::span< const std::int32_t > ramp_exp () const noexcept
 The shared g_ramp_exp_tbl.
double cutoff_hz (double cutoff15, int resonance_byte, int sample_rate=32000) const noexcept
 The cutoff in Hz. Diagnostic only — the filter never needs it.
int effective_velocity (const PartialParameters &partial, int velocity) const noexcept
 The velocity the filter envelope's depth actually responds to.
Offsets envelope_offsets (const PartialParameters &partial, int key, int velocity) const
 The envelope's peak and its five stage offsets.
Envelope create_envelope (const PartialParameters &partial, int velocity, int key, int sample_rate=32000, const PartModifiers &modifiers={}) const
 Builds the cutoff envelope for one note, ready to be evaluated at any sample position.
std::vector< double > envelope (const PartialParameters &partial, int velocity, int key, double hold_seconds, double tail_seconds, int sample_rate=32000) const
 The 15-bit cutoff trajectory over a note, clamped to 15 bits.
void apply (std::span< float > signal, std::span< const double > cutoff15, int filter_type, int resonance_byte, int block_samples=control_block_samples) const noexcept
 Filters a signal in place with a cutoff that moves per control block.

Static Public Member Functions

static int resonance_byte (const PartialParameters &partial, int part_resonance=0x40, int part_resonance_default=0x40) noexcept
 The resonance byte — stage A. Floored at 4.
static bool responds_to_env_modifiers (const PartialParameters &partial) noexcept
 Whether this partial's filter envelope follows the part's envelope modify offsets.

Static Public Attributes

static constexpr int control_block_samples = 320
 Samples between coefficient refreshes — the 100 Hz control tick.

Detailed Description

The filter side of a voice: the cutoff chain that turns static bytes into the two coefficients the state-variable filter needs, and the envelope that moves the cutoff over time.

The cutoff runs through four stages. A derives the resonance byte, B sums the base with the envelope, C warps the sum and clamps it to a resonance-dependent ceiling, and D produces the Q trim. The warped result then passes through an exponential table before it reaches the filter, which is the step an earlier attempt missed: feeding linear cutoff units to a Chamberlin filter drives f to about 1.9, where the state matrix diverges.

Cutoff is note-independent. There is no key-follow on it; brightness tracks the keyboard through the multisample instead.

Constructor & Destructor Documentation

◆ TvfChain()

ts::TvfChain::TvfChain ( const TableSet & tables,
const EnvelopeMachine & envelope )

Creates the chain over a loaded table set and the shared machine, both of which must outlive it.

Member Function Documentation

◆ tap()

FilterTap ts::TvfChain::tap ( int filter_type) const
nodiscardnoexcept

Which filter response a partial takes, or FilterTap::bypass.

Only types 0, 1, 2, 4, 5 and 6 are valid; 3 and 7 bypass. The response comes from bits 10–11 of the type's coefficient word, so the mapping is not the obvious one — type 1 is highpass and type 2 is bandpass.

◆ resonance_byte()

int ts::TvfChain::resonance_byte ( const PartialParameters & partial,
int part_resonance = 0x40,
int part_resonance_default = 0x40 )
staticnodiscardnoexcept

The resonance byte — stage A. Floored at 4.

◆ cutoff_units()

int ts::TvfChain::cutoff_units ( double cutoff15,
int resonance_byte ) const
nodiscardnoexcept

Warps a 15-bit cutoff sum and clamps it to the resonance-dependent ceiling — stage C.

The ceiling at neutral resonance times four is 245,760 — the "fully open" constant an earlier calibration measured empirically. It is a ceiling, not a saturation of the sum.

◆ frequency_coefficient()

double ts::TvfChain::frequency_coefficient ( int units) const
nodiscardnoexcept

The filter's frequency coefficient f.

Every ramp target passes through the exponential table before it reaches the filter, so what the filter sees is exponential in the cutoff units. This collapses to f = 2^(C/16384 - 15), and Chamberlin's f = 2*sin(pi*fc/fs) then gives the cutoff in Hz with no fitted constant at all. The table entries reach 2^18, so the interpolation must be widened before the shift.

◆ damping_coefficient()

double ts::TvfChain::damping_coefficient ( int units,
int resonance_byte,
int filter_type ) const
nodiscardnoexcept

The filter's damping coefficient q — stage D.

This is reciprocal-Q, so the neutral resonance byte 0x40 yields exactly 1.0 and smaller values are more resonant — effectively Q = 64 / resonance_byte.

◆ coefficients()

Coefficients ts::TvfChain::coefficients ( int units,
int resonance_byte,
int filter_type ) const
nodiscardnoexcept

Both coefficients at once, with f clamped to the stability ceiling q selects.

The engine couples the two: voice_ctrl_ramp_d ramps q and, from the same value, clamps the f that voice_ctrl_ramp_c has just written — f = min(f, g_svf_f_ceil[q_raw >> 8]). The ceiling is Chamberlin's own stability bound, sqrt(q^2 + 4) − q, so the clamp keeps the filter out of the region where the loop diverges. Anything that runs the filter must take its coefficients from here rather than from the two accessors separately.

The clamp is inert over every cutoff and resonance the engine can reach: cutoff_units already holds f below the ceiling for all three q branches, though only just — filter type 6 at resonance byte 4 comes within 0.78%. It is implemented because it is a real stage, not because it changes a render.

◆ ramp_exp()

std::span< const std::int32_t > ts::TvfChain::ramp_exp ( ) const
inlinenodiscardnoexcept

The shared g_ramp_exp_tbl.

The pitch ramp decodes its sampler increment from the same table and the same octave size, so it is exposed here rather than loaded twice.

◆ cutoff_hz()

double ts::TvfChain::cutoff_hz ( double cutoff15,
int resonance_byte,
int sample_rate = 32000 ) const
nodiscardnoexcept

The cutoff in Hz. Diagnostic only — the filter never needs it.

◆ effective_velocity()

int ts::TvfChain::effective_velocity ( const PartialParameters & partial,
int velocity ) const
nodiscardnoexcept

The velocity the filter envelope's depth actually responds to.

Velocity does not reach the depth scaler raw: block[0x2e] picks one of sixteen response curves first. Row 0 is the identity, so the majority of the library is unaffected — Trumpet selects it and is exact either way. Brass 1 selects row 1, which reads velocity 100 as 71.

Using raw velocity leaves Brass 1's filter about a third of an octave too open for the whole note, which measures as +3.5 dB at 4–8 kHz and +6.3 dB above it.

◆ envelope_offsets()

Offsets ts::TvfChain::envelope_offsets ( const PartialParameters & partial,
int key,
int velocity ) const
nodiscard

The envelope's peak and its five stage offsets.

◆ create_envelope()

Envelope ts::TvfChain::create_envelope ( const PartialParameters & partial,
int velocity,
int key,
int sample_rate = 32000,
const PartModifiers & modifiers = {} ) const
nodiscard

Builds the cutoff envelope for one note, ready to be evaluated at any sample position.

The running level starts at zero rather than at the release level: all five targets are made relative to the peak, and the peak itself is folded into the base cutoff instead. TVF segments are always linear — unlike the TVA, the shape is not data-driven here.

modifiers supplies the part's cutoff offset, which always applies, and its envelope offsets, which apply only when the partial opts in — see responds_to_env_modifiers.

◆ responds_to_env_modifiers()

bool ts::TvfChain::responds_to_env_modifiers ( const PartialParameters & partial)
inlinestaticnodiscardnoexcept

Whether this partial's filter envelope follows the part's envelope modify offsets.

Bit 4 of block byte 0x0E. tvf_compute_env_rates zeroes its bias outright when the bit is clear, so on those partials CC#73/75/72 move the amplitude envelope and leave the filter envelope alone. The amplitude side has no such gate.

◆ envelope()

std::vector< double > ts::TvfChain::envelope ( const PartialParameters & partial,
int velocity,
int key,
double hold_seconds,
double tail_seconds,
int sample_rate = 32000 ) const
nodiscard

The 15-bit cutoff trajectory over a note, clamped to 15 bits.

◆ apply()

void ts::TvfChain::apply ( std::span< float > signal,
std::span< const double > cutoff15,
int filter_type,
int resonance_byte,
int block_samples = control_block_samples ) const
noexcept

Filters a signal in place with a cutoff that moves per control block.

Coefficients refresh once per control block, matching the engine's 100 Hz rate. The engine additionally slews them over 2–40 ms through its anti-zipper ramps, which is not modelled.

The coefficients come from the cutoff's mean over the block they will serve, not its value at the tick: the envelope can cross several segments inside one 10 ms tick, and a single sample point costs about 1.7% of peak on a piano attack.

Member Data Documentation

◆ control_block_samples

int ts::TvfChain::control_block_samples = 320
staticconstexpr

Samples between coefficient refreshes — the 100 Hz control tick.


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