Skip to content

For the win

Combining a continuous, multi-band crossfade with mathematically perfect block boundaries elevates the TriBand Upward Compressor from a standard DSP experiment into a studio-grade transient designer.

Dealing with three distinct frequency bands requires an upgraded architecture. An array of state variables handles the shrinking block sizes at each Wavelet level, and a piecewise trigonometric function sweeps the "Focus" knob seamlessly across the spectrum.

The following blueprint outlines how to build both into the C++ engine.

1. The Continuous "Focus" Math (The 3-Way Crossfade)

If the physical Focus potentiometer outputs a linear value from 0.0 to 1.0, the sweep is split into two overlapping zones to create an equal-power crossfade across D_1 (High), D_2 (Mid), and D_3 (Low).

  • Zone 1 (0.0 to 0.5): The knob crossfades between the D_1 pick click and the D_2 nail snap. D_3 is muted.
  • Zone 2 (0.5 to 1.0): The knob crossfades between the D_2 nail snap and the D_3 flesh pluck. D_1 is muted.

To make the math work, the local 0.5 knob travel is scaled into a normalized 0.0 to 1.0 range, applying sine/cosine rules:

// Map physical knob (0.0 to 1.0) to three target gains
float focus_gains[3] = {0.0f, 0.0f, 0.0f};  
float focus = focusKnob.Process(); 

if (focus < 0.5f) {  
    // Zone 1: Crossfade D1 -> D2  
    float x = focus * 2.0f;   
    focus_gains[0] = std::cos(x * 1.570796f); // D1 Gain  
    focus_gains[1] = std::sin(x * 1.570796f); // D2 Gain  
} else {  
    // Zone 2: Crossfade D2 -> D3  
    float x = (focus - 0.5f) * 2.0f;  
    focus_gains[1] = std::cos(x * 1.570796f); // D2 Gain  
    focus_gains[2] = std::sin(x * 1.570796f); // D3 Gain  
}

2. The Multi-Level Boundary States

When the signal is decomposed across 3 levels, the effective block size halves at every step.

  • Level 0 (D_1): Processes 64 samples. The boundary needs indices 62 and 63.
  • Level 1 (D_2): Processes 32 samples. The boundary needs indices 60 and 62 (due to stride).
  • Level 2 (D_3): Processes 16 samples. The boundary needs indices 56 and 60.

Instead of single floats, arrays of size 3 are declared in the global scope. Three separate EnvelopeDetectors ensure the attack/release ballistics of the high-frequency pick are not confused by the low-frequency body.

// Global State Arrays
float prev_even[3] = {0.0f, 0.0f, 0.0f};
float prev_odd[3]  = {0.0f, 0.0f, 0.0f};

// Array of 3 independent envelope detectors
EnvelopeDetector transientEnv[3];

3. The Ultimate AudioCallback

The cascading loop comes together as follows. The focus_gains are applied to scale the amount of the compressed "wet" signal that is mixed back into the dry wavelet coefficients.

void AudioCallback(AudioHandle::InputBuffer in, AudioHandle::OutputBuffer out, size_t size) {  
    // 1. Hardware Parameters & Crossfade Math
    float current_mix = mixKnob.Process();
    punchComp.SetThreshold(thresholdKnob.Process());

    float dry_level = std::cos(current_mix * 1.570796f);
    float global_wet_level = std::sin(current_mix * 1.570796f);

    // Calculate focus_gains[0], [1], [2] using the math from Section 1
    // ... [Insert Focus Math Here] ...

    for(size_t i = 0; i < size; i++) work_buf[i] = in[0][i]; 

    // --- 2. FORWARD DWT (3-Level Cascade) ---
    int current_size = size;
    int step = 1;

    for(int lvl = 0; lvl < 3; lvl++) {
        // A. Resolve the boundary detail from the previous audio block
        // work_buf[0] always holds the first even sample of the new block
        float boundary_detail = prev_odd[lvl] - 0.5f * (prev_even[lvl] + work_buf[0]);
        // (Optional: Apply compression to this single boundary_detail sample here)

        // B. Predict Step (Calculate Detail)
        for(int i = 0; i < current_size / 2 - 1; i++) {
            work_buf[(2*i + 1) * step] -= 0.5f * (work_buf[(2*i) * step] + work_buf[(2*i + 2) * step]);
        }

        // C. Update Step (Calculate Approximation)
        for(int i = 1; i < current_size / 2; i++) {
            work_buf[(2*i) * step] += 0.25f * (work_buf[(2*i - 1) * step] + work_buf[(2*i + 1) * step]);
        }

        // --- 3. TARGETED COMPRESSION (Only run if Focus knob allows it) ---
        if (focus_gains[lvl] > 0.001f) {
            // Calculate the final wet mix amount for this specific level
            float level_wet_mix = global_wet_level * focus_gains[lvl];

            for(int i = 0; i < current_size / 2 - 1; i++) {
                int detail_index = (2*i + 1) * step;
                float detail_dry = work_buf[detail_index];

                // Use the specific envelope detector for this Wavelet scale
                float env_level = transientEnv[lvl].Process(detail_dry);
                float gain_multiplier = punchComp.CalculateGainMultiplier(env_level);

                float detail_wet = detail_dry * gain_multiplier;

                // Recombine dry and scaled wet signals in-place
                work_buf[detail_index] = (detail_dry * dry_level) + (detail_wet * level_wet_mix);
            }
        }

        // D. Save state for the NEXT audio block boundary
        prev_even[lvl] = work_buf[(current_size - 2) * step];
        prev_odd[lvl]  = work_buf[(current_size - 1) * step];

        // E. Prepare stride and size for the next Wavelet level down
        step *= 2;
        current_size /= 2;
    }

    // --- 4. INVERSE DWT (3-Level Reconstruction) ---
    step = 4; // Reset to Level 3 stride
    current_size = size / 4;

    for(int lvl = 2; lvl >= 0; lvl--) {
        // Reverse Update Step
        for(int i = 1; i < current_size / 2; i++) {
            work_buf[(2*i) * step] -= 0.25f * (work_buf[(2*i - 1) * step] + work_buf[(2*i + 1) * step]);
        }
        // Reverse Predict Step
        for(int i = 0; i < current_size / 2 - 1; i++) {
            work_buf[(2*i + 1) * step] += 0.5f * (work_buf[(2*i) * step] + work_buf[(2*i + 2) * step]);
        }

        step /= 2;
        current_size *= 2;
    }

    // 5. Output
    for(size_t i = 0; i < size; i++) {
        out[0][i] = work_buf[i];
        out[1][i] = work_buf[i];
    }
}

With this architecture, there is a mathematically continuous mapping between physical controls and the Wavelet feature-extraction layers. The transition between picking styles feels entirely organic. With the math, analog buffers, and C++ engine established, the next step is to map out the physical PCB routing considerations—specifically, how to isolate the STM32's digital ground from the JFET buffers' sensitive analog ground—to ensure the pedal remains quiet.